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
+20 -9
View File
@@ -84,19 +84,30 @@ func (a *Actions) Scan(value interface{}) error {
// Reference: Chatwoot AutomationRule — account_id, event_name, conditions, actions, active, name, description
type AutomationRule struct {
model.Base
AccountID uint `gorm:"index;not null" json:"account_id"`
EventName string `gorm:"size:100;index;not null" json:"event_name"` // e.g. "conversation_created", "message_created"
Name string `gorm:"size:255;not null" json:"name"`
Description string `gorm:"type:text" json:"description,omitempty"`
Conditions Conditions `gorm:"type:jsonb;default:'[]'" json:"conditions"`
Actions Actions `gorm:"type:jsonb;default:'[]'" json:"actions"`
Active bool `gorm:"not null" json:"active"`
ActiveAt *time.Time `gorm:"index" json:"active_at,omitempty"`
InactiveAt *time.Time `gorm:"index" json:"inactive_at,omitempty"`
AccountID uint `gorm:"index;not null" json:"account_id"`
EventName string `gorm:"size:100;index;not null" json:"event_name"` // e.g. "conversation_created", "message_created"
Name string `gorm:"size:255;not null" json:"name"`
Description string `gorm:"type:text" json:"description,omitempty"`
Conditions Conditions `gorm:"type:jsonb;default:'[]'" json:"conditions"`
Actions Actions `gorm:"type:jsonb;default:'[]'" json:"actions"`
Active bool `gorm:"not null" json:"active"`
ActiveAt *time.Time `gorm:"index" json:"active_at,omitempty"`
InactiveAt *time.Time `gorm:"index" json:"inactive_at,omitempty"`
Files []AutomationRuleFile `gorm:"-" json:"files,omitempty"`
}
func (AutomationRule) TableName() string { return "automation_rules" }
type AutomationRuleFile struct {
ID uint `json:"id"`
AutomationRuleID uint `json:"automation_rule_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"`
}
// ===========================
// Macro model
// ===========================
+108 -3
View File
@@ -2,9 +2,12 @@ package automation
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/worker"
applogger "github.com/gochat/gochat/pkg/logger"
"gorm.io/gorm"
@@ -44,6 +47,7 @@ func (s *AutomationRuleService) GetByID(ctx context.Context, id uint) (*Automati
if err := s.db.DB().WithContext(ctx).First(&rule, id).Error; err != nil {
return nil, err
}
_ = s.hydrateAutomationRuleFiles(ctx, &rule)
return &rule, nil
}
@@ -55,6 +59,7 @@ func (s *AutomationRuleService) GetByIDForAccount(ctx context.Context, accountID
First(&rule, id).Error; err != nil {
return nil, err
}
_ = s.hydrateAutomationRuleFiles(ctx, &rule)
return &rule, nil
}
@@ -67,6 +72,9 @@ func (s *AutomationRuleService) ListByAccount(ctx context.Context, accountID uin
Find(&rules).Error; err != nil {
return nil, err
}
for i := range rules {
_ = s.hydrateAutomationRuleFiles(ctx, &rules[i])
}
return rules, nil
}
@@ -91,16 +99,22 @@ func (s *AutomationRuleService) Create(ctx context.Context, rule *AutomationRule
if err := ValidateConditions(rule.Conditions); err != nil {
return fmt.Errorf("invalid conditions: %w", err)
}
if err := s.normalizeAutomationRuleAttachmentActions(ctx, rule.AccountID, &rule.Actions); err != nil {
return err
}
// Validate actions before saving
if err := ValidateActions(rule.Actions); err != nil {
return fmt.Errorf("invalid actions: %w", err)
}
// Use Select to force all fields including zero-value bool Active=false
// Without Select, GORM skips zero-value fields and uses column defaults.
return s.db.DB().WithContext(ctx).Select(
if err := s.db.DB().WithContext(ctx).Select(
"AccountID", "EventName", "Name", "Description",
"Conditions", "Actions", "Active",
).Create(rule).Error
).Create(rule).Error; err != nil {
return err
}
return s.hydrateAutomationRuleFiles(ctx, rule)
}
// Update updates an existing automation rule.
@@ -110,6 +124,9 @@ func (s *AutomationRuleService) Update(ctx context.Context, rule *AutomationRule
if err := ValidateConditions(rule.Conditions); err != nil {
return fmt.Errorf("invalid conditions: %w", err)
}
if err := s.normalizeAutomationRuleAttachmentActions(ctx, rule.AccountID, &rule.Actions); err != nil {
return err
}
// Validate actions before saving
if err := ValidateActions(rule.Actions); err != nil {
return fmt.Errorf("invalid actions: %w", err)
@@ -130,12 +147,100 @@ func (s *AutomationRuleService) UpdateForAccount(ctx context.Context, accountID
existing.Description = rule.Description
existing.EventName = rule.EventName
existing.Conditions = NormalizeConditions(rule.Conditions)
existing.Actions = rule.Actions
if rule.Actions != nil {
if err := s.normalizeAutomationRuleAttachmentActions(ctx, accountID, &rule.Actions); err != nil {
return err
}
existing.Actions = rule.Actions
}
existing.Active = rule.Active
return s.Update(ctx, &existing)
}
func (s *AutomationRuleService) normalizeAutomationRuleAttachmentActions(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")
}
upload, err := s.findAutomationRuleUpload(ctx, accountID, firstAutomationRuleBlobID(action.ActionParams))
if err != nil {
return fmt.Errorf("invalid attachment")
}
action.ActionParams["blob_id"] = upload.ID
delete(action.ActionParams, "attachment_url")
}
return nil
}
func firstAutomationRuleBlobID(params map[string]interface{}) interface{} {
if value := params["blob_id"]; value != nil {
return value
}
return params["attachment_url"]
}
func (s *AutomationRuleService) findAutomationRuleUpload(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.findAutomationRuleUploadByID(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.findAutomationRuleUploadByID(ctx, accountID, uint(v))
case int:
return s.findAutomationRuleUploadByID(ctx, accountID, uint(v))
case uint:
return s.findAutomationRuleUploadByID(ctx, accountID, v)
case json.Number:
parsed, err := strconv.ParseUint(string(v), 10, 64)
if err != nil {
return nil, err
}
return s.findAutomationRuleUploadByID(ctx, accountID, uint(parsed))
default:
return nil, gorm.ErrRecordNotFound
}
}
func (s *AutomationRuleService) findAutomationRuleUploadByID(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 *AutomationRuleService) hydrateAutomationRuleFiles(ctx context.Context, rule *AutomationRule) error {
ids := macroAttachmentBlobIDs(rule.Actions)
if len(ids) == 0 {
rule.Files = nil
return nil
}
var uploads []model.DirectUpload
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id IN ?", rule.AccountID, ids).Order("id ASC").Find(&uploads).Error; err != nil {
return err
}
files := make([]AutomationRuleFile, 0, len(uploads))
for _, upload := range uploads {
files = append(files, AutomationRuleFile{ID: upload.ID, AutomationRuleID: rule.ID, FileType: upload.MimeType, AccountID: upload.AccountID, FileURL: upload.FileURL, BlobID: upload.ID, Filename: upload.OriginalName})
}
rule.Files = files
return nil
}
// Delete soft-deletes an automation rule.
func (s *AutomationRuleService) Delete(ctx context.Context, id uint) error {
return s.db.DB().WithContext(ctx).Delete(&AutomationRule{}, id).Error
+68
View File
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"testing"
"github.com/gochat/gochat/internal/model"
)
func TestAutomationRuleService_Create(t *testing.T) {
@@ -34,6 +36,72 @@ func TestAutomationRuleService_Create(t *testing.T) {
}
}
func TestAutomationRuleService_CreateNormalizesAttachmentActionsAndHydratesFiles(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
db := dbProvider.DB()
accountID, _ := seedTestAccount(db, t)
upload := &model.DirectUpload{UploadUUID: "automation-service-upload", AccountID: accountID, Status: model.DirectUploadStatusPending, Source: model.DirectUploadSourceAccount, OriginalName: "rule.pdf", FileType: "file", MimeType: "application/pdf", FileSize: 789, FileURL: "/uploads/account/1/rule.pdf"}
if err := db.Create(upload).Error; err != nil {
t.Fatalf("create upload: %v", err)
}
svc := NewAutomationRuleService(dbProvider)
rule := &AutomationRule{
AccountID: accountID,
EventName: "conversation_created",
Name: "Send attachment",
Conditions: Conditions{},
Actions: Actions{{ActionName: "send_attachment", ActionParams: map[string]interface{}{"blob_id": "automation-service-upload", "attachment_url": "stale"}}},
Active: true,
}
if err := svc.Create(context.Background(), rule); err != nil {
t.Fatalf("create rule: %v", err)
}
if got := macroBlobIDAsUint(rule.Actions[0].ActionParams["blob_id"]); got != upload.ID {
t.Fatalf("expected blob id %d, got %d", upload.ID, got)
}
if _, ok := rule.Actions[0].ActionParams["attachment_url"]; ok {
t.Fatal("expected attachment_url to be removed")
}
if len(rule.Files) != 1 || rule.Files[0].BlobID != upload.ID || rule.Files[0].AutomationRuleID != rule.ID {
t.Fatalf("unexpected files payload: %#v", rule.Files)
}
}
func TestAutomationRuleService_MatchAndExecuteCreatesAttachmentMessages(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
db := dbProvider.DB()
accountID, _ := seedTestAccount(db, t)
inboxID := seedTestInbox(db, t, accountID)
contactID := seedTestContact(db, t, accountID)
conversationID := seedTestConversationWithDetails(db, t, accountID, inboxID, contactID, "open", "low", "web", 0)
upload := &model.DirectUpload{UploadUUID: "automation-exec-upload", AccountID: accountID, Status: model.DirectUploadStatusPending, Source: model.DirectUploadSourceAccount, OriginalName: "automation.pdf", FileType: "file", MimeType: "application/pdf", FileSize: 456, FileURL: "/uploads/account/1/automation.pdf"}
if err := db.Create(upload).Error; err != nil {
t.Fatalf("create upload: %v", err)
}
svc := NewAutomationRuleService(dbProvider)
rule := &AutomationRule{AccountID: accountID, EventName: "conversation_created", Name: "file rule", Conditions: Conditions{}, Actions: Actions{{ActionName: "send_attachment", ActionParams: map[string]interface{}{"blob_id": "automation-exec-upload"}}}, Active: true}
if err := svc.Create(context.Background(), rule); err != nil {
t.Fatalf("create rule: %v", err)
}
if err := svc.MatchAndExecute(context.Background(), accountID, "conversation_created", conversationID, map[string]interface{}{}); err != nil {
t.Fatalf("execute automation: %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)
}
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)
}
}
func TestAutomationRuleService_GetByID(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
accountID, _ := seedTestAccount(dbProvider.DB(), t)