From a02d11e73c22defebd70156e59e30fb800b74abc Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 03:20:51 +0800 Subject: [PATCH] feat(campaigns): derive chatwoot scheduling --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 10 +- .../handler/api/v1/campaign_handler_test.go | 55 +++++- internal/repository/campaign_repo.go | 21 +++ internal/service/campaign_service.go | 169 ++++++++++++++++-- 4 files changed, 235 insertions(+), 20 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 78f125e2..1d69499c 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,10 +16,10 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- 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`. +- Current tracking checkpoint: 2026-06-06 after `ca3c045 feat(campaigns): align chatwoot payloads`, with this implementation checkpoint prepared as `feat(campaigns): derive chatwoot scheduling`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(campaigns): derive chatwoot scheduling`. - 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: 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. +- Worktree status at this implementation checkpoint: Campaign create/update now also mirrors Chatwoot `Campaign#ensure_correct_campaign_attributes` for reused SMS, WhatsApp, and live-chat forms: frontend bodies no longer need `campaign_type`, one-off SMS/Twilio/WhatsApp campaigns derive `campaign_type=one_off`, persist ISO `scheduled_at`, default missing one-off schedules to current UTC, live-chat/web-widget campaigns derive `campaign_type=ongoing` and clear `scheduled_at`, update accepts frontend `inbox_id`, `sender_id`, `scheduled_at`, and omitted descriptions without clearing them, and sender/inbox account scope is validated. Account campaigns already 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. Agent/settings/profile/notification/contact/custom-field/report enterprise drifts listed below remain at their prior checkpoint states. 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: 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`. @@ -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): derive chatwoot scheduling` | Advances P3.7 campaign behavior parity beyond response envelopes by matching Chatwoot `Campaign#ensure_correct_campaign_attributes`, `campaign_params`, and the reused SMS/WhatsApp/live-chat campaign forms. Create no longer requires frontend-absent `campaign_type`; SMS/Twilio/WhatsApp inboxes derive one-off campaigns, persist ISO `scheduled_at`, and default missing schedules to current UTC; web-widget/live-chat inboxes derive ongoing campaigns and clear schedules; update accepts frontend `inbox_id`, `sender_id`, and `scheduled_at`, preserves omitted descriptions, reloads nested inbox/sender data, and validates inbox/sender account scope. | `go test ./internal/handler/api/v1 -run CampaignHandler -count=1`; `go test ./internal/service ./internal/repository -run Campaign -count=1`; full `go test ./...`; `git diff --check`. No route changes; route dump remains `TOTAL: 846`. | Continue Phase 6 placeholder audit or the next named Phase 2/3 drift from reference/smoke evidence. | | `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. | @@ -1576,7 +1577,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, reports, and account campaign CRUD now have focused Chatwoot payload fixtures, while help center and deeper campaign side effects 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/scheduling now have focused Chatwoot payload fixtures, while help center and deeper campaign execution/provider 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 | @@ -2085,6 +2086,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-06: P3.7 campaigns scheduling checkpoint prepared as `feat(campaigns): derive chatwoot scheduling`; audited Chatwoot `CampaignsController#campaign_params`, `Campaign#ensure_correct_campaign_attributes`, and reused SMS/WhatsApp/live-chat campaign forms. Campaign create now accepts frontend bodies without `campaign_type`, derives one-off campaigns for SMS/Twilio/WhatsApp inboxes, persists ISO `scheduled_at`, defaults missing one-off schedules to current UTC, derives ongoing campaigns for web-widget/live-chat inboxes and clears schedules, while update accepts `inbox_id`, `sender_id`, and `scheduled_at` with account-scope checks and preserves omitted descriptions. Focused campaign handler tests, service/repository campaign tests, 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.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. diff --git a/internal/handler/api/v1/campaign_handler_test.go b/internal/handler/api/v1/campaign_handler_test.go index 6e7eb1f8..be083da3 100644 --- a/internal/handler/api/v1/campaign_handler_test.go +++ b/internal/handler/api/v1/campaign_handler_test.go @@ -141,6 +141,12 @@ func (s *CampaignHandlerTestSuite) accountURL() string { return "/api/v1/accounts/" + strconv.FormatUint(uint64(s.account.ID), 10) + "/campaigns" } +func (s *CampaignHandlerTestSuite) seedInbox(channelType string) *model.Inbox { + inbox := &model.Inbox{AccountID: s.account.ID, Name: "Campaign " + channelType + " Inbox", ChannelType: channelType, ChannelID: 1} + s.Require().NoError(s.db.Create(inbox).Error) + return inbox +} + // Helper: seed a campaign directly into the DB for Get/List/Delete/Update tests func (s *CampaignHandlerTestSuite) seedCampaign(title, message, campaignType string) *campaign.Campaign { c := &campaign.Campaign{ @@ -256,12 +262,13 @@ func (s *CampaignHandlerTestSuite) TestGet_Unauthorized() { // ========== Create Tests ========== func (s *CampaignHandlerTestSuite) TestCreate_Success() { + smsInbox := s.seedInbox("Channel::Sms") body := map[string]interface{}{ - "inbox_id": s.inbox.ID, + "inbox_id": smsInbox.ID, "title": "New Campaign", "message": "Hello from campaign", - "campaign_type": "one_off", "enabled": true, + "scheduled_at": "2026-06-07T10:30:00Z", "audience": []map[string]interface{}{{"type": "Label", "id": 1}}, "trigger_rules": map[string]interface{}{"url": "https://example.com"}, "template_params": map[string]interface{}{"name": "value"}, @@ -281,11 +288,40 @@ func (s *CampaignHandlerTestSuite) TestCreate_Success() { s.NotContains(resp, "data") s.Equal("New Campaign", resp["title"]) s.Equal(float64(s.account.ID), resp["account_id"]) + s.Equal("one_off", resp["campaign_type"]) + s.Equal(float64(1780828200), resp["scheduled_at"]) s.Greater(resp["id"].(float64), float64(0)) + s.NotNil(resp["inbox"]) s.Contains(resp, "audience") s.Contains(resp, "template_params") } +func (s *CampaignHandlerTestSuite) TestCreate_LiveChatDefaultsOngoingWithoutCampaignType() { + body := map[string]interface{}{ + "inbox_id": s.inbox.ID, + "title": "Live Chat Campaign", + "message": "Hello from live chat", + "enabled": true, + "scheduled_at": "2026-06-07T10:30:00Z", + "trigger_rules": map[string]interface{}{"url": "https://example.com", "time_on_page": 10}, + } + bodyBytes := marshalNested("campaign", body) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", s.accountURL(), bytes.NewReader(bodyBytes)) + 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)) + s.Equal("Live Chat Campaign", resp["title"]) + s.Equal("ongoing", resp["campaign_type"]) + s.NotContains(resp, "scheduled_at") + s.NotContains(resp, "audience") +} + func (s *CampaignHandlerTestSuite) TestCreate_ValidationError() { // Missing required fields (title, message, inbox_id) body := map[string]interface{}{ @@ -335,10 +371,13 @@ func (s *CampaignHandlerTestSuite) TestCreate_Unauthorized() { func (s *CampaignHandlerTestSuite) TestUpdate_Success() { c := s.seedCampaign("Original Title", "Original message", "ongoing") + smsInbox := s.seedInbox("Channel::Sms") body := map[string]interface{}{ - "title": "Updated Title", - "message": "Updated message", + "title": "Updated Title", + "message": "Updated message", + "inbox_id": smsInbox.ID, + "scheduled_at": "2026-06-07T10:30:00Z", } bodyBytes := marshalNested("campaign", body) @@ -353,6 +392,14 @@ func (s *CampaignHandlerTestSuite) TestUpdate_Success() { s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) s.NotContains(resp, "success") s.Equal("Updated Title", resp["title"]) + s.Equal("one_off", resp["campaign_type"]) + s.Equal(float64(1780828200), resp["scheduled_at"]) + + var updated campaign.Campaign + s.Require().NoError(s.db.First(&updated, c.ID).Error) + s.Equal(smsInbox.ID, updated.InboxID) + s.Require().NotNil(updated.ScheduledAt) + s.Equal(int64(1780828200), updated.ScheduledAt.Unix()) } func (s *CampaignHandlerTestSuite) TestUpdate_NotFound() { diff --git a/internal/repository/campaign_repo.go b/internal/repository/campaign_repo.go index 54190ca5..83f24612 100644 --- a/internal/repository/campaign_repo.go +++ b/internal/repository/campaign_repo.go @@ -6,6 +6,7 @@ import ( "gorm.io/gorm" "github.com/gochat/gochat/internal/campaign" + "github.com/gochat/gochat/internal/model" ) // CampaignRepo implements GORM repository for Campaign. @@ -46,6 +47,26 @@ func (r *CampaignRepo) FindByIDAndAccount(ctx context.Context, id, accountID uin return r.FindByDisplayIDAndAccountOrID(ctx, id, accountID) } +// FindInboxByIDAndAccount retrieves the campaign inbox scoped to the same account. +func (r *CampaignRepo) FindInboxByIDAndAccount(ctx context.Context, inboxID, accountID uint) (*model.Inbox, error) { + var inbox model.Inbox + if err := r.db.WithContext(ctx).Where("id = ? AND account_id = ?", inboxID, accountID).First(&inbox).Error; err != nil { + return nil, err + } + return &inbox, nil +} + +// AccountHasUser returns whether the user belongs to the account. +func (r *CampaignRepo) AccountHasUser(ctx context.Context, accountID, userID uint) (bool, error) { + var count int64 + if err := r.db.WithContext(ctx).Model(&model.AccountUser{}). + Where("account_id = ? AND user_id = ?", accountID, userID). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + // 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 diff --git a/internal/service/campaign_service.go b/internal/service/campaign_service.go index bcf72a06..8106327e 100644 --- a/internal/service/campaign_service.go +++ b/internal/service/campaign_service.go @@ -4,9 +4,12 @@ import ( "context" "encoding/json" "fmt" + "strconv" "strings" + "time" "github.com/gochat/gochat/internal/campaign" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" @@ -31,7 +34,7 @@ type CreateCampaignRequest struct { 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"` + CampaignType string `json:"campaign_type" 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"` @@ -42,6 +45,8 @@ type CreateCampaignRequest struct { // UpdateCampaignRequest is the DTO for updating a campaign. type UpdateCampaignRequest struct { + InboxID uint `json:"inbox_id,omitempty"` + SenderID *uint `json:"sender_id,omitempty"` Title string `json:"title,omitempty" validate:"omitempty,min=2"` Message string `json:"message,omitempty"` Description string `json:"description,omitempty"` @@ -52,8 +57,35 @@ type UpdateCampaignRequest struct { ScheduledAt *string `json:"scheduled_at,omitempty"` Enabled *bool `json:"enabled,omitempty"` TriggerOnlyDuringBusinessHours *bool `json:"trigger_only_during_business_hours,omitempty"` + inboxIDSet bool + senderIDSet bool + descriptionSet bool + scheduledAtSet bool } +func (r *UpdateCampaignRequest) UnmarshalJSON(data []byte) error { + type alias UpdateCampaignRequest + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + var decoded alias + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = UpdateCampaignRequest(decoded) + _, r.inboxIDSet = raw["inbox_id"] + _, r.senderIDSet = raw["sender_id"] + _, r.descriptionSet = raw["description"] + _, r.scheduledAtSet = raw["scheduled_at"] + return nil +} + +func (r UpdateCampaignRequest) InboxIDSet() bool { return r.inboxIDSet } +func (r UpdateCampaignRequest) SenderIDSet() bool { return r.senderIDSet } +func (r UpdateCampaignRequest) DescriptionSet() bool { return r.descriptionSet } +func (r UpdateCampaignRequest) ScheduledAtSet() bool { return r.scheduledAtSet } + // List retrieves all campaigns for an account with pagination. func (s *CampaignService) List(ctx context.Context, accountID uint, offset, limit int) ([]campaign.Campaign, int64, error) { return s.campaignRepo.ListByAccount(ctx, accountID, offset, limit) @@ -84,6 +116,18 @@ func (s *CampaignService) Create(ctx context.Context, accountID uint, req Create triggerDuringBH = *req.TriggerOnlyDuringBusinessHours } + inbox, err := s.campaignRepo.FindInboxByIDAndAccount(ctx, req.InboxID, accountID) + if err != nil { + return nil, fmt.Errorf("inbox not found: %w", err) + } + if err := s.validateCampaignSender(ctx, accountID, req.SenderID); err != nil { + return nil, err + } + campaignType, scheduledAt, err := deriveCampaignAttributes(*inbox, req.ScheduledAt, nil) + if err != nil { + return nil, err + } + c := &campaign.Campaign{ AccountID: accountID, InboxID: req.InboxID, @@ -92,18 +136,14 @@ func (s *CampaignService) Create(ctx context.Context, accountID uint, req Create Message: req.Message, Description: req.Description, CampaignStatus: campaign.CampaignStatusActive, - CampaignType: campaign.CampaignType(req.CampaignType), + CampaignType: campaignType, Audience: rawJSONParamString(req.Audience), TriggerRules: rawJSONParamString(req.TriggerRules), TemplateParams: rawJSONParamString(req.TemplateParams), + ScheduledAt: scheduledAt, Enabled: enabled, TriggerOnlyDuringBusinessHours: triggerDuringBH, - } - - // Parse scheduled_at if provided - if req.ScheduledAt != nil && *req.ScheduledAt != "" { - // ScheduledAt parsing deferred — stored as string for now - // Full implementation would parse ISO8601 to time.Time + Inbox: *inbox, } if err := s.campaignSvc.Create(ctx, c); err != nil { @@ -124,15 +164,39 @@ func (s *CampaignService) Update(ctx context.Context, id, accountID uint, req Up return nil, fmt.Errorf("campaign not found: %w", err) } + inbox := &c.Inbox + if req.InboxIDSet() { + if req.InboxID == 0 { + return nil, fmt.Errorf("invalid inbox_id") + } + resolvedInbox, err := s.campaignRepo.FindInboxByIDAndAccount(ctx, req.InboxID, accountID) + if err != nil { + return nil, fmt.Errorf("inbox not found: %w", err) + } + inbox = resolvedInbox + c.InboxID = req.InboxID + } + if req.SenderIDSet() { + if err := s.validateCampaignSender(ctx, accountID, req.SenderID); err != nil { + return nil, err + } + c.SenderID = req.SenderID + } + campaignType, scheduledAt, err := deriveCampaignAttributes(*inbox, req.ScheduledAt, c.ScheduledAt) + if err != nil { + return nil, err + } + c.CampaignType = campaignType + c.ScheduledAt = scheduledAt + if req.Title != "" { c.Title = req.Title } if req.Message != "" { c.Message = req.Message } - c.Description = req.Description - if req.CampaignType != "" { - c.CampaignType = campaign.CampaignType(req.CampaignType) + if req.DescriptionSet() { + c.Description = req.Description } if len(req.Audience) > 0 { c.Audience = rawJSONParamString(req.Audience) @@ -151,6 +215,8 @@ func (s *CampaignService) Update(ctx context.Context, id, accountID uint, req Up } if err := s.campaignSvc.Update(ctx, c.ID, map[string]interface{}{ + "inbox_id": c.InboxID, + "sender_id": c.SenderID, "title": c.Title, "message": c.Message, "description": c.Description, @@ -158,13 +224,18 @@ func (s *CampaignService) Update(ctx context.Context, id, accountID uint, req Up "audience": c.Audience, "trigger_rules": c.TriggerRules, "template_params": c.TemplateParams, + "scheduled_at": c.ScheduledAt, "enabled": c.Enabled, "trigger_only_during_business_hours": c.TriggerOnlyDuringBusinessHours, }); err != nil { applogger.L().Errorf("failed to update campaign: %v", err) return nil, fmt.Errorf("failed to update campaign: %w", err) } - return c, nil + reloadedID := c.DisplayID + if reloadedID == 0 { + reloadedID = c.ID + } + return s.campaignRepo.FindByDisplayIDAndAccountOrID(ctx, reloadedID, accountID) } // Delete soft-deletes a campaign scoped to an account. @@ -203,6 +274,80 @@ func rawJSONParamString(raw json.RawMessage) string { return trimmed } +func (s *CampaignService) validateCampaignSender(ctx context.Context, accountID uint, senderID *uint) error { + if senderID == nil || *senderID == 0 { + return nil + } + ok, err := s.campaignRepo.AccountHasUser(ctx, accountID, *senderID) + if err != nil { + return fmt.Errorf("validate sender: %w", err) + } + if !ok { + return fmt.Errorf("invalid sender_id: must belong to the same account as the campaign") + } + return nil +} + +func deriveCampaignAttributes(inbox model.Inbox, scheduledAt *string, current *time.Time) (campaign.CampaignType, *time.Time, error) { + if campaignInboxIsOneOff(inbox.ChannelType) { + parsed, err := parseCampaignScheduledAt(scheduledAt) + if err != nil { + return "", nil, err + } + if parsed == nil { + if current != nil { + copy := current.UTC() + parsed = © + } else { + now := time.Now().UTC() + parsed = &now + } + } + return campaign.CampaignTypeOneOff, parsed, nil + } + if campaignInboxIsOngoing(inbox.ChannelType) { + return campaign.CampaignTypeOngoing, nil, nil + } + return "", nil, fmt.Errorf("invalid inbox: Unsupported Inbox type") +} + +func campaignInboxIsOneOff(channelType string) bool { + normalized := normalizeCampaignInboxType(channelType) + return normalized == "sms" || normalized == "twiliosms" || normalized == "whatsapp" +} + +func campaignInboxIsOngoing(channelType string) bool { + normalized := normalizeCampaignInboxType(channelType) + return normalized == "webwidget" || normalized == "website" +} + +func normalizeCampaignInboxType(channelType string) string { + normalized := strings.ToLower(strings.TrimSpace(channelType)) + normalized = strings.TrimPrefix(normalized, "channel::") + normalized = strings.ReplaceAll(normalized, "_", "") + normalized = strings.ReplaceAll(normalized, " ", "") + normalized = strings.ReplaceAll(normalized, "-", "") + return normalized +} + +func parseCampaignScheduledAt(raw *string) (*time.Time, error) { + if raw == nil || strings.TrimSpace(*raw) == "" { + return nil, nil + } + trimmed := strings.TrimSpace(*raw) + if unix, err := strconv.ParseInt(trimmed, 10, 64); err == nil { + parsed := time.Unix(unix, 0).UTC() + return &parsed, nil + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05 -0700", "2006-01-02 15:04:05"} { + if parsed, err := time.Parse(layout, trimmed); err == nil { + parsed = parsed.UTC() + return &parsed, nil + } + } + return nil, fmt.Errorf("invalid scheduled_at") +} + // 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)