feat(conversations): align transcript delivery
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE accounts DROP COLUMN IF EXISTS limits;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS limits JSONB NOT NULL DEFAULT '{}';
|
||||
Reference in New Issue
Block a user