From f7515b111d514bfa03447c28e10a2fb1bb6f917f Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 10:44:45 +0800 Subject: [PATCH] feat(automation): align attachment uploads --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 20 ++-- internal/automation/model.go | 29 +++-- internal/automation/service.go | 111 +++++++++++++++++- internal/automation/service_test.go | 68 +++++++++++ .../handler/api/v1/automation_rule_handler.go | 48 ++++++-- .../api/v1/automation_rule_handler_test.go | 73 +++++++++++- 6 files changed, 319 insertions(+), 30 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 3f8cd651..f50ce441 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,12 +49,12 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-07 P3.91 upload and macro attachment parity, committed as `168f120 feat(macros): align attachment uploads`. -- Latest implementation checkpoint: `168f120 feat(macros): align attachment uploads`. -- Latest documentation/tooling checkpoint: this tracker update records the committed baseline, the user-confirmed scope, and P3.92 automation-rule attachment parity as the next executable drift slice. No route artifacts change. +- Current tracking checkpoint: 2026-06-07 P3.92 automation-rule attachment parity, prepared as `feat(automation): align attachment uploads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(automation): align attachment uploads`. +- Latest documentation/tooling checkpoint: this tracker update records Chatwoot automation-rule attachment parity. No route artifacts change. - Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker. -- Worktree status at this implementation checkpoint: account upload now matches the reused Chatwoot helper by accepting multipart `attachment` and JSON `external_url`, returning raw `{ file_url, blob_id, blob_key }` without the local success/data envelope, and macro `send_attachment` actions now validate uploaded blob IDs, serialize `files`, and create outgoing attachment messages during macro execution. This retains P3.90 SearchAPI filter-surface parity, P3.89 fixed page size, P3.88 global result type scoping, P3.87 article payloads, P3.86 message payloads, P3.85 conversation payloads, P3.84 agent-sender filter parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. -- Next executable implementation checkpoint: P3.92 automation-rule attachment parity, found by the Phase 2/3 drift audit after P3.91. The slice must align `AutomationRulesController` with Chatwoot `AttachmentConcern`: validate `send_attachment` upload blobs, normalize action params, serialize `files`, preserve actions when update omits them, and prove execution creates outgoing attachment messages. Run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes. +- Worktree status at this implementation checkpoint: automation rules now share the Chatwoot `AttachmentConcern` contract already used by uploads/macros: `send_attachment` actions validate account uploads, normalize signed upload UUIDs or numeric blob IDs into stored `blob_id` values, serialize `files`, preserve existing actions when update omits `actions`, reject invalid blobs with raw `422`, and execute outgoing attachment messages with persisted attachment rows. This retains P3.91 upload/macro attachment parity, P3.90 SearchAPI filter-surface parity, P3.89 fixed page size, P3.88 global result type scoping, P3.87 article payloads, P3.86 message payloads, P3.85 conversation payloads, P3.84 agent-sender filter parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Next executable implementation checkpoint: continue Phase 2/3 drift audit for the next reused-frontend mismatch, or run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes. - `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full. - Route dump succeeds with `967` registered routes after profile MFA route tracking. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -96,7 +96,7 @@ Next ordered checkpoints: | Order | Slice | Required outcome | Primary verification | | --- | --- | --- | --- | -| 1 | P3.92 automation-rule attachment parity | Close the current drift between Chatwoot automation rules and GoChat for `send_attachment` uploads, serialized files, and update semantics. | Focused automation handler/service/action tests, full `go test ./...`, and `git diff --check`. | +| 1 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. | | 2 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. | | 3 | B12 optional live smoke | Run the checked smoke harness in a full PostgreSQL/Redis/Meilisearch/Vite/Chrome environment and turn failures into named slices. | `docs/parity/frontend_smoke_report.md` pass/fail entries linked to owners. | @@ -104,7 +104,7 @@ Execution queue for the next agent turn: | Order | Slice ID | Why now | Required commit contents | | --- | --- | --- | --- | -| 1 | P3.92 automation-rule attachment parity | P3.91 closed the shared upload/macro side, and fresh reference inspection shows automation rules use the same `AttachmentConcern` contract. | Code/tests/docs for automation `send_attachment` upload validation, `files` serialization, omitted-action update preservation, and attachment-message execution. | +| 1 | Phase 2/3 drift audit | Route parity is currently green for the tracked set, but only fresh reference/frontend inspection proves the next missing reused-frontend path. | Named drift rows with inspected reference files, fixture tests where behavior is known, regenerated route artifacts if the route set changes. | | 2 | B12 live smoke | The harness is checked in; live pass/fail still needs the full PostgreSQL/Redis/Meilisearch/Vite/Chrome stack. | Updated smoke report with command, environment, failures, and linked owner rows. | | Guard | Phase 6 placeholder audit | Current audit is clean for reused-frontend critical handlers; re-run after future route/smoke changes so no frontend-critical stub becomes ownerless. | `rg` audit result, either a burn-down implementation or explicit non-frontend fallback classification, focused tests if code changes. | @@ -156,7 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | -| 0 | P3.92 automation-rule attachment parity | Todo from fresh Phase 2/3 drift audit. Chatwoot automation rules include `AttachmentConcern`, `has_many_attached :files`, `send_attachment` action validation, signed/upload blob normalization, and `files` serialization; GoChat currently has macro-side parity but automation rules still need the same contract. | Implement create/update attachment validation and action normalization, serialize automation `files`, preserve existing actions when update omits `actions`, reject invalid blobs with raw `422`, and prove rule execution creates outgoing attachment messages through the existing action service. | Focused AutomationRuleHandler, AutomationRuleService, and ActionService tests must pass; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change unless automation routes are touched. | +| 0 | P3.92 automation-rule attachment parity | Implemented for reused automation settings flows: automation rule create/update validates `send_attachment` uploads, normalizes signed upload UUIDs or numeric IDs into stored blob IDs, serializes Chatwoot-style `files`, preserves existing actions when update omits `actions`, and rule execution creates outgoing attachment messages. | Keep in Review; reopen from automation attachment smoke, fresh ActiveStorage signed-id evidence, or reference changes in automation `AttachmentConcern` semantics. | Focused AutomationRuleHandler and AutomationRuleService tests passed; focused ActionService coverage is exercised through automation execution; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P3.91 upload and macro attachment parity | Implemented for reused `uploadHelper` and macro settings flows: account upload accepts Chatwoot `attachment` multipart fields plus `external_url` JSON bodies, returns raw blob payloads consumed by the frontend, and macro `send_attachment` actions resolve those blob IDs into `files` payload entries and executable attachment messages. | Keep in Review; reopen from macro/automation attachment smoke, external fetch edge cases, or fresh reference evidence for ActiveStorage signed-id semantics not covered by the current direct-upload bridge. | Focused UploadHandler, MacroHandler, MacroService, and macro execution tests passed, including the external-url test outside the restricted socket sandbox; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P3.90 SearchAPI filter-surface parity | Implemented for reused SearchAPI endpoints: only Chatwoot-supported search filters are allowed through. `since/until` date filters remain available across result families when advanced search is enabled; `from` and `inbox_id` are preserved only for message search; unsupported local filters are stripped before DB fallback and Meilisearch execution. Global search applies message-only filters only to message results, so conversations/contacts/articles are not narrowed by message inbox or sender filters. | Keep in Review; reopen from B12 advanced-search smoke or fresh reference evidence for new upstream filter params, Searchkick advanced-search extensions, or endpoint-specific filter drift. | Focused SearchHandler, SearchService, and Meilisearch filter-surface tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P3.89 SearchAPI fixed page-size parity | Implemented for reused SearchAPI endpoints: GoChat now ignores local `per_page` query params and forces Chatwoot's fixed `.per(15)` page size for global, conversation, message, contact, and article search before DB fallback or Meilisearch execution. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for endpoint-specific pagination changes, Kaminari default drift, or frontend callers adding explicit per-page support upstream. | Focused SearchHandler pagination tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | @@ -256,7 +256,7 @@ These rows are the executable development plan from this point forward. A checkp | ID | Owner files | Reference files | Work to land | Exit gate | | --- | --- | --- | --- | --- | -| P3.92 automation-rule attachment parity | `internal/handler/api/v1/automation_rule_handler.go`, `internal/automation/service.go`, `internal/automation/model.go`, `internal/automation/action_service.go`, automation rule handler/service/action tests | `reference/chatwoot/app/controllers/api/v1/accounts/automation_rules_controller.rb`, `reference/chatwoot/app/controllers/concerns/attachment_concern.rb`, `reference/chatwoot/app/models/automation_rule.rb`, `reference/chatwoot/app/views/api/v1/accounts/automation_rules/partials/_automation_rule.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/store/modules/automations.js`, `reference/chatwoot/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue` | Automations must share the Chatwoot attachment contract already implemented for uploads/macros: create/update validate `send_attachment` blobs, normalize signed upload UUIDs or numeric blob IDs to stored upload IDs in `action_params`, serialize rule `files` with `id`, `automation_rule_id`, `file_type`, `account_id`, `file_url`, `blob_id`, and `filename`, preserve existing actions when update omits `actions`, and let execution create outgoing attachment messages with persisted attachment rows. | Close with focused tests for create normalization/files serialization, invalid blob `422`, omitted-action update preservation, and execution attachment side effects; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change unless automation routes are modified. | +| P3.92 automation-rule attachment parity | `internal/handler/api/v1/automation_rule_handler.go`, `internal/automation/service.go`, `internal/automation/model.go`, `internal/automation/action_service.go`, automation rule handler/service/action tests | `reference/chatwoot/app/controllers/api/v1/accounts/automation_rules_controller.rb`, `reference/chatwoot/app/controllers/concerns/attachment_concern.rb`, `reference/chatwoot/app/models/automation_rule.rb`, `reference/chatwoot/app/views/api/v1/accounts/automation_rules/partials/_automation_rule.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/store/modules/automations.js`, `reference/chatwoot/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue` | Automation rules now share the Chatwoot attachment contract already implemented for uploads/macros: create/update validate `send_attachment` blobs, normalize signed upload UUIDs or numeric blob IDs to stored upload IDs in `action_params`, serialize rule `files` with `id`, `automation_rule_id`, `file_type`, `account_id`, `file_url`, `blob_id`, and `filename`, preserve existing actions when update omits `actions`, and execution creates outgoing attachment messages with persisted attachment rows. | Review by `feat(automation): align attachment uploads`; focused tests prove create normalization/files serialization, invalid blob `422`, omitted-action update preservation, and automation execution attachment side effects; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | P3.91 upload and macro attachment parity | `internal/handler/api/v1/upload_handler.go`, `internal/service/upload_service.go`, `internal/handler/api/v1/macro_handler.go`, `internal/automation/macro_service.go`, `internal/automation/action_service.go`, upload/macro tests | `reference/chatwoot/app/controllers/api/v1/accounts/upload_controller.rb`, `reference/chatwoot/app/controllers/concerns/attachment_concern.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/macros_controller.rb`, `reference/chatwoot/app/models/macro.rb`, `reference/chatwoot/app/services/macros/execution_service.rb`, `reference/chatwoot/app/javascript/dashboard/helper/uploadHelper.js`, macro settings helper/components | Account upload now follows Chatwoot's reused frontend helper contract: multipart field `attachment`, JSON `external_url`, raw `file_url/blob_id/blob_key` response. Macro create/update validates `send_attachment` blob IDs, normalizes signed upload UUIDs to stored blob IDs, serializes `files` with `blob_id`, `filename`, `file_url`, and macro execution creates outgoing messages with persisted attachment rows. | Review by `feat(macros): align attachment uploads`; focused tests prove upload helper payloads, invalid attachment rejection, macro file serialization, durable/synchronous macro execution, and attachment message creation; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | P3.90 SearchAPI filter-surface parity | `internal/handler/api/v1/search_handler.go`, `internal/search/search_service.go`, `internal/search/engine_meili.go`, SearchHandler/SearchService/Meilisearch tests | `reference/chatwoot/app/services/search_service.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/search_controller.rb`, `reference/chatwoot/app/javascript/dashboard/api/search.js`, `reference/chatwoot/app/javascript/dashboard/modules/search/components/SearchView.vue`, `reference/chatwoot/app/javascript/dashboard/modules/search/helpers/searchHelper.js` | SearchAPI filters now match the reused dashboard/reference surface: `since/until` are retained as advanced date filters; `from` and `inbox_id` are retained for message search only; local filters not present in Chatwoot `SearchService` (`status`, `priority`, `labels`, `assignee_id`, `team_id`, `message_type`, `content_type`, `private`, `contact_source`, `portal_id`, `article_status`, `locale`) are stripped by the handler. Global search applies message-only filters only to message results, including Meilisearch multi-type searches. | Review by `fix(search): align search filters`; focused tests prove conversation endpoints strip unsupported filters, message endpoints keep only `from/inbox_id`, global service filters per result type, and Meilisearch applies explicit inbox only to messages; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | P3.89 SearchAPI fixed page-size parity | `internal/handler/api/v1/search_handler.go`, `internal/handler/api/v1/search_handler_test.go` | `reference/chatwoot/app/services/search_service.rb`, `reference/chatwoot/app/controllers/api/v1/accounts/search_controller.rb`, `reference/chatwoot/app/javascript/dashboard/api/search.js` | SearchAPI handlers now match Chatwoot `SearchService` pagination by forcing `PerPage = 15` for global, conversation, message, contact, and article search regardless of local `per_page` query params. This keeps DB fallback and Meilisearch searches on the same fixed page-size contract used by every `page(params[:page]).per(15)` branch in the reference. | Review by `fix(search): align search page size`; focused SearchHandler tests prove explicit `per_page` on global/message search is ignored and the backend filter remains `15`; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | @@ -372,6 +372,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(automation): align attachment uploads` | Advances P3.92 with Chatwoot automation-rule attachment parity. Automation rule create/update now validates and normalizes `send_attachment` uploads like `AttachmentConcern`, exposes Chatwoot-style `files`, preserves existing actions when update omits `actions`, and execution creates outgoing attachment messages with persisted attachment rows. | Focused AutomationRuleHandler and AutomationRuleService tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.92 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `docs: land parity tracking handoff` | Documentation-only checkpoint after `168f120 feat(macros): align attachment uploads`. Records the committed baseline SHA, keeps user-confirmed scope and Hermes plan landing rules in this active tracker, and promotes P3.92 automation-rule attachment parity from audit evidence into the next executable slice. | `git diff --check`; no Go code or route artifacts changed. | Start P3.92 automation-rule attachment parity, then continue Phase 2/3 drift audit or B12 live smoke when the full stack is available. | | `feat(macros): align attachment uploads` | Advances P3.91 with Chatwoot account upload and macro attachment parity. Account upload now accepts the frontend `attachment` form key and `external_url` JSON body, returns raw blob fields, and macro attachment actions validate/normalize those blob IDs, expose Chatwoot-style `files`, and create outgoing attachment messages during execution. | Focused UploadHandler/MacroHandler/MacroService tests passed, including the external-url upload test outside the restricted socket sandbox; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.91 to Review; continue Phase 2/3 drift audit, B9.4 automation attachment depth if fresh smoke exposes it, Phase 6 placeholder audit, or B12 live smoke. | | `fix(search): align search filters` | Advances P3.90 with Chatwoot SearchAPI filter-surface parity. Search handlers now strip unsupported local filter params, retain only date filters plus message-only `from/inbox_id`, and global search applies those message-only filters only to message results in both DB fallback and Meilisearch paths. | `go test ./internal/handler/api/v1 ./internal/search -run 'TestSearchHandler_Search(Conversations_IgnoresUnsupportedSearchFilters\|Messages_KeepsOnlyReferenceAdvancedFilters)\|TestGlobalSearch_AppliesReferenceFiltersPerResultType\|TestMeiliSearchEngine_SearchAppliesExplicitInboxOnlyToMessages' -count=1` passed with workspace-local temp/cache dirs; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.90 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | @@ -2748,3 +2749,4 @@ Verification milestone gates: - 2026-06-07: P3.90 SearchAPI filter-surface checkpoint prepared as `fix(search): align search filters`; audited Chatwoot `SearchService`, `SearchController`, reused dashboard `SearchAPI`, `SearchView.vue`, and search helper URL param generation. GoChat SearchAPI handlers now strip unsupported local filter params, retain `since/until` date filters, keep `from/inbox_id` only for message search, and make global search apply message-only filters only to message results across DB fallback and Meilisearch execution. Focused SearchHandler/SearchService/Meilisearch tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.91 upload and macro attachment checkpoint prepared as `feat(macros): align attachment uploads`; audited Chatwoot `UploadController`, `AttachmentConcern`, `MacrosController`, `Macro#file_base_data`, `Macros::ExecutionService#send_attachment`, and reused dashboard `uploadHelper` plus macro attachment helpers. GoChat account upload now accepts `attachment` multipart fields and `external_url` JSON, returns raw `file_url/blob_id/blob_key`, normalizes macro `send_attachment` actions from upload UUIDs to stored blob IDs, serializes macro `files`, rejects invalid blobs with raw `422`, and creates outgoing attachment messages from macro execution. Focused UploadHandler/MacroHandler/MacroService tests passed, including external-url upload outside the restricted socket sandbox; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: Documentation checkpoint prepared as `docs: land parity tracking handoff`; confirms `168f120 feat(macros): align attachment uploads` is committed, records the user-confirmed scope and Hermes plan landing rules in this active tracker, and opens P3.92 automation-rule attachment parity as the next executable slice from fresh Chatwoot `AutomationRulesController`, `AttachmentConcern`, automation model/Jbuilder, and reused automation frontend inspection. Verification for this docs-only checkpoint: `git diff --check`; no Go code or route artifacts changed. +- 2026-06-07: P3.92 automation-rule attachment checkpoint prepared as `feat(automation): align attachment uploads`; audited Chatwoot `AutomationRulesController`, `AttachmentConcern`, `AutomationRule#file_base_data`, automation rule Jbuilder partial, and reused dashboard automation upload/form flows. GoChat automation rule create/update now validates `send_attachment` direct uploads, normalizes upload UUIDs or numeric blob IDs into stored `blob_id` values, serializes `files` with `automation_rule_id`, rejects invalid blobs with raw `422`, preserves existing actions when update omits `actions`, and automation execution creates outgoing attachment messages with persisted attachment rows. Focused AutomationRuleHandler/AutomationRuleService tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. diff --git a/internal/automation/model.go b/internal/automation/model.go index 60244fcd..3f73accd 100644 --- a/internal/automation/model.go +++ b/internal/automation/model.go @@ -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 // =========================== diff --git a/internal/automation/service.go b/internal/automation/service.go index ba68695b..ec5a33d6 100644 --- a/internal/automation/service.go +++ b/internal/automation/service.go @@ -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 diff --git a/internal/automation/service_test.go b/internal/automation/service_test.go index 2085e9d7..62e350a6 100644 --- a/internal/automation/service_test.go +++ b/internal/automation/service_test.go @@ -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) diff --git a/internal/handler/api/v1/automation_rule_handler.go b/internal/handler/api/v1/automation_rule_handler.go index 149b8dfa..945015bb 100644 --- a/internal/handler/api/v1/automation_rule_handler.go +++ b/internal/handler/api/v1/automation_rule_handler.go @@ -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 { diff --git a/internal/handler/api/v1/automation_rule_handler_test.go b/internal/handler/api/v1/automation_rule_handler_test.go index 8d7ca985..e61aeea3 100644 --- a/internal/handler/api/v1/automation_rule_handler_test.go +++ b/internal/handler/api/v1/automation_rule_handler_test.go @@ -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()