diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 61f2f76c..d9e2eb04 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,11 +49,11 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-07 P3.97 conversation typing event parity, prepared as `feat(conversations): align typing events`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align typing events`. -- Latest documentation/tooling checkpoint: this tracker update records Chatwoot conversation `toggle_typing_status` permissive request and event parity. No route artifacts change. +- Current tracking checkpoint: 2026-06-07 P3.98 conversation transcript delivery parity, prepared as `feat(conversations): align transcript delivery`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align transcript delivery`. +- Latest documentation/tooling checkpoint: this tracker update records Chatwoot conversation transcript plan gate, rate limit, mailer, and message-scope 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 conversation `toggle_typing_status` now accepts empty bodies or missing `typing_status` as Chatwoot no-op `200 OK`, still rejects malformed JSON, and dispatches `conversation.typing_on` / `conversation.typing_off` channel events with current user and `is_private` metadata when `on`/`off` status is present. This retains P3.96 conversation last-seen read parity, P3.95 conversation mute contact-block parity, P3.94 conversation toggle-priority empty-body parity, P3.93 conversation toggle-status fallback parity, P3.92 automation-rule attachment parity, 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. +- Worktree status at this implementation checkpoint: account conversation transcript delivery now follows Chatwoot's `ConversationsController#transcript` gates: missing email remains `422`, disabled transcript plans return raw `402`, configured daily email limits return empty `429`, delivered transcripts use the SMTP transcript boundary, increment the account outbound-email counter, and include only public incoming/outgoing chat messages. Automation `send_email_transcript` shares the same enabled/rate-limit behavior and stops at the configured limit. This retains P3.97 conversation typing event parity, P3.96 conversation last-seen read parity, P3.95 conversation mute contact-block parity, P3.94 conversation toggle-priority empty-body parity, P3.93 conversation toggle-status fallback parity, P3.92 automation-rule attachment parity, P3.91 upload/macro attachment parity, P3.90 SearchAPI filter-surface 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. @@ -156,6 +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.98 conversation transcript delivery parity | Implemented for reused conversation transcript and automation transcript actions: `POST /conversations/:conversation_id/transcript` now checks Chatwoot-style email transcript availability, enforces configured daily email limits, delivers through the SMTP transcript boundary, increments account outbound-email counters, and scopes transcript content to public incoming/outgoing chat messages. Automation `send_email_transcript` no-ops when disabled and stops recipient fan-out at the rate limit before sync delivery or durable enqueue. | Keep in Review; reopen from B12 transcript smoke or fresh reference evidence for exact Redis TTL behavior, cloud-only global default limit handling, mailer template HTML body drift, or provider-specific SMTP availability checks beyond the inspected mailer gate. | Focused ConversationService, ConversationHandler, ActionService, and Account model transcript tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. `accounts.limits` migration is included; no route artifacts change. | | 0 | P3.97 conversation typing event parity | Implemented for reused conversation typing action: `POST /conversations/:conversation_id/toggle_typing_status` now accepts empty bodies and `{}` as Chatwoot no-op `200 OK`, rejects malformed JSON, and emits `conversation.typing_on` / `conversation.typing_off` events with conversation/contact/user context and `is_private` metadata for valid statuses. | Keep in Review; reopen from B12 conversation typing smoke or fresh reference evidence for ActionCable event payload fields beyond the inspected `TypingStatusManager` contract. | Focused ConversationService and ConversationHandler toggle-typing tests passed; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | 0 | P3.96 conversation last-seen read parity | Implemented for reused conversation read/unread actions: `POST /conversations/:conversation_id/update_last_seen` marks current-user unread notifications for the conversation as read, updates `agent_last_seen_at` plus `assignee_last_seen_at` when the viewer is the assignee and there are unread messages, updates only agent last-seen for non-assignees, and applies Chatwoot's one-hour no-unread write throttle; `unread` backdates both last-seen columns to the last incoming message minus one second. | Keep in Review; reopen from B12 conversation read/unread smoke or fresh reference evidence for unread-count notifier/event side effects not covered by the inspected controller/model contract. | Focused ConversationService and ConversationHandler last-seen/unread tests passed; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | 0 | P3.95 conversation mute contact-block parity | Implemented for reused conversation mute/unmute actions and automation `mute_conversation`: `POST /conversations/:conversation_id/mute` now resolves the conversation, blocks the contact, and serializes `muted` from the contact blocked state; `unmute` unblocks the contact without reopening the conversation, matching Chatwoot `ConversationMuteHelpers`. | Keep in Review; reopen from B12 conversation action smoke, automation mute smoke, or fresh reference evidence for activity-message wording/Current.user side effects beyond the inspected mute helper. | Focused ConversationService, ConversationHandler CRUD, and ActionService mute tests passed; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | @@ -261,6 +262,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.98 conversation transcript delivery parity | `internal/handler/api/v1/conversation_handler.go`, `internal/service/conversation_service.go`, `internal/model/account.go`, `internal/model/account_email.go`, `internal/automation/action_service.go`, migration `000037_add_accounts_limits`, conversation handler/service/action/model tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#transcript`, `reference/chatwoot/app/models/concerns/account_email_rate_limitable.rb`, `reference/chatwoot/app/mailers/conversation_reply_mailer.rb#conversation_transcript`, `reference/chatwoot/app/models/message.rb#chat`, `reference/chatwoot/app/models/concerns/message_filter_helpers.rb#conversation_transcriptable?`, `reference/chatwoot/app/services/action_service.rb#send_email_transcript`, reused dashboard transcript modal/API/i18n payment-required behavior | Account conversation transcript delivery now matches the inspected Chatwoot controller and mailer contract: missing email returns raw `422`, disabled accounts return raw `402`, configured email limits return empty `429`, success returns empty `200 OK` after delivering through a fakeable SMTP transcript boundary and incrementing account outbound-email count. Transcript bodies include only public incoming/outgoing chat messages, and automation transcript actions respect the same enabled/rate behavior while splitting comma recipients and stopping at the first rate-limit miss. | Review by `feat(conversations): align transcript delivery`; focused tests prove account gate/rate helpers, handler `402/429` shape, service delivery/count/message-scope behavior, and automation disabled/rate/filter behavior; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | P3.97 conversation typing event parity | `internal/handler/api/v1/conversation_handler.go`, `internal/service/conversation_service.go`, `internal/channel/event.go`, conversation handler/service tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#toggle_typing_status`, `reference/chatwoot/app/services/conversations/typing_status_manager.rb`, `reference/chatwoot/app/javascript/dashboard/api/inbox/conversation.js`, `reference/chatwoot/app/javascript/dashboard/store/modules/conversationTypingStatus.js`, reused dashboard reply-box typing callers | Account conversation `toggle_typing_status` now matches Chatwoot's permissive member action: missing body or missing `typing_status` resolves the conversation and returns empty `200 OK` without dispatching an event, malformed JSON still fails, and `on`/`off` statuses dispatch `conversation.typing_on` / `conversation.typing_off` events with conversation/contact/user context plus `is_private`. | Review by `feat(conversations): align typing events`; focused tests prove service event type/data behavior, blank-status no-op after conversation lookup, handler empty-body and `{}` no-op success, and invalid JSON failure; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | P3.96 conversation last-seen read parity | `internal/service/conversation_service.go`, `internal/handler/api/v1/conversation_handler.go`, conversation handler/service tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#update_last_seen`, `#unread`, `reference/chatwoot/app/models/conversation.rb#unread_messages`, `#assignee_unread_messages`, `reference/chatwoot/app/services/notification/mark_conversation_read_service.rb`, reused dashboard `ConversationApi.markMessageRead/markMessagesUnread` and store `messageReadActions.js` | Account conversation `update_last_seen` now mirrors Chatwoot's read path: it marks unread notifications for the conversation/current user as read, updates assignee last-seen only when the current viewer is the assignee, always updates agent last-seen when unread messages exist, and throttles no-unread writes to one hour. The `unread` endpoint now uses the Chatwoot helper shape by updating both `agent_last_seen_at` and `assignee_last_seen_at` to the last incoming message timestamp minus one second, or clearing both when no incoming message exists. | Review by `feat(conversations): align last seen reads`; focused tests prove notification read side effects, assignee/non-assignee column behavior, throttle behavior, and unread dual-column backdating; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | P3.95 conversation mute contact-block parity | `internal/service/conversation_service.go`, `internal/handler/api/v1/conversation_handler.go`, `internal/handler/api/v1/conversation_serializer.go`, `internal/automation/action_service.go`, conversation handler/service/action tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#mute`, `#unmute`, `reference/chatwoot/app/models/concerns/conversation_mute_helpers.rb`, `reference/chatwoot/app/services/action_service.rb#mute_conversation`, `reference/chatwoot/app/views/api/v1/conversations/partials/_conversation.json.jbuilder`, reused conversation mute/unmute store/actions | Account conversation mute/unmute and automation `mute_conversation` now use Chatwoot's contact-block semantics instead of the old local label/legacy mute-row behavior: mute resolves the conversation, blocks the contact, sets the local conversation muted cache, and serialization derives `muted` from `contact.blocked`; unmute unblocks and clears muted without reopening. | Review by `feat(conversations): align mute semantics`; focused tests prove service persistence, handler empty `200 OK` plus serialized payload state, and automation action side effects; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | @@ -382,6 +384,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(conversations): align transcript delivery` | Advances P3.98 with Chatwoot conversation transcript delivery parity. GoChat now adds the Chatwoot `accounts.limits` field, uses account email transcript availability/rate helpers, maps dashboard transcript disabled/rate-limited responses to raw `402`/empty `429`, delivers transcript emails through the fakeable SMTP boundary, increments outbound-email counters, and filters transcript content to public incoming/outgoing chat messages. Automation `send_email_transcript` now no-ops when disabled and stops recipient fan-out at the configured rate limit. | Focused Account model, ConversationService, ConversationHandler, and ActionService transcript tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.98 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align typing events` | Advances P3.97 with Chatwoot conversation `toggle_typing_status` parity. GoChat now treats empty or missing `typing_status` as a no-op `200 OK`, keeps malformed JSON as a validation error, and dispatches Chatwoot-style `conversation.typing_on` / `conversation.typing_off` events with user/contact/conversation context and `is_private` metadata. | Focused ConversationService and ConversationHandler toggle-typing tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.97 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align last seen reads` | Advances P3.96 with Chatwoot conversation `update_last_seen` and `unread` parity. GoChat now marks current-user conversation notifications read, applies assignee-aware last-seen updates and the no-unread one-hour throttle, and marks conversations unread by backdating both agent and assignee last-seen columns. | Focused ConversationService and ConversationHandler last-seen/unread tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.96 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align mute semantics` | Advances P3.95 with Chatwoot conversation mute/unmute and automation mute parity. GoChat now follows `ConversationMuteHelpers`: mute resolves the conversation, blocks the conversation contact, keeps serialized `muted` tied to contact blocked state, and automation `mute_conversation` applies the same side effects instead of writing only a legacy mute row. | Focused ConversationService mute/unmute tests, ConversationHandler CRUD mute/unmute tests, and ActionService `mute_conversation` tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.95 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | @@ -2770,3 +2773,4 @@ Verification milestone gates: - 2026-06-07: P3.95 conversation mute checkpoint prepared as `feat(conversations): align mute semantics`; audited Chatwoot `ConversationsController#mute/#unmute`, `ConversationMuteHelpers`, `ActionService#mute_conversation`, `_conversation.json.jbuilder`, and reused dashboard mute/unmute store actions. GoChat account conversation mute now resolves the conversation, blocks the contact, and serializes `muted` from contact blocked state; unmute unblocks without reopening; automation `mute_conversation` applies the same Chatwoot side effects instead of writing only a legacy mute row. Focused ConversationService, ConversationHandler CRUD, and ActionService mute tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.96 conversation last-seen checkpoint prepared as `feat(conversations): align last seen reads`; audited Chatwoot `ConversationsController#update_last_seen/#unread`, `Conversation#unread_messages/#assignee_unread_messages`, `Notification::MarkConversationReadService`, and reused dashboard message read/unread API/store actions. GoChat account conversation `update_last_seen` now marks current-user unread notifications for the conversation as read, updates agent and assignee last-seen columns according to assignee/unread-message state, and throttles no-unread writes to once per hour; `unread` now backdates or clears both agent and assignee last-seen columns. Focused ConversationService and ConversationHandler tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.97 conversation typing checkpoint prepared as `feat(conversations): align typing events`; audited Chatwoot `ConversationsController#toggle_typing_status`, `Conversations::TypingStatusManager`, and reused dashboard conversation typing API/store/reply-box callers. GoChat account conversation `toggle_typing_status` now accepts empty bodies and missing `typing_status` as no-op `200 OK`, still rejects malformed JSON, and dispatches Chatwoot-style `conversation.typing_on` / `conversation.typing_off` events with current user, contact, conversation, and `is_private` metadata for valid `on`/`off` statuses. Focused ConversationService and ConversationHandler toggle-typing tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. +- 2026-06-07: P3.98 conversation transcript checkpoint prepared as `feat(conversations): align transcript delivery`; audited Chatwoot `ConversationsController#transcript`, `AccountEmailRateLimitable`, `ConversationReplyMailer#conversation_transcript`, `Message.chat`, `MessageFilterHelpers#conversation_transcriptable?`, and automation `ActionService#send_email_transcript`. GoChat account conversation transcript now honors email transcript availability, configured email limits, raw `402`/empty `429` response shapes, SMTP transcript delivery, outbound-email count increments, and public incoming/outgoing chat-message scoping; automation `send_email_transcript` no-ops when disabled and stops recipient fan-out at the rate limit before sync delivery or durable enqueue. Focused Account model, ConversationService, ConversationHandler, and ActionService transcript tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. diff --git a/internal/automation/action_service.go b/internal/automation/action_service.go index 5a35634b..fa5ad695 100644 --- a/internal/automation/action_service.go +++ b/internal/automation/action_service.go @@ -525,10 +525,24 @@ func (s *ActionService) handleSendEmailTranscript(ctx context.Context, accountID if len(recipients) == 0 { return ActionDeliveryResult{DeliveryType: "email_transcript"}, fmt.Errorf("send_email_transcript action requires 'email' param") } + account, err := s.loadTranscriptAccount(ctx, accountID) + if err != nil { + return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")}, err + } + if !account.EmailTranscriptEnabled() { + return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ","), ResponseBody: "email_transcript_disabled"}, nil + } if s.worker != nil { for _, recipient := range recipients { - _, err := s.worker.Enqueue(ctx, TaskTypeAutomationTranscriptDelivery, automationTranscriptDeliveryJob{ + allowed, err := s.consumeTranscriptEmailQuota(ctx, account, time.Now()) + if err != nil { + return ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")}, err + } + if !allowed { + break + } + _, err = s.worker.Enqueue(ctx, TaskTypeAutomationTranscriptDelivery, automationTranscriptDeliveryJob{ AccountID: accountID, ConversationID: conversationID, Recipient: recipient, @@ -547,6 +561,13 @@ func (s *ActionService) handleSendEmailTranscript(ctx context.Context, accountID aggregate := ActionDeliveryResult{DeliveryType: "email_transcript", Target: strings.Join(recipients, ",")} for _, recipient := range recipients { + allowed, err := s.consumeTranscriptEmailQuota(ctx, account, time.Now()) + if err != nil { + return aggregate, err + } + if !allowed { + break + } result, err := s.transcriptDeliverer.DeliverTranscript(ctx, AutomationTranscriptRequest{ AccountID: accountID, ConversationID: conversationID, @@ -847,6 +868,30 @@ func extractTranscriptRecipients(params map[string]interface{}) []string { return recipients } +func (s *ActionService) loadTranscriptAccount(ctx context.Context, accountID uint) (*model.Account, error) { + var account model.Account + if err := s.db.DB().WithContext(ctx).First(&account, accountID).Error; err != nil { + return nil, err + } + return &account, nil +} + +func (s *ActionService) consumeTranscriptEmailQuota(ctx context.Context, account *model.Account, now time.Time) (bool, error) { + if account == nil { + return false, fmt.Errorf("account is required") + } + if limit := account.EmailRateLimit(); limit > 0 && account.EmailsSentToday(now) >= limit { + return false, nil + } + if err := account.IncrementEmailSentCount(now); err != nil { + return false, err + } + if err := s.db.DB().WithContext(ctx).Model(&model.Account{}).Where("id = ?", account.ID).Update("custom_attributes", account.CustomAttributes).Error; err != nil { + return false, err + } + return true, nil +} + func (s *ActionService) buildAutomationWebhookPayload(ctx context.Context, accountID, conversationID uint, eventName string) (map[string]interface{}, error) { var conversation model.Conversation if err := s.db.DB().WithContext(ctx). @@ -937,7 +982,7 @@ func (s *ActionService) buildTranscriptEmail(ctx context.Context, accountID, con var messages []model.Message if err := s.db.DB().WithContext(ctx). - Where("conversation_id = ? AND account_id = ? AND private = ?", conversationID, accountID, false). + Where("conversation_id = ? AND account_id = ? AND private = ? AND message_type IN ?", conversationID, accountID, false, []string{string(model.MessageTypeIncoming), string(model.MessageTypeOutgoing)}). Order("id ASC"). Find(&messages).Error; err != nil { return "", "", err diff --git a/internal/automation/action_service_test.go b/internal/automation/action_service_test.go index 0b21bf7a..4c1350ca 100644 --- a/internal/automation/action_service_test.go +++ b/internal/automation/action_service_test.go @@ -13,6 +13,7 @@ import ( "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/worker" + "gorm.io/datatypes" ) type roundTripFunc func(*http.Request) (*http.Response, error) @@ -123,6 +124,9 @@ func TestActionService_SendEmailTranscript_DeliversSplitRecipients(t *testing.T) if err := db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "transcript body", ContentType: "text", MessageType: "incoming"}).Error; err != nil { t.Fatalf("seed message: %v", err) } + _ = db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "private body", ContentType: "text", MessageType: "incoming", Private: true}).Error + _ = db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "activity body", ContentType: "text", MessageType: "activity"}).Error + _ = db.Create(&model.Message{ConversationID: conversationID, AccountID: accountID, InboxID: inboxID, Content: "template body", ContentType: "text", MessageType: "template"}).Error result, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{ ActionName: "send_email_transcript", @@ -140,11 +144,70 @@ func TestActionService_SendEmailTranscript_DeliversSplitRecipients(t *testing.T) if !strings.Contains(transcript.requests[0].Subject, "Conversation Transcript") || !strings.Contains(transcript.requests[0].Body, "transcript body") { t.Fatalf("expected transcript subject/body to be populated: %#v", transcript.requests[0]) } + if strings.Contains(transcript.requests[0].Body, "private body") || strings.Contains(transcript.requests[0].Body, "activity body") || strings.Contains(transcript.requests[0].Body, "template body") { + t.Fatalf("transcript body should include only public incoming/outgoing chat messages: %q", transcript.requests[0].Body) + } + var account model.Account + if err := db.First(&account, accountID).Error; err != nil || account.EmailsSentToday(time.Now()) != 2 { + t.Fatalf("expected two transcript sends to increment account counter, account=%#v err=%v", account, err) + } if result.DeliveryType != "email_transcript" || result.Target != "first@example.com,second@example.com" || result.Attempts != 2 { t.Fatalf("unexpected transcript action result metadata: %#v", result) } } +func TestActionService_SendEmailTranscript_DisabledNoops(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + db := dbProvider.DB() + accountID, _ := seedTestAccount(db, t) + if err := db.Model(&model.Account{}).Where("id = ?", accountID).Update("limits", datatypes.JSON(`{"email_transcript_enabled":false}`)).Error; err != nil { + t.Fatalf("disable transcripts: %v", err) + } + inboxID := seedTestInbox(db, t, accountID) + contactID := seedTestContact(db, t, accountID) + conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) + transcript := &recordingTranscriptDeliverer{result: ActionDeliveryResult{DeliveryType: "email_transcript", Attempts: 1}} + restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{}, transcript) + defer restore() + + result, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{ + ActionName: "send_email_transcript", + ActionParams: map[string]interface{}{"email": "first@example.com"}, + }, ActionSourceAutomation, 99) + if err != nil { + t.Fatalf("disabled transcript action should no-op: %v", err) + } + if len(transcript.requests) != 0 || result.ResponseBody != "email_transcript_disabled" { + t.Fatalf("expected disabled transcript no-op, result=%#v requests=%#v", result, transcript.requests) + } +} + +func TestActionService_SendEmailTranscript_StopsAtRateLimit(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + db := dbProvider.DB() + accountID, _ := seedTestAccount(db, t) + if err := db.Model(&model.Account{}).Where("id = ?", accountID).Updates(map[string]any{"limits": datatypes.JSON(`{"emails":1}`)}).Error; err != nil { + t.Fatalf("set email limit: %v", err) + } + inboxID := seedTestInbox(db, t, accountID) + contactID := seedTestContact(db, t, accountID) + conversationID := seedTestConversation(db, t, accountID, inboxID, contactID) + transcript := &recordingTranscriptDeliverer{result: ActionDeliveryResult{DeliveryType: "email_transcript", Attempts: 1}} + restore := setAutomationActionDeliverersForTest(&recordingWebhookDeliverer{}, transcript) + defer restore() + + _, err := NewActionService(dbProvider).ExecuteWithResult(context.Background(), accountID, conversationID, Action{ + ActionName: "send_email_transcript", + ActionParams: map[string]interface{}{"email": "first@example.com,second@example.com"}, + }, ActionSourceAutomation, 99) + if err != nil { + t.Fatalf("rate-limited transcript action should stop without error: %v", err) + } + if len(transcript.requests) != 1 || transcript.requests[0].Recipient != "first@example.com" { + t.Fatalf("expected only first recipient before rate limit, got %#v", transcript.requests) + } +} + func TestActionService_SendWebhookEvent_QueuesDurableDelivery(t *testing.T) { dbProvider := setupAutomationTestDBProvider(t) db := dbProvider.DB() diff --git a/internal/handler/api/v1/conversation_handler.go b/internal/handler/api/v1/conversation_handler.go index d93bba52..74486fd9 100644 --- a/internal/handler/api/v1/conversation_handler.go +++ b/internal/handler/api/v1/conversation_handler.go @@ -721,6 +721,14 @@ func (h *ConversationHandler) Transcript(c *gin.Context) { return } if svcErr := h.conversationSvc.SendTranscript(c.Request.Context(), accountID, conversation.ID, req.Email); svcErr != nil { + if errors.Is(svcErr, service.ErrEmailTranscriptDisabled) { + c.JSON(http.StatusPaymentRequired, gin.H{"error": "Email transcript is not available on your plan"}) + return + } + if errors.Is(svcErr, service.ErrEmailRateLimited) { + c.Status(http.StatusTooManyRequests) + return + } handleServiceError(c, svcErr) return } diff --git a/internal/handler/api/v1/conversation_handler_test.go b/internal/handler/api/v1/conversation_handler_test.go index a1892202..2c197f2f 100644 --- a/internal/handler/api/v1/conversation_handler_test.go +++ b/internal/handler/api/v1/conversation_handler_test.go @@ -14,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "gorm.io/datatypes" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -395,6 +396,46 @@ func (s *ConversationHandlerTestSuite) TestTranscript_InvalidEmail() { assert.Empty(s.T(), w.Body.String()) } +func (s *ConversationHandlerTestSuite) TestTranscript_PaymentRequiredWhenDisabled() { + s.Require().NoError(s.db.Model(s.testAccount).Updates(map[string]any{ + "limits": datatypes.JSON(`{"email_transcript_enabled":false}`), + "custom_attributes": datatypes.JSON(`{}`), + }).Error) + s.T().Cleanup(func() { + _ = s.db.Model(s.testAccount).Updates(map[string]any{"limits": datatypes.JSON(`{}`), "custom_attributes": datatypes.JSON(`{}`)}).Error + }) + bodyBytes, _ := json.Marshal(map[string]string{"email": "test@example.com"}) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusPaymentRequired, w.Code) + var resp map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(s.T(), "Email transcript is not available on your plan", resp["error"]) +} + +func (s *ConversationHandlerTestSuite) TestTranscript_TooManyRequestsWhenRateLimited() { + s.Require().NoError(s.db.Model(s.testAccount).Updates(map[string]any{ + "limits": datatypes.JSON(`{"emails":1}`), + "custom_attributes": datatypes.JSON(`{"_outbound_email_count":{"date":"` + time.Now().Format("2006-01-02") + `","count":1}}`), + }).Error) + s.T().Cleanup(func() { + _ = s.db.Model(s.testAccount).Updates(map[string]any{"limits": datatypes.JSON(`{}`), "custom_attributes": datatypes.JSON(`{}`)}).Error + }) + bodyBytes, _ := json.Marshal(map[string]string{"email": "test@example.com"}) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", s.accountURL()+"/conversations/"+strconv.FormatUint(uint64(s.testConv.ID), 10)+"/transcript", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusTooManyRequests, w.Code) + assert.Empty(s.T(), w.Body.String()) +} + func (s *ConversationHandlerTestSuite) TestTranscript_ConversationNotFound() { body := map[string]string{"email": "test@example.com"} bodyBytes, _ := json.Marshal(body) diff --git a/internal/model/account.go b/internal/model/account.go index d577783d..90a74d26 100644 --- a/internal/model/account.go +++ b/internal/model/account.go @@ -20,6 +20,7 @@ type Account struct { CustomAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"custom_attributes,omitempty"` FeatureFlags string `gorm:"type:text" json:"feature_flags,omitempty"` // JSON-encoded feature flags AutoResolveDuration int `gorm:"default:0" json:"auto_resolve_duration,omitempty"` // days + Limits datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"limits,omitempty"` // Chatwoot account limits, e.g. emails AgentLimit int `gorm:"default:0" json:"agent_limit,omitempty"` // max agents allowed (0 = unlimited), Chatwoot usage_limits[:agents] InboxLimit int `gorm:"default:0" json:"inbox_limit,omitempty"` // max inboxes allowed (0 = unlimited), Chatwoot usage_limits[:inboxes] CaptainModels datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"captain_models,omitempty"` diff --git a/internal/model/account_email.go b/internal/model/account_email.go new file mode 100644 index 00000000..208e33a3 --- /dev/null +++ b/internal/model/account_email.go @@ -0,0 +1,133 @@ +package model + +import ( + "encoding/json" + "strconv" + "strings" + "time" + + "gorm.io/datatypes" +) + +const accountOutboundEmailCountKey = "_outbound_email_count" + +// LimitsMap returns the Chatwoot-style account limits JSON object. +func (a *Account) LimitsMap() map[string]any { + return jsonMap(a.Limits) +} + +// EmailTranscriptEnabled mirrors Account#email_transcript_enabled?. Community +// accounts allow transcripts by default; enterprise tests may disable it via +// limits or custom attributes. +func (a *Account) EmailTranscriptEnabled() bool { + if a == nil { + return true + } + for _, attrs := range []map[string]any{a.LimitsMap(), a.CustomAttributesMap()} { + if value, ok := firstPresent(attrs, "email_transcript_enabled", "email_transcript"); ok { + return truthy(value, true) + } + } + return true +} + +// EmailRateLimit returns the configured daily transcript/outbound email limit. +// A zero value means unlimited, matching non-cloud Chatwoot behavior. +func (a *Account) EmailRateLimit() int { + if a == nil { + return 0 + } + for _, attrs := range []map[string]any{a.LimitsMap(), a.CustomAttributesMap()} { + if value, ok := firstPresent(attrs, "emails", "email_rate_limit", "outbound_email_limit"); ok { + if n := intValue(value); n > 0 { + return n + } + } + } + return 0 +} + +func (a *Account) EmailsSentToday(now time.Time) int { + attrs := a.CustomAttributesMap() + value, ok := attrs[accountOutboundEmailCountKey] + if !ok { + return intValue(attrs["emails_sent_today"]) + } + record, ok := value.(map[string]any) + if !ok || strings.TrimSpace(stringValue(record["date"])) != now.Format("2006-01-02") { + return 0 + } + return intValue(record["count"]) +} + +func (a *Account) IncrementEmailSentCount(now time.Time) error { + attrs := a.CustomAttributesMap() + count := a.EmailsSentToday(now) + 1 + attrs[accountOutboundEmailCountKey] = map[string]any{ + "date": now.Format("2006-01-02"), + "count": count, + } + return a.SetCustomAttributesMap(attrs) +} + +func jsonMap(raw datatypes.JSON) map[string]any { + attrs := map[string]any{} + if len(raw) > 0 { + _ = json.Unmarshal(raw, &attrs) + } + return attrs +} + +func firstPresent(attrs map[string]any, keys ...string) (any, bool) { + for _, key := range keys { + if value, ok := attrs[key]; ok { + return value, true + } + } + return nil, false +} + +func truthy(value any, defaultValue bool) bool { + switch v := value.(type) { + case bool: + return v + case string: + s := strings.TrimSpace(strings.ToLower(v)) + if s == "" { + return defaultValue + } + return s == "true" || s == "1" || s == "yes" || s == "enabled" + case float64: + return v != 0 + case int: + return v != 0 + default: + return defaultValue + } +} + +func intValue(value any) int { + switch v := value.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case json.Number: + n, _ := v.Int64() + return int(n) + case string: + n, _ := strconv.Atoi(strings.TrimSpace(v)) + return n + default: + return 0 + } +} + +func stringValue(value any) string { + if s, ok := value.(string); ok { + return s + } + return "" +} diff --git a/internal/service/conversation_service.go b/internal/service/conversation_service.go index 9cec172d..daaf15ac 100644 --- a/internal/service/conversation_service.go +++ b/internal/service/conversation_service.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/gochat/gochat/internal/automation" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" @@ -22,20 +23,21 @@ import ( // ConversationService implements business logic for Conversation operations. // Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb type ConversationService struct { - repo *repository.ConversationRepo - msgRepo *repository.MessageRepo - dispatcher *channel.Dispatcher - inboxMemberSvc *InboxMemberService - accountUserRepo *repository.AccountUserRepo - teamRepo *repository.TeamRepo - teamMemberRepo *repository.TeamMemberRepo - searchIndexer SearchIndexer - appliedSlaSvc *AppliedSlaService + repo *repository.ConversationRepo + msgRepo *repository.MessageRepo + dispatcher *channel.Dispatcher + inboxMemberSvc *InboxMemberService + accountUserRepo *repository.AccountUserRepo + teamRepo *repository.TeamRepo + teamMemberRepo *repository.TeamMemberRepo + searchIndexer SearchIndexer + appliedSlaSvc *AppliedSlaService + transcriptMailer automation.AutomationTranscriptDeliverer } // NewConversationService creates a new Conversation service. func NewConversationService(repo *repository.ConversationRepo, msgRepo *repository.MessageRepo, dispatcher *channel.Dispatcher, inboxMemberSvc *InboxMemberService, accountUserRepo *repository.AccountUserRepo, teamRepo *repository.TeamRepo, teamMemberRepo *repository.TeamMemberRepo) *ConversationService { - return &ConversationService{repo: repo, msgRepo: msgRepo, dispatcher: dispatcher, inboxMemberSvc: inboxMemberSvc, accountUserRepo: accountUserRepo, teamRepo: teamRepo, teamMemberRepo: teamMemberRepo} + return &ConversationService{repo: repo, msgRepo: msgRepo, dispatcher: dispatcher, inboxMemberSvc: inboxMemberSvc, accountUserRepo: accountUserRepo, teamRepo: teamRepo, teamMemberRepo: teamMemberRepo, transcriptMailer: automation.NewEnvAutomationTranscriptDeliverer()} } func (s *ConversationService) SetSearchIndexer(indexer SearchIndexer) { @@ -46,6 +48,10 @@ func (s *ConversationService) SetAppliedSlaService(appliedSlaSvc *AppliedSlaServ s.appliedSlaSvc = appliedSlaSvc } +func (s *ConversationService) SetTranscriptDeliverer(deliverer automation.AutomationTranscriptDeliverer) { + s.transcriptMailer = deliverer +} + func (s *ConversationService) DB() *gorm.DB { if s == nil || s.repo == nil { return nil @@ -1454,24 +1460,81 @@ func (s *ConversationService) MarkUnread(ctx context.Context, accountID, id uint // SendTranscript sends a conversation transcript via email. // Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb#transcript func (s *ConversationService) SendTranscript(ctx context.Context, accountID, conversationID uint, email string) error { + recipient := strings.TrimSpace(email) + if recipient == "" { + return errors.New("email address is required for transcript") + } + conversation, err := s.repo.FindByAccountAndID(ctx, accountID, conversationID) if err != nil { return err } - // In a full implementation, this would generate the transcript and send via an email service. - // For now, we log the transcript request for future integration. - applogger.L().Infof("Transcript request: conversation=%d, account=%d, email=%s, contact=%d", - conversation.ID, conversation.AccountID, email, conversation.ContactID) - - // Validate email is not empty - if strings.TrimSpace(email) == "" { - return errors.New("email address is required for transcript") + var account model.Account + if err := s.repo.DB().WithContext(ctx).First(&account, accountID).Error; err != nil { + return err + } + now := time.Now() + if !account.EmailTranscriptEnabled() { + return ErrEmailTranscriptDisabled + } + if limit := account.EmailRateLimit(); limit > 0 && account.EmailsSentToday(now) >= limit { + return ErrEmailRateLimited } + subject, body, err := s.buildTranscriptEmail(ctx, accountID, conversation) + if err != nil { + return err + } + if s.transcriptMailer != nil { + _, err = s.transcriptMailer.DeliverTranscript(ctx, automation.AutomationTranscriptRequest{ + AccountID: accountID, + ConversationID: conversation.ID, + Recipient: recipient, + Subject: subject, + Body: body, + }) + if err != nil { + return err + } + } + if err := account.IncrementEmailSentCount(now); err != nil { + return err + } + if err := s.repo.DB().WithContext(ctx).Model(&model.Account{}).Where("id = ?", account.ID).Update("custom_attributes", account.CustomAttributes).Error; err != nil { + return err + } + applogger.L().Infof("Transcript request: conversation=%d, account=%d, email=%s, contact=%d", + conversation.ID, conversation.AccountID, recipient, conversation.ContactID) + return nil } +func (s *ConversationService) buildTranscriptEmail(ctx context.Context, accountID uint, conversation *model.Conversation) (string, string, error) { + var messages []model.Message + if err := s.repo.DB().WithContext(ctx). + Where("conversation_id = ? AND account_id = ? AND private = ? AND message_type IN ?", conversation.ID, accountID, false, []string{string(model.MessageTypeIncoming), string(model.MessageTypeOutgoing)}). + Order("id ASC"). + Find(&messages).Error; err != nil { + return "", "", err + } + + displayID := conversation.ID + if conversation.DisplayID != nil && *conversation.DisplayID > 0 { + displayID = *conversation.DisplayID + } + subject := fmt.Sprintf("[#%d] Conversation Transcript", displayID) + var body strings.Builder + body.WriteString(fmt.Sprintf("Conversation #%d transcript\n\n", displayID)) + for _, message := range messages { + if strings.TrimSpace(message.Content) == "" { + continue + } + body.WriteString(fmt.Sprintf("[%s] %s\n", message.MessageType, message.Content)) + } + return subject, body.String(), nil +} + // UpdateCustomAttributes updates the custom attributes of a conversation. // Reference: Chatwoot app/controllers/api/v1/conversations_controller.rb#custom_attributes func (s *ConversationService) UpdateCustomAttributes(ctx context.Context, accountID, id uint, attrs datatypes.JSON) (*model.Conversation, error) { @@ -1503,6 +1566,8 @@ type UnreadCountsPayload struct { } var ErrConversationUnreadCountsFeatureNotEnabled = errors.New("Conversation unread counts feature not enabled for this account") +var ErrEmailTranscriptDisabled = errors.New("Email transcript is not available on your plan") +var ErrEmailRateLimited = errors.New("email transcript rate limit exceeded") // GetUnreadCounts returns unread conversation counts grouped by inbox, label, and team. // Reference: Chatwoot app/services/conversations/unread_counts/counter.rb diff --git a/internal/service/conversation_service_test.go b/internal/service/conversation_service_test.go index 32c1cee5..e7a3f8b4 100644 --- a/internal/service/conversation_service_test.go +++ b/internal/service/conversation_service_test.go @@ -12,6 +12,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" + "github.com/gochat/gochat/internal/automation" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" @@ -120,6 +121,24 @@ func (l *captureConversationEventsListener) OnEvent(ctx context.Context, event * return nil } +type recordingConversationTranscriptDeliverer struct { + requests []automation.AutomationTranscriptRequest + result automation.ActionDeliveryResult + err error +} + +func (d *recordingConversationTranscriptDeliverer) DeliverTranscript(ctx context.Context, req automation.AutomationTranscriptRequest) (automation.ActionDeliveryResult, error) { + d.requests = append(d.requests, req) + result := d.result + if result.DeliveryType == "" { + result.DeliveryType = "email_transcript" + } + if result.Target == "" { + result.Target = req.Recipient + } + return result, d.err +} + func createConversationServiceTestSlaPolicy(t *testing.T, db *gorm.DB, accountID uint) *model.SlaPolicy { t.Helper() policy := &model.SlaPolicy{ @@ -659,15 +678,35 @@ func TestConversationService_UpdateLastSeen_ThrottlesRecentNoUnreadTimestamps(t func TestConversationService_SendTranscript(t *testing.T) { svc, db := setupConversationService(t) + deliverer := &recordingConversationTranscriptDeliverer{} + svc.SetTranscriptDeliverer(deliverer) account := createConversationServiceTestAccount(t, db) inbox := createConversationServiceTestInbox(t, db, account.ID) contact := createConversationServiceTestContact(t, db, account.ID) conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + require.NoError(t, db.Model(conv).Update("display_id", 42).Error) + require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "visible incoming", ContentType: "text", MessageType: string(model.MessageTypeIncoming)}).Error) + require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "visible outgoing", ContentType: "text", MessageType: string(model.MessageTypeOutgoing)}).Error) + require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "hidden private", ContentType: "text", MessageType: string(model.MessageTypeIncoming), Private: true}).Error) + require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "hidden activity", ContentType: "text", MessageType: string(model.MessageTypeActivity)}).Error) + require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, Content: "hidden template", ContentType: "text", MessageType: string(model.MessageTypeTemplate)}).Error) err := svc.SendTranscript(context.Background(), account.ID, conv.ID, "test@example.com") assert.NoError(t, err) + require.Len(t, deliverer.requests, 1) + assert.Equal(t, "test@example.com", deliverer.requests[0].Recipient) + assert.Equal(t, "[#42] Conversation Transcript", deliverer.requests[0].Subject) + assert.Contains(t, deliverer.requests[0].Body, "visible incoming") + assert.Contains(t, deliverer.requests[0].Body, "visible outgoing") + assert.NotContains(t, deliverer.requests[0].Body, "hidden private") + assert.NotContains(t, deliverer.requests[0].Body, "hidden activity") + assert.NotContains(t, deliverer.requests[0].Body, "hidden template") + + var updated model.Account + require.NoError(t, db.First(&updated, account.ID).Error) + assert.Equal(t, 1, updated.EmailsSentToday(time.Now())) } func TestConversationService_SendTranscript_EmptyEmail(t *testing.T) { @@ -705,6 +744,41 @@ func TestConversationService_SendTranscript_ConversationNotFound(t *testing.T) { assert.Error(t, err) } +func TestConversationService_SendTranscript_Disabled(t *testing.T) { + svc, db := setupConversationService(t) + deliverer := &recordingConversationTranscriptDeliverer{} + svc.SetTranscriptDeliverer(deliverer) + + account := createConversationServiceTestAccount(t, db) + require.NoError(t, db.Model(account).Update("limits", datatypes.JSON(`{"email_transcript_enabled":false}`)).Error) + inbox := createConversationServiceTestInbox(t, db, account.ID) + contact := createConversationServiceTestContact(t, db, account.ID) + conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + + err := svc.SendTranscript(context.Background(), account.ID, conv.ID, "test@example.com") + assert.ErrorIs(t, err, ErrEmailTranscriptDisabled) + assert.Empty(t, deliverer.requests) +} + +func TestConversationService_SendTranscript_RateLimited(t *testing.T) { + svc, db := setupConversationService(t) + deliverer := &recordingConversationTranscriptDeliverer{} + svc.SetTranscriptDeliverer(deliverer) + + account := createConversationServiceTestAccount(t, db) + require.NoError(t, db.Model(account).Updates(map[string]any{ + "limits": datatypes.JSON(`{"emails":1}`), + "custom_attributes": datatypes.JSON(`{"_outbound_email_count":{"date":"` + time.Now().Format("2006-01-02") + `","count":1}}`), + }).Error) + inbox := createConversationServiceTestInbox(t, db, account.ID) + contact := createConversationServiceTestContact(t, db, account.ID) + conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open") + + err := svc.SendTranscript(context.Background(), account.ID, conv.ID, "test@example.com") + assert.ErrorIs(t, err, ErrEmailRateLimited) + assert.Empty(t, deliverer.requests) +} + // ========== UpdateCustomAttributes Tests ========== func TestConversationService_UpdateCustomAttributes(t *testing.T) { diff --git a/migrations/000001_init_schema.up.sql b/migrations/000001_init_schema.up.sql index d01288bd..42979dbb 100644 --- a/migrations/000001_init_schema.up.sql +++ b/migrations/000001_init_schema.up.sql @@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS accounts ( timezone VARCHAR(50) DEFAULT 'UTC', domain VARCHAR(255), feature_flags TEXT DEFAULT '{}', + limits JSONB NOT NULL DEFAULT '{}', status VARCHAR(50) DEFAULT 'active', active BOOLEAN DEFAULT TRUE, auto_resolve_duration INTEGER DEFAULT 0, diff --git a/migrations/000037_add_accounts_limits.down.sql b/migrations/000037_add_accounts_limits.down.sql new file mode 100644 index 00000000..fb169d5f --- /dev/null +++ b/migrations/000037_add_accounts_limits.down.sql @@ -0,0 +1 @@ +ALTER TABLE accounts DROP COLUMN IF EXISTS limits; diff --git a/migrations/000037_add_accounts_limits.up.sql b/migrations/000037_add_accounts_limits.up.sql new file mode 100644 index 00000000..ac306b9c --- /dev/null +++ b/migrations/000037_add_accounts_limits.up.sql @@ -0,0 +1 @@ +ALTER TABLE accounts ADD COLUMN IF NOT EXISTS limits JSONB NOT NULL DEFAULT '{}';