feat(automation): align attachment uploads

This commit is contained in:
2026-06-07 10:44:45 +08:00
parent f46bce7528
commit f7515b111d
6 changed files with 319 additions and 30 deletions
@@ -3,6 +3,7 @@ package v1
import (
"encoding/json"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/automation"
@@ -33,7 +34,7 @@ type automationRuleRequest struct {
EventName string `json:"event_name"`
Active *bool `json:"active"`
Conditions []automationRuleConditionRequest `json:"conditions"`
Actions []automationRuleActionRequest `json:"actions"`
Actions *[]automationRuleActionRequest `json:"actions"`
}
type automationRuleConditionRequest struct {
@@ -120,6 +121,10 @@ func (h *AutomationRuleHandler) Create(c *gin.Context) {
}
if svcErr := h.svc.Create(c.Request.Context(), rule); svcErr != nil {
if strings.Contains(svcErr.Error(), "invalid attachment") {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid attachment"})
return
}
handleServiceError(c, svcErr)
return
}
@@ -167,6 +172,10 @@ func (h *AutomationRuleHandler) Update(c *gin.Context) {
rule.ID = automationID
if svcErr := h.svc.UpdateForAccount(c.Request.Context(), accountID, rule); svcErr != nil {
if strings.Contains(svcErr.Error(), "invalid attachment") {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid attachment"})
return
}
handleServiceError(c, svcErr)
return
}
@@ -325,13 +334,16 @@ func buildAutomationRuleFromRequest(accountID uint, req automationRuleRequest) (
})
}
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.AutomationRule{
@@ -354,7 +366,7 @@ func serializeAutomationRules(rules []automation.AutomationRule) []gin.H {
}
func serializeAutomationRule(rule *automation.AutomationRule) gin.H {
return gin.H{
serialized := gin.H{
"id": rule.ID,
"account_id": rule.AccountID,
"name": rule.Name,
@@ -365,6 +377,26 @@ func serializeAutomationRule(rule *automation.AutomationRule) gin.H {
"created_on": rule.CreatedAt.Unix(),
"active": rule.Active,
}
if len(rule.Files) > 0 {
serialized["files"] = serializeAutomationRuleFiles(rule.Files)
}
return serialized
}
func serializeAutomationRuleFiles(files []automation.AutomationRuleFile) []gin.H {
result := make([]gin.H, 0, len(files))
for _, file := range files {
result = append(result, gin.H{
"id": file.ID,
"automation_rule_id": file.AutomationRuleID,
"file_type": file.FileType,
"account_id": file.AccountID,
"file_url": file.FileURL,
"blob_id": file.BlobID,
"filename": file.Filename,
})
}
return result
}
func serializeAutomationConditions(conditions automation.Conditions) []gin.H {
@@ -15,6 +15,7 @@ import (
"gorm.io/gorm/logger"
"github.com/gochat/gochat/internal/automation"
"github.com/gochat/gochat/internal/model"
)
// automationDBProvider wraps *gorm.DB to implement automation.DBProvider.
@@ -40,7 +41,7 @@ func (s *AutomationRuleHandlerTestSuite) SetupSuite() {
Logger: logger.Default.LogMode(logger.Silent),
})
s.Require().NoError(err, "failed to open SQLite test database")
s.Require().NoError(db.AutoMigrate(&automation.AutomationRule{}), "failed to auto-migrate AutomationRule model")
s.Require().NoError(db.AutoMigrate(&automation.AutomationRule{}, &model.DirectUpload{}), "failed to auto-migrate AutomationRule model")
s.db = db
provider := &automationDBProvider{db: db}
@@ -74,6 +75,7 @@ func (s *AutomationRuleHandlerTestSuite) TearDownSuite() {
func (s *AutomationRuleHandlerTestSuite) SetupTest() {
s.db.Exec("DELETE FROM automation_rules")
s.db.Exec("DELETE FROM direct_uploads")
}
func (s *AutomationRuleHandlerTestSuite) createRule(accountID uint, eventName, name string, active bool) *automation.AutomationRule {
@@ -192,6 +194,53 @@ func (s *AutomationRuleHandlerTestSuite) TestCreate_Success() {
s.Equal(float64(1), saved.Actions[0].ActionParams["team_id"])
}
func (s *AutomationRuleHandlerTestSuite) TestCreate_AttachmentActionAcceptsSignedUploadAndSerializesFiles() {
upload := &model.DirectUpload{
UploadUUID: "automation-upload-signed-id",
AccountID: 1,
Status: model.DirectUploadStatusPending,
Source: model.DirectUploadSourceAccount,
OriginalName: "automation.pdf",
FileType: "file",
MimeType: "application/pdf",
FileSize: 321,
FileURL: "/uploads/account/1/automation.pdf",
}
s.Require().NoError(s.db.Create(upload).Error)
body := `{"event_name":"conversation_created","name":"Send file","active":true,"conditions":[],"actions":[{"action_name":"send_attachment","action_params":["automation-upload-signed-id"]}]}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/automation_rules", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
action := resp["actions"].([]interface{})[0].(map[string]interface{})
s.Equal([]interface{}{float64(upload.ID)}, action["action_params"])
files := resp["files"].([]interface{})
s.Require().Len(files, 1)
file := files[0].(map[string]interface{})
s.Equal(float64(upload.ID), file["blob_id"])
s.Equal(resp["id"], file["automation_rule_id"])
s.Equal("automation.pdf", file["filename"])
}
func (s *AutomationRuleHandlerTestSuite) TestCreate_AttachmentActionRejectsInvalidBlob() {
body := `{"event_name":"conversation_created","name":"Bad file","active":true,"conditions":[],"actions":[{"action_name":"send_attachment","action_params":["missing-upload"]}]}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/automation_rules", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusUnprocessableEntity, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
s.Equal("invalid attachment", resp["error"])
}
func (s *AutomationRuleHandlerTestSuite) TestCreate_InvalidJSON() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/automation_rules", bytes.NewBufferString(`{invalid`))
@@ -234,6 +283,28 @@ func (s *AutomationRuleHandlerTestSuite) TestUpdate_Success() {
s.Equal("UpdatedName", saved.Name)
}
func (s *AutomationRuleHandlerTestSuite) TestUpdate_OmittedActionsPreservesExistingActions() {
rule := s.createRule(1, "conversation_created", "KeepActions", true)
body := `{"event_name":"message_created","name":"Renamed","active":false,"conditions":[{"attribute_key":"status","filter_operator":"equal_to","values":["resolved"],"query_operator":"and"}]}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/accounts/1/automation_rules/%d", rule.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
payload := resp["payload"].(map[string]interface{})
action := payload["actions"].([]interface{})[0].(map[string]interface{})
s.Equal("assign_team", action["action_name"])
s.Equal([]interface{}{float64(1)}, action["action_params"])
var saved automation.AutomationRule
s.Require().NoError(s.db.First(&saved, rule.ID).Error)
s.Equal(float64(1), saved.Actions[0].ActionParams["team_id"])
}
func (s *AutomationRuleHandlerTestSuite) TestUpdate_InvalidID() {
body := `{"name":"Updated","active":true}`
w := httptest.NewRecorder()