From ca3c045f21d71347a48ca1cdcff4787de6ebed43 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 03:09:54 +0800 Subject: [PATCH] feat(campaigns): align chatwoot payloads --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 14 +- docs/parity/gochat_routes.txt | 5 +- internal/campaign/model.go | 3 + internal/campaign/service.go | 8 + internal/handler/api/v1/campaign_handler.go | 79 +++++++++- .../handler/api/v1/campaign_handler_test.go | 146 +++++++----------- internal/repository/campaign_repo.go | 35 ++++- internal/router/router.go | 3 + internal/service/campaign_service.go | 100 +++++++----- 9 files changed, 242 insertions(+), 151 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 196cd240..78f125e2 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,12 +16,12 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Current tracking checkpoint: 2026-06-06 after `d49aafd feat(agents): align index pagination`, with this implementation checkpoint prepared as `feat(notification-settings): align chatwoot payloads`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(notification-settings): align chatwoot payloads`. +- Current tracking checkpoint: 2026-06-06 after `4b2c1a9 feat(notification-settings): align chatwoot payloads`, with this implementation checkpoint prepared as `feat(campaigns): align chatwoot payloads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(campaigns): align chatwoot payloads`. - Latest documentation-only checkpoint: `8b378c7 docs: refresh parity tracker baseline`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this implementation checkpoint: User notification settings now match Chatwoot `NotificationSettingsController#show/#update` and `show.json.jbuilder`: account-scoped `GET/PATCH /notification_settings` accepts the reused frontend no-trailing-slash URL, update accepts nested `notification_settings`, and both actions return the raw notification-setting object without local `{ success, data }` or `notification_setting` wrappers. Agent index pagination already matches Chatwoot `AgentsController#index`, which renders `Current.account.users.order_by_full_name` without `.page/.per`: reused frontend `page/per_page` query values are ignored and the endpoint returns the full account-agent array ordered by full name. Agent update blank-name validation already matches Chatwoot `User#update!` plus `RequestExceptionHandler#render_record_invalid`: explicit blank `agent[name]` returns `422 { message: "Name can't be blank", attributes: ["name"] }` and leaves the user name unchanged. Agent delete/create/bulk-create drifts, account attachment pagination, profile account permissions, platform account-user membership, B10.3 CustomRole frontend update, contacts pagination/active payloads, conversation transcript, and custom-attribute response drifts are already closed. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is the next named Phase 2/3 or Phase 6 drift from fresh reference/smoke evidence. +- Worktree status at this implementation checkpoint: Account campaigns now match the reused dashboard `campaigns` store and Chatwoot `CampaignsController`/Jbuilder contract for the core CRUD path: index returns a raw unpaginated array, show/create/update return raw campaign objects with `id` mapped from `display_id`, nested inbox/sender data, parsed JSON `audience/trigger_rules/template_params`, create returns `200 OK`, delete returns empty `200 OK`, PATCH update is registered for `ApiClient.update`, and no-trailing-slash index/create routes are registered for `ApiClient` URLs. User notification settings already match Chatwoot `NotificationSettingsController#show/#update` and `show.json.jbuilder`: account-scoped `GET/PATCH /notification_settings` accepts the reused frontend no-trailing-slash URL, update accepts nested `notification_settings`, and both actions return the raw notification-setting object without local `{ success, data }` or `notification_setting` wrappers. Agent index/update/delete/create/bulk-create drifts, account attachment pagination, profile account permissions, platform account-user membership, B10.3 CustomRole frontend update, contacts pagination/active payloads, conversation transcript, and custom-attribute response drifts are already closed. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is the next named Phase 2/3 or Phase 6 drift from fresh reference/smoke evidence. - `go test ./...` passes. -- Route dump succeeds with `TOTAL: 843` after adding no-trailing-slash notification-settings route aliases for the reused frontend. +- Route dump succeeds with `TOTAL: 846` after adding no-trailing-slash campaign index/create aliases and the frontend-used campaign PATCH update route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. - Tracked frontend-critical route audit covers 277 Chatwoot routes: 270 exact, 0 method-compatible, 7 parameter-compatible, 0 missing. The 7 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits; the external URL shape is equivalent. - `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers. @@ -87,7 +87,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | -| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current critical set; notification list/action serializers, user notification-settings raw payloads, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, label CRUD payloads, custom filters, and custom attribute definitions now match the inspected Chatwoot frontend contract. | Continue Phase 6 placeholder audit, then open the next named serializer/controller drift slice from reference/smoke evidence. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | +| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current critical set; notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, label CRUD payloads, custom filters, and custom attribute definitions now match the inspected Chatwoot frontend contract. | Continue Phase 6 placeholder audit, then open the next named serializer/controller drift slice from reference/smoke evidence. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | | 2 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; fresh `rg` audit shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript response drift is closed. | Continue targeted account/contact/conversation/message/inbox drift from reference/smoke evidence. | `rg` placeholder audit is recorded and no reused-frontend blocker is ownerless. | | 3 | B12 optional live smoke | API/browser/enterprise smoke commands are checked in; live runs need PostgreSQL, Redis, Meilisearch, Vite, and Chrome. | Run full live smoke when environment is available and map failures to the board. | `docs/parity/frontend_smoke_report.md` records pass/fail and linked owners. | | 4 | B9.3 delayed automation actions | Current reference exposes no delayed automation action params; scheduled-item work is already P5.12; `send_email_to_team` is durable and `add_sla` mutates conversation/applied SLA state. | Keep automation drift closed if future reference/smoke exposes delayed params or unsupported action shapes. | Automation worker/action fixtures verify queued team email replay, retry visibility through worker jobs, and SLA action idempotency. | @@ -143,6 +143,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(campaigns): align chatwoot payloads` | Advances P3.7 campaign/settings parity by matching Chatwoot `CampaignsController#index/show/create/update/destroy`, `_campaign.json.jbuilder`, and the reused dashboard `campaigns` API/store. Account campaigns now return raw arrays/objects instead of local success/data/meta envelopes, ignore frontend `page/per_page`, use `display_id` as the external `id`, resolve show/update/delete/start/stop through display ID with legacy primary-key fallback, serialize nested inbox/sender data plus parsed JSON `audience`, `trigger_rules`, and `template_params`, create returns `200 OK`, delete returns empty `200 OK`, and the frontend-used no-trailing-slash index/create plus `PATCH` update routes are registered. | `go test ./internal/handler/api/v1 -run CampaignHandler -count=1`; `go test ./internal/service ./internal/repository -run Campaign -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/repository ./internal/router -run 'Campaign\|Router' -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 846`; tracked route parity remains `270 exact, 7 parameter-compatible, 0 missing`. | Continue Phase 6 placeholder audit or the next named Phase 2/3 drift from reference/smoke evidence. | | `feat(notification-settings): align chatwoot payloads` | Advances P3.1 auth/profile settings parity by matching Chatwoot `NotificationSettingsController#show/#update`, `show.json.jbuilder`, and reused dashboard `userNotificationSettings` store/API. Account notification-settings show/update now return the raw object with `id`, `user_id`, `account_id`, `all_email_flags`, `selected_email_flags`, `all_push_flags`, and `selected_push_flags`; the local `{ success, data: { notification_setting } }` wrapper is removed, nested update params are retained, and no-trailing-slash route aliases are registered for the frontend `ApiClient` URL. | `go test ./internal/handler/api/v1 -run NotificationSetting -count=1`; `go test ./internal/service -run NotificationSetting -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 843`; tracked route parity remains `270 exact, 7 parameter-compatible, 0 missing`. | Continue Phase 6 placeholder audit or the next named Phase 2/3 drift from reference/smoke evidence. | | `feat(agents): align index pagination` | Advances P3.2 agents/settings parity by matching Chatwoot `AgentsController#index`, which assigns `@agents = agents` from `Current.account.users.order_by_full_name.includes(...)` without pagination. Account agent lists now ignore reused-frontend `page/per_page` query values, return the full account-agent array, and retain `lower(users.name) ASC` ordering plus account-user role, availability, auto-offline, inviter, account-user ID, and custom-role metadata. | `go test ./internal/handler/api/v1 -run 'TestAgentHandlerSuite/TestList' -count=1`; `go test ./internal/service ./internal/repository -run Agent -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/repository -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 840`. | Continue P3.2 agents/account-user drift or the next Phase 6 reference/smoke finding. | | `feat(agents): align update validation errors` | Advances P3.2 agents/settings parity by matching Chatwoot `AgentsController#update`, `User#update!`, and `RequestExceptionHandler#render_record_invalid`. Explicit blank `agent[name]` updates now return `422 { message: "Name can't be blank", attributes: ["name"] }` and preserve the existing user name instead of silently ignoring the invalid value with a `200 OK`. | `go test ./internal/handler/api/v1 -run 'TestAgentHandlerSuite/TestUpdateAgent' -count=1`; `go test ./internal/service -run Agent -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/repository -count=1`; `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 840`. | Continue P3.2 agents/account-user drift or the next Phase 6 reference/smoke finding. | @@ -1575,7 +1576,7 @@ Frontend-critical API groups to audit first: | P3.4 | Conversations/messages | List filters, status changes, assignment, labels, private notes, attachments, drafts, typing/read events. | Review | | P3.5 | Contacts/companies | CRUD, merge, labels, notes, custom attributes, import/export, conversations relation, and shared attachments. | Doing | | P3.6 | Labels/custom attributes/custom filters | Create/update/list behavior and exact response shapes. | Doing; label CRUD is in Review with Chatwoot `title/description/color/show_on_sidebar` payloads and raw/list response contracts, while custom attributes/custom filters remain evidence-driven follow-ups. | -| P3.7 | Notifications/reports/help center/campaigns | Frontend-visible payloads and pagination/error envelopes. | Doing; notifications and reports now have focused Chatwoot payload fixtures, help center/campaigns remain broader audit scope. | +| P3.7 | Notifications/reports/help center/campaigns | Frontend-visible payloads and pagination/error envelopes. | Doing; notifications, reports, and account campaign CRUD now have focused Chatwoot payload fixtures, while help center and deeper campaign side effects remain broader audit scope. | | P3.8 | Widget/public APIs | Widget init, campaigns, config, contact, conversations, messages, direct uploads, public inbox flow, public CSAT. | Doing | | P3.9 | Search payloads | Global search and entity search documents backed by Meilisearch. | Review | @@ -2084,6 +2085,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-06: P3.7 campaigns checkpoint prepared as `feat(campaigns): align chatwoot payloads`; audited Chatwoot `CampaignsController`, campaign Jbuilder views, `_campaign.json.jbuilder`, and the reused dashboard campaigns API/store. Account campaigns now return raw Chatwoot arrays/objects, ignore `page/per_page`, use `display_id` as API `id`, resolve route IDs by display ID with legacy primary-key fallback, serialize nested inbox/sender data and parsed campaign JSON fields, accept object/array campaign params, return `200 OK` for create/delete, and register frontend-used no-trailing-slash index/create plus `PATCH` update routes. Focused campaign handler tests, service/repository campaign tests, combined handler/service/repository/router tests, route dump/parity, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit or the next named Phase 2/3 drift. - 2026-06-06: P3.1 notification-settings checkpoint prepared as `feat(notification-settings): align chatwoot payloads`; audited Chatwoot `NotificationSettingsController#show/#update`, `notification_settings/show.json.jbuilder`, and the reused dashboard `userNotificationSettings` API/store. Account-scoped notification settings now return the raw Jbuilder object with flag arrays instead of the local success/data wrapper, keep nested `notification_settings` update params, and register no-trailing-slash GET/PATCH/PUT route aliases for the frontend client URL. Focused handler/service tests, combined handler/service/router tests, route dump/parity, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit or the next named Phase 2/3 drift. - 2026-06-06: P3.2 agent index-pagination checkpoint prepared as `feat(agents): align index pagination`; audited Chatwoot `AgentsController#index`, `agents`, and `User.order_by_full_name`. Account agent lists now ignore reused-frontend `page/per_page` query values and return the full account-agent array like Chatwoot's unpaginated `@agents = agents`, while preserving lowercased full-name ordering and account-user metadata. Focused list tests, service/repository agent tests, combined handler/service/repository tests, full `go test ./...`, and `git diff --check` passed; continue P3.2 agents/account-user drift or the next Phase 6 reference/smoke finding. - 2026-06-06: P3.2 agent update-validation checkpoint prepared as `feat(agents): align update validation errors`; audited Chatwoot `AgentsController#update`, `User` name presence validation, and `RequestExceptionHandler#render_record_invalid`. Explicit blank `agent[name]` updates now return Chatwoot `422 { message, attributes }`, preserve the existing user name, and continue to allow omitted names plus explicit `auto_offline: false`. Focused update tests, service agent tests, combined handler/service/repository tests, full `go test ./...`, and `git diff --check` passed; continue P3.2 agents/account-user drift or the next Phase 6 reference/smoke finding. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 7591cf71..81d0334d 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -145,6 +145,7 @@ GET /api/v1/accounts/:account_id/automation_rules/ GET /api/v1/accounts/:account_id/automation_rules/:automation_id GET /api/v1/accounts/:account_id/banners GET /api/v1/accounts/:account_id/cache_keys +GET /api/v1/accounts/:account_id/campaigns GET /api/v1/accounts/:account_id/campaigns/ GET /api/v1/accounts/:account_id/campaigns/:campaign_id GET /api/v1/accounts/:account_id/canned_responses/ @@ -450,6 +451,7 @@ PATCH /api/v1/accounts/:account_id/agent_bot_inboxes/:agent_bot_inbox_id/status PATCH /api/v1/accounts/:account_id/agent_capacity_policies/:id PATCH /api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id PATCH /api/v1/accounts/:account_id/agents/:agent_id +PATCH /api/v1/accounts/:account_id/campaigns/:campaign_id PATCH /api/v1/accounts/:account_id/channels/facebook_channel/:fb_id PATCH /api/v1/accounts/:account_id/companies/:company_id PATCH /api/v1/accounts/:account_id/contacts/:contact_id @@ -516,6 +518,7 @@ POST /api/v1/accounts/:account_id/automation_rules/ POST /api/v1/accounts/:account_id/automation_rules/:automation_id/clone POST /api/v1/accounts/:account_id/automation_rules/:automation_id/toggle_active POST /api/v1/accounts/:account_id/bulk_actions +POST /api/v1/accounts/:account_id/campaigns POST /api/v1/accounts/:account_id/campaigns/ POST /api/v1/accounts/:account_id/campaigns/:campaign_id/start POST /api/v1/accounts/:account_id/campaigns/:campaign_id/stop @@ -841,4 +844,4 @@ PUT /public/api/v1/csat_survey/:id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id PUT /widget/direct_uploads/:upload_uuid -TOTAL: 843 +TOTAL: 846 diff --git a/internal/campaign/model.go b/internal/campaign/model.go index a15c5dbc..884f6559 100644 --- a/internal/campaign/model.go +++ b/internal/campaign/model.go @@ -45,6 +45,9 @@ type Campaign struct { ScheduledAt *time.Time `gorm:"index" json:"scheduled_at,omitempty"` Enabled bool `gorm:"default:true" json:"enabled"` TriggerOnlyDuringBusinessHours bool `gorm:"default:false" json:"trigger_only_during_business_hours"` + + Inbox model.Inbox `gorm:"foreignKey:InboxID" json:"-"` + Sender *model.User `gorm:"foreignKey:SenderID" json:"-"` } func (Campaign) TableName() string { return "campaigns" } diff --git a/internal/campaign/service.go b/internal/campaign/service.go index 444e660c..96c2d63e 100644 --- a/internal/campaign/service.go +++ b/internal/campaign/service.go @@ -22,6 +22,14 @@ func NewCampaignService(db *gorm.DB) *CampaignService { // Create creates a new campaign. func (s *CampaignService) Create(ctx context.Context, campaign *Campaign) error { + if campaign.DisplayID == 0 { + var next uint + if err := s.db.WithContext(ctx).Model(&Campaign{}). + Select("COALESCE(MAX(display_id), 0) + 1").Scan(&next).Error; err != nil { + return err + } + campaign.DisplayID = next + } return s.db.WithContext(ctx).Create(campaign).Error } diff --git a/internal/handler/api/v1/campaign_handler.go b/internal/handler/api/v1/campaign_handler.go index 282374eb..51e270e1 100644 --- a/internal/handler/api/v1/campaign_handler.go +++ b/internal/handler/api/v1/campaign_handler.go @@ -1,14 +1,16 @@ package v1 import ( + "encoding/json" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/campaign" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" - "github.com/gochat/gochat/pkg/pagination" "github.com/gochat/gochat/pkg/response" ) @@ -32,15 +34,14 @@ func (h *CampaignHandler) List(c *gin.Context) { return } - pg := pagination.Parse(c) - campaigns, total, err := h.svc.List(c.Request.Context(), accountID, pg.Offset, pg.PerPage) + campaigns, _, err := h.svc.List(c.Request.Context(), accountID, 0, 0) if err != nil { applogger.L().Errorf("List campaigns for account %d: %v", accountID, err) handleServiceError(c, err) return } - response.OKWithMeta(c, campaigns, pg.Page, pg.PerPage, total) + c.JSON(http.StatusOK, serializeCampaigns(campaigns, accountID)) } // Get returns a single campaign by ID. @@ -65,7 +66,7 @@ func (h *CampaignHandler) Get(c *gin.Context) { return } - response.OK(c, campaign) + c.JSON(http.StatusOK, serializeCampaign(campaign, accountID)) } // Create creates a new campaign within an account. @@ -94,7 +95,7 @@ func (h *CampaignHandler) Create(c *gin.Context) { return } - response.Created(c, campaign) + c.JSON(http.StatusOK, serializeCampaign(campaign, accountID)) } // Update updates an existing campaign. @@ -129,7 +130,7 @@ func (h *CampaignHandler) Update(c *gin.Context) { return } - response.OK(c, campaign) + c.JSON(http.StatusOK, serializeCampaign(campaign, accountID)) } // Delete soft-deletes a campaign. @@ -153,7 +154,7 @@ func (h *CampaignHandler) Delete(c *gin.Context) { return } - response.NoContent(c) + c.Status(http.StatusOK) } // Start triggers a campaign execution. @@ -180,6 +181,68 @@ func (h *CampaignHandler) Start(c *gin.Context) { response.OK(c, gin.H{"message": "campaign triggered successfully"}) } +func serializeCampaigns(campaigns []campaign.Campaign, accountID uint) []map[string]any { + payload := make([]map[string]any, 0, len(campaigns)) + for i := range campaigns { + payload = append(payload, serializeCampaign(&campaigns[i], accountID)) + } + return payload +} + +func serializeCampaign(item *campaign.Campaign, accountID uint) map[string]any { + if item == nil { + return map[string]any{} + } + id := item.DisplayID + if id == 0 { + id = item.ID + } + payload := map[string]any{ + "id": id, + "title": item.Title, + "description": item.Description, + "account_id": item.AccountID, + "inbox": nil, + "sender": nil, + "message": item.Message, + "template_params": campaignJSONValue(item.TemplateParams), + "campaign_status": item.CampaignStatus, + "enabled": item.Enabled, + "campaign_type": item.CampaignType, + "trigger_rules": campaignJSONValue(item.TriggerRules), + "trigger_only_during_business_hours": item.TriggerOnlyDuringBusinessHours, + "created_at": item.CreatedAt, + "updated_at": item.UpdatedAt, + } + if item.Inbox.ID != 0 { + payload["inbox"] = serializeInbox(&item.Inbox) + } + if item.Sender != nil && item.Sender.ID != 0 { + payload["sender"] = serializeAgentUser(item.Sender, accountID, "", "", false, 0) + } + if item.CampaignType == campaign.CampaignTypeOneOff { + if item.ScheduledAt != nil { + payload["scheduled_at"] = item.ScheduledAt.Unix() + } else { + payload["scheduled_at"] = nil + } + payload["audience"] = campaignJSONValue(item.Audience) + } + return payload +} + +func campaignJSONValue(raw string) any { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return map[string]any{} + } + var value any + if err := json.Unmarshal([]byte(trimmed), &value); err != nil { + return trimmed + } + return value +} + // Stop marks a campaign as completed. // POST /api/v1/accounts/:id/campaigns/:campaign_id/stop func (h *CampaignHandler) Stop(c *gin.Context) { diff --git a/internal/handler/api/v1/campaign_handler_test.go b/internal/handler/api/v1/campaign_handler_test.go index cd3e9935..6e7eb1f8 100644 --- a/internal/handler/api/v1/campaign_handler_test.go +++ b/internal/handler/api/v1/campaign_handler_test.go @@ -83,6 +83,7 @@ func (s *CampaignHandlerTestSuite) SetupSuite() { campaigns.GET("", s.handler.List) campaigns.GET("/:campaign_id", s.handler.Get) campaigns.POST("", s.handler.Create) + campaigns.PATCH("/:campaign_id", s.handler.Update) campaigns.PUT("/:campaign_id", s.handler.Update) campaigns.DELETE("/:campaign_id", s.handler.Delete) campaigns.POST("/:campaign_id/start", s.handler.Start) @@ -171,19 +172,12 @@ func (s *CampaignHandlerTestSuite) TestList_Success() { s.Equal(http.StatusOK, w.Code) - var resp struct { - Success bool `json:"success"` - Data []campaign.Campaign `json:"data"` - Meta struct { - Page int `json:"page"` - PerPage int `json:"per_page"` - TotalCount int64 `json:"total_count"` - } `json:"meta"` - } + var resp []map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp.Success) - s.Len(resp.Data, 2) - s.Equal(int64(2), resp.Meta.TotalCount) + s.Len(resp, 2) + s.NotContains(resp[0], "success") + s.NotContains(resp[0], "data") + s.Contains(resp[0], "inbox") } func (s *CampaignHandlerTestSuite) TestList_Empty() { @@ -193,17 +187,9 @@ func (s *CampaignHandlerTestSuite) TestList_Empty() { s.Equal(http.StatusOK, w.Code) - var resp struct { - Success bool `json:"success"` - Data []campaign.Campaign `json:"data"` - Meta struct { - TotalCount int64 `json:"total_count"` - } `json:"meta"` - } + var resp []map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp.Success) - s.Empty(resp.Data) - s.Equal(int64(0), resp.Meta.TotalCount) + s.Empty(resp) } func (s *CampaignHandlerTestSuite) TestList_Unauthorized() { @@ -225,19 +211,18 @@ func (s *CampaignHandlerTestSuite) TestGet_Success() { c := s.seedCampaign("GetTest Campaign", "Test message", "ongoing") w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", s.accountURL()+"/"+strconv.FormatUint(uint64(c.ID), 10), nil) + req, _ := http.NewRequest("GET", s.accountURL()+"/"+strconv.FormatUint(uint64(c.DisplayID), 10), nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) - var resp struct { - Success bool `json:"success"` - Data campaign.Campaign `json:"data"` - } + var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp.Success) - s.Equal(c.Title, resp.Data.Title) - s.Equal(c.ID, resp.Data.ID) + s.NotContains(resp, "success") + s.NotContains(resp, "data") + s.Equal(c.Title, resp["title"]) + s.Equal(float64(c.DisplayID), resp["id"]) + s.Contains(resp, "inbox") } func (s *CampaignHandlerTestSuite) TestGet_NotFound() { @@ -272,11 +257,14 @@ func (s *CampaignHandlerTestSuite) TestGet_Unauthorized() { func (s *CampaignHandlerTestSuite) TestCreate_Success() { body := map[string]interface{}{ - "inbox_id": s.inbox.ID, - "title": "New Campaign", - "message": "Hello from campaign", - "campaign_type": "ongoing", - "enabled": true, + "inbox_id": s.inbox.ID, + "title": "New Campaign", + "message": "Hello from campaign", + "campaign_type": "one_off", + "enabled": true, + "audience": []map[string]interface{}{{"type": "Label", "id": 1}}, + "trigger_rules": map[string]interface{}{"url": "https://example.com"}, + "template_params": map[string]interface{}{"name": "value"}, } bodyBytes := marshalNested("campaign", body) @@ -285,17 +273,17 @@ func (s *CampaignHandlerTestSuite) TestCreate_Success() { req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) - s.Equal(http.StatusCreated, w.Code) + s.Equal(http.StatusOK, w.Code) - var resp struct { - Success bool `json:"success"` - Data campaign.Campaign `json:"data"` - } + var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp.Success) - s.Equal("New Campaign", resp.Data.Title) - s.Equal(s.account.ID, resp.Data.AccountID) - s.Equal(s.inbox.ID, resp.Data.InboxID) + s.NotContains(resp, "success") + s.NotContains(resp, "data") + s.Equal("New Campaign", resp["title"]) + s.Equal(float64(s.account.ID), resp["account_id"]) + s.Greater(resp["id"].(float64), float64(0)) + s.Contains(resp, "audience") + s.Contains(resp, "template_params") } func (s *CampaignHandlerTestSuite) TestCreate_ValidationError() { @@ -355,19 +343,16 @@ func (s *CampaignHandlerTestSuite) TestUpdate_Success() { bodyBytes := marshalNested("campaign", body) w := httptest.NewRecorder() - req, _ := http.NewRequest("PUT", s.accountURL()+"/"+strconv.FormatUint(uint64(c.ID), 10), bytes.NewReader(bodyBytes)) + req, _ := http.NewRequest("PATCH", s.accountURL()+"/"+strconv.FormatUint(uint64(c.DisplayID), 10), bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) - var resp struct { - Success bool `json:"success"` - Data campaign.Campaign `json:"data"` - } + var resp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp.Success) - s.Equal("Updated Title", resp.Data.Title) + s.NotContains(resp, "success") + s.Equal("Updated Title", resp["title"]) } func (s *CampaignHandlerTestSuite) TestUpdate_NotFound() { @@ -423,10 +408,10 @@ func (s *CampaignHandlerTestSuite) TestDelete_Success() { c := s.seedCampaign("DeleteTest Campaign", "Test message", "ongoing") w := httptest.NewRecorder() - req, _ := http.NewRequest("DELETE", s.accountURL()+"/"+strconv.FormatUint(uint64(c.ID), 10), nil) + req, _ := http.NewRequest("DELETE", s.accountURL()+"/"+strconv.FormatUint(uint64(c.DisplayID), 10), nil) s.router.ServeHTTP(w, req) - s.Equal(http.StatusNoContent, w.Code) + s.Equal(http.StatusOK, w.Code) // Verify the campaign is soft-deleted var count int64 @@ -439,9 +424,7 @@ func (s *CampaignHandlerTestSuite) TestDelete_NotFound() { req, _ := http.NewRequest("DELETE", s.accountURL()+"/99999", nil) s.router.ServeHTTP(w, req) - // GORM soft-delete on a non-existent ID succeeds silently (0 rows affected is not an error), - // so the handler returns 204 No Content rather than 404/500. - s.Equal(http.StatusNoContent, w.Code) + s.Equal(http.StatusNotFound, w.Code) } func (s *CampaignHandlerTestSuite) TestDelete_InvalidID() { @@ -595,11 +578,11 @@ func (s *CampaignHandlerTestSuite) TestLifecycle_StartThenStop() { func (s *CampaignHandlerTestSuite) TestCRUD_FullLifecycle() { // Create createBody := map[string]interface{}{ - "inbox_id": s.inbox.ID, - "title": "Lifecycle Campaign", - "message": "Test lifecycle message", - "campaign_type": "one_off", - "enabled": true, + "inbox_id": s.inbox.ID, + "title": "Lifecycle Campaign", + "message": "Test lifecycle message", + "campaign_type": "one_off", + "enabled": true, } createBytes := marshalNested("campaign", createBody) @@ -607,15 +590,11 @@ func (s *CampaignHandlerTestSuite) TestCRUD_FullLifecycle() { req, _ := http.NewRequest("POST", s.accountURL(), bytes.NewReader(createBytes)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) - s.Equal(http.StatusCreated, w.Code) + s.Equal(http.StatusOK, w.Code) - var createResp struct { - Success bool `json:"success"` - Data campaign.Campaign `json:"data"` - } + var createResp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &createResp)) - s.True(createResp.Success) - createdID := createResp.Data.ID + createdID := uint(createResp["id"].(float64)) // Get w = httptest.NewRecorder() @@ -623,12 +602,9 @@ func (s *CampaignHandlerTestSuite) TestCRUD_FullLifecycle() { s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) - var getResp struct { - Success bool `json:"success"` - Data campaign.Campaign `json:"data"` - } + var getResp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &getResp)) - s.Equal("Lifecycle Campaign", getResp.Data.Title) + s.Equal("Lifecycle Campaign", getResp["title"]) // Update updateBody := map[string]interface{}{ @@ -638,17 +614,14 @@ func (s *CampaignHandlerTestSuite) TestCRUD_FullLifecycle() { updateBytes := marshalNested("campaign", updateBody) w = httptest.NewRecorder() - req, _ = http.NewRequest("PUT", s.accountURL()+"/"+strconv.FormatUint(uint64(createdID), 10), bytes.NewReader(updateBytes)) + req, _ = http.NewRequest("PATCH", s.accountURL()+"/"+strconv.FormatUint(uint64(createdID), 10), bytes.NewReader(updateBytes)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) - var updateResp struct { - Success bool `json:"success"` - Data campaign.Campaign `json:"data"` - } + var updateResp map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &updateResp)) - s.Equal("Updated Lifecycle", updateResp.Data.Title) + s.Equal("Updated Lifecycle", updateResp["title"]) // List (should include our campaign) w = httptest.NewRecorder() @@ -656,22 +629,15 @@ func (s *CampaignHandlerTestSuite) TestCRUD_FullLifecycle() { s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) - var listResp struct { - Success bool `json:"success"` - Data []campaign.Campaign `json:"data"` - Meta struct { - TotalCount int64 `json:"total_count"` - } `json:"meta"` - } + var listResp []map[string]interface{} s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &listResp)) - s.True(listResp.Success) - s.GreaterOrEqual(int64(len(listResp.Data)), int64(1)) + s.GreaterOrEqual(len(listResp), 1) // Delete w = httptest.NewRecorder() req, _ = http.NewRequest("DELETE", s.accountURL()+"/"+strconv.FormatUint(uint64(createdID), 10), nil) s.router.ServeHTTP(w, req) - s.Equal(http.StatusNoContent, w.Code) + s.Equal(http.StatusOK, w.Code) // Get after delete → should be not found (soft delete) w = httptest.NewRecorder() @@ -718,4 +684,4 @@ func (s *CampaignHandlerTestSuite) TestGet_DifferentAccount() { func TestCampaignHandlerTestSuite(t *testing.T) { suite.Run(t, new(CampaignHandlerTestSuite)) -} \ No newline at end of file +} diff --git a/internal/repository/campaign_repo.go b/internal/repository/campaign_repo.go index 9c37835d..54190ca5 100644 --- a/internal/repository/campaign_repo.go +++ b/internal/repository/campaign_repo.go @@ -21,6 +21,14 @@ func NewCampaignRepo(db *gorm.DB) *CampaignRepo { // Create creates a new campaign. func (r *CampaignRepo) Create(ctx context.Context, c *campaign.Campaign) error { + if c.DisplayID == 0 { + var next uint + if err := r.db.WithContext(ctx).Model(&campaign.Campaign{}). + Select("COALESCE(MAX(display_id), 0) + 1").Scan(&next).Error; err != nil { + return err + } + c.DisplayID = next + } return r.db.WithContext(ctx).Create(c).Error } @@ -35,9 +43,19 @@ func (r *CampaignRepo) FindByID(ctx context.Context, id uint) (*campaign.Campaig // FindByIDAndAccount retrieves a campaign scoped to an account. func (r *CampaignRepo) FindByIDAndAccount(ctx context.Context, id, accountID uint) (*campaign.Campaign, error) { + return r.FindByDisplayIDAndAccountOrID(ctx, id, accountID) +} + +// FindByDisplayIDAndAccountOrID retrieves a campaign by Chatwoot display_id, falling back to primary key for legacy callers/tests. +func (r *CampaignRepo) FindByDisplayIDAndAccountOrID(ctx context.Context, routeID, accountID uint) (*campaign.Campaign, error) { var c campaign.Campaign - if err := r.db.WithContext(ctx). - Where("id = ? AND account_id = ?", id, accountID).First(&c).Error; err != nil { + base := r.db.WithContext(ctx).Preload("Inbox").Preload("Sender") + if err := base.Where("account_id = ? AND display_id = ?", accountID, routeID).First(&c).Error; err == nil { + return &c, nil + } else if err != gorm.ErrRecordNotFound { + return nil, err + } + if err := r.db.WithContext(ctx).Preload("Inbox").Preload("Sender").Where("account_id = ? AND id = ?", accountID, routeID).First(&c).Error; err != nil { return nil, err } return &c, nil @@ -53,10 +71,17 @@ func (r *CampaignRepo) ListByAccount(ctx context.Context, accountID uint, offset return nil, 0, err } - err := r.db.WithContext(ctx). + query := r.db.WithContext(ctx). + Preload("Inbox").Preload("Sender"). Where("account_id = ?", accountID). - Order("created_at DESC").Offset(offset).Limit(limit). - Find(&campaigns).Error + Order("created_at DESC") + if offset > 0 { + query = query.Offset(offset) + } + if limit > 0 { + query = query.Limit(limit) + } + err := query.Find(&campaigns).Error if err != nil { return nil, 0, err } diff --git a/internal/router/router.go b/internal/router/router.go index f0275875..1d5eba23 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1575,9 +1575,12 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) { // Reference: Chatwoot namespace :campaigns campaigns := accountScoped.Group("/campaigns") { + campaigns.GET("", h.Campaign.List) campaigns.GET("/", h.Campaign.List) + campaigns.POST("", h.Campaign.Create) campaigns.POST("/", h.Campaign.Create) campaigns.GET("/:campaign_id", h.Campaign.Get) + campaigns.PATCH("/:campaign_id", h.Campaign.Update) campaigns.PUT("/:campaign_id", h.Campaign.Update) campaigns.DELETE("/:campaign_id", h.Campaign.Delete) campaigns.POST("/:campaign_id/start", h.Campaign.Start) diff --git a/internal/service/campaign_service.go b/internal/service/campaign_service.go index e7be219f..bcf72a06 100644 --- a/internal/service/campaign_service.go +++ b/internal/service/campaign_service.go @@ -2,7 +2,9 @@ package service import ( "context" + "encoding/json" "fmt" + "strings" "github.com/gochat/gochat/internal/campaign" "github.com/gochat/gochat/internal/repository" @@ -13,7 +15,7 @@ import ( // CampaignService implements business logic for Campaign operations. // Reference: Chatwoot app/controllers/api/v1/campaigns_controller.rb type CampaignService struct { - campaignSvc *campaign.CampaignService + campaignSvc *campaign.CampaignService campaignRepo *repository.CampaignRepo } @@ -24,32 +26,32 @@ func NewCampaignService(campaignSvc *campaign.CampaignService, campaignRepo *rep // CreateCampaignRequest is the DTO for creating a campaign. type CreateCampaignRequest struct { - InboxID uint `json:"inbox_id" validate:"required"` - SenderID *uint `json:"sender_id,omitempty"` - Title string `json:"title" validate:"required,min=2"` - Message string `json:"message" validate:"required"` - Description string `json:"description,omitempty"` - CampaignType string `json:"campaign_type" validate:"required,oneof=ongoing one_off"` - Audience string `json:"audience,omitempty"` - TriggerRules string `json:"trigger_rules,omitempty"` - TemplateParams string `json:"template_params,omitempty"` - ScheduledAt *string `json:"scheduled_at,omitempty"` // ISO8601 timestamp string - Enabled *bool `json:"enabled,omitempty"` - TriggerOnlyDuringBusinessHours *bool `json:"trigger_only_during_business_hours,omitempty"` + InboxID uint `json:"inbox_id" validate:"required"` + SenderID *uint `json:"sender_id,omitempty"` + Title string `json:"title" validate:"required,min=2"` + Message string `json:"message" validate:"required"` + Description string `json:"description,omitempty"` + CampaignType string `json:"campaign_type" validate:"required,oneof=ongoing one_off"` + Audience json.RawMessage `json:"audience,omitempty"` + TriggerRules json.RawMessage `json:"trigger_rules,omitempty"` + TemplateParams json.RawMessage `json:"template_params,omitempty"` + ScheduledAt *string `json:"scheduled_at,omitempty"` // ISO8601 timestamp string + Enabled *bool `json:"enabled,omitempty"` + TriggerOnlyDuringBusinessHours *bool `json:"trigger_only_during_business_hours,omitempty"` } // UpdateCampaignRequest is the DTO for updating a campaign. type UpdateCampaignRequest struct { - Title string `json:"title,omitempty" validate:"omitempty,min=2"` - Message string `json:"message,omitempty"` - Description string `json:"description,omitempty"` - CampaignType string `json:"campaign_type,omitempty" validate:"omitempty,oneof=ongoing one_off"` - Audience string `json:"audience,omitempty"` - TriggerRules string `json:"trigger_rules,omitempty"` - TemplateParams string `json:"template_params,omitempty"` - ScheduledAt *string `json:"scheduled_at,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - TriggerOnlyDuringBusinessHours *bool `json:"trigger_only_during_business_hours,omitempty"` + Title string `json:"title,omitempty" validate:"omitempty,min=2"` + Message string `json:"message,omitempty"` + Description string `json:"description,omitempty"` + CampaignType string `json:"campaign_type,omitempty" validate:"omitempty,oneof=ongoing one_off"` + Audience json.RawMessage `json:"audience,omitempty"` + TriggerRules json.RawMessage `json:"trigger_rules,omitempty"` + TemplateParams json.RawMessage `json:"template_params,omitempty"` + ScheduledAt *string `json:"scheduled_at,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + TriggerOnlyDuringBusinessHours *bool `json:"trigger_only_during_business_hours,omitempty"` } // List retrieves all campaigns for an account with pagination. @@ -91,9 +93,9 @@ func (s *CampaignService) Create(ctx context.Context, accountID uint, req Create Description: req.Description, CampaignStatus: campaign.CampaignStatusActive, CampaignType: campaign.CampaignType(req.CampaignType), - Audience: req.Audience, - TriggerRules: req.TriggerRules, - TemplateParams: req.TemplateParams, + Audience: rawJSONParamString(req.Audience), + TriggerRules: rawJSONParamString(req.TriggerRules), + TemplateParams: rawJSONParamString(req.TemplateParams), Enabled: enabled, TriggerOnlyDuringBusinessHours: triggerDuringBH, } @@ -132,14 +134,14 @@ func (s *CampaignService) Update(ctx context.Context, id, accountID uint, req Up if req.CampaignType != "" { c.CampaignType = campaign.CampaignType(req.CampaignType) } - if req.Audience != "" { - c.Audience = req.Audience + if len(req.Audience) > 0 { + c.Audience = rawJSONParamString(req.Audience) } - if req.TriggerRules != "" { - c.TriggerRules = req.TriggerRules + if len(req.TriggerRules) > 0 { + c.TriggerRules = rawJSONParamString(req.TriggerRules) } - if req.TemplateParams != "" { - c.TemplateParams = req.TemplateParams + if len(req.TemplateParams) > 0 { + c.TemplateParams = rawJSONParamString(req.TemplateParams) } if req.Enabled != nil { c.Enabled = *req.Enabled @@ -149,14 +151,14 @@ func (s *CampaignService) Update(ctx context.Context, id, accountID uint, req Up } if err := s.campaignSvc.Update(ctx, c.ID, map[string]interface{}{ - "title": c.Title, - "message": c.Message, - "description": c.Description, - "campaign_type": c.CampaignType, - "audience": c.Audience, - "trigger_rules": c.TriggerRules, - "template_params": c.TemplateParams, - "enabled": c.Enabled, + "title": c.Title, + "message": c.Message, + "description": c.Description, + "campaign_type": c.CampaignType, + "audience": c.Audience, + "trigger_rules": c.TriggerRules, + "template_params": c.TemplateParams, + "enabled": c.Enabled, "trigger_only_during_business_hours": c.TriggerOnlyDuringBusinessHours, }); err != nil { applogger.L().Errorf("failed to update campaign: %v", err) @@ -167,7 +169,11 @@ func (s *CampaignService) Update(ctx context.Context, id, accountID uint, req Up // Delete soft-deletes a campaign scoped to an account. func (s *CampaignService) Delete(ctx context.Context, id, accountID uint) error { - if err := s.campaignSvc.Delete(ctx, id); err != nil { + c, err := s.campaignRepo.FindByDisplayIDAndAccountOrID(ctx, id, accountID) + if err != nil { + return fmt.Errorf("campaign not found: %w", err) + } + if err := s.campaignSvc.Delete(ctx, c.ID); err != nil { return fmt.Errorf("failed to delete campaign: %w", err) } return nil @@ -185,6 +191,18 @@ func (s *CampaignService) Start(ctx context.Context, id, accountID uint) error { return nil } +func rawJSONParamString(raw json.RawMessage) string { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" || trimmed == "null" { + return "" + } + var value string + if err := json.Unmarshal(raw, &value); err == nil { + return value + } + return trimmed +} + // Stop marks a campaign as completed. func (s *CampaignService) Stop(ctx context.Context, id, accountID uint) error { c, err := s.campaignRepo.FindByIDAndAccount(ctx, id, accountID)