feat(conversations): align direct upload routes

This commit is contained in:
2026-06-06 14:50:14 +08:00
parent 6b90aedc83
commit 7554a75427
9 changed files with 300 additions and 79 deletions
+53
View File
@@ -118,3 +118,56 @@ func (h *UploadHandler) AccountDirectUpload(c *gin.Context) {
response.OK(c, result)
}
// ConversationDirectUpload handles Chatwoot's nested conversation direct upload
// endpoint used by the reused dashboard message composer.
// Reference: POST /api/v1/accounts/:account_id/conversations/:conversation_id/direct_uploads
func (h *UploadHandler) ConversationDirectUpload(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "account_id is required")
return
}
conversationID, err := parseUintParam(c, "conversation_id")
if err != nil || conversationID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id")
return
}
if !strings.Contains(c.GetHeader("Content-Type"), "application/json") {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "invalid direct upload metadata")
return
}
var req service.ActiveStorageDirectUploadRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "invalid direct upload metadata")
return
}
result, svcErr := h.svc.CreateConversationDirectUpload(c.Request.Context(), accountID, conversationID, req)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
c.JSON(http.StatusOK, result)
}
func (h *UploadHandler) CompleteConversationDirectUpload(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "account_id is required")
return
}
conversationID, err := parseUintParam(c, "conversation_id")
if err != nil || conversationID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id")
return
}
result, svcErr := h.svc.CompleteConversationDirectUpload(c.Request.Context(), accountID, conversationID, c.Param("upload_uuid"), c.Request.Body)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, result)
}
@@ -8,6 +8,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"github.com/gin-gonic/gin"
@@ -37,6 +38,8 @@ func setupUploadHandlerRouter(h *UploadHandler) *gin.Engine {
// Account direct upload route
api.POST("/direct_uploads", h.AccountDirectUpload)
api.POST("/conversations/:conversation_id/direct_uploads", h.ConversationDirectUpload)
api.PUT("/conversations/:conversation_id/direct_uploads/:upload_uuid", h.CompleteConversationDirectUpload)
// Widget direct upload route
widget := r.Group("/widget")
@@ -163,6 +166,84 @@ func TestUploadHandler_WidgetActiveStorageDirectUploadFlow(t *testing.T) {
assert.Equal(t, []byte("hello image"), storedBytes)
}
func TestUploadHandler_ConversationActiveStorageDirectUploadFlow(t *testing.T) {
gin.SetMode(gin.TestMode)
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.Account{},
&model.Inbox{},
&model.Contact{},
&model.Conversation{},
&model.DirectUpload{},
))
account := &model.Account{Name: "Conversation Upload Org", Status: "active"}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "Conversation Upload Inbox", ChannelType: "web_widget", Enabled: true}
require.NoError(t, db.Create(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "Composer"}
require.NoError(t, db.Create(contact).Error)
displayID := uint(44)
conversation := &model.Conversation{
AccountID: account.ID,
InboxID: inbox.ID,
ContactID: contact.ID,
DisplayID: &displayID,
Status: "open",
ChannelType: "web_widget",
Channel: "web_widget",
}
require.NoError(t, db.Create(conversation).Error)
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{
Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20},
}).WithConversationRepo(repository.NewConversationRepo(db))
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
metadataBody, err := json.Marshal(map[string]any{
"blob": map[string]any{
"filename": "agent-note.pdf",
"byte_size": 12,
"checksum": "pdf-checksum",
"content_type": "application/pdf",
"metadata": map[string]any{"identified": true},
},
})
require.NoError(t, err)
createPath := "/api/v1/accounts/" + strconv.FormatUint(uint64(account.ID), 10) + "/conversations/44/direct_uploads"
wCreate := httptest.NewRecorder()
reqCreate, _ := http.NewRequest("POST", createPath, bytes.NewReader(metadataBody))
reqCreate.Header.Set("Content-Type", "application/json")
router.ServeHTTP(wCreate, reqCreate)
require.Equal(t, http.StatusOK, wCreate.Code)
var createResp map[string]any
require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp))
signedID, ok := createResp["signed_id"].(string)
require.True(t, ok)
require.NotEmpty(t, signedID)
assert.Equal(t, "agent-note.pdf", createResp["filename"])
directUpload := createResp["direct_upload"].(map[string]any)
assert.Equal(t, createPath+"/"+signedID, directUpload["url"])
wPut := httptest.NewRecorder()
reqPut, _ := http.NewRequest("PUT", directUpload["url"].(string), bytes.NewReader([]byte("hello report")))
reqPut.Header.Set("Content-Type", "application/pdf")
router.ServeHTTP(wPut, reqPut)
require.Equal(t, http.StatusOK, wPut.Code)
var upload model.DirectUpload
require.NoError(t, db.Where("upload_uuid = ?", signedID).First(&upload).Error)
assert.Equal(t, account.ID, upload.AccountID)
assert.Equal(t, model.DirectUploadSourceAccount, upload.Source)
storedBytes, err := os.ReadFile(filepath.Join(tmpDir, "account", strconv.FormatUint(uint64(account.ID), 10), signedID+".pdf"))
require.NoError(t, err)
assert.Equal(t, []byte("hello report"), storedBytes)
}
func TestUploadHandler_AccountDirectUpload_NoFile(t *testing.T) {
// Create handler with nil service — we only test validation before service call
h := &UploadHandler{svc: nil}