diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index a0dc3357..a6ea8bd4 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-09 P3.166 agent bot message sendable parity, prepared as `fix(webhooks): align bot message sendable`. -- Latest implementation checkpoint: this checkpoint, prepared as `fix(webhooks): align bot message sendable`. -- Latest documentation/tooling checkpoint: this tracker update records Chatwoot-compatible agent bot message sendable filtering that skips activity/template-exception messages for `message_created`/`message_updated`, matching Chatwoot `MessageFilterHelpers#webhook_sendable?`, while retaining P3.165 opened/resolved event names, P3.164 dispatcher registration, P3.163 assigned agent bot delivery, and P3.162 agent bot webhook request headers and P3.161 agent bot webhook payload shape, P3.160 agent bot webhook failure handling from top-level payload `id`, P3.159 `WEBHOOK_TIMEOUT` configuration support for account/API/agent-bot webhook clients, P3.158 5-second default timeout fallback, and P3.157 `keep_pending_on_bot_failure` behavior for agent bot webhook failures, P3.156 retryable agent bot webhook error handling that skips conversation reopen for 429/500 statuses, P3.155 agent bot webhook failure activity message parity, P3.147 inbox created/updated account webhook delivery, P3.146 contact created/updated account webhook delivery, P3.145 conversation typing webhook delivery, P3.144 account webhook delivery for subscribed message/conversation events, P3.143 API inbox webhook delivery, P3.142 API channel `reset_secret` behavior and P3.141 API channel webhook signing `secret` persistence, P3.140 API channel `agent_reply_time_window` validation, P3.139 API channel `additional_attributes` persistence, P3.138 API channel `hmac_mandatory`/identifier/HMAC-token persistence, P3.137 public API `hmac_mandatory` identity-validation behavior, P3.136 public HMAC-verified conversation scoping, P3.135 public conversation embedded-message attachment serialization, durable maintenance search-index clock preservation, P3.134 public message attachment creation/list serialization, P3.133 public message index latest/before windows, P3.132 public message content-length validation, P3.131 public conversation message visibility, P3.130 widget config invalid-token and suspended-account status behavior, P3.129 widget contact show/update/destroy-custom-attributes `404` behavior, P3.128 widget campaigns/events/inbox-members `404` and campaign feature-gate behavior, P3.127 widget label action `404` and undefined-label no-op behavior, P3.126 widget conversation helper `404` behavior, P3.125 widget incoming-message reopen behavior, P3.124 widget input-email contact identification, P3.123 widget message index finder behavior, P3.122 widget Dyte participant token scoping, P3.121 widget set-user HMAC gating, P3.120 macro webhook event naming, P3.119 widget message reply metadata/latest-conversation reuse, and P3.118 widget message content-length validation, P3.117 first-message widget conversation metadata behavior, P3.116 widget contact custom-attribute deletion routing, P3.115 widget transcript delivery and status behavior, P3.114 widget end-conversation gating, P3.113 widget conversation response semantics, P3.112 CSAT review-note update behavior, P3.111 date-range behavior, P3.110 CSV recorded-at formatting, P3.109 fixed report pagination, and P3.108 CSAT message-window send behavior. +- Current tracking checkpoint: 2026-06-10 P3.209 widget/public attachment push-event serializer parity, prepared as `fix(widget): align attachment payloads`. +- Latest implementation checkpoint: this checkpoint, prepared as `fix(widget): align attachment payloads`. +- Latest documentation/tooling checkpoint: this tracker update records P3.209 widget/public attachment push-event serializer parity, matching Chatwoot widget and public message serializers that embed `attachment.push_event_data` by including `account_id` and omitting REST-only `created_at`, while retaining P3.208 message attachment push-event serializer parity, matching Chatwoot message serializers that embed `attachment.push_event_data` with `account_id` while keeping P3.207 REST attachment-list partials free of `account_id`, while retaining P3.207 REST attachment serializer parity, matching Chatwoot `api/v1/models/_attachment.json.jbuilder` by omitting the non-reference `account_id` field from REST attachment payloads while retaining push-event attachment `account_id` behavior, while retaining P3.206 notification conversation contact-inbox serializer parity, matching Chatwoot conversation `push_event_data` contact_inbox shape with `source_id` and nested `inbox_slim`, while retaining P3.205 inbox callback webhook URL serializer parity, matching Chatwoot `Inbox#callback_webhook_url` computed values for Twilio, SMS, LINE, and WhatsApp channels from `FRONTEND_URL`, while retaining P3.204 inbox sender-name-type serializer parity, matching Chatwoot inbox enum values `friendly` and `professional` while normalizing legacy GoChat aliases, while retaining P3.203 inbox conditional channel-field serializer parity, matching Chatwoot inbox partial behavior for inbound-mail forwarding fields and Twilio voice webhook fields, while retaining P3.202 voice-call message call serializer parity, matching Chatwoot enterprise `Message#push_event_data` and message partial behavior that embed `call.push_event_data` for `voice_call` messages, while retaining P3.201 message payload account-id serializer parity, matching Chatwoot `Message#push_event_data` account model attributes by exposing `account_id` in GoChat shared message payloads, hydrated search message payloads, and nested conversation-search message payloads, while retaining P3.200 email OAuth reauthorization serializer parity, matching Chatwoot email inbox partial behavior that exposes admin-only `reauthorization_required` for Google/Microsoft/legacy Google providers when `provider_config` is empty or the channel is marked reauthorization-required, while retaining P3.199 inbox slim serializer parity, matching Chatwoot `api/v1/models/_inbox_slim.json.jbuilder` with `id`, `avatar_url`, `channel_id`, `name`, `channel_type`, and `provider`, while removing the non-reference `account_id` field from slim inbox payloads, while retaining P3.198 contact create contact-inbox shell parity, matching Chatwoot contact create response `contact_inbox.inbox` to the `inbox_slim` partial instead of leaking a raw local inbox model, while retaining P3.197 Twitter inbox tweets-enabled parity, matching Chatwoot inbox partial `tweets_enabled` for `Channel::TwitterProfile` inboxes and Twitter channel `tweets_enabled` storage, while retaining P3.196 contact availability serializer parity, matching Chatwoot contact partial `availability_status` from live `OnlineStatusTracker` presence in contact and conversation payloads, while retaining P3.195 inbox help-center serializer parity, matching Chatwoot inbox partial `help_center` embedding when an inbox is linked to a portal, while retaining P3.194 inbox sensitive-field admin gating parity, matching Chatwoot inbox partial administrator-only exposure for channel secrets and credentials, while retaining P3.193 shared agent serializer custom fields parity, matching Chatwoot agent partial `custom_attributes` and enterprise `custom_role_id` in shared user payloads used by conversations, message senders, CSAT, SLA, and notes, while retaining P3.192 conversation team and SLA event serializer parity, matching Chatwoot team partial `allow_auto_assign`/`is_member` naming and enterprise conversation partial `sla_events`, while retaining P3.191 notification actor serialization parity, resolving `primary_actor`, `secondary_actor`, and `user` through DB-backed Chatwoot `push_event_data` shapes for conversations, contacts, users, and agent bots while preserving account-scoped fallbacks, while retaining P3.190 company `additional_attributes` model/request/serializer parity, matching Chatwoot enterprise company params and model serializer, while retaining P3.189 nested contact inbox account scoping parity, matching Chatwoot's `Current.account.contacts` parent lookup, while retaining P3.188 contact identity validation parity for account-scoped email/identifier/phone uniqueness and Chatwoot phone-number format validation, while retaining P3.187 contact create/update `phone_number` param parity, matching Chatwoot `contacts#permitted_params`, while retaining P3.186 contact update attribute merge parity, where Chatwoot `contacts#update` merges `custom_attributes` and `additional_attributes` with existing values instead of replacing them, while retaining P3.185 three Chatwoot-compatible conversation partial serializer fixes: (1) `last_non_activity_message` now uses the newest non-activity message by `created_at DESC` instead of only the latest message when non-activity, (2) `meta.assignee` now emits `agent_bot_slim` shape with `assignee_type: "AgentBot"` when `assignee_agent_bot_id` is set, and (3) the embedded latest message now uses `created_at DESC, id DESC` ordering matching Chatwoot `Message.default_scope { order(created_at: :asc) }` plus `.last`, while retaining P3.184 message create scoping to the route conversation and P3.183 nested message action route lookup by conversation `display_id`, while retaining P3.182 translate response shape (`{ content }`) and empty cached-translation response behavior, while retaining P3.181 conversation-scoped translation lookup plus `content_attributes.translations` caching/reuse, while retaining P3.180 retry lookup failure handling as 422 unprocessable entity responses, while retaining P3.179 status-only message update behavior guarded to API inbox conversations, while retaining P3.178 `in_reply_to`/`in_reply_to_external_id` normalization for message creation while preserving bot `items`, while retaining P3.177 conversation-scoped message show/update/delete/retry lookups, while retaining P3.176 email message `email.text_content`/`email.html_content` generation, while retaining P3.175 structured message content types and empty-content creation for bot UI messages, while retaining P3.174 `external_created_at` persistence into message `content_attributes`, while retaining P3.173 stringified `content_attributes` parsing, while retaining P3.172 `is_voice_message` audio attachment metadata tagging, while retaining P3.171 `sender_type=AgentBot` message sender resolution and serialization, while retaining P3.170 email message `cc_emails`, `bcc_emails`, and `to_emails` persistence into `content_attributes`, while retaining P3.169 `campaign_id` and `template_params` persistence into message `additional_attributes`, while retaining P3.168 behavior that allows `incoming` messages only for API inbox conversations, while retaining P3.167 conversation `can_reply` serialization using `Conversations::MessageWindowService` message-window behavior for API, Facebook, Instagram, WhatsApp, TikTok, and Twilio WhatsApp channels, while retaining P3.166 agent bot message sendable filtering that skips activity/template-exception messages for `message_created`/`message_updated`, matching Chatwoot `MessageFilterHelpers#webhook_sendable?`, while retaining P3.165 opened/resolved event names, P3.164 dispatcher registration, P3.163 assigned agent bot delivery, and P3.162 agent bot webhook request headers and P3.161 agent bot webhook payload shape, P3.160 agent bot webhook failure handling from top-level payload `id`, P3.159 `WEBHOOK_TIMEOUT` configuration support for account/API/agent-bot webhook clients, P3.158 5-second default timeout fallback, and P3.157 `keep_pending_on_bot_failure` behavior for agent bot webhook failures, P3.156 retryable agent bot webhook error handling that skips conversation reopen for 429/500 statuses, P3.155 agent bot webhook failure activity message parity, P3.147 inbox created/updated account webhook delivery, P3.146 contact created/updated account webhook delivery, P3.145 conversation typing webhook delivery, P3.144 account webhook delivery for subscribed message/conversation events, P3.143 API inbox webhook delivery, P3.142 API channel `reset_secret` behavior and P3.141 API channel webhook signing `secret` persistence, P3.140 API channel `agent_reply_time_window` validation, P3.139 API channel `additional_attributes` persistence, P3.138 API channel `hmac_mandatory`/identifier/HMAC-token persistence, P3.137 public API `hmac_mandatory` identity-validation behavior, P3.136 public HMAC-verified conversation scoping, P3.135 public conversation embedded-message attachment serialization, durable maintenance search-index clock preservation, P3.134 public message attachment creation/list serialization, P3.133 public message index latest/before windows, P3.132 public message content-length validation, P3.131 public conversation message visibility, P3.130 widget config invalid-token and suspended-account status behavior, P3.129 widget contact show/update/destroy-custom-attributes `404` behavior, P3.128 widget campaigns/events/inbox-members `404` and campaign feature-gate behavior, P3.127 widget label action `404` and undefined-label no-op behavior, P3.126 widget conversation helper `404` behavior, P3.125 widget incoming-message reopen behavior, P3.124 widget input-email contact identification, P3.123 widget message index finder behavior, P3.122 widget Dyte participant token scoping, P3.121 widget set-user HMAC gating, P3.120 macro webhook event naming, P3.119 widget message reply metadata/latest-conversation reuse, and P3.118 widget message content-length validation, P3.117 first-message widget conversation metadata behavior, P3.116 widget contact custom-attribute deletion routing, P3.115 widget transcript delivery and status behavior, P3.114 widget end-conversation gating, P3.113 widget conversation response semantics, P3.112 CSAT review-note update behavior, P3.111 date-range behavior, P3.110 CSV recorded-at formatting, P3.109 fixed report pagination, and P3.108 CSAT message-window send behavior. - 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: `AgentBotListener` now filters message events by message type before delivery, sending only `incoming`/`outgoing`/`template` messages to agent bots and skipping `activity`/`csat`/other non-sendable message types, matching Chatwoot `MessageFilterHelpers#webhook_sendable?`; `AgentBotListener` still maps `EventConversationOpened` to `conversation_opened` and `EventConversationResolved` to `conversation_resolved`, implements the channel dispatcher listener interface, and is registered in bootstrap so message/conversation/webwidget events actually reach agent bot webhook delivery; conversations still expose `assignee_agent_bot_id`, and agent bot webhook delivery includes the assigned agent bot alongside the active inbox bot, de-duplicating when they are the same bot, matching Chatwoot `AgentBotListener#agent_bots_for`; agent bot webhook delivery still sends Chatwoot-style headers: JSON `Accept`, per-delivery `X-Chatwoot-Delivery`, and `X-Chatwoot-Timestamp` plus `X-Chatwoot-Signature` over `timestamp.body` when the bot has a secret, removing the previous local `X-Signature`/`X-Agent-Bot-*` delivery headers; agent bot webhook delivery still sends event data as top-level JSON fields with `event`, `account_id`, and `inbox_id`, without the prior local `data`/`timestamp` wrapper, matching Chatwoot `AgentBotListener` payloads built from `message.webhook_data` / `conversation.webhook_data`; agent bot webhook failure handling still resolves the failed message from top-level payload `id` when conversation data is absent, then uses that message conversation for pending reopen / keep-pending behavior, matching Chatwoot `Webhooks::Trigger#message_id`, `#message`, and `#update_conversation_status`; outgoing account/API webhooks and agent bot webhooks still read positive integer `WEBHOOK_TIMEOUT` installation config values for HTTP client timeouts and fall back to the Chatwoot-style 5-second default for missing, blank, or invalid values, matching `Webhooks::Trigger#webhook_timeout`; accounts still expose `keep_pending_on_bot_failure`, and non-retryable agent bot webhook failures skip reopening pending conversations and skip bot-failure activity messages when that setting is enabled, matching Chatwoot account settings and `Webhooks::Trigger#update_conversation_status`; agent bot webhook failures for 429/500 statuses still skip conversation reopen to allow worker retry, matching Chatwoot `Webhooks::Trigger#retryable_agent_bot_error?`; non-retryable failures still reopen pending conversations AND create activity messages when the account setting is disabled, matching `#update_conversation_status` and `#create_agent_bot_error_activity`; `webwidget_triggered` events still flow from the widget service `TrackEvent` through the webhook listener to subscribed account webhooks with Chatwoot event name; conversation_updated webhook tests still verify nested custom_attributes changed-attribute formatting matches Chatwoot previous/current values; API inbox webhook failures for message_created/message_updated still mark the message status as `failed` in the database, matching Chatwoot `Webhooks::Trigger#update_message_status`; activity messages still skip webhook delivery matching Chatwoot `webhook_sendable?`; outgoing account/API inbox webhook requests now include Chatwoot-style `X-Chatwoot-Delivery` UUID headers; conversation opened/resolved events still flow as Chatwoot `conversation_status_changed` webhooks with previous/current status change payloads through both account and API inbox webhook paths; inbox created/updated events still flow to subscribed account webhooks, with inbox_updated skipped when changed attributes are blank and formatted as Chatwoot previous/current values when present; contact created/updated events still flow to subscribed account webhooks, with contact_updated skipped when changed attributes are blank and formatted as Chatwoot previous/current values when present; conversation typing_on/typing_off events still flow through the generic webhook listener to subscribed account webhooks and configured API inbox webhooks; account webhook subscriptions still receive subscribed message/conversation events as signed `account_webhook` POSTs, while API inbox message/conversation webhook events still POST Chatwoot-style payloads to `channel_api.webhook_url` as `api_inbox_webhook`, signed with `channel_api.secret`; API inbox reset_secret still regenerates the webhook signing `secret` on `channel_api` and the mirrored inbox secret while preserving the public HMAC token; API inbox create/update still persists the webhook signing `secret` on `channel_api`, validates `additional_attributes.agent_reply_time_window` as a positive integer, and syncs identifier, HMAC token, `hmac_mandatory`, webhook URL, and `additional_attributes` into the `channel_api` row used by public lookup/reset-secret paths, so public API inboxes continue to expose `identity_validation_enabled` from `channel_api.hmac_mandatory` and reject contact create/update without a valid `identifier_hash` when HMAC is mandatory, matching Chatwoot `process_hmac`; public HMAC-verified contacts still show and operate on conversations scoped to the contact across inbox contact-inbox records, while non-verified contacts remain scoped to their current contact inbox. Public inbox conversation index/show payloads still serialize attachments for embedded messages, and public message create/list still accepts staged widget upload signed IDs from multipart/JSON `attachments`, creates `Attachment` rows with Chatwoot-style file metadata, marks direct uploads completed, and serializes attachments on public message create/list payloads. Conversation maintenance search-index registration also preserves the worker clock so queued maintenance index jobs remain immediately processable in deterministic worker runs. This retains P3.133 public message finder-window parity, P3.132 public message content-length parity, P3.131 public conversation message visibility parity, P3.130 widget config status parity, P3.129 widget contact action status parity, P3.128 widget public-support endpoint parity, P3.127 widget label action status parity, P3.126 widget conversation helper status parity, P3.125 widget incoming reopen parity, P3.124 widget input-email parity, P3.123 widget message index parity, P3.122 widget Dyte participant parity, P3.121 widget set-user HMAC parity, P3.120 macro webhook event parity, P3.119 widget reply-to parity, P3.118 content-length parity, P3.117 first-message conversation metadata parity, P3.116 widget contact custom-attribute route parity, P3.115 widget transcript delivery parity, P3.114 widget end-conversation gate parity, P3.113 widget conversation response parity, P3.112 CSAT review-note update parity, P3.111 date-range behavior, P3.110 CSV recorded-at formatting, P3.109 fixed report pagination, P3.108 message-window send behavior, P3.107 Captain custom-tool policy/auth serialization parity, P3.106 custom-tool validation parity, P5.3i CSAT survey indexing, 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: message create now always resolves and uses the route conversation, ignoring conflicting body `conversation_id`, matching Chatwoot `@conversation.messages.build`; nested message show/update/delete/retry/translate actions still resolve the route `conversation_id` as Chatwoot display_id before scoping message lookup; message translate still returns raw Chatwoot `{ content }` payloads for new translations and empty `200 OK` responses for cached translations; message translate still scopes lookup to the route conversation and stores/reuses translated content in `content_attributes.translations`, matching Chatwoot `already_translated_content_available?`; message retry lookups still return 422 unprocessable entity on failures, matching Chatwoot `render_could_not_create_error` behavior through `Messages::StatusUpdateService`; message update still matches Chatwoot `MessagesController#update` and `ensure_api_inbox` by accepting only status/external-error updates and rejecting non-API inbox conversations; message creation still normalizes reply metadata against messages in the same conversation, setting `content_attributes.in_reply_to` and `content_attributes.in_reply_to_external_id` like Chatwoot `Messages::InReplyToMessageBuilder` while preserving bot `items`; message show/update/delete/retry still scope message lookup to the route conversation before mutating or returning data, matching Chatwoot `@conversation.messages.find`; email inbox outgoing messages now generate nested `email.text_content.full/reply` and `email.html_content.full/reply` content attributes from `content`/`email_html_content`; message creation still accepts Chatwoot content types such as `input_select`, `cards`, `input_textarea`, `form`, `article`, `incoming_email`, and `sticker`, and permits structured UI messages with content attributes but no text body; message creation still persists `external_created_at` request values into message `content_attributes`, matching Chatwoot `Messages::MessageBuilder#external_created_at`; message creation still parses stringified JSON `content_attributes` into object attributes, matching Chatwoot `Messages::MessageBuilder#content_attributes`; message creation still tags audio attachments with `metadata.is_voice_message=true` when `is_voice_message` is set, matching Chatwoot `Messages::MessageBuilder#tag_voice_message`; message creation still resolves `sender_type=AgentBot` plus `sender_id` to accessible account/global agent bots, falls back to the current user when the bot is missing, and serializes agent-bot message senders with Chatwoot `push_event_data`; email inbox message creation still normalizes comma-separated `cc_emails`, `bcc_emails`, and `to_emails` request values into message `content_attributes`, preserving existing content attributes; message creation still persists `campaign_id` and `template_params` request values into message `additional_attributes`, matching Chatwoot `Messages::MessageBuilder#campaign_id` and `#template_params`; message creation still rejects `incoming` messages outside API inbox conversations with Chatwoot `MessageBuilder#message_type` error text, while API inbox conversations still accept incoming messages; conversation serializers still calculate `can_reply` from the last incoming account-scoped message and per-channel messaging windows: API `agent_reply_time_window`, Meta 24h/7d Human Agent windows, WhatsApp 24h, TikTok 48h, and Twilio WhatsApp 24h; `AgentBotListener` still filters message events by message type before delivery, sending only `incoming`/`outgoing`/`template` messages to agent bots and skipping `activity`/`csat`/other non-sendable message types, matching Chatwoot `MessageFilterHelpers#webhook_sendable?`; `AgentBotListener` still maps `EventConversationOpened` to `conversation_opened` and `EventConversationResolved` to `conversation_resolved`, implements the channel dispatcher listener interface, and is registered in bootstrap so message/conversation/webwidget events actually reach agent bot webhook delivery; conversations still expose `assignee_agent_bot_id`, and agent bot webhook delivery includes the assigned agent bot alongside the active inbox bot, de-duplicating when they are the same bot, matching Chatwoot `AgentBotListener#agent_bots_for`; agent bot webhook delivery still sends Chatwoot-style headers: JSON `Accept`, per-delivery `X-Chatwoot-Delivery`, and `X-Chatwoot-Timestamp` plus `X-Chatwoot-Signature` over `timestamp.body` when the bot has a secret, removing the previous local `X-Signature`/`X-Agent-Bot-*` delivery headers; agent bot webhook delivery still sends event data as top-level JSON fields with `event`, `account_id`, and `inbox_id`, without the prior local `data`/`timestamp` wrapper, matching Chatwoot `AgentBotListener` payloads built from `message.webhook_data` / `conversation.webhook_data`; agent bot webhook failure handling still resolves the failed message from top-level payload `id` when conversation data is absent, then uses that message conversation for pending reopen / keep-pending behavior, matching Chatwoot `Webhooks::Trigger#message_id`, `#message`, and `#update_conversation_status`; outgoing account/API webhooks and agent bot webhooks still read positive integer `WEBHOOK_TIMEOUT` installation config values for HTTP client timeouts and fall back to the Chatwoot-style 5-second default for missing, blank, or invalid values, matching `Webhooks::Trigger#webhook_timeout`; accounts still expose `keep_pending_on_bot_failure`, and non-retryable agent bot webhook failures skip reopening pending conversations and skip bot-failure activity messages when that setting is enabled, matching Chatwoot account settings and `Webhooks::Trigger#update_conversation_status`; agent bot webhook failures for 429/500 statuses still skip conversation reopen to allow worker retry, matching Chatwoot `Webhooks::Trigger#retryable_agent_bot_error?`; non-retryable failures still reopen pending conversations AND create activity messages when the account setting is disabled, matching `#update_conversation_status` and `#create_agent_bot_error_activity`; `webwidget_triggered` events still flow from the widget service `TrackEvent` through the webhook listener to subscribed account webhooks with Chatwoot event name; conversation_updated webhook tests still verify nested custom_attributes changed-attribute formatting matches Chatwoot previous/current values; API inbox webhook failures for message_created/message_updated still mark the message status as `failed` in the database, matching Chatwoot `Webhooks::Trigger#update_message_status`; activity messages still skip webhook delivery matching Chatwoot `webhook_sendable?`; outgoing account/API inbox webhook requests now include Chatwoot-style `X-Chatwoot-Delivery` UUID headers; conversation opened/resolved events still flow as Chatwoot `conversation_status_changed` webhooks with previous/current status change payloads through both account and API inbox webhook paths; inbox created/updated events still flow to subscribed account webhooks, with inbox_updated skipped when changed attributes are blank and formatted as Chatwoot previous/current values when present; contact created/updated events still flow to subscribed account webhooks, with contact_updated skipped when changed attributes are blank and formatted as Chatwoot previous/current values when present; conversation typing_on/typing_off events still flow through the generic webhook listener to subscribed account webhooks and configured API inbox webhooks; account webhook subscriptions still receive subscribed message/conversation events as signed `account_webhook` POSTs, while API inbox message/conversation webhook events still POST Chatwoot-style payloads to `channel_api.webhook_url` as `api_inbox_webhook`, signed with `channel_api.secret`; API inbox reset_secret still regenerates the webhook signing `secret` on `channel_api` and the mirrored inbox secret while preserving the public HMAC token; API inbox create/update still persists the webhook signing `secret` on `channel_api`, validates `additional_attributes.agent_reply_time_window` as a positive integer, and syncs identifier, HMAC token, `hmac_mandatory`, webhook URL, and `additional_attributes` into the `channel_api` row used by public lookup/reset-secret paths, so public API inboxes continue to expose `identity_validation_enabled` from `channel_api.hmac_mandatory` and reject contact create/update without a valid `identifier_hash` when HMAC is mandatory, matching Chatwoot `process_hmac`; public HMAC-verified contacts still show and operate on conversations scoped to the contact across inbox contact-inbox records, while non-verified contacts remain scoped to their current contact inbox. Public inbox conversation index/show payloads still serialize attachments for embedded messages, and public message create/list still accepts staged widget upload signed IDs from multipart/JSON `attachments`, creates `Attachment` rows with Chatwoot-style file metadata, marks direct uploads completed, and serializes attachments on public message create/list payloads. Conversation maintenance search-index registration also preserves the worker clock so queued maintenance index jobs remain immediately processable in deterministic worker runs. This retains P3.133 public message finder-window parity, P3.132 public message content-length parity, P3.131 public conversation message visibility parity, P3.130 widget config status parity, P3.129 widget contact action status parity, P3.128 widget public-support endpoint parity, P3.127 widget label action status parity, P3.126 widget conversation helper status parity, P3.125 widget incoming reopen parity, P3.124 widget input-email parity, P3.123 widget message index parity, P3.122 widget Dyte participant parity, P3.121 widget set-user HMAC parity, P3.120 macro webhook event parity, P3.119 widget reply-to parity, P3.118 content-length parity, P3.117 first-message conversation metadata parity, P3.116 widget contact custom-attribute route parity, P3.115 widget transcript delivery parity, P3.114 widget end-conversation gate parity, P3.113 widget conversation response parity, P3.112 CSAT review-note update parity, P3.111 date-range behavior, P3.110 CSV recorded-at formatting, P3.109 fixed report pagination, P3.108 message-window send behavior, P3.107 Captain custom-tool policy/auth serialization parity, P3.106 custom-tool validation parity, P5.3i CSAT survey indexing, 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 with workspace-local Go build/module/tmp caches 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 `972` registered routes after enterprise account route tracking. @@ -438,6 +438,16 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `fix(widget): align attachment payloads` | Advances P3.209 widget/public attachment push-event serializer parity by matching Chatwoot widget and public message serializers, where message-embedded attachments use `Attachment#push_event_data` with `account_id` and no REST attachment-list `created_at`. | Focused widget direct-upload and public API attachment tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. | Move P3.209 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | +| `fix(messages): preserve attachment account ids` | Advances P3.208 message attachment push-event serializer parity by matching Chatwoot `_message.json.jbuilder`, widget/public message serializers, and `Attachment#push_event_data`, where message-embedded attachments keep `account_id` while REST attachment-list partials omit it. GoChat now uses a separate push-event attachment map for message payloads. | Focused message/rest attachment serializer tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. | Move P3.208 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | +| `fix(messages): align attachment serializer` | Advances P3.207 REST attachment serializer parity by matching Chatwoot `api/v1/models/_attachment.json.jbuilder`, where REST attachment payloads include file/message metadata but not `account_id`; push-event attachment payload behavior remains separate. | Focused attachment serializer test passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. | Move P3.207 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | +| `fix(notifications): slim conversation contact inbox` | Advances P3.206 notification conversation contact-inbox serializer parity by matching Chatwoot conversation `push_event_data`, where `contact_inbox` is the Chatwoot contact-inbox partial shape (`source_id` plus nested `inbox_slim`) rather than a raw persistence object. GoChat notification primary conversation actors now reuse the contact-inbox shell serializer, avoiding leaked contact IDs, HMAC tokens, and local join metadata. | Focused notification actor serializer test passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. | Move P3.206 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | +| `fix(inboxes): compute callback webhook urls` | Advances P3.205 inbox callback webhook URL serializer parity by matching Chatwoot `Inbox#callback_webhook_url`: GoChat now computes `callback_webhook_url` from `FRONTEND_URL` for Twilio (`/twilio/callback`), SMS (`/webhooks/sms/:phone_without_plus`), LINE (`/webhooks/line/:line_channel_id`), and WhatsApp (`/webhooks/whatsapp/:phone_number`) instead of relying on optional channel config. Non-webhook channel types continue to serialize `nil`. | Focused inbox callback serializer tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. | Move P3.205 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | +| `fix(inboxes): align sender name type values` | Advances P3.204 inbox sender-name-type serializer parity by matching Chatwoot `Inbox.sender_name_type` enum values used by the reused settings frontend. GoChat now defaults new inboxes to `friendly`, accepts and stores frontend `friendly`/`professional` values, serializes legacy local `friendly_name`/`business_name` aliases as Chatwoot `friendly`/`professional`, and updates WhatsApp authorization inbox defaults accordingly. | Focused inbox serializer/create tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. | Move P3.204 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | +| `fix(inboxes): align conditional channel fields` | Advances P3.203 inbox conditional channel-field serializer parity by matching Chatwoot `api/v1/models/_inbox.json.jbuilder`: email inboxes now always expose `forwarding_enabled` from `MAILER_INBOUND_EMAIL_DOMAIN` presence and only include `forward_to_email` when inbound forwarding is configured, while Twilio voice webhook URLs are emitted only when `twiml_app_sid` is present and `voice_configured`/`has_api_key_secret` treat blank strings as absent. | Focused inbox serializer/channel-depth tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. | Move P3.203 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | +| `fix(messages): embed voice call payloads` | Advances P3.202 voice-call message call serializer parity by matching Chatwoot enterprise `Enterprise::Message#push_event_data` and message partial behavior, where `voice_call` messages embed `call.push_event_data` when a linked call exists. GoChat shared message serialization now resolves account-scoped calls by `message_id` and emits the Chatwoot call payload fields including provider identifiers, display status, accepted agent details, started/ended timestamps, from/to numbers, recording URL, and transcript. | Focused voice-call message serializer test passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. | Move P3.202 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | +| `fix(messages): expose account ids in payloads` | Advances P3.201 message payload account-id serializer parity by matching Chatwoot `Message#push_event_data`, which starts from model attributes and therefore includes `account_id`. GoChat shared message payloads now carry `account_id` through direct message serialization, hydrated search-message payloads, Meilisearch fallback hit payloads, and nested conversation-search message payloads. | Focused conversation/search handler serializer tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. | Move P3.201 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | +| `fix(conversations): align chatwoot partial serializer` | Advances P3.185 conversation partial serializer parity with three fixes: (1) `last_non_activity_message` now uses the newest non-activity message by `created_at DESC`, matching Chatwoot `Message.non_activity_messages.first`, so a latest activity event no longer clears the non-activity preview; (2) `meta.assignee` now emits `agent_bot_slim` shape (`id/name/description/thumbnail/outgoing_url/bot_type`) with `assignee_type: "AgentBot"` when `AssigneeAgentBotID` is set, matching Chatwoot `assigned_entity.is_a?(AgentBot)` plus `_agent_bot_slim.json.jbuilder`; (3) embedded latest message query now uses `created_at DESC, id DESC` ordering, matching Chatwoot `Message.default_scope { order(created_at: :asc) }` plus `.last` for non-monotonic IDs from imports/backfills. | Focused conversation serializer tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. | Move P3.185 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(captain): secure custom tool auth config` | Advances P3.107 with Chatwoot Captain custom-tool policy and serializer parity. GoChat now hides `auth_config` from non-admin list/show/create/update payloads and enforces the inspected administrator-only policy for create/test/update/delete when an authenticated request role is present. | Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.107 to Review; continue Phase 2/3 audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(captain): validate custom tools` | Advances P3.106 with Chatwoot `Captain::CustomTool` model validation parity. GoChat now returns record-invalid-shaped raw `422 { message, attributes }` responses for blank required fields, explicit duplicate slugs, unsupported custom-tool enums, generated slug length overflow, and invalid `param_schema` items instead of generic internal errors. | Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.106 to Review; continue admin-only custom-tool auth serialization, Phase 2/3 audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(captain): align custom tool limits` | Advances P3.105 with Chatwoot Captain custom-tool create limits and generated slug parity. GoChat now enforces the 15-tools-per-account cap, returns the inspected limit error as raw `422`, generates default `custom_` underscore slugs capped at 64 characters, adds six-character lowercase alphanumeric collision suffixes, and makes the GORM model uniqueness boundary match the reference `(account_id, slug)` schema. | Focused Captain custom-tool/resource tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.105 to Review; continue Captain custom-tool validation/administrator serialization drift, Phase 2/3 audit, Phase 6 placeholder audit, or B12 live smoke. | @@ -2974,3 +2984,46 @@ Verification milestone gates: - 2026-06-09: P3.165 agent bot opened/resolved event parity prepared as `fix(webhooks): align opened resolved events`; audited Chatwoot `AgentBotListener#conversation_opened` and `#conversation_resolved`, which use `__method__.to_s` as the event name, producing `conversation_opened` and `conversation_resolved` respectively. GoChat `agentBotEventName` previously mapped both to `conversation_status_changed`, now correctly returns `conversation_opened` and `conversation_resolved`. Focused event name mapping tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. - 2026-06-09: P3.166 agent bot message sendable parity prepared as `fix(webhooks): align bot message sendable`; audited Chatwoot `MessageFilterHelpers#webhook_sendable?` which returns true only for `incoming`/`outgoing`/`template` message types, and `AgentBotListener#message_created`/`#message_updated` which calls `webhook_sendable?` before processing. GoChat `AgentBotListener.OnEvent` now checks message type from event data and skips delivery for `activity`, `csat`, and other non-sendable message types, matching Chatwoot behavior. Focused activity-message-skip tests, full `go test ./...`, and `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.167 conversation can-reply message-window parity prepared as `fix(conversations): align can reply window`; audited Chatwoot `Conversations::MessageWindowService`, which returns unrestricted replies for channels without a configured window and otherwise requires the last incoming account-scoped message to fall within API `agent_reply_time_window`, Facebook/Instagram 24h or Human Agent 7d, WhatsApp 24h, TikTok 48h, or Twilio WhatsApp 24h. GoChat conversation serialization now emits `can_reply` from those channel windows instead of hard-coded `true`, including API `channel_api.additional_attributes` parsing and Twilio `channel_config.medium` handling. Focused serializer tests and `git diff --check` passed; full `go test ./...` remains pending for the broader goal. No route artifacts change. +- 2026-06-09: P3.168 message create incoming API-inbox parity prepared as `fix(messages): restrict incoming creation`; audited Chatwoot `Messages::MessageBuilder#message_type`, which raises `Incoming messages are only allowed in Api inboxes` when `message_type` is `incoming` outside `Channel::Api` conversations. GoChat `MessageService.Create` now resolves the conversation inbox channel and rejects non-API incoming message creation with the same error while continuing to allow API inbox incoming messages. Focused message service create tests and serializer can-reply regression tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.169 message create additional-attributes parity prepared as `fix(messages): persist builder attributes`; audited Chatwoot `Messages::MessageBuilder#campaign_id` and `#template_params`, which merge these request values into message `additional_attributes`. GoChat create-message request binding now accepts JSON and multipart `template_params` plus `campaign_id`, and `MessageService.Create` stores non-empty values in `Message.AdditionalAttributes` while leaving empty requests nil. Focused message service create tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.170 email message recipient attributes parity prepared as `fix(messages): persist email recipients`; audited Chatwoot `Messages::MessageBuilder#process_emails`, which only applies to email inbox conversations and stores comma-split `cc_emails`, `bcc_emails`, and `to_emails` arrays in message `content_attributes`. GoChat create-message request binding now accepts those JSON and multipart params, and `MessageService.Create` merges non-empty recipient arrays into existing content attributes for email inboxes. Focused message service create tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.171 agent-bot message sender parity prepared as `fix(messages): support agent bot sender`; audited Chatwoot `Messages::MessageBuilder#message_sender`, which accepts `sender_type=AgentBot` only when `sender_id` resolves to a global or current-account agent bot and otherwise falls back to the current user sender. GoChat create-message request binding now accepts `sender_id` and `sender_type`, `MessageService.Create` stores accessible agent bot senders with `SenderType=AgentBot`, and message serialization returns agent bot `push_event_data` instead of attempting user lookup. Focused message service and serializer tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.172 voice message attachment metadata parity prepared as `fix(messages): tag voice attachments`; audited Chatwoot `Messages::MessageBuilder#tag_voice_message`, which tags only audio attachments with `is_voice_message=true` when request param `is_voice_message` is truthy. GoChat create-message request binding now accepts multipart `is_voice_message`, and `MessageService.Create` stores `{"is_voice_message":true}` in audio attachment metadata while leaving non-audio attachments untagged. Focused message service create tests and serializer regression tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.173 message content-attributes string parity prepared as `fix(messages): parse string attributes`; audited Chatwoot `Messages::MessageBuilder#content_attributes`, which safely parses string `content_attributes` JSON and otherwise accepts hash attributes. GoChat `MessageService.Create` now normalizes stringified JSON `content_attributes` into object JSON before persistence, so automation/macros/API clients that send encoded attributes keep `automation_rule_id`, `items`, and other nested fields as structured attributes. Focused message service create tests and serializer regression tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.174 message external-created-at parity prepared as `fix(messages): persist external created at`; audited Chatwoot `Messages::MessageBuilder#external_created_at` and controller spec that expects the value under response `content_attributes.external_created_at`. GoChat create-message request binding now accepts `external_created_at`, and `MessageService.Create` merges non-empty values into message `content_attributes` while preserving parsed existing attributes. Focused message service create tests and serializer regression tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.175 structured message content-type parity prepared as `fix(messages): allow structured content types`; audited Chatwoot `Message.content_type` enum and messages controller specs for `input_select` and `cards` creation without text content. GoChat `validContentType` now accepts Chatwoot structured content types (`input_textarea`, `input_select`, `cards`, `form`, `article`, `incoming_email`, `sticker`) while retaining local legacy attachment types, and `MessageService.Create` permits structured messages with `content_attributes` but no `content`. Focused message service create tests and serializer regression tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.176 email message content-attributes parity prepared as `fix(messages): build email content attributes`; audited Chatwoot `Messages::MessageBuilder#process_email_content`, which applies to non-private email inbox messages with content and stores nested `email.text_content.full/reply` plus `email.html_content.full/reply`, using `email_html_content` when provided. GoChat create-message request binding now accepts `email_html_content`, and `MessageService.Create` merges generated email content attributes while preserving existing email content hashes. Focused message service create tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.177 conversation-scoped message actions parity prepared as `fix(messages): scope message actions to conversations`; audited Chatwoot messages controller nested `@conversation.messages.find` behavior for show/update/destroy/retry. GoChat message handlers now parse route `conversation_id` and route show/update/delete/retry through conversation-scoped service/repository lookups, preventing cross-conversation message reads or mutations under the same account. Focused message service create/action tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.178 message reply-attribute parity prepared as `fix(messages): normalize reply attributes`; audited Chatwoot `Messages::MessageBuilder` extraction of `content_attributes[:in_reply_to]`/`[:items]` and `Messages::InReplyToMessageBuilder`, which resolves quoted messages only inside the same conversation and stores both internal id and external source id. GoChat `MessageService.Create` now normalizes `content_attributes.in_reply_to` and `content_attributes.in_reply_to_external_id` against the route conversation while preserving bot `items`, and clears both reply attributes when the target message is outside the conversation. Focused message service create tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.179 message status-update parity prepared as `fix(messages): align status updates`; audited Chatwoot `Api::V1::Accounts::Conversations::MessagesController#update`, `permitted_params`, and `ensure_api_inbox`, which only permits status/external-error changes and returns forbidden for non-API inbox conversations. GoChat `UpdateMessageRequest` no longer accepts content edits, `MessageService.Update` always gates updates to API inboxes, rejects invalid statuses, preserves message content, and continues to store/clear `content_attributes.external_error` through the status update path. Focused message service, handler, and search-index hook tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.180 message retry error-status parity prepared as `fix(messages): align retry errors`; audited Chatwoot `MessagesController#retry`, which rescues `MessageBuilder` and `StatusUpdateService` errors through `render_could_not_create_error`, returning unprocessable entity for missing messages or failed retries instead of a generic 404. GoChat message retry handler now returns 422 unprocessable entity on all service errors, matching Chatwoot rescue behavior, while other message actions retain the standard not-found mapping. Focused message handler retry tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.181 message translation cache parity prepared as `fix(messages): cache translations`; audited Chatwoot `MessagesController#translate` and `already_translated_content_available?`, which scope the message through the route conversation and store translated text in `content_attributes.translations[target_language]` for reuse. GoChat translate now uses conversation-scoped message lookup, returns cached translations without re-calling the LLM provider, persists non-empty translated content under `content_attributes.translations`, and indexes the updated message. Focused message service and handler translate tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.182 message translation response parity prepared as `fix(messages): align translation response`; audited Chatwoot `MessagesController#translate`, which renders raw `{ content: translated_content }` for fresh translations and returns bare `200 OK` with no body when `already_translated_content_available?` is true. GoChat translate handler now returns the same raw response shape, and cached translations short-circuit to an empty success response while still avoiding LLM calls. Focused message handler translate tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.183 message route display-id parity prepared as `fix(messages): resolve display routes`; audited Chatwoot nested message routes under conversations, where route `conversation_id` is the account-scoped conversation `display_id` and message lookup uses `@conversation.messages.find`. GoChat message show/update/delete/retry/translate handlers now resolve route conversation IDs through `ResolveConversationForRoute` before message lookup, so display-id routes work consistently while still preserving legacy primary-key fallback. Focused message handler display-id action tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-09: P3.184 message create route scoping parity prepared as `fix(messages): scope create to route`; audited Chatwoot `MessagesController#create` and `Messages::MessageBuilder`, where the controller builds through the already-resolved `@conversation` and does not trust a request-body `conversation_id`. GoChat message create now always resolves the URL conversation through `ResolveConversationForRoute`, overwrites any body `conversation_id` with the resolved database ID, and serializes the route conversation display ID in the response. Focused message handler create tests passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.209 widget/public attachment push-event serializer parity prepared as `fix(widget): align attachment payloads`; audited Chatwoot `api/v1/models/_widget_message.json.jbuilder`, `public/api/v1/models/_message.json.jbuilder`, and `Attachment#push_event_data`, where widget/public message attachments include `account_id` from push-event data and do not include REST attachment-list `created_at`. GoChat widget and public message attachment payloads now include `account_id` and remove `created_at` while preserving direct-upload file metadata. Focused widget direct-upload and public API attachment tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.208 message attachment push-event serializer parity prepared as `fix(messages): preserve attachment account ids`; audited Chatwoot `_message.json.jbuilder`, widget/public message serializers, and `Attachment#push_event_data`, where message-embedded attachments use push-event data and retain `account_id`, while standalone attachment-list endpoints render the `_attachment` partial without `account_id`. GoChat now separates `serializeAttachmentPushEventData` for message payloads from REST attachment-list serialization, preserving `account_id` in message attachments without regressing P3.207 list payloads. Focused message/rest attachment serializer tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.207 REST attachment serializer parity prepared as `fix(messages): align attachment serializer`; audited Chatwoot `api/v1/models/_attachment.json.jbuilder`, where REST attachment partial payloads serialize attachment metadata without `account_id`, even though attachment push-event data can include it from the model. GoChat `serializeAttachment` now omits `account_id` from REST/shared message attachment maps and adds a focused serializer regression covering file metadata plus `account_id` absence. Focused attachment serializer test passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.206 notification conversation contact-inbox serializer parity prepared as `fix(notifications): slim conversation contact inbox`; audited Chatwoot `Conversations::EventDataPresenter#push_data` and `api/v1/models/_contact_inbox.json.jbuilder`, where conversation push payloads embed `contact_inbox` as `{ source_id, inbox: inbox_slim }`. GoChat notification primary conversation actors now reuse the shared contact-inbox shell serializer instead of returning raw contact-inbox persistence fields such as contact IDs, HMAC tokens, or local join metadata. Focused notification actor serializer test passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.205 inbox callback webhook URL serializer parity prepared as `fix(inboxes): compute callback webhook urls`; audited Chatwoot `Inbox#callback_webhook_url` and `_inbox.json.jbuilder`, where callback URLs are computed from `FRONTEND_URL` by channel type. GoChat now computes Twilio, SMS, LINE, and WhatsApp callback URLs from channel type and phone/line identifiers instead of reading optional channel config, while non-webhook channel types continue to serialize `nil`. Focused inbox callback serializer tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.204 inbox sender-name-type serializer parity prepared as `fix(inboxes): align sender name type values`; audited Chatwoot `Inbox.sender_name_type` enum (`friendly`/`professional`) and the reused settings frontend sender-name radio values. GoChat now defaults new inboxes to `friendly`, accepts and stores frontend `friendly`/`professional` values, serializes legacy local `friendly_name`/`business_name` aliases as Chatwoot `friendly`/`professional`, and updates WhatsApp authorization inbox defaults accordingly. Focused inbox serializer/create tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.203 inbox conditional channel-field serializer parity prepared as `fix(inboxes): align conditional channel fields`; audited Chatwoot `api/v1/models/_inbox.json.jbuilder` for email forwarding and Twilio voice field guards. GoChat email inbox serialization now derives `forwarding_enabled` from `MAILER_INBOUND_EMAIL_DOMAIN` and hides `forward_to_email` unless inbound forwarding is configured, while Twilio voice serialization treats blank `twiml_app_sid`/`api_key_secret` as absent and emits voice webhook URLs only when the TwiML app SID is present. Focused inbox serializer/channel-depth tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.202 voice-call message call serializer parity prepared as `fix(messages): embed voice call payloads`; audited Chatwoot enterprise `Enterprise::Message#push_event_data`, `Call#push_event_data`, and the shared message partial that embeds `call.push_event_data` for `voice_call` messages. GoChat shared message serialization now resolves account-scoped calls by `message_id` and emits provider identifiers, display status, accepted agent details, started/ended timestamps, from/to numbers, recording URL, and transcript in the nested `call` payload. Focused voice-call message serializer test passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.201 message payload account-id serializer parity prepared as `fix(messages): expose account ids in payloads`; audited Chatwoot `Message#push_event_data`, which starts from message model attributes and therefore includes `account_id` in realtime push payloads, plus Chatwoot search views that reuse the message partial for message and nested conversation search payloads. GoChat shared message payloads now carry `account_id` through direct message serialization, hydrated search-message payloads, Meilisearch fallback hit payloads, and nested conversation-search message payloads. Focused conversation/search serializer tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.200 email OAuth reauthorization serializer parity prepared as `fix(inboxes): expose email oauth reauthorization`; audited Chatwoot `api/v1/models/_inbox.json.jbuilder`, where email inboxes expose admin-only `reauthorization_required` for Microsoft, Google, or legacy Google providers when `provider_config` is empty or the channel says reauthorization is required. GoChat email inbox serialization now mirrors that provider-specific condition and keeps the field hidden from non-admin responses alongside other sensitive email channel fields. Focused inbox role serializer test passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.199 inbox slim serializer parity prepared as `fix(contacts): align inbox slim shape`; audited Chatwoot `api/v1/models/_inbox_slim.json.jbuilder`, which emits exactly `id`, `avatar_url`, `channel_id`, `name`, `channel_type`, and channel `provider`. GoChat `serializeInboxSlim` now includes `avatar_url`, `channel_id`, and `provider`, and stops emitting non-reference `account_id`, so nested contact inbox payloads match the reused frontend's slim inbox contract. Focused contact create serializer test passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.198 contact create contact-inbox shell parity prepared as `fix(contacts): slim contact inbox create response`; audited Chatwoot `contacts#create` response composition and `api/v1/models/_contact_inbox.json.jbuilder`, where contact inboxes serialize `source_id` plus nested `inbox_slim`. GoChat contact create response now hydrates the contact inbox's inbox when needed and serializes the nested inbox through the shared slim shape instead of returning a raw GORM inbox object with local fields/timestamps. Focused contact create serializer tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.197 Twitter inbox tweets-enabled parity prepared as `fix(inboxes): expose twitter tweet toggle`; audited Chatwoot `api/v1/models/_inbox.json.jbuilder`, `Channel::TwitterProfile`, and Twitter webhook editable attributes, where Twitter inboxes emit `tweets_enabled` and the channel stores the toggle with default `true`. GoChat `ChannelTwitter` now includes `TweetsEnabled`, migrations add a safe `tweets_enabled` column for current and historical Twitter channel table names, and inbox serialization emits `tweets_enabled` for `Channel::TwitterProfile` while preserving explicit `false` from channel config and defaulting missing values to `true`. Focused inbox serializer test passed; full `go test ./...` passed on rerun after a transient worker SQLite setup failure; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.196 contact availability serializer parity prepared as `fix(contacts): serialize live availability`; audited Chatwoot `AvailabilityStatusable`, `api/v1/models/_contact.json.jbuilder`, and contact usage inside conversation partial payloads, where contacts return `availability_status` from `OnlineStatusTracker` presence rather than a hardcoded value. GoChat now exposes a Redis-backed `PresenceTracker.GetContactStatus`, injects optional contact presence into contact and conversation handlers, and serializes live `online`/`offline` contact availability across CRM contact payloads, conversation meta/senders, and shared contact serializers while preserving offline fallback when presence is unavailable. Focused presence and serializer tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.195 inbox help-center serializer parity prepared as `fix(inboxes): include help center portal`; audited Chatwoot `api/v1/models/_inbox.json.jbuilder`, where linked portals render `help_center: { name, slug }`. GoChat inbox serialization now accepts DB context from inbox handlers, looks up linked portals by `portal_id`, and emits the Chatwoot-shaped `help_center` object in inbox list/show/create/update/reset-secret responses while leaving non-inbox nested serializers DB-free. Focused inbox serializer parity tests passed; full `go test ./...` passed on rerun after a transient worker SQLite setup failure; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.194 inbox sensitive-field admin gating parity prepared as `fix(inboxes): gate sensitive serializer fields`; audited Chatwoot `api/v1/models/_inbox.json.jbuilder`, where `hmac_token`, API `secret`, Twilio credentials, email IMAP/SMTP credentials, and WhatsApp `provider_config` are emitted only for `Current.account_user.administrator?`. GoChat inbox serialization now accepts current admin context from inbox/channel/Captain handlers, keeps mutation audit payloads admin-shaped, redacts nested campaign inboxes, and hides those sensitive fields from agent responses while preserving non-sensitive channel settings. Focused inbox sensitive-field role test passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.193 shared agent serializer custom fields parity prepared as `fix(agents): expose custom fields in shared serializer`; audited Chatwoot `api/v1/models/_agent.json.jbuilder`, which emits `custom_attributes` when present and enterprise `custom_role_id`, and compared GoChat agents endpoint serializer with the shared `serializeUser` used by conversation meta, message senders, notes, CSAT, and SLA payloads. GoChat shared user serialization now includes non-empty `custom_attributes` and non-zero `custom_role_id`, matching the agent serializer and preventing reused frontend agent badges/custom metadata from disappearing outside `/agents` responses. Focused conversation meta assignee custom-field serializer test passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.192 conversation team and SLA event serializer parity prepared as `fix(conversations): align team and sla event payloads`; audited Chatwoot conversation partial, enterprise conversation partial, team model partial, applied SLA partial, and SLA event partial. GoChat conversation serialization now emits meta team fields with Chatwoot `allow_auto_assign` plus `is_member` and omits non-reference `allow_auto_assignment`/timestamp keys, while enterprise conversation payloads now include `sla_events` with `{id, event_type, meta, created_at, updated_at}` alongside existing `applied_sla`. Focused conversation serializer tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.191 notification actor serialization parity prepared as `fix(notifications): serialize push event actors`; audited Chatwoot notification index JSON, `PushDataHelper`, `Conversations::EventDataPresenter`, and User/Contact/AgentBot `push_event_data` methods. GoChat notification serializers now resolve `primary_actor`, `secondary_actor`, and `user` through DB-backed Chatwoot-shaped actor payloads for conversations, contacts, users, and agent bots, preserve `last_activity_at` from notification `updated_at`, and fall back to the legacy stub when actor records are missing or outside the notification account. Focused notification handler actor tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.190 company additional-attributes parity prepared as `fix(companies): persist additional attributes`; audited Chatwoot enterprise `Company` model, `CompaniesController#company_params`, `company_update_params`, and `_company.json.jbuilder`, which permit, normalize, and serialize `additional_attributes`. GoChat companies now model `AdditionalAttributes`, accept nested/raw JSON and multipart `company[additional_attributes][...]` values on create/update, merge additional attributes on update, and include `additional_attributes` in company payloads. Focused company handler tests passed; full `go test ./...` passed on rerun after a transient worker SQLite setup failure; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.189 contact inbox account scoping parity prepared as `fix(contacts): scope contact inboxes to account`; audited Chatwoot nested contact routes, where contact lookups are scoped through `Current.account.contacts` before contact inboxes are listed. GoChat `ListContactInboxes` now parses `account_id`, verifies the parent contact belongs to that account, and returns 404 for cross-account contacts instead of listing another account's contact inboxes. Focused contact handler CRUD tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.188 contact validation parity prepared as `fix(contacts): validate identity fields`; audited Chatwoot `Contact` model validations and `RequestExceptionHandler#render_record_invalid`, which enforce account-scoped unique email (case-insensitive), phone number, and identifier plus E.164-style phone-number format, then return raw `422 { message, attributes }` on invalid records. GoChat `ContactService.Create/Update` now validates these identity fields before persistence and `ContactHandler` renders Chatwoot record-invalid-shaped responses for contact validation errors. Focused contact handler CRUD tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.187 contact phone-number param parity prepared as `fix(contacts): accept phone_number params`; audited Chatwoot `contacts#permitted_params`, which permits `phone_number` on create/update while the reused dashboard API sends PATCH update bodies directly. GoChat `CreateContactRequest` and `UpdateContactRequest` now bind `phone_number` and prefer it over the legacy local `phone` alias, preserving backward compatibility while matching Chatwoot request bodies and response field names. Focused contact handler CRUD tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.186 contact update attribute merge parity prepared as `fix(contacts): merge attributes on update`; audited Chatwoot `contacts#update`, which builds `contact_update_params` through `contact_custom_attributes` and `contact_additional_attributes` that merge incoming params with `@contact.custom_attributes.merge(...)` and `@contact.additional_attributes.merge(...)` instead of replacing them. GoChat `ContactService.Update` now uses `mergeContactJSON` for both `custom_attributes` and `additional_attributes` on update, so partial updates preserve keys not present in the request body while overwriting only the provided keys. Focused contact handler CRUD test passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. +- 2026-06-10: P3.185 conversation partial serializer parity prepared as `fix(conversations): align chatwoot partial serializer`; audited Chatwoot `api/v1/conversations/partials/_conversation.json.jbuilder`, `_agent_bot_slim.json.jbuilder`, and `Message.non_activity_messages` scope plus `Message.default_scope { order(created_at: :asc) }`. Three conversation partial serializer mismatches were found and fixed: (1) `last_non_activity_message` now uses the newest non-activity message by `created_at DESC`, matching Chatwoot `non_activity_messages.first`, so a latest activity event no longer clears the non-activity preview; (2) `meta.assignee` now emits the Chatwoot `agent_bot_slim` shape with `assignee_type: "AgentBot"` when `AssigneeAgentBotID` is set, instead of ignoring agent-bot assignees; (3) the embedded latest message query now uses `created_at DESC, id DESC` ordering instead of `id DESC`, matching Chatwoot `.last` under `default_scope { order(created_at: :asc) }` for non-monotonic IDs from imports/backfills. Focused conversation serializer tests passed; full `go test ./...` passed; `git diff --check` passed. No route artifacts change. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index b5517a44..7db593ab 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -723,6 +723,8 @@ func Bootstrap(env string) (*App, error) { // Step 11 (early): Create WebSocket hub (ref: Chatwoot ActionCable) // Must be created before handlers so the hubTypingAdapter can reference it. wsHub := ws.NewHubSimple() + wsRelay := wspkg.NewBroadcastRelay(rdb, wsHub) + presenceTracker := wspkg.NewPresenceTracker(rdb, wsRelay) // Widget service + handler (M11 — WebWidget channel completion) // hubTypingAdapter delegates typing events to the WS hub's direct broadcast, @@ -751,8 +753,8 @@ func Bootstrap(env string) (*App, error) { SAML: v1.NewSAMLHandler(samlService, jwtService, refreshStore, ssoSessionStore, &cfg.SAML), Account: v1.NewAccountHandler(accountService), EnterpriseAccount: v1.NewEnterpriseAccountHandler(accountService), - Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService), - Conversation: v1.NewConversationHandler(conversationService, messageService).WithAuditService(auditService), + Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService).WithContactPresence(presenceTracker), + Conversation: v1.NewConversationHandler(conversationService, messageService).WithAuditService(auditService).WithContactPresence(presenceTracker), Inbox: v1.NewInboxHandler(inboxService).WithAuditService(auditService), InboxMember: v1.NewInboxMemberHandler(inboxMemberService), WebWidget: v1.NewWebWidgetHandler(inboxService), diff --git a/internal/handler/api/v1/campaign_handler.go b/internal/handler/api/v1/campaign_handler.go index 51e270e1..e0559a45 100644 --- a/internal/handler/api/v1/campaign_handler.go +++ b/internal/handler/api/v1/campaign_handler.go @@ -215,7 +215,7 @@ func serializeCampaign(item *campaign.Campaign, accountID uint) map[string]any { "updated_at": item.UpdatedAt, } if item.Inbox.ID != 0 { - payload["inbox"] = serializeInbox(&item.Inbox) + payload["inbox"] = serializeInbox(&item.Inbox, nil, false) } if item.Sender != nil && item.Sender.ID != 0 { payload["sender"] = serializeAgentUser(item.Sender, accountID, "", "", false, 0) diff --git a/internal/handler/api/v1/captain_assistant_handler.go b/internal/handler/api/v1/captain_assistant_handler.go index 115bc8ff..2f1815ca 100644 --- a/internal/handler/api/v1/captain_assistant_handler.go +++ b/internal/handler/api/v1/captain_assistant_handler.go @@ -241,7 +241,7 @@ func (h *CaptainAssistantHandler) AssociateInbox(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeInbox(inbox)) + c.JSON(http.StatusOK, serializeInbox(inbox, nil, inboxSerializationAdmin(c))) } // DissociateInbox unbinds an assistant from an inbox. @@ -290,7 +290,7 @@ func (h *CaptainAssistantHandler) ListInboxes(c *gin.Context) { } payload := make([]gin.H, 0, len(inboxes)) for i := range inboxes { - payload = append(payload, serializeInbox(&inboxes[i])) + payload = append(payload, serializeInbox(&inboxes[i], nil, inboxSerializationAdmin(c))) } c.JSON(http.StatusOK, gin.H{"payload": payload, "meta": gin.H{"total_count": len(payload), "page": 1}}) } diff --git a/internal/handler/api/v1/company_handler.go b/internal/handler/api/v1/company_handler.go index 753f2d80..24e20da4 100644 --- a/internal/handler/api/v1/company_handler.go +++ b/internal/handler/api/v1/company_handler.go @@ -500,6 +500,7 @@ func bindCompanyRequest[T companyRequest](c *gin.Context, req T) error { if file, err := c.FormFile("company[avatar]"); err == nil && file != nil { faviconURL = file.Filename } + additionalAttributes := companyFormJSON(c, "additional_attributes") customAttributes := companyFormJSON(c, "custom_attributes") switch r := any(req).(type) { @@ -509,6 +510,7 @@ func bindCompanyRequest[T companyRequest](c *gin.Context, req T) error { r.WebsiteURL = websiteURL r.FaviconURL = faviconURL r.Domain = domain + r.AdditionalAttributes = additionalAttributes r.CustomAttributes = customAttributes case *service.UpdateCompanyRequest: r.Name = name @@ -516,6 +518,7 @@ func bindCompanyRequest[T companyRequest](c *gin.Context, req T) error { r.WebsiteURL = websiteURL r.FaviconURL = faviconURL r.Domain = domain + r.AdditionalAttributes = additionalAttributes r.CustomAttributes = customAttributes } return nil diff --git a/internal/handler/api/v1/company_handler_test.go b/internal/handler/api/v1/company_handler_test.go index f6876e9d..c0b32e85 100644 --- a/internal/handler/api/v1/company_handler_test.go +++ b/internal/handler/api/v1/company_handler_test.go @@ -226,9 +226,10 @@ func (s *CompanyHandlerTestSuite) TestList_IgnoresPerPage() { func (s *CompanyHandlerTestSuite) TestCreate_Success() { body := map[string]interface{}{ "company": map[string]interface{}{ - "name": "NewCorp", - "description": "A new company", - "domain": "newcorp.com", + "name": "NewCorp", + "description": "A new company", + "domain": "newcorp.com", + "additional_attributes": map[string]interface{}{"industry": "SaaS"}, }, } w := s.makeRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/companies/", s.accountID), body) @@ -239,6 +240,8 @@ func (s *CompanyHandlerTestSuite) TestCreate_Success() { companyData := resp["payload"].(map[string]interface{}) assert.Equal(s.T(), "NewCorp", companyData["name"]) + additionalAttrs := companyData["additional_attributes"].(map[string]interface{}) + assert.Equal(s.T(), "SaaS", additionalAttrs["industry"]) } func (s *CompanyHandlerTestSuite) TestCreate_MultipartAvatar() { @@ -317,13 +320,14 @@ func (s *CompanyHandlerTestSuite) TestGet_InvalidID() { func (s *CompanyHandlerTestSuite) TestUpdate_Success() { companyRepo := repository.NewCompanyRepo(s.db) - company := &model.Company{AccountID: s.accountID, Name: "UpdateCorp"} + company := &model.Company{AccountID: s.accountID, Name: "UpdateCorp", AdditionalAttributes: datatypes.JSON(`{"industry":"SaaS","region":"EU"}`)} s.Require().NoError(companyRepo.Create(context.Background(), company)) body := map[string]interface{}{ "company": map[string]interface{}{ - "name": "UpdateCorpUpdated", - "domain": "updated.com", + "name": "UpdateCorpUpdated", + "domain": "updated.com", + "additional_attributes": map[string]interface{}{"industry": "Fintech", "size": "100"}, }, } w := s.makeRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/companies/%d", s.accountID, company.ID), body) @@ -334,6 +338,10 @@ func (s *CompanyHandlerTestSuite) TestUpdate_Success() { companyData := resp["payload"].(map[string]interface{}) assert.Equal(s.T(), "UpdateCorpUpdated", companyData["name"]) + additionalAttrs := companyData["additional_attributes"].(map[string]interface{}) + assert.Equal(s.T(), "Fintech", additionalAttrs["industry"]) + assert.Equal(s.T(), "EU", additionalAttrs["region"]) + assert.Equal(s.T(), "100", additionalAttrs["size"]) } func (s *CompanyHandlerTestSuite) TestUpdate_MultipartAvatar() { diff --git a/internal/handler/api/v1/contact_handler.go b/internal/handler/api/v1/contact_handler.go index fef9afe8..7bca4e8d 100644 --- a/internal/handler/api/v1/contact_handler.go +++ b/internal/handler/api/v1/contact_handler.go @@ -1,6 +1,7 @@ package v1 import ( + "context" "encoding/json" "errors" "io" @@ -26,6 +27,7 @@ type ContactHandler struct { mergeSvc *service.ContactMergeService contactNoteSvc *service.ContactNoteService conversationSvc *service.ConversationService + presence contactPresenceReader } const chatwootContactResultsPerPage = 15 @@ -39,6 +41,15 @@ func NewContactHandler(svc *service.ContactService, contactInboxSvc *service.Con return h } +func (h *ContactHandler) WithContactPresence(presence contactPresenceReader) *ContactHandler { + h.presence = presence + return h +} + +func (h *ContactHandler) requestContext(c *gin.Context) context.Context { + return withContactPresence(c.Request.Context(), h.presence) +} + // @Summary List contacts for an account // @Description Retrieves all contacts for an account with pagination and optional sort // @Tags Contacts @@ -75,7 +86,7 @@ func (h *ContactHandler) List(c *gin.Context) { return } - c.JSON(http.StatusOK, contactListResponse(c.Request.Context(), h.svc.DB(), contacts, total, page, includeContactInboxes(c), nil)) + c.JSON(http.StatusOK, contactListResponse(h.requestContext(c), h.svc.DB(), contacts, total, page, includeContactInboxes(c), nil)) } // @Summary Search contacts @@ -123,7 +134,7 @@ func (h *ContactHandler) Search(c *gin.Context) { } hasMore := int64(len(contacts)) < total - c.JSON(http.StatusOK, contactListResponse(c.Request.Context(), h.svc.DB(), contacts, int64(len(contacts)), page, includeContactInboxes(c), &hasMore)) + c.JSON(http.StatusOK, contactListResponse(h.requestContext(c), h.svc.DB(), contacts, int64(len(contacts)), page, includeContactInboxes(c), &hasMore)) } // @Summary Get a single contact @@ -161,7 +172,7 @@ func (h *ContactHandler) Get(c *gin.Context) { return } - c.JSON(http.StatusOK, contactPayloadResponse(c.Request.Context(), h.svc.DB(), contact, includeContactInboxes(c))) + c.JSON(http.StatusOK, contactPayloadResponse(h.requestContext(c), h.svc.DB(), contact, includeContactInboxes(c))) } // @Summary Create a new contact @@ -196,6 +207,11 @@ func (h *ContactHandler) Create(c *gin.Context) { contact, svcErr := h.svc.Create(c.Request.Context(), accountID, req) if svcErr != nil { + var validationErr *service.ContactValidationError + if errors.As(svcErr, &validationErr) { + c.JSON(http.StatusUnprocessableEntity, gin.H{"message": validationErr.Message, "attributes": validationErr.Attributes}) + return + } c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to create contact"}) return } @@ -207,7 +223,7 @@ func (h *ContactHandler) Create(c *gin.Context) { contactInbox = &ci } } - c.JSON(http.StatusOK, contactCreateResponse(c.Request.Context(), h.svc.DB(), contact, contactInbox)) + c.JSON(http.StatusOK, contactCreateResponse(h.requestContext(c), h.svc.DB(), contact, contactInbox)) } // @Summary Update a contact @@ -248,11 +264,16 @@ func (h *ContactHandler) Update(c *gin.Context) { contact, svcErr := h.svc.Update(c.Request.Context(), accountID, contactID, req) if svcErr != nil { + var validationErr *service.ContactValidationError + if errors.As(svcErr, &validationErr) { + c.JSON(http.StatusUnprocessableEntity, gin.H{"message": validationErr.Message, "attributes": validationErr.Attributes}) + return + } c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to update contact"}) return } - c.JSON(http.StatusOK, contactPayloadResponse(c.Request.Context(), h.svc.DB(), contact, includeContactInboxes(c))) + c.JSON(http.StatusOK, contactPayloadResponse(h.requestContext(c), h.svc.DB(), contact, includeContactInboxes(c))) } // @Summary Delete a contact @@ -350,7 +371,7 @@ func (h *ContactHandler) DeleteAvatar(c *gin.Context) { handleServiceError(c, svcErr) return } - c.JSON(http.StatusOK, contactPayloadResponse(c.Request.Context(), h.svc.DB(), contact, false)) + c.JSON(http.StatusOK, contactPayloadResponse(h.requestContext(c), h.svc.DB(), contact, false)) } func (h *ContactHandler) ListLabels(c *gin.Context) { @@ -402,15 +423,20 @@ func (h *ContactHandler) UpdateLabels(c *gin.Context) { // GET /api/v1/accounts/:id/contacts/:contact_id/contact_inboxes // Reference: Chatwoot contacts#contact_inboxes (nested resource) func (h *ContactHandler) ListContactInboxes(c *gin.Context) { + accountID := parseAccountIDParam(c) + if accountID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"}) + return + } contactID, err := parseUintParam(c, "contact_id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"}) return } - contactInboxes, svcErr := h.svc.ListContactInboxes(c.Request.Context(), contactID) + contactInboxes, svcErr := h.svc.ListContactInboxesByAccount(c.Request.Context(), accountID, contactID) if svcErr != nil { - c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to list contact inboxes"}) + c.JSON(http.StatusNotFound, gin.H{"error": "contact not found"}) return } @@ -846,7 +872,7 @@ func (h *ContactHandler) Active(c *gin.Context) { return } - c.JSON(http.StatusOK, contactListResponse(c.Request.Context(), h.svc.DB(), contacts, total, page, includeContactInboxes(c), nil)) + c.JSON(http.StatusOK, contactListResponse(h.requestContext(c), h.svc.DB(), contacts, total, page, includeContactInboxes(c), nil)) } // Export downloads contacts as CSV. @@ -1031,7 +1057,7 @@ func (h *ContactHandler) DeleteCustomAttributes(c *gin.Context) { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to load contact"}) return } - c.JSON(http.StatusOK, contactPayloadResponse(c.Request.Context(), h.svc.DB(), contact, true)) + c.JSON(http.StatusOK, contactPayloadResponse(h.requestContext(c), h.svc.DB(), contact, true)) } // Merge two contacts into one. The base contact survives, mergee is deleted. @@ -1056,7 +1082,7 @@ func (h *ContactHandler) Merge(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeCRMContact(c.Request.Context(), h.svc.DB(), result, false)) + c.JSON(http.StatusOK, serializeCRMContact(h.requestContext(c), h.svc.DB(), result, false)) } // Filter retrieves contacts matching advanced filter criteria. @@ -1088,7 +1114,7 @@ func (h *ContactHandler) Filter(c *gin.Context) { return } - c.JSON(http.StatusOK, contactListResponse(c.Request.Context(), h.svc.DB(), contacts, total, page, includeContactInboxes(c), nil)) + c.JSON(http.StatusOK, contactListResponse(h.requestContext(c), h.svc.DB(), contacts, total, page, includeContactInboxes(c), nil)) } // DestroyCustomAttributes removes all custom attributes from a contact. @@ -1121,5 +1147,5 @@ func (h *ContactHandler) DestroyCustomAttributes(c *gin.Context) { return } - c.JSON(http.StatusOK, contactPayloadResponse(c.Request.Context(), h.svc.DB(), contact, true)) + c.JSON(http.StatusOK, contactPayloadResponse(h.requestContext(c), h.svc.DB(), contact, true)) } diff --git a/internal/handler/api/v1/contact_handler_crud_test.go b/internal/handler/api/v1/contact_handler_crud_test.go index 831469ce..5a9bee80 100644 --- a/internal/handler/api/v1/contact_handler_crud_test.go +++ b/internal/handler/api/v1/contact_handler_crud_test.go @@ -2,6 +2,7 @@ package v1 import ( "bytes" + "context" "encoding/json" "fmt" "mime/multipart" @@ -98,6 +99,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() { s.router.GET("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Get) s.router.POST("/api/v1/accounts/:id/contacts", s.handler.Create) s.router.PUT("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Update) + s.router.PATCH("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Update) s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Delete) s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id/avatar", s.handler.DeleteAvatar) s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/call", s.handler.InitiateCall) @@ -456,7 +458,7 @@ func (s *ContactHandlerCRUDTestSuite) TestCreate_Success() { body := map[string]interface{}{ "name": "New Contact", "email": "new@example.com", - "phone": "5551234", + "phone": "+15551234", } bodyBytes, _ := json.Marshal(body) @@ -479,6 +481,102 @@ func (s *ContactHandlerCRUDTestSuite) TestCreate_Success() { s.Contains(payload, "contact_inbox") } +func (s *ContactHandlerCRUDTestSuite) TestContactCreateResponseUsesInboxSlimShape() { + inbox := &model.Inbox{AccountID: s.account.ID, Name: "Slim Inbox", ChannelType: "Channel::WebWidget", ChannelID: 10, Enabled: true, AvatarURL: "https://example.com/avatar.png", ChannelConfig: `{"provider":"web"}`} + s.Require().NoError(s.db.Create(inbox).Error) + contact := &model.Contact{AccountID: s.account.ID, Name: "Slim Contact"} + s.Require().NoError(s.db.Create(contact).Error) + contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "source-1"} + s.Require().NoError(s.db.Create(contactInbox).Error) + + resp := contactCreateResponse(context.Background(), s.db, contact, contactInbox) + payload := resp["payload"].(map[string]any) + serializedContactInbox := payload["contact_inbox"].(map[string]any) + serializedInbox := serializedContactInbox["inbox"].(map[string]any) + + s.Equal("source-1", serializedContactInbox["source_id"]) + s.Equal(inbox.ID, serializedInbox["id"]) + s.Equal("https://example.com/avatar.png", serializedInbox["avatar_url"]) + s.Equal(uint(10), serializedInbox["channel_id"]) + s.Equal("Slim Inbox", serializedInbox["name"]) + s.Equal("Channel::WebWidget", serializedInbox["channel_type"]) + s.Equal("web", serializedInbox["provider"]) + s.NotContains(serializedInbox, "account_id") + s.NotContains(serializedInbox, "created_at") + s.NotContains(serializedInbox, "updated_at") +} + +func (s *ContactHandlerCRUDTestSuite) TestCreate_AcceptsChatwootPhoneNumberParam() { + body := map[string]interface{}{ + "name": "Phone Number Contact", + "email": "phone-number@example.com", + "phone_number": "+15551234567", + } + bodyBytes, _ := json.Marshal(body) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", + fmt.Sprintf("/api/v1/accounts/%d/contacts", s.account.ID), + bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + contactPayload := payload["contact"].(map[string]interface{}) + s.Equal("+15551234567", contactPayload["phone_number"]) + + var contact model.Contact + s.Require().NoError(s.db.Where("account_id = ? AND email = ?", s.account.ID, "phone-number@example.com").First(&contact).Error) + s.Equal("+15551234567", contact.PhoneNumber) +} + +func (s *ContactHandlerCRUDTestSuite) TestCreate_DuplicateEmailReturnsChatwootRecordInvalid() { + existing := &model.Contact{AccountID: s.account.ID, Name: "Existing Email", Email: "dup@example.com"} + s.Require().NoError(s.db.Create(existing).Error) + body := map[string]interface{}{ + "name": "Duplicate Email", + "email": "Dup@Example.com", + } + bodyBytes, _ := json.Marshal(body) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", + fmt.Sprintf("/api/v1/accounts/%d/contacts", s.account.ID), + bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusUnprocessableEntity, w.Code) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.Equal("Email has already been taken", resp["message"]) + s.Contains(resp["attributes"], "email") +} + +func (s *ContactHandlerCRUDTestSuite) TestCreate_InvalidPhoneNumberReturnsChatwootRecordInvalid() { + body := map[string]interface{}{ + "name": "Invalid Phone", + "phone_number": "12345", + } + bodyBytes, _ := json.Marshal(body) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", + fmt.Sprintf("/api/v1/accounts/%d/contacts", s.account.ID), + bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusUnprocessableEntity, w.Code) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.Equal("Phone number is not valid", resp["message"]) + s.Contains(resp["attributes"], "phone_number") +} + func (s *ContactHandlerCRUDTestSuite) TestCreate_InvalidAccountID() { body := map[string]interface{}{ "name": "New Contact", @@ -551,7 +649,7 @@ func (s *ContactHandlerCRUDTestSuite) TestUpdate_Success() { body := map[string]interface{}{ "name": "Jane Updated", "email": "jane.updated@example.com", - "phone": "9998887776", + "phone": "+19998887776", } bodyBytes, _ := json.Marshal(body) @@ -571,6 +669,103 @@ func (s *ContactHandlerCRUDTestSuite) TestUpdate_Success() { s.Equal("jane.updated@example.com", payload["email"]) } +func (s *ContactHandlerCRUDTestSuite) TestUpdate_AcceptsChatwootPhoneNumberParam() { + body := map[string]interface{}{ + "phone_number": "+19998887777", + } + bodyBytes, _ := json.Marshal(body) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", + fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, s.contact.ID), + bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + s.Equal("+19998887777", payload["phone_number"]) + + var contact model.Contact + s.Require().NoError(s.db.First(&contact, s.contact.ID).Error) + s.Equal("+19998887777", contact.PhoneNumber) +} + +func (s *ContactHandlerCRUDTestSuite) TestUpdate_DuplicatePhoneNumberReturnsChatwootRecordInvalid() { + existing := &model.Contact{AccountID: s.account.ID, Name: "Existing Phone", PhoneNumber: "+12000000"} + s.Require().NoError(s.db.Create(existing).Error) + body := map[string]interface{}{ + "phone_number": "+12000000", + } + bodyBytes, _ := json.Marshal(body) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PATCH", + fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, s.contact.ID), + bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusUnprocessableEntity, w.Code) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.Equal("Phone number has already been taken", resp["message"]) + s.Contains(resp["attributes"], "phone_number") +} + +func (s *ContactHandlerCRUDTestSuite) TestUpdate_MergesCustomAndAdditionalAttributes() { + contact := &model.Contact{ + AccountID: s.account.ID, + Name: "Merge Attributes", + Email: "merge@example.com", + CustomAttributes: datatypes.JSON(`{"plan":"starter","region":"apac"}`), + AdditionalAttributes: datatypes.JSON(`{"city":"London","country_code":"gb"}`), + } + s.Require().NoError(s.db.Create(contact).Error) + body := map[string]interface{}{ + "custom_attributes": map[string]interface{}{ + "plan": "enterprise", + "tier": "gold", + }, + "additional_attributes": map[string]interface{}{ + "city": "Paris", + "company": "Acme", + }, + } + bodyBytes, _ := json.Marshal(body) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PUT", + fmt.Sprintf("/api/v1/accounts/%d/contacts/%d", s.account.ID, contact.ID), + bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + customAttrs := payload["custom_attributes"].(map[string]interface{}) + s.Equal("enterprise", customAttrs["plan"]) + s.Equal("apac", customAttrs["region"]) + s.Equal("gold", customAttrs["tier"]) + additionalAttrs := payload["additional_attributes"].(map[string]interface{}) + s.Equal("Paris", additionalAttrs["city"]) + s.Equal("gb", additionalAttrs["country_code"]) + s.Equal("Acme", additionalAttrs["company"]) + + var reloaded model.Contact + s.Require().NoError(s.db.First(&reloaded, contact.ID).Error) + var persistedCustom map[string]interface{} + s.NoError(json.Unmarshal(reloaded.CustomAttributes, &persistedCustom)) + s.Equal("apac", persistedCustom["region"]) + var persistedAdditional map[string]interface{} + s.NoError(json.Unmarshal(reloaded.AdditionalAttributes, &persistedAdditional)) + s.Equal("gb", persistedAdditional["country_code"]) +} + func (s *ContactHandlerCRUDTestSuite) TestUpdate_InvalidAccountID() { body := map[string]interface{}{ "name": "Updated Name", @@ -1144,6 +1339,24 @@ func (s *ContactHandlerCRUDTestSuite) TestListContactInboxes_InvalidContactID() s.Contains(resp, "error") } +func (s *ContactHandlerCRUDTestSuite) TestListContactInboxes_CrossAccountContactNotFound() { + otherAccount := &model.Account{Name: "Other Contact Inbox Account", Active: true} + s.Require().NoError(s.db.Create(otherAccount).Error) + otherContact := &model.Contact{AccountID: otherAccount.ID, Name: "Other Contact"} + s.Require().NoError(s.db.Create(otherContact).Error) + otherInbox := &model.Inbox{AccountID: otherAccount.ID, Name: "Other Inbox", ChannelType: "web_widget", ChannelID: 1} + s.Require().NoError(s.db.Create(otherInbox).Error) + otherContactInbox := &model.ContactInbox{ContactID: otherContact.ID, InboxID: otherInbox.ID, SourceID: "other-source"} + s.Require().NoError(s.db.Create(otherContactInbox).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", + fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/contact_inboxes", s.account.ID, otherContact.ID), nil) + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusNotFound, w.Code) +} + func (s *ContactHandlerCRUDTestSuite) TestListContactInboxes_EmptyResult() { // Create a new contact with no inboxes emptyContact := &model.Contact{ diff --git a/internal/handler/api/v1/contact_presence.go b/internal/handler/api/v1/contact_presence.go new file mode 100644 index 00000000..9bacb762 --- /dev/null +++ b/internal/handler/api/v1/contact_presence.go @@ -0,0 +1,35 @@ +package v1 + +import ( + "context" + + "github.com/gochat/gochat/internal/model" +) + +type contactPresenceReader interface { + GetContactStatus(ctx context.Context, contactID, accountID uint) (string, error) +} + +type contactPresenceContextKey struct{} + +func withContactPresence(ctx context.Context, presence contactPresenceReader) context.Context { + if presence == nil { + return ctx + } + return context.WithValue(ctx, contactPresenceContextKey{}, presence) +} + +func contactAvailabilityStatus(ctx context.Context, contact *model.Contact) string { + if ctx == nil || contact == nil { + return "offline" + } + presence, ok := ctx.Value(contactPresenceContextKey{}).(contactPresenceReader) + if !ok || presence == nil { + return "offline" + } + status, err := presence.GetContactStatus(ctx, contact.ID, contact.AccountID) + if err != nil || status != "online" { + return "offline" + } + return "online" +} diff --git a/internal/handler/api/v1/conversation_handler.go b/internal/handler/api/v1/conversation_handler.go index af50aad2..c784ad99 100644 --- a/internal/handler/api/v1/conversation_handler.go +++ b/internal/handler/api/v1/conversation_handler.go @@ -1,6 +1,7 @@ package v1 import ( + "context" "errors" "io" "net/http" @@ -23,6 +24,7 @@ type ConversationHandler struct { conversationSvc *service.ConversationService messageSvc *service.MessageService auditSvc *service.AuditService + presence contactPresenceReader } const chatwootAttachmentResultsPerPage = 100 @@ -37,6 +39,15 @@ func (h *ConversationHandler) WithAuditService(auditSvc *service.AuditService) * return h } +func (h *ConversationHandler) WithContactPresence(presence contactPresenceReader) *ConversationHandler { + h.presence = presence + return h +} + +func (h *ConversationHandler) requestContext(c *gin.Context) context.Context { + return withContactPresence(c.Request.Context(), h.presence) +} + // @Summary List conversations for an account // @Description Retrieves all conversations for an account with pagination, optionally filtered by status query param // @Tags Conversations @@ -73,7 +84,7 @@ func (h *ConversationHandler) List(c *gin.Context) { return } - payload := serializeConversationList(c.Request.Context(), h.conversationSvc.DB(), result.Conversations, result.Count.AllCount) + payload := serializeConversationList(h.requestContext(c), h.conversationSvc.DB(), result.Conversations, result.Count.AllCount) payload.Data.Meta = chatwootConversationCounts{ MineCount: result.Count.MineCount, AssignedCount: result.Count.AssignedCount, @@ -111,7 +122,7 @@ func (h *ConversationHandler) Create(c *gin.Context) { Action: "update", AuditedChanges: gin.H{"status": conversation.Status, "priority": conversation.Priority, "sla_policy_id": conversation.SlaPolicyID}, }) - c.JSON(http.StatusOK, serializeConversation(c.Request.Context(), h.conversationSvc.DB(), conversation)) + c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation)) } // @Summary Get a single conversation @@ -145,7 +156,7 @@ func (h *ConversationHandler) Get(c *gin.Context) { handleServiceError(c, svcErr) return } - c.JSON(http.StatusOK, serializeConversation(c.Request.Context(), h.conversationSvc.DB(), conversation)) + c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation)) } // Update updates a conversation (status, priority). @@ -179,7 +190,7 @@ func (h *ConversationHandler) Update(c *gin.Context) { handleServiceError(c, svcErr) return } - c.JSON(http.StatusOK, serializeConversation(c.Request.Context(), h.conversationSvc.DB(), conversation)) + c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation)) } // Delete soft-deletes a conversation. @@ -439,7 +450,7 @@ func (h *ConversationHandler) UpdateLabels(c *gin.Context) { handleServiceError(c, svcErr) return } - c.JSON(http.StatusOK, serializeConversation(c.Request.Context(), h.conversationSvc.DB(), conversation)) + c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation)) } // @Summary Search conversations @@ -481,7 +492,7 @@ func (h *ConversationHandler) Search(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeConversationSearchList(c.Request.Context(), h.conversationSvc.DB(), result.Conversations, result.Count)) + c.JSON(http.StatusOK, serializeConversationSearchList(h.requestContext(c), h.conversationSvc.DB(), result.Conversations, result.Count)) } // Filter retrieves conversations matching advanced filter criteria. @@ -514,7 +525,7 @@ func (h *ConversationHandler) Filter(c *gin.Context) { return } - payload := serializeConversationList(c.Request.Context(), h.conversationSvc.DB(), result.Conversations, result.Count.AllCount) + payload := serializeConversationList(h.requestContext(c), h.conversationSvc.DB(), result.Conversations, result.Count.AllCount) payload.Data.Meta = chatwootConversationCounts{ MineCount: result.Count.MineCount, AssignedCount: result.Count.AssignedCount, @@ -555,7 +566,7 @@ func (h *ConversationHandler) UpdatePriority(c *gin.Context) { handleServiceError(c, svcErr) return } - c.JSON(http.StatusOK, serializeConversation(c.Request.Context(), h.conversationSvc.DB(), conversation)) + c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation)) } // TogglePriority updates a conversation priority using Chatwoot's member action path. @@ -685,7 +696,7 @@ func (h *ConversationHandler) Unread(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeConversation(c.Request.Context(), h.conversationSvc.DB(), conversation)) + c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation)) } // Transcript sends a conversation transcript via email. @@ -1008,7 +1019,7 @@ func (h *ConversationHandler) AssignTeam(c *gin.Context) { c.JSON(http.StatusOK, serializeTeamFromDB(c.Request.Context(), h.conversationSvc.DB(), *req.TeamID, accountID)) return } - c.JSON(http.StatusOK, serializeConversation(c.Request.Context(), h.conversationSvc.DB(), conversation)) + c.JSON(http.StatusOK, serializeConversation(h.requestContext(c), h.conversationSvc.DB(), conversation)) } // handleServiceError maps service-layer errors to appropriate HTTP responses. diff --git a/internal/handler/api/v1/conversation_serializer.go b/internal/handler/api/v1/conversation_serializer.go index 51f0c5d7..57ee911f 100644 --- a/internal/handler/api/v1/conversation_serializer.go +++ b/internal/handler/api/v1/conversation_serializer.go @@ -3,11 +3,13 @@ package v1 import ( "context" "encoding/json" + "os" "path/filepath" "strings" "time" "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/service" "gorm.io/datatypes" "gorm.io/gorm" @@ -57,6 +59,7 @@ type chatwootConversationPayload struct { WaitingSince int64 `json:"waiting_since"` SlaPolicyID *uint `json:"sla_policy_id"` AppliedSLA map[string]any `json:"applied_sla,omitempty"` + SlaEvents []map[string]any `json:"sla_events,omitempty"` } type chatwootConversationMeta struct { @@ -115,6 +118,7 @@ type chatwootMessagePayload struct { ID uint `json:"id"` Content string `json:"content"` InboxID uint `json:"inbox_id"` + AccountID uint `json:"account_id"` EchoID string `json:"echo_id,omitempty"` ConversationID uint `json:"conversation_id"` MessageType int `json:"message_type"` @@ -126,6 +130,7 @@ type chatwootMessagePayload struct { SourceID string `json:"source_id"` Sender map[string]any `json:"sender,omitempty"` Attachments []any `json:"attachments,omitempty"` + Call map[string]any `json:"call,omitempty"` } func serializeConversationList(ctx context.Context, db *gorm.DB, conversations []model.Conversation, total int64) chatwootConversationListResponse { @@ -209,7 +214,7 @@ func serializeConversation(ctx context.Context, db *gorm.DB, conversation *model var lastMessage *model.Message if db != nil { var msg model.Message - if err := db.WithContext(ctx).Where("account_id = ? AND conversation_id = ?", conversation.AccountID, conversation.ID).Order("id DESC").First(&msg).Error; err == nil { + if err := db.WithContext(ctx).Where("account_id = ? AND conversation_id = ?", conversation.AccountID, conversation.ID).Order("created_at DESC, id DESC").First(&msg).Error; err == nil { lastMessage = &msg } } @@ -219,9 +224,19 @@ func serializeConversation(ctx context.Context, db *gorm.DB, conversation *model if lastMessage != nil { serialized := serializeMessage(ctx, db, lastMessage, conversation) messages = append(messages, serialized) - if lastMessage.MessageType != "activity" { + } + if db != nil { + var nonActivityMessage model.Message + if err := db.WithContext(ctx). + Where("account_id = ? AND conversation_id = ? AND message_type <> ?", conversation.AccountID, conversation.ID, "activity"). + Order("created_at DESC, id DESC"). + First(&nonActivityMessage).Error; err == nil { + serialized := serializeMessage(ctx, db, &nonActivityMessage, conversation) lastNonActivity = &serialized } + } else if lastMessage != nil && lastMessage.MessageType != "activity" { + serialized := serializeMessage(ctx, db, lastMessage, conversation) + lastNonActivity = &serialized } payload := chatwootConversationPayload{ @@ -233,7 +248,7 @@ func serializeConversation(ctx context.Context, db *gorm.DB, conversation *model AdditionalAttributes: jsonObject(conversation.AdditionalAttributes), AgentLastSeenAt: int64Value(conversation.AgentLastSeenAt), AssigneeLastSeenAt: int64Value(conversation.AssigneeLastSeenAt), - CanReply: true, + CanReply: conversationCanReply(ctx, db, conversation), ContactLastSeenAt: int64Value(conversation.ContactLastSeenAt), CustomAttributes: jsonObject(conversation.CustomAttributes), InboxID: conversation.InboxID, @@ -256,6 +271,9 @@ func serializeConversation(ctx context.Context, db *gorm.DB, conversation *model if appliedSLA := serializeAppliedSlaForConversation(ctx, db, conversation.ID); appliedSLA != nil { payload.AppliedSLA = appliedSLA } + if slaEvents := serializeSlaEventsForConversation(ctx, db, conversation.ID); len(slaEvents) > 0 { + payload.SlaEvents = slaEvents + } return payload } @@ -303,6 +321,32 @@ func serializeAppliedSlaForConversation(ctx context.Context, db *gorm.DB, conver } } +func serializeSlaEventsForConversation(ctx context.Context, db *gorm.DB, conversationID uint) []map[string]any { + if db == nil || conversationID == 0 { + return nil + } + + var events []model.SlaEvent + if err := db.WithContext(ctx). + Where("conversation_id = ?", conversationID). + Order("created_at ASC, id ASC"). + Find(&events).Error; err != nil || len(events) == 0 { + return nil + } + + payload := make([]map[string]any, 0, len(events)) + for i := range events { + payload = append(payload, map[string]any{ + "id": events[i].ID, + "event_type": string(events[i].EventType), + "meta": jsonObject(events[i].Meta), + "updated_at": events[i].UpdatedAt.Unix(), + "created_at": events[i].CreatedAt.Unix(), + }) + } + return payload +} + func serializeConversationMeta(ctx context.Context, db *gorm.DB, conversation *model.Conversation) chatwootConversationMeta { meta := chatwootConversationMeta{Channel: conversation.ChannelType} if db == nil { @@ -312,12 +356,18 @@ func serializeConversationMeta(ctx context.Context, db *gorm.DB, conversation *m var contact model.Contact if err := db.WithContext(ctx).First(&contact, conversation.ContactID).Error; err == nil { - meta.Sender = serializeContact(&contact) + meta.Sender = serializeContactWithContext(ctx, &contact) } else { meta.Sender = map[string]any{"id": conversation.ContactID} } - if conversation.AssigneeID != nil && *conversation.AssigneeID != 0 { + if conversation.AssigneeAgentBotID != nil && *conversation.AssigneeAgentBotID != 0 { + var bot model.AgentBot + if err := db.WithContext(ctx).First(&bot, *conversation.AssigneeAgentBotID).Error; err == nil { + meta.Assignee = serializeAgentBotSlim(&bot) + meta.AssigneeType = "AgentBot" + } + } else if conversation.AssigneeID != nil && *conversation.AssigneeID != 0 { var user model.User if err := db.WithContext(ctx).First(&user, *conversation.AssigneeID).Error; err == nil { meta.Assignee = serializeUser(&user, conversation.AccountID) @@ -356,7 +406,7 @@ func serializeMessageIndex(ctx context.Context, db *gorm.DB, conversation *model if db != nil { var contact model.Contact if err := db.WithContext(ctx).First(&contact, conversation.ContactID).Error; err == nil { - meta.Contact = serializeContact(&contact) + meta.Contact = serializeContactWithContext(ctx, &contact) } if conversation.AssigneeID != nil && *conversation.AssigneeID != 0 { var user model.User @@ -378,6 +428,7 @@ func serializeMessage(ctx context.Context, db *gorm.DB, message *model.Message, ID: message.ID, Content: message.Content, InboxID: message.InboxID, + AccountID: message.AccountID, EchoID: message.EchoID, ConversationID: conversationID, MessageType: messageTypeValue(message.MessageType), @@ -389,13 +440,19 @@ func serializeMessage(ctx context.Context, db *gorm.DB, message *model.Message, SourceID: message.SourceID, } if db != nil && message.SenderID != nil && *message.SenderID != 0 { - senderType := strings.ToLower(message.SenderType) - if senderType == "contact" { + senderType := normalizedSenderType(message.SenderType) + switch senderType { + case "contact": var contact model.Contact if err := db.WithContext(ctx).First(&contact, *message.SenderID).Error; err == nil { - payload.Sender = serializeContact(&contact) + payload.Sender = serializeContactWithContext(ctx, &contact) } - } else { + case "agent_bot": + var bot model.AgentBot + if err := db.WithContext(ctx).First(&bot, *message.SenderID).Error; err == nil { + payload.Sender = serializeAgentBotSender(&bot) + } + default: var user model.User if err := db.WithContext(ctx).First(&user, *message.SenderID).Error; err == nil { payload.Sender = serializeUser(&user, message.AccountID) @@ -407,28 +464,126 @@ func serializeMessage(ctx context.Context, db *gorm.DB, message *model.Message, if err := db.WithContext(ctx).Where("message_id = ?", message.ID).Order("id ASC").Find(&attachments).Error; err == nil && len(attachments) > 0 { payload.Attachments = make([]any, 0, len(attachments)) for i := range attachments { - payload.Attachments = append(payload.Attachments, serializeAttachment(ctx, db, &attachments[i])) + payload.Attachments = append(payload.Attachments, serializeAttachmentPushEventData(&attachments[i])) } } + if strings.EqualFold(message.ContentType, "voice_call") { + payload.Call = serializeCallForMessage(ctx, db, message) + } } return payload } -func serializeAttachment(ctx context.Context, db *gorm.DB, attachment *model.Attachment) map[string]any { - extension := strings.TrimPrefix(filepath.Ext(attachment.FileName), ".") - dataURL := nonEmpty(attachment.FileURL, attachment.ExternalURL) - payload := map[string]any{ - "id": attachment.ID, - "message_id": attachment.MessageID, - "file_type": attachment.FileType, - "account_id": attachment.AccountID, - "data_url": dataURL, - "thumb_url": attachment.ThumbURL, - "file_size": attachment.FileSize, - "extension": extension, - "width": attachment.Width, - "height": attachment.Height, +func serializeCallForMessage(ctx context.Context, db *gorm.DB, message *model.Message) map[string]any { + if db == nil || message == nil || message.ID == 0 { + return nil } + var call model.Call + if err := db.WithContext(ctx). + Where("account_id = ? AND message_id = ?", message.AccountID, message.ID). + First(&call).Error; err != nil { + return nil + } + return serializeCallPushEventData(ctx, db, &call) +} + +func serializeCallPushEventData(ctx context.Context, db *gorm.DB, call *model.Call) map[string]any { + if call == nil || call.ID == 0 { + return nil + } + payload := map[string]any{ + "id": call.ID, + "provider_call_id": call.ProviderCallID, + "provider": call.Provider, + "direction": call.Direction, + "status": strings.ReplaceAll(call.Status, "_", "-"), + "duration_seconds": call.Duration, + "end_reason": call.EndReason, + "conference_sid": call.ConferenceSID, + "accepted_by_agent_id": callAcceptedByAgentID(call), + "accepted_by_agent_name": nil, + "started_at": nil, + "ended_at": callAdditionalAttribute(call, "ended_at"), + "from_number": nil, + "to_number": nil, + "recording_url": nilIfEmpty(call.RecordingURL), + "transcript": callAdditionalAttribute(call, "transcript"), + } + if call.StartedAt != nil { + payload["started_at"] = call.StartedAt.Unix() + } + if db != nil { + if call.AcceptedByAgentID != nil && *call.AcceptedByAgentID != 0 { + var user model.User + if err := db.WithContext(ctx).First(&user, *call.AcceptedByAgentID).Error; err == nil { + payload["accepted_by_agent_name"] = nonEmpty(user.DisplayName, user.Name) + } + } + fromNumber, toNumber := callPhoneNumbers(ctx, db, call) + payload["from_number"] = fromNumber + payload["to_number"] = toNumber + } + return payload +} + +func callAcceptedByAgentID(call *model.Call) any { + if call == nil || call.AcceptedByAgentID == nil || *call.AcceptedByAgentID == 0 { + return nil + } + return *call.AcceptedByAgentID +} + +func callPhoneNumbers(ctx context.Context, db *gorm.DB, call *model.Call) (any, any) { + contactNumber := "" + if call.ContactID != 0 { + var contact model.Contact + if err := db.WithContext(ctx).First(&contact, call.ContactID).Error; err == nil { + contactNumber = contact.PhoneNumber + } + } + channelNumber := callInboxPhoneNumber(ctx, db, call) + if strings.EqualFold(call.Direction, "incoming") || strings.EqualFold(call.CallDirection, "inbound") { + return nilIfEmpty(contactNumber), nilIfEmpty(channelNumber) + } + return nilIfEmpty(channelNumber), nilIfEmpty(contactNumber) +} + +func callInboxPhoneNumber(ctx context.Context, db *gorm.DB, call *model.Call) string { + if call.InboxID == 0 { + return "" + } + var twilio channelmodel.ChannelTwilioSMS + if err := db.WithContext(ctx).Where("account_id = ? AND inbox_id = ?", call.AccountID, call.InboxID).First(&twilio).Error; err == nil { + return twilio.PhoneNumber + } + var whatsapp channelmodel.ChannelWhatsApp + if err := db.WithContext(ctx).Where("account_id = ? AND inbox_id = ?", call.AccountID, call.InboxID).First(&whatsapp).Error; err == nil { + return whatsapp.PhoneNumber + } + return "" +} + +func callAdditionalAttribute(call *model.Call, key string) any { + if call == nil || len(call.AdditionalAttributes) == 0 || string(call.AdditionalAttributes) == "null" { + return nil + } + var attrs map[string]any + if err := json.Unmarshal(call.AdditionalAttributes, &attrs); err != nil { + return nil + } + return attrs[key] +} + +func nilIfEmpty(value string) any { + if value == "" { + return nil + } + return value +} + +func serializeAttachment(ctx context.Context, db *gorm.DB, attachment *model.Attachment) map[string]any { + payload := serializeAttachmentPushEventData(attachment) + delete(payload, "account_id") message := attachment.Message if message.ID == 0 && db != nil && attachment.MessageID != 0 { @@ -443,6 +598,23 @@ func serializeAttachment(ctx context.Context, db *gorm.DB, attachment *model.Att return payload } +func serializeAttachmentPushEventData(attachment *model.Attachment) map[string]any { + extension := strings.TrimPrefix(filepath.Ext(attachment.FileName), ".") + dataURL := nonEmpty(attachment.FileURL, attachment.ExternalURL) + return map[string]any{ + "id": attachment.ID, + "message_id": attachment.MessageID, + "file_type": attachment.FileType, + "account_id": attachment.AccountID, + "data_url": dataURL, + "thumb_url": attachment.ThumbURL, + "file_size": attachment.FileSize, + "extension": extension, + "width": attachment.Width, + "height": attachment.Height, + } +} + func serializeAttachmentWithConversation(ctx context.Context, db *gorm.DB, attachment *model.Attachment) map[string]any { payload := serializeAttachment(ctx, db, attachment) message := attachment.Message @@ -462,11 +634,18 @@ func serializeMessageSender(ctx context.Context, db *gorm.DB, message *model.Mes if db == nil || message == nil || message.SenderID == nil || *message.SenderID == 0 { return nil } - senderType := strings.ToLower(message.SenderType) + senderType := normalizedSenderType(message.SenderType) if senderType == "contact" { var contact model.Contact if err := db.WithContext(ctx).First(&contact, *message.SenderID).Error; err == nil { - return serializeContact(&contact) + return serializeContactWithContext(ctx, &contact) + } + return nil + } + if senderType == "agent_bot" { + var bot model.AgentBot + if err := db.WithContext(ctx).First(&bot, *message.SenderID).Error; err == nil { + return serializeAgentBotSender(&bot) } return nil } @@ -477,6 +656,17 @@ func serializeMessageSender(ctx context.Context, db *gorm.DB, message *model.Mes return nil } +func normalizedSenderType(senderType string) string { + switch strings.ToLower(strings.TrimSpace(senderType)) { + case "contact": + return "contact" + case "agentbot", "agent_bot": + return "agent_bot" + default: + return "user" + } +} + func messageSenderName(ctx context.Context, db *gorm.DB, message *model.Message) *string { sender := serializeMessageSender(ctx, db, message) if sender == nil { @@ -487,9 +677,13 @@ func messageSenderName(ctx context.Context, db *gorm.DB, message *model.Message) } func serializeContact(contact *model.Contact) map[string]any { + return serializeContactWithContext(context.Background(), contact) +} + +func serializeContactWithContext(ctx context.Context, contact *model.Contact) map[string]any { return map[string]any{ "additional_attributes": jsonObject(contact.AdditionalAttributes), - "availability_status": "offline", + "availability_status": contactAvailabilityStatus(ctx, contact), "email": contact.Email, "id": contact.ID, "name": contact.Name, @@ -504,7 +698,7 @@ func serializeContact(contact *model.Contact) map[string]any { } func serializeUser(user *model.User, accountID uint) map[string]any { - return map[string]any{ + payload := map[string]any{ "id": user.ID, "account_id": accountID, "availability_status": availabilityStatus(user.Available), @@ -517,6 +711,33 @@ func serializeUser(user *model.User, accountID uint) map[string]any { "role": nonEmpty(user.Role, "agent"), "thumbnail": user.AvatarURL, } + if attrs := jsonObject(user.CustomAttributes); len(attrs) > 0 { + payload["custom_attributes"] = attrs + } + if user.CustomRoleID != nil && *user.CustomRoleID != 0 { + payload["custom_role_id"] = *user.CustomRoleID + } + return payload +} + +func serializeAgentBotSender(bot *model.AgentBot) map[string]any { + return map[string]any{ + "id": bot.ID, + "name": bot.Name, + "avatar_url": bot.AvatarURL, + "type": "agent_bot", + } +} + +func serializeAgentBotSlim(bot *model.AgentBot) map[string]any { + return map[string]any{ + "id": bot.ID, + "name": bot.Name, + "description": bot.Description, + "thumbnail": bot.AvatarURL, + "outgoing_url": bot.OutgoingURL, + "bot_type": bot.BotType, + } } func serializeUserFromDB(ctx context.Context, db *gorm.DB, userID uint, accountID uint) any { @@ -532,13 +753,12 @@ func serializeUserFromDB(ctx context.Context, db *gorm.DB, userID uint, accountI func serializeTeam(team *model.Team) map[string]any { return map[string]any{ - "id": team.ID, - "account_id": team.AccountID, - "name": team.Name, - "description": team.Description, - "allow_auto_assignment": team.AllowAutoAssignment, - "created_at": team.CreatedAt.Unix(), - "updated_at": team.UpdatedAt.Unix(), + "id": team.ID, + "account_id": team.AccountID, + "name": team.Name, + "description": team.Description, + "allow_auto_assign": team.AllowAutoAssignment, + "is_member": false, } } @@ -642,3 +862,118 @@ func unreadCount(ctx context.Context, db *gorm.DB, conversation *model.Conversat _ = query.Count(&count).Error return count } + +func conversationCanReply(ctx context.Context, db *gorm.DB, conversation *model.Conversation) bool { + if db == nil { + return true + } + window := messagingWindowHours(ctx, db, conversation) + if window == 0 { + return true + } + var lastIncoming model.Message + err := db.WithContext(ctx). + Where("account_id = ? AND conversation_id = ? AND message_type = ?", conversation.AccountID, conversation.ID, "incoming"). + Order("id DESC").First(&lastIncoming).Error + if err != nil { + return false + } + return time.Now().UTC().Before(lastIncoming.CreatedAt.Add(time.Duration(window) * time.Hour)) +} + +func messagingWindowHours(ctx context.Context, db *gorm.DB, conversation *model.Conversation) int { + var inbox model.Inbox + if err := db.WithContext(ctx).Select("id", "channel_type", "channel_id", "channel_config").Where("id = ?", conversation.InboxID).First(&inbox).Error; err != nil { + return 0 + } + switch normalizedChannelType(inbox.ChannelType) { + case "api", string(model.InboxChannelTypeAPI): + return apiMessagingWindowHours(ctx, db, inbox.ID) + case "facebook", string(model.InboxChannelTypeFacebook): + return metaMessagingWindowHours("ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT") + case "instagram", string(model.InboxChannelTypeInstagram): + return metaMessagingWindowHours("ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT") + case "whatsapp", string(model.InboxChannelTypeWhatsApp): + return 24 + case "tiktok", "Channel::Tiktok", string(model.InboxChannelTypeTikTok): + return 48 + case "twilio_sms", string(model.InboxChannelTypeTwilioSMS): + if channelConfigString(inbox.ChannelConfig, "medium") == "whatsapp" { + return 24 + } + return 0 + default: + return 0 + } +} + +func apiMessagingWindowHours(ctx context.Context, db *gorm.DB, inboxID uint) int { + var channelAPI struct { + AdditionalAttributes datatypes.JSON `gorm:"column:additional_attributes"` + } + if err := db.WithContext(ctx).Table("channel_api").Select("additional_attributes").Where("inbox_id = ?", inboxID).First(&channelAPI).Error; err != nil { + return 0 + } + attrs := map[string]any{} + _ = json.Unmarshal(channelAPI.AdditionalAttributes, &attrs) + return positiveInt(attrs["agent_reply_time_window"]) +} + +func metaMessagingWindowHours(configKey string) int { + if truthyEnv(os.Getenv(configKey)) { + return 24 * 7 + } + return 24 +} + +func truthyEnv(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "t", "true", "yes", "y", "on": + return true + default: + return false + } +} + +func normalizedChannelType(channelType string) string { + channelType = strings.TrimSpace(channelType) + if strings.HasPrefix(channelType, "Channel::") { + return channelType + } + return strings.ToLower(channelType) +} + +func channelConfigString(raw, key string) string { + if strings.TrimSpace(raw) == "" { + return "" + } + config := map[string]any{} + if err := json.Unmarshal([]byte(raw), &config); err != nil { + return "" + } + value, _ := config[key].(string) + return strings.ToLower(strings.TrimSpace(value)) +} + +func positiveInt(value any) int { + switch v := value.(type) { + case float64: + if v > 0 { + return int(v) + } + case int: + if v > 0 { + return v + } + case string: + parsed := 0 + for _, r := range strings.TrimSpace(v) { + if r < '0' || r > '9' { + return 0 + } + parsed = parsed*10 + int(r-'0') + } + return parsed + } + return 0 +} diff --git a/internal/handler/api/v1/conversation_serializer_test.go b/internal/handler/api/v1/conversation_serializer_test.go new file mode 100644 index 00000000..72259162 --- /dev/null +++ b/internal/handler/api/v1/conversation_serializer_test.go @@ -0,0 +1,435 @@ +package v1 + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type fakeContactPresence struct { + statuses map[string]string +} + +func (f fakeContactPresence) GetContactStatus(_ context.Context, contactID, accountID uint) (string, error) { + status := f.statuses[fmt.Sprintf("%d:%d", contactID, accountID)] + if status == "" { + status = "offline" + } + return status, nil +} + +func TestConversationCanReplyRespectsMessagingWindows(t *testing.T) { + db := newConversationSerializerTestDB(t) + ctx := context.Background() + account := &model.Account{Name: "Reply Window Account", Active: true} + require.NoError(t, db.Create(account).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Reply Window Contact"} + require.NoError(t, db.Create(contact).Error) + + t.Run("api channel uses configured agent reply window", func(t *testing.T) { + inbox := createSerializerInbox(t, db, account.ID, "Channel::Api", "API") + require.NoError(t, db.Table("channel_api").Create(map[string]any{ + "inbox_id": inbox.ID, + "additional_attributes": datatypes.JSON([]byte(`{"agent_reply_time_window":"12"}`)), + }).Error) + conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) + createSerializerMessage(t, db, account.ID, inbox.ID, conversation.ID, "incoming", time.Now().Add(-13*time.Hour)) + require.False(t, conversationCanReply(ctx, db, conversation)) + }) + + t.Run("facebook defaults to 24 hours", func(t *testing.T) { + inbox := createSerializerInbox(t, db, account.ID, "Channel::FacebookPage", "Facebook") + conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) + createSerializerMessage(t, db, account.ID, inbox.ID, conversation.ID, "incoming", time.Now().Add(-25*time.Hour)) + require.False(t, conversationCanReply(ctx, db, conversation)) + }) + + t.Run("facebook human agent env extends to seven days", func(t *testing.T) { + t.Setenv("ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT", "true") + inbox := createSerializerInbox(t, db, account.ID, "Channel::FacebookPage", "Facebook Human Agent") + conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) + createSerializerMessage(t, db, account.ID, inbox.ID, conversation.ID, "incoming", time.Now().Add(-6*24*time.Hour)) + require.True(t, conversationCanReply(ctx, db, conversation)) + }) + + t.Run("twilio sms has no reply window unless medium is whatsapp", func(t *testing.T) { + smsInbox := createSerializerInbox(t, db, account.ID, "Channel::TwilioSms", "Twilio SMS") + smsConversation := createSerializerConversation(t, db, account.ID, smsInbox.ID, contact.ID) + createSerializerMessage(t, db, account.ID, smsInbox.ID, smsConversation.ID, "incoming", time.Now().Add(-10*24*time.Hour)) + require.True(t, conversationCanReply(ctx, db, smsConversation)) + + whatsappInbox := createSerializerInbox(t, db, account.ID, "twilio_sms", "Twilio WhatsApp") + whatsappInbox.ChannelConfig = `{"medium":"whatsapp"}` + require.NoError(t, db.Save(whatsappInbox).Error) + whatsappConversation := createSerializerConversation(t, db, account.ID, whatsappInbox.ID, contact.ID) + createSerializerMessage(t, db, account.ID, whatsappInbox.ID, whatsappConversation.ID, "incoming", time.Now().Add(-25*time.Hour)) + require.False(t, conversationCanReply(ctx, db, whatsappConversation)) + }) +} + +func TestSerializeMessageIncludesAgentBotSender(t *testing.T) { + db := newConversationSerializerTestDB(t) + ctx := context.Background() + account := &model.Account{Name: "Agent Bot Sender Account", Active: true} + require.NoError(t, db.Create(account).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Agent Bot Sender Contact"} + require.NoError(t, db.Create(contact).Error) + inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") + conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) + agentBot := &model.AgentBot{AccountID: &account.ID, Name: "Reply Bot", AvatarURL: "https://example.com/bot.png"} + require.NoError(t, db.Create(agentBot).Error) + message := &model.Message{ + AccountID: account.ID, + InboxID: inbox.ID, + ConversationID: conversation.ID, + MessageType: "outgoing", + ContentType: "text", + Content: "bot reply", + SenderID: &agentBot.ID, + SenderType: string(model.SenderTypeAgentBot), + ContentAttributes: datatypes.JSON([]byte(`{}`)), + } + require.NoError(t, db.Create(message).Error) + + payload := serializeMessage(ctx, db, message, conversation) + require.Equal(t, account.ID, payload.AccountID) + sender := payload.Sender + require.Equal(t, agentBot.ID, sender["id"]) + require.Equal(t, "Reply Bot", sender["name"]) + require.Equal(t, "agent_bot", sender["type"]) +} + +func TestSerializeVoiceCallMessageIncludesCallPayload(t *testing.T) { + db := newConversationSerializerTestDB(t) + ctx := context.Background() + account := &model.Account{Name: "Voice Call Account", Active: true} + require.NoError(t, db.Create(account).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Call Contact", PhoneNumber: "+1555010001"} + require.NoError(t, db.Create(contact).Error) + inbox := createSerializerInbox(t, db, account.ID, "Channel::TwilioSms", "Voice Inbox") + require.NoError(t, db.Create(&channelmodel.ChannelTwilioSMS{AccountID: account.ID, InboxID: inbox.ID, AccountSID: "AC123", PhoneNumber: "+1555010000"}).Error) + agent := &model.User{AccountID: account.ID, Name: "Voice Agent", DisplayName: "Agent Voice", Email: "voice@example.com", Password: "pass"} + require.NoError(t, db.Create(agent).Error) + conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) + startedAt := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + message := &model.Message{ + AccountID: account.ID, + InboxID: inbox.ID, + ConversationID: conversation.ID, + SenderID: &agent.ID, + SenderType: "user", + MessageType: "outgoing", + ContentType: "voice_call", + Content: "Twilio voice call", + Status: "sent", + } + require.NoError(t, db.Create(message).Error) + call := &model.Call{ + AccountID: account.ID, + InboxID: inbox.ID, + ConversationID: conversation.ID, + ContactID: contact.ID, + MessageID: &message.ID, + AcceptedByAgentID: &agent.ID, + Provider: "twilio", + Direction: "outgoing", + ProviderCallID: "CA123", + ConferenceSID: "CF123", + StartedAt: &startedAt, + CallerType: "User", + CallerID: agent.ID, + Status: "in_progress", + Duration: 42, + CallDirection: "outbound", + RecordingURL: "https://recordings.example/call.mp3", + AdditionalAttributes: json.RawMessage([]byte(`{"ended_at":1760000000,"transcript":"hello"}`)), + } + require.NoError(t, db.Create(call).Error) + + payload := serializeMessage(ctx, db, message, conversation) + require.NotNil(t, payload.Call) + require.Equal(t, call.ID, payload.Call["id"]) + require.Equal(t, "CA123", payload.Call["provider_call_id"]) + require.Equal(t, "twilio", payload.Call["provider"]) + require.Equal(t, "outgoing", payload.Call["direction"]) + require.Equal(t, "in-progress", payload.Call["status"]) + require.Equal(t, 42, payload.Call["duration_seconds"]) + require.Equal(t, "CF123", payload.Call["conference_sid"]) + require.Equal(t, agent.ID, payload.Call["accepted_by_agent_id"]) + require.Equal(t, "Agent Voice", payload.Call["accepted_by_agent_name"]) + require.Equal(t, startedAt.Unix(), payload.Call["started_at"]) + require.Equal(t, float64(1760000000), payload.Call["ended_at"]) + require.Equal(t, "+1555010000", payload.Call["from_number"]) + require.Equal(t, "+1555010001", payload.Call["to_number"]) + require.Equal(t, "https://recordings.example/call.mp3", payload.Call["recording_url"]) + require.Equal(t, "hello", payload.Call["transcript"]) +} + +func TestSerializeConversationLastNonActivityMessageSkipsLatestActivity(t *testing.T) { + db := newConversationSerializerTestDB(t) + ctx := context.Background() + account := &model.Account{Name: "Last Non Activity Account", Active: true} + require.NoError(t, db.Create(account).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Last Non Activity Contact"} + require.NoError(t, db.Create(contact).Error) + inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") + conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) + olderMessage := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "incoming", ContentType: "text", Content: "older reply"} + olderMessage.CreatedAt = time.Now().Add(-3 * time.Hour) + olderMessage.UpdatedAt = olderMessage.CreatedAt + require.NoError(t, db.Create(olderMessage).Error) + recentMessage := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "outgoing", ContentType: "text", Content: "recent reply"} + recentMessage.CreatedAt = time.Now().Add(-2 * time.Hour) + recentMessage.UpdatedAt = recentMessage.CreatedAt + require.NoError(t, db.Create(recentMessage).Error) + activityMessage := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "activity", ContentType: "text", Content: "activity event"} + activityMessage.CreatedAt = time.Now().Add(-1 * time.Hour) + activityMessage.UpdatedAt = activityMessage.CreatedAt + require.NoError(t, db.Create(activityMessage).Error) + + payload := serializeConversation(ctx, db, conversation) + require.Len(t, payload.Messages, 1) + require.Equal(t, activityMessage.ID, payload.Messages[0].ID) + require.NotNil(t, payload.LastNonActivityMessage) + require.Equal(t, recentMessage.ID, payload.LastNonActivityMessage.ID) + require.Equal(t, "recent reply", payload.LastNonActivityMessage.Content) +} + +func TestSerializeAttachmentOmitAccountID(t *testing.T) { + db := newConversationSerializerTestDB(t) + ctx := context.Background() + account := &model.Account{Name: "Attachment Account", Active: true} + require.NoError(t, db.Create(account).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Contact"} + require.NoError(t, db.Create(contact).Error) + inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") + conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) + message := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "incoming", Content: "hi"} + require.NoError(t, db.Create(message).Error) + attachment := &model.Attachment{AccountID: account.ID, MessageID: message.ID, FileType: "file", FileName: "report.pdf", FileURL: "https://files.example/report.pdf", FileSize: 512} + require.NoError(t, db.Create(attachment).Error) + + payload := serializeAttachment(ctx, db, attachment) + require.Equal(t, message.ID, payload["message_id"]) + require.Equal(t, "file", payload["file_type"]) + require.Equal(t, "https://files.example/report.pdf", payload["data_url"]) + require.Equal(t, "pdf", payload["extension"]) + require.NotContains(t, payload, "account_id") +} + +func TestSerializeMessageAttachmentIncludesAccountID(t *testing.T) { + db := newConversationSerializerTestDB(t) + ctx := context.Background() + account := &model.Account{Name: "Message Attachment Account", Active: true} + require.NoError(t, db.Create(account).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Contact"} + require.NoError(t, db.Create(contact).Error) + inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") + conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) + message := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "incoming", ContentType: "text", Content: "hi"} + require.NoError(t, db.Create(message).Error) + attachment := &model.Attachment{AccountID: account.ID, MessageID: message.ID, FileType: "image", FileName: "photo.png", FileURL: "https://files.example/photo.png", FileSize: 128, Width: 80, Height: 60} + require.NoError(t, db.Create(attachment).Error) + + payload := serializeMessage(ctx, db, message, conversation) + require.Len(t, payload.Attachments, 1) + attachmentPayload, ok := payload.Attachments[0].(map[string]any) + require.True(t, ok) + require.Equal(t, account.ID, attachmentPayload["account_id"]) + require.Equal(t, attachment.ID, attachmentPayload["id"]) + require.Equal(t, message.ID, attachmentPayload["message_id"]) + require.Equal(t, "image", attachmentPayload["file_type"]) + require.Equal(t, "https://files.example/photo.png", attachmentPayload["data_url"]) + require.Equal(t, "png", attachmentPayload["extension"]) + require.NotContains(t, attachmentPayload, "created_at") + require.NotContains(t, attachmentPayload, "sender") +} + +func TestSerializeConversationUsesLatestMessageByCreatedAt(t *testing.T) { + db := newConversationSerializerTestDB(t) + ctx := context.Background() + account := &model.Account{Name: "Latest Message Account", Active: true} + require.NoError(t, db.Create(account).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Latest Message Contact"} + require.NoError(t, db.Create(contact).Error) + inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") + conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) + newerCreatedMessage := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "incoming", ContentType: "text", Content: "newer by timestamp"} + newerCreatedMessage.CreatedAt = time.Now().Add(-1 * time.Hour) + newerCreatedMessage.UpdatedAt = newerCreatedMessage.CreatedAt + require.NoError(t, db.Create(newerCreatedMessage).Error) + olderCreatedMessage := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: "incoming", ContentType: "text", Content: "older by timestamp"} + olderCreatedMessage.CreatedAt = time.Now().Add(-2 * time.Hour) + olderCreatedMessage.UpdatedAt = olderCreatedMessage.CreatedAt + require.NoError(t, db.Create(olderCreatedMessage).Error) + require.Greater(t, olderCreatedMessage.ID, newerCreatedMessage.ID) + + payload := serializeConversation(ctx, db, conversation) + require.Len(t, payload.Messages, 1) + require.Equal(t, newerCreatedMessage.ID, payload.Messages[0].ID) + require.Equal(t, "newer by timestamp", payload.Messages[0].Content) +} + +func TestSerializeConversationMetaIncludesAgentBotAssignee(t *testing.T) { + db := newConversationSerializerTestDB(t) + ctx := context.Background() + account := &model.Account{Name: "Bot Assignee Account", Active: true} + require.NoError(t, db.Create(account).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Bot Assignee Contact"} + require.NoError(t, db.Create(contact).Error) + inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") + agentBot := &model.AgentBot{AccountID: &account.ID, Name: "Routing Bot", Description: "Routes conversations", AvatarURL: "https://example.com/routing-bot.png", OutgoingURL: "https://example.com/webhook", BotType: "webhook"} + require.NoError(t, db.Create(agentBot).Error) + conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) + conversation.AssigneeAgentBotID = &agentBot.ID + require.NoError(t, db.Save(conversation).Error) + + payload := serializeConversation(ctx, db, conversation) + require.Equal(t, "AgentBot", payload.Meta.AssigneeType) + require.Equal(t, agentBot.ID, payload.Meta.Assignee["id"]) + require.Equal(t, "Routing Bot", payload.Meta.Assignee["name"]) + require.Equal(t, "Routes conversations", payload.Meta.Assignee["description"]) + require.Equal(t, "https://example.com/routing-bot.png", payload.Meta.Assignee["thumbnail"]) + require.Equal(t, "https://example.com/webhook", payload.Meta.Assignee["outgoing_url"]) + require.Equal(t, "webhook", payload.Meta.Assignee["bot_type"]) +} + +func TestSerializeConversationMetaIncludesAgentCustomFields(t *testing.T) { + db := newConversationSerializerTestDB(t) + ctx := context.Background() + account := &model.Account{Name: "Agent Custom Fields Account", Active: true} + require.NoError(t, db.Create(account).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Agent Custom Fields Contact"} + require.NoError(t, db.Create(contact).Error) + inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") + customRoleID := uint(77) + agent := &model.User{AccountID: account.ID, Name: "Custom Agent", Email: "custom-agent@example.com", Password: "pass", Role: "agent", CustomRoleID: &customRoleID, CustomAttributes: datatypes.JSON([]byte(`{"region":"emea"}`))} + require.NoError(t, db.Create(agent).Error) + conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) + conversation.AssigneeID = &agent.ID + require.NoError(t, db.Save(conversation).Error) + + payload := serializeConversation(ctx, db, conversation) + require.Equal(t, "User", payload.Meta.AssigneeType) + require.Equal(t, agent.ID, payload.Meta.Assignee["id"]) + require.Equal(t, "Custom Agent", payload.Meta.Assignee["name"]) + require.Equal(t, map[string]any{"region": "emea"}, payload.Meta.Assignee["custom_attributes"]) + require.Equal(t, customRoleID, payload.Meta.Assignee["custom_role_id"]) +} + +func TestSerializeConversationMetaTeamUsesChatwootFieldNames(t *testing.T) { + db := newConversationSerializerTestDB(t) + ctx := context.Background() + account := &model.Account{Name: "Team Meta Account", Active: true} + require.NoError(t, db.Create(account).Error) + contact := &model.Contact{AccountID: account.ID, Name: "Team Meta Contact"} + require.NoError(t, db.Create(contact).Error) + inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") + team := &model.Team{AccountID: account.ID, Name: "Tier 1", Description: "Support team", AllowAutoAssignment: true} + require.NoError(t, db.Create(team).Error) + conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) + conversation.TeamID = &team.ID + require.NoError(t, db.Save(conversation).Error) + + payload := serializeConversation(ctx, db, conversation) + require.NotNil(t, payload.Meta.Team) + require.Equal(t, team.ID, payload.Meta.Team["id"]) + require.Equal(t, "Tier 1", payload.Meta.Team["name"]) + require.Equal(t, true, payload.Meta.Team["allow_auto_assign"]) + require.Equal(t, false, payload.Meta.Team["is_member"]) + require.NotContains(t, payload.Meta.Team, "allow_auto_assignment") + require.NotContains(t, payload.Meta.Team, "created_at") + require.NotContains(t, payload.Meta.Team, "updated_at") +} + +func TestSerializeConversationIncludesSlaEvents(t *testing.T) { + db := newConversationSerializerTestDB(t) + ctx := context.Background() + account := &model.Account{Name: "SLA Event Account", Active: true} + require.NoError(t, db.Create(account).Error) + contact := &model.Contact{AccountID: account.ID, Name: "SLA Event Contact"} + require.NoError(t, db.Create(contact).Error) + inbox := createSerializerInbox(t, db, account.ID, "web_widget", "Web Widget") + conversation := createSerializerConversation(t, db, account.ID, inbox.ID, contact.ID) + policy := &model.SlaPolicy{AccountID: account.ID, Name: "Gold SLA", FirstResponseTimeThreshold: 10} + require.NoError(t, db.Create(policy).Error) + applied := &model.AppliedSLA{AccountID: account.ID, ConversationID: conversation.ID, SlaPolicyID: policy.ID, SLAStatus: model.SLAStatusActive} + require.NoError(t, db.Create(applied).Error) + eventCreatedAt := time.Now().Add(-30 * time.Minute).UTC().Truncate(time.Second) + event := &model.SlaEvent{AppliedSlaID: applied.ID, AccountID: account.ID, ConversationID: conversation.ID, InboxID: inbox.ID, SlaPolicyID: policy.ID, EventType: model.SLAEventNRT, Meta: datatypes.JSON([]byte(`{"message_id":123}`))} + event.CreatedAt = eventCreatedAt + event.UpdatedAt = eventCreatedAt.Add(5 * time.Minute) + require.NoError(t, db.Create(event).Error) + + payload := serializeConversation(ctx, db, conversation) + require.NotNil(t, payload.AppliedSLA) + require.Equal(t, policy.ID, payload.AppliedSLA["sla_id"]) + require.Len(t, payload.SlaEvents, 1) + require.Equal(t, event.ID, payload.SlaEvents[0]["id"]) + require.Equal(t, "nrt", payload.SlaEvents[0]["event_type"]) + require.Equal(t, map[string]any{"message_id": float64(123)}, payload.SlaEvents[0]["meta"]) + require.Equal(t, event.CreatedAt.Unix(), payload.SlaEvents[0]["created_at"]) + require.Equal(t, event.UpdatedAt.Unix(), payload.SlaEvents[0]["updated_at"]) +} + +func TestSerializeContactUsesPresenceStatus(t *testing.T) { + contact := &model.Contact{AccountID: 7, Name: "Online Contact"} + contact.ID = 42 + ctx := withContactPresence(context.Background(), fakeContactPresence{statuses: map[string]string{"42:7": "online"}}) + + payload := serializeContactWithContext(ctx, contact) + + require.Equal(t, "online", payload["availability_status"]) + require.Equal(t, "offline", serializeContact(contact)["availability_status"]) +} + +func TestSerializeCRMContactUsesPresenceStatus(t *testing.T) { + contact := &model.Contact{AccountID: 9, Name: "CRM Online Contact"} + contact.ID = 99 + ctx := withContactPresence(context.Background(), fakeContactPresence{statuses: map[string]string{"99:9": "online"}}) + + payload := serializeCRMContact(ctx, nil, contact, false) + + require.Equal(t, "online", payload["availability_status"]) +} + +func newConversationSerializerTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{}, &model.Attachment{}, &model.AgentBot{}, &model.Team{}, &model.SlaPolicy{}, &model.AppliedSLA{}, &model.SlaEvent{}, &model.Call{}, &channelmodel.ChannelTwilioSMS{}, &channelmodel.ChannelWhatsApp{})) + require.NoError(t, db.Exec(`CREATE TABLE channel_api (id integer primary key autoincrement, inbox_id integer, additional_attributes json)`).Error) + return db +} + +func createSerializerInbox(t *testing.T, db *gorm.DB, accountID uint, channelType, name string) *model.Inbox { + t.Helper() + inbox := &model.Inbox{AccountID: accountID, Name: name, ChannelType: channelType, ChannelID: 1, Enabled: true} + require.NoError(t, db.Create(inbox).Error) + return inbox +} + +func createSerializerConversation(t *testing.T, db *gorm.DB, accountID, inboxID, contactID uint) *model.Conversation { + t.Helper() + conversation := &model.Conversation{AccountID: accountID, InboxID: inboxID, ContactID: contactID, Status: "open"} + require.NoError(t, db.Create(conversation).Error) + return conversation +} + +func createSerializerMessage(t *testing.T, db *gorm.DB, accountID, inboxID, conversationID uint, messageType string, createdAt time.Time) { + t.Helper() + message := &model.Message{AccountID: accountID, InboxID: inboxID, ConversationID: conversationID, MessageType: messageType, Content: "hello"} + message.CreatedAt = createdAt + message.UpdatedAt = createdAt + require.NoError(t, db.Create(message).Error) +} diff --git a/internal/handler/api/v1/crm_serializer.go b/internal/handler/api/v1/crm_serializer.go index a0a92c45..81b4ce3c 100644 --- a/internal/handler/api/v1/crm_serializer.go +++ b/internal/handler/api/v1/crm_serializer.go @@ -38,7 +38,7 @@ func contactPayloadResponse(ctx context.Context, db *gorm.DB, contact *model.Con func contactCreateResponse(ctx context.Context, db *gorm.DB, contact *model.Contact, contactInbox *model.ContactInbox) map[string]any { payload := map[string]any{ "contact": serializeCRMContact(ctx, db, contact, true), - "contact_inbox": serializeContactInboxShell(contactInbox), + "contact_inbox": serializeContactInboxShell(ctx, db, contactInbox), } return map[string]any{"payload": payload} } @@ -46,7 +46,7 @@ func contactCreateResponse(ctx context.Context, db *gorm.DB, contact *model.Cont func serializeCRMContact(ctx context.Context, db *gorm.DB, contact *model.Contact, includeInboxes bool) map[string]any { payload := map[string]any{ "additional_attributes": jsonObject(contact.AdditionalAttributes), - "availability_status": "offline", + "availability_status": contactAvailabilityStatus(ctx, contact), "email": contact.Email, "id": contact.ID, "name": contact.Name, @@ -99,11 +99,14 @@ func serializeContactInbox(contactInbox *model.ContactInbox) map[string]any { } } -func serializeContactInboxShell(contactInbox *model.ContactInbox) map[string]any { +func serializeContactInboxShell(ctx context.Context, db *gorm.DB, contactInbox *model.ContactInbox) map[string]any { if contactInbox == nil { return map[string]any{"inbox": nil, "source_id": nil} } - return map[string]any{"inbox": contactInbox.Inbox, "source_id": contactInbox.SourceID} + if contactInbox.Inbox.ID == 0 && db != nil && contactInbox.InboxID != 0 { + _ = db.WithContext(ctx).First(&contactInbox.Inbox, contactInbox.InboxID).Error + } + return map[string]any{"inbox": serializeInboxSlim(&contactInbox.Inbox), "source_id": contactInbox.SourceID} } func serializeInboxSlim(inbox *model.Inbox) map[string]any { @@ -112,9 +115,11 @@ func serializeInboxSlim(inbox *model.Inbox) map[string]any { } return map[string]any{ "id": inbox.ID, + "avatar_url": inbox.AvatarURL, + "channel_id": inbox.ChannelID, "name": inbox.Name, "channel_type": inbox.ChannelType, - "account_id": inbox.AccountID, + "provider": configValue(parseInboxChannelConfig(inbox.ChannelConfig), "provider"), } } @@ -136,16 +141,17 @@ func companyPayloadResponse(ctx context.Context, db *gorm.DB, company *model.Com func serializeCompany(ctx context.Context, db *gorm.DB, company *model.Company) map[string]any { return map[string]any{ - "id": company.ID, - "name": company.Name, - "contacts_count": companyContactsCount(ctx, db, company), - "domain": company.Domain, - "description": company.Description, - "custom_attributes": jsonObject(company.CustomAttributes), - "avatar_url": company.FaviconURL, - "last_activity_at": unixTimePtr(company.LastActivityAt), - "created_at": company.CreatedAt.Unix(), - "updated_at": company.UpdatedAt.Unix(), + "id": company.ID, + "name": company.Name, + "contacts_count": companyContactsCount(ctx, db, company), + "domain": company.Domain, + "description": company.Description, + "additional_attributes": jsonObject(company.AdditionalAttributes), + "custom_attributes": jsonObject(company.CustomAttributes), + "avatar_url": company.FaviconURL, + "last_activity_at": unixTimePtr(company.LastActivityAt), + "created_at": company.CreatedAt.Unix(), + "updated_at": company.UpdatedAt.Unix(), } } diff --git a/internal/handler/api/v1/email_channel_handler.go b/internal/handler/api/v1/email_channel_handler.go index 20394a0f..28eb9522 100644 --- a/internal/handler/api/v1/email_channel_handler.go +++ b/internal/handler/api/v1/email_channel_handler.go @@ -160,7 +160,7 @@ func (h *EmailChannelHandler) Create(c *gin.Context) { applogger.L().Warnf("Failed to bind Email inbox channel config: %v", err) } - c.JSON(http.StatusOK, serializeInbox(inbox)) + c.JSON(http.StatusOK, serializeInbox(inbox, nil, inboxSerializationAdmin(c))) } // Get retrieves an Email channel by ID. @@ -309,7 +309,7 @@ func (h *EmailChannelHandler) Update(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeInbox(inbox)) + c.JSON(http.StatusOK, serializeInbox(inbox, nil, inboxSerializationAdmin(c))) } // Delete removes an Email channel and its associated inbox. @@ -386,7 +386,7 @@ func (h *EmailChannelHandler) List(c *gin.Context) { func (h *EmailChannelHandler) serializeInboxForEmailChannel(c *gin.Context, ch *channelmodel.ChannelEmail) map[string]any { if ch != nil && ch.InboxID > 0 { if inbox, err := h.inboxSvc.GetByAccountAndID(c.Request.Context(), ch.AccountID, ch.InboxID); err == nil { - return serializeInbox(inbox) + return serializeInbox(inbox, nil, inboxSerializationAdmin(c)) } } return gin.H{"id": ch.ID, "account_id": ch.AccountID, "inbox_id": ch.InboxID, "email": ch.Email} diff --git a/internal/handler/api/v1/email_channel_handler_test.go b/internal/handler/api/v1/email_channel_handler_test.go index 7e89e2cd..63129d8b 100644 --- a/internal/handler/api/v1/email_channel_handler_test.go +++ b/internal/handler/api/v1/email_channel_handler_test.go @@ -78,6 +78,10 @@ func emailAccountIDUint(db *gorm.DB) uint { func setupEmailTestRouter(handler *EmailChannelHandler) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "administrator") + c.Next() + }) ag := r.Group("/api/v1/accounts/:id") ag.POST("/email_channels", handler.Create) ag.GET("/email_channels", handler.List) diff --git a/internal/handler/api/v1/facebook_channel_handler.go b/internal/handler/api/v1/facebook_channel_handler.go index ab7ca7ae..5a262e69 100644 --- a/internal/handler/api/v1/facebook_channel_handler.go +++ b/internal/handler/api/v1/facebook_channel_handler.go @@ -264,7 +264,7 @@ func (h *FacebookChannelHandler) ReauthorizePage(c *gin.Context) { return } h.updateFacebookInboxConfig(c.Request.Context(), inbox, channel) - c.JSON(http.StatusOK, gin.H{"data": serializeInbox(inbox)}) + c.JSON(http.StatusOK, gin.H{"data": serializeInbox(inbox, nil, inboxSerializationAdmin(c))}) } // OAuthCallback exchanges the Facebook OAuth code for a long-lived access token. diff --git a/internal/handler/api/v1/inbox_handler.go b/internal/handler/api/v1/inbox_handler.go index 398379d4..ae1c5fb6 100644 --- a/internal/handler/api/v1/inbox_handler.go +++ b/internal/handler/api/v1/inbox_handler.go @@ -65,7 +65,7 @@ func (h *InboxHandler) List(c *gin.Context) { return } - c.JSON(http.StatusOK, inboxListResponse(inboxes)) + c.JSON(http.StatusOK, inboxListResponse(inboxes, h.svc.DB(), inboxSerializationAdmin(c))) } // @Summary Get a single inbox @@ -103,7 +103,7 @@ func (h *InboxHandler) Get(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeInbox(inbox)) + c.JSON(http.StatusOK, serializeInbox(inbox, h.svc.DB(), inboxSerializationAdmin(c))) } // @Summary Create a new inbox @@ -147,10 +147,10 @@ func (h *InboxHandler) Create(c *gin.Context) { AuditableType: "Inbox", AuditableID: inbox.ID, Action: "create", - AuditedChanges: serializeInbox(inbox), + AuditedChanges: serializeInbox(inbox, h.svc.DB(), true), }) - c.JSON(http.StatusOK, serializeInbox(inbox)) + c.JSON(http.StatusOK, serializeInbox(inbox, h.svc.DB(), inboxSerializationAdmin(c))) } // WhatsAppAuthorization handles Chatwoot's embedded signup callback for @@ -238,10 +238,10 @@ func (h *InboxHandler) Update(c *gin.Context) { AuditableType: "Inbox", AuditableID: inbox.ID, Action: "update", - AuditedChanges: serializeInbox(inbox), + AuditedChanges: serializeInbox(inbox, h.svc.DB(), true), }) - c.JSON(http.StatusOK, serializeInbox(inbox)) + c.JSON(http.StatusOK, serializeInbox(inbox, h.svc.DB(), inboxSerializationAdmin(c))) } // @Summary Delete an inbox @@ -937,5 +937,9 @@ func (h *InboxHandler) ResetSecret(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeInbox(inbox)) + c.JSON(http.StatusOK, serializeInbox(inbox, h.svc.DB(), inboxSerializationAdmin(c))) +} + +func inboxSerializationAdmin(c *gin.Context) bool { + return getRole(c) == "administrator" } diff --git a/internal/handler/api/v1/inbox_handler_parity_test.go b/internal/handler/api/v1/inbox_handler_parity_test.go index 552f90f3..9d18198a 100644 --- a/internal/handler/api/v1/inbox_handler_parity_test.go +++ b/internal/handler/api/v1/inbox_handler_parity_test.go @@ -34,10 +34,12 @@ func TestInboxHandler_ChatwootSerializerParity(t *testing.T) { _ = sqlDB.Close() } }) - require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelAPI{})) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &model.Portal{}, &channelmodel.ChannelAPI{})) account := &model.Account{Name: "Inbox Parity", Locale: "en", Active: true} require.NoError(t, db.Create(account).Error) + portal := &model.Portal{AccountID: account.ID, Name: "Docs", Slug: "docs"} + require.NoError(t, db.Create(portal).Error) widget := &model.Inbox{ AccountID: account.ID, Name: "Website", @@ -53,6 +55,7 @@ func TestInboxHandler_ChatwootSerializerParity(t *testing.T) { SenderNameType: "friendly_name", BusinessName: "Example Co", Timezone: "UTC", + PortalID: &portal.ID, ChannelConfig: `{"website_token":"web-token","hmac_token":"hmac-token","widget_color":"#1f93ff","website_url":"https://example.com","welcome_title":"Hi","welcome_tagline":"We reply fast","reply_time":"in_a_few_minutes","pre_chat_form_enabled":true,"pre_chat_form_options":{"fields":[{"name":"email"}]},"continuity_via_email":true}`, } require.NoError(t, db.Create(widget).Error) @@ -86,7 +89,11 @@ func TestInboxHandler_ChatwootSerializerParity(t *testing.T) { showData := inboxParityObject(t, show) require.NotContains(t, showData, "payload") require.Equal(t, "Channel::WebWidget", showData["channel_type"]) + helpCenter := showData["help_center"].(map[string]any) + require.Equal(t, "Docs", helpCenter["name"]) + require.Equal(t, "docs", helpCenter["slug"]) require.Equal(t, "web-token", showData["website_token"]) + require.Equal(t, "friendly", showData["sender_name_type"]) require.Equal(t, "hmac-token", showData["hmac_token"]) require.Equal(t, "#1f93ff", showData["widget_color"]) require.Equal(t, "https://example.com", showData["website_url"]) @@ -111,6 +118,140 @@ func TestInboxHandler_ChatwootSerializerParity(t *testing.T) { require.Equal(t, "Your inbox deletion request will be processed in some time.", inboxParityObject(t, destroy)["message"]) } +func TestSerializeInboxIncludesTwitterTweetsEnabled(t *testing.T) { + defaultInbox := &model.Inbox{AccountID: 1, Name: "Twitter", ChannelType: "Channel::TwitterProfile"} + defaultPayload := serializeInbox(defaultInbox, nil, false) + require.Equal(t, "Channel::TwitterProfile", defaultPayload["channel_type"]) + require.Equal(t, true, defaultPayload["tweets_enabled"]) + + disabledInbox := &model.Inbox{AccountID: 1, Name: "Twitter", ChannelType: "twitter", ChannelConfig: `{"tweets_enabled":false}`} + disabledPayload := serializeInbox(disabledInbox, nil, false) + require.Equal(t, "Channel::TwitterProfile", disabledPayload["channel_type"]) + require.Equal(t, false, disabledPayload["tweets_enabled"]) +} + +func TestSerializeInboxEmailForwardingDependsOnInboundMailerDomain(t *testing.T) { + inbox := &model.Inbox{AccountID: 1, Name: "Email", ChannelType: "email", ChannelConfig: `{"email":"support@example.com","forward_to_email":"support+forward@example.test"}`} + + t.Setenv("MAILER_INBOUND_EMAIL_DOMAIN", "") + disabled := serializeInbox(inbox, nil, false) + require.Equal(t, "Channel::Email", disabled["channel_type"]) + require.Equal(t, false, disabled["forwarding_enabled"]) + require.NotContains(t, disabled, "forward_to_email") + + t.Setenv("MAILER_INBOUND_EMAIL_DOMAIN", "mail.example.test") + enabled := serializeInbox(inbox, nil, false) + require.Equal(t, true, enabled["forwarding_enabled"]) + require.Equal(t, "support+forward@example.test", enabled["forward_to_email"]) +} + +func TestSerializeInboxTwilioVoiceWebhookURLsRequireConfiguredTwimlApp(t *testing.T) { + unconfigured := &model.Inbox{AccountID: 1, Name: "Twilio", ChannelType: "twilio_sms", ChannelConfig: `{"voice_enabled":true,"twiml_app_sid":"","api_key_secret":"","voice_call_webhook_url":"https://voice.example/call","voice_status_webhook_url":"https://voice.example/status"}`} + unconfiguredPayload := serializeInbox(unconfigured, nil, true) + require.Equal(t, "Channel::TwilioSms", unconfiguredPayload["channel_type"]) + require.Equal(t, false, unconfiguredPayload["voice_configured"]) + require.Equal(t, false, unconfiguredPayload["has_api_key_secret"]) + require.NotContains(t, unconfiguredPayload, "voice_call_webhook_url") + require.NotContains(t, unconfiguredPayload, "voice_status_webhook_url") + + configured := &model.Inbox{AccountID: 1, Name: "Twilio", ChannelType: "Channel::TwilioSms", ChannelConfig: `{"voice_enabled":true,"twiml_app_sid":"AP123","api_key_secret":"secret","voice_call_webhook_url":"https://voice.example/call","voice_status_webhook_url":"https://voice.example/status"}`} + configuredPayload := serializeInbox(configured, nil, true) + require.Equal(t, true, configuredPayload["voice_configured"]) + require.Equal(t, true, configuredPayload["has_api_key_secret"]) + require.Equal(t, "https://voice.example/call", configuredPayload["voice_call_webhook_url"]) + require.Equal(t, "https://voice.example/status", configuredPayload["voice_status_webhook_url"]) +} + +func TestSerializeInboxUsesChatwootSenderNameTypeValues(t *testing.T) { + defaultInbox := &model.Inbox{AccountID: 1, Name: "Default", ChannelType: "web_widget"} + require.Equal(t, "friendly", serializeInbox(defaultInbox, nil, false)["sender_name_type"]) + + legacyFriendly := &model.Inbox{AccountID: 1, Name: "Legacy Friendly", ChannelType: "web_widget", SenderNameType: "friendly_name"} + require.Equal(t, "friendly", serializeInbox(legacyFriendly, nil, false)["sender_name_type"]) + + legacyBusiness := &model.Inbox{AccountID: 1, Name: "Legacy Business", ChannelType: "web_widget", SenderNameType: "business_name"} + require.Equal(t, "professional", serializeInbox(legacyBusiness, nil, false)["sender_name_type"]) +} + +func TestSerializeInboxComputesCallbackWebhookURL(t *testing.T) { + t.Setenv("FRONTEND_URL", "https://app.example.test/") + + twilio := &model.Inbox{AccountID: 1, Name: "Twilio", ChannelType: "twilio_sms"} + require.Equal(t, "https://app.example.test/twilio/callback", serializeInbox(twilio, nil, false)["callback_webhook_url"]) + + sms := &model.Inbox{AccountID: 1, Name: "SMS", ChannelType: "sms", ChannelConfig: `{"phone_number":"+1555010000"}`} + require.Equal(t, "https://app.example.test/webhooks/sms/1555010000", serializeInbox(sms, nil, false)["callback_webhook_url"]) + + line := &model.Inbox{AccountID: 1, Name: "Line", ChannelType: "line", ChannelConfig: `{"line_channel_id":"line-id"}`} + require.Equal(t, "https://app.example.test/webhooks/line/line-id", serializeInbox(line, nil, false)["callback_webhook_url"]) + + whatsapp := &model.Inbox{AccountID: 1, Name: "WhatsApp", ChannelType: "whatsapp", ChannelConfig: `{"phone_number":"+1555010001"}`} + require.Equal(t, "https://app.example.test/webhooks/whatsapp/+1555010001", serializeInbox(whatsapp, nil, false)["callback_webhook_url"]) + + website := &model.Inbox{AccountID: 1, Name: "Website", ChannelType: "web_widget"} + require.Nil(t, serializeInbox(website, nil, false)["callback_webhook_url"]) +} + +func TestInboxHandler_SensitiveFieldsRequireAdministratorRole(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open("file:inbox_handler_sensitive_fields?mode=memory&cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + require.NoError(t, err) + t.Cleanup(func() { + sqlDB, dbErr := db.DB() + if dbErr == nil { + _ = sqlDB.Close() + } + }) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.WorkingHour{}, &channelmodel.ChannelAPI{})) + + account := &model.Account{Name: "Sensitive Inbox", Locale: "en", Active: true} + require.NoError(t, db.Create(account).Error) + apiInbox := &model.Inbox{AccountID: account.ID, Name: "API", ChannelType: "api", ChannelID: 1, Enabled: true, WebhookURL: "https://example.com/hook", Secret: "api-secret", ChannelConfig: `{"hmac_token":"api-hmac","identifier":"api-identifier"}`} + require.NoError(t, db.Create(apiInbox).Error) + emailInbox := &model.Inbox{AccountID: account.ID, Name: "Email", ChannelType: "email", ChannelID: 2, Enabled: true, ChannelConfig: `{"email":"support@example.com","provider":"google","provider_config":{},"imap_password":"imap-secret","smtp_password":"smtp-secret","smtp_address":"smtp.example.com"}`} + require.NoError(t, db.Create(emailInbox).Error) + whatsappInbox := &model.Inbox{AccountID: account.ID, Name: "WhatsApp", ChannelType: "whatsapp", ChannelID: 3, Enabled: true, ChannelConfig: `{"phone_number":"+1555010000","provider_config":{"api_key":"wa-secret"}}`} + require.NoError(t, db.Create(whatsappInbox).Error) + + router := setupInboxParityRouter(db) + + agentShow := inboxParityRequestWithRole(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, apiInbox.ID), nil, "agent") + require.Equal(t, http.StatusOK, agentShow.Code, agentShow.Body.String()) + agentAPIData := inboxParityObject(t, agentShow) + require.Equal(t, "https://example.com/hook", agentAPIData["webhook_url"]) + require.NotContains(t, agentAPIData, "secret") + require.NotContains(t, agentAPIData, "hmac_token") + + adminShow := inboxParityRequestWithRole(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, apiInbox.ID), nil, "administrator") + require.Equal(t, http.StatusOK, adminShow.Code, adminShow.Body.String()) + adminAPIData := inboxParityObject(t, adminShow) + require.Equal(t, "api-secret", adminAPIData["secret"]) + require.Equal(t, "api-hmac", adminAPIData["hmac_token"]) + + agentEmailShow := inboxParityRequestWithRole(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, emailInbox.ID), nil, "agent") + require.Equal(t, http.StatusOK, agentEmailShow.Code, agentEmailShow.Body.String()) + agentEmailData := inboxParityObject(t, agentEmailShow) + require.Equal(t, "support@example.com", agentEmailData["email"]) + require.NotContains(t, agentEmailData, "imap_password") + require.NotContains(t, agentEmailData, "smtp_password") + require.NotContains(t, agentEmailData, "smtp_address") + require.NotContains(t, agentEmailData, "reauthorization_required") + + adminEmailShow := inboxParityRequestWithRole(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, emailInbox.ID), nil, "administrator") + require.Equal(t, http.StatusOK, adminEmailShow.Code, adminEmailShow.Body.String()) + adminEmailData := inboxParityObject(t, adminEmailShow) + require.Equal(t, true, adminEmailData["reauthorization_required"]) + + agentWhatsappShow := inboxParityRequestWithRole(t, router, http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d", account.ID, whatsappInbox.ID), nil, "agent") + require.Equal(t, http.StatusOK, agentWhatsappShow.Code, agentWhatsappShow.Body.String()) + agentWhatsappData := inboxParityObject(t, agentWhatsappShow) + require.Equal(t, "+1555010000", agentWhatsappData["phone_number"]) + require.NotContains(t, agentWhatsappData, "provider_config") +} + func TestInboxHandler_ChatwootCreateUpdateRequestBinding(t *testing.T) { gin.SetMode(gin.TestMode) @@ -136,7 +277,7 @@ func TestInboxHandler_ChatwootCreateUpdateRequestBinding(t *testing.T) { "greeting_message": {"Hello from Chatwoot"}, "enable_email_collect": {"false"}, "allow_messages_after_resolved": {"true"}, - "sender_name_type": {"business_name"}, + "sender_name_type": {"professional"}, "business_name": {"Acme Support"}, "channel[type]": {"web_widget"}, "channel[website_url]": {"https://acme.example"}, @@ -154,7 +295,7 @@ func TestInboxHandler_ChatwootCreateUpdateRequestBinding(t *testing.T) { require.NotEmpty(t, websiteData["website_token"]) require.NotEmpty(t, websiteData["hmac_token"]) require.Equal(t, false, websiteData["enable_email_collect"]) - require.Equal(t, "business_name", websiteData["sender_name_type"]) + require.Equal(t, "professional", websiteData["sender_name_type"]) require.Equal(t, "Acme Support", websiteData["business_name"]) require.ElementsMatch(t, []any{"attachments", "emoji_picker"}, websiteData["selected_feature_flags"].([]any)) @@ -280,6 +421,7 @@ func TestInboxHandler_ChatwootCreateRejectsAccountInboxLimit(t *testing.T) { func TestInboxHandler_ChatwootChannelSpecificConfigDepth(t *testing.T) { gin.SetMode(gin.TestMode) + t.Setenv("MAILER_INBOUND_EMAIL_DOMAIN", "mail.example.test") db, err := gorm.Open(sqlite.Open("file:inbox_handler_channel_depth?mode=memory&cache=shared"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), @@ -396,6 +538,14 @@ func setupInboxParityRouter(db *gorm.DB) *gin.Engine { inboxSvc := service.NewInboxService(repository.NewInboxRepo(db), nil, nil, nil, nil, nil, nil) handler := NewInboxHandler(inboxSvc) router := gin.New() + router.Use(func(c *gin.Context) { + role := c.GetHeader("X-Test-Role") + if role == "" { + role = "administrator" + } + c.Set("role", role) + c.Next() + }) inboxes := router.Group("/api/v1/accounts/:id/inboxes") { inboxes.GET("/", handler.List) @@ -410,6 +560,10 @@ func setupInboxParityRouter(db *gorm.DB) *gin.Engine { } func inboxParityRequest(t *testing.T, router *gin.Engine, method string, path string, body any) *httptest.ResponseRecorder { + return inboxParityRequestWithRole(t, router, method, path, body, "") +} + +func inboxParityRequestWithRole(t *testing.T, router *gin.Engine, method string, path string, body any, role string) *httptest.ResponseRecorder { t.Helper() var reader *bytes.Reader if body == nil { @@ -421,6 +575,9 @@ func inboxParityRequest(t *testing.T, router *gin.Engine, method string, path st } req := httptest.NewRequest(method, path, reader) req.Header.Set("Content-Type", "application/json") + if role != "" { + req.Header.Set("X-Test-Role", role) + } w := httptest.NewRecorder() router.ServeHTTP(w, req) return w diff --git a/internal/handler/api/v1/inbox_serializer.go b/internal/handler/api/v1/inbox_serializer.go index b09a8584..116bf9b1 100644 --- a/internal/handler/api/v1/inbox_serializer.go +++ b/internal/handler/api/v1/inbox_serializer.go @@ -2,20 +2,22 @@ package v1 import ( "encoding/json" + "os" "strings" "github.com/gochat/gochat/internal/model" + "gorm.io/gorm" ) -func inboxListResponse(inboxes []model.Inbox) map[string]any { +func inboxListResponse(inboxes []model.Inbox, db *gorm.DB, isAdmin bool) map[string]any { payload := make([]any, 0, len(inboxes)) for i := range inboxes { - payload = append(payload, serializeInbox(&inboxes[i])) + payload = append(payload, serializeInbox(&inboxes[i], db, isAdmin)) } return map[string]any{"payload": payload} } -func serializeInbox(inbox *model.Inbox) map[string]any { +func serializeInbox(inbox *model.Inbox, db *gorm.DB, isAdmin bool) map[string]any { config := parseInboxChannelConfig(inbox.ChannelConfig) payload := map[string]any{ "id": inbox.ID, @@ -34,10 +36,10 @@ func serializeInbox(inbox *model.Inbox) map[string]any { "out_of_office_message": inbox.OutOfOfficeMessage, "working_hours": serializeInboxWorkingHours(inbox, config), "timezone": inbox.Timezone, - "callback_webhook_url": configValue(config, "callback_webhook_url"), + "callback_webhook_url": callbackWebhookURL(inbox, config), "allow_messages_after_resolved": inbox.AllowMessagesAfterResolved, "lock_to_single_conversation": inbox.LockToSingleConversation, - "sender_name_type": inbox.SenderNameType, + "sender_name_type": chatwootSenderNameType(inbox.SenderNameType), "business_name": inbox.BusinessName, "allowed_domains": configValue(config, "allowed_domains"), "widget_color": configValue(config, "widget_color"), @@ -54,13 +56,17 @@ func serializeInbox(inbox *model.Inbox) map[string]any { switch chatwootChannelType(inbox.ChannelType) { case "Channel::WebWidget": - payload["hmac_token"] = configValue(config, "hmac_token") + if isAdmin { + payload["hmac_token"] = configValue(config, "hmac_token") + } payload["pre_chat_form_enabled"] = configValue(config, "pre_chat_form_enabled") payload["pre_chat_form_options"] = configValue(config, "pre_chat_form_options") payload["continuity_via_email"] = configValue(config, "continuity_via_email") case "Channel::Api": - payload["hmac_token"] = configValue(config, "hmac_token") - payload["secret"] = inbox.Secret + if isAdmin { + payload["hmac_token"] = configValue(config, "hmac_token") + payload["secret"] = inbox.Secret + } payload["webhook_url"] = inbox.WebhookURL payload["inbox_identifier"] = firstConfigValue(config, "inbox_identifier", "identifier") payload["additional_attributes"] = configValue(config, "additional_attributes") @@ -72,6 +78,8 @@ func serializeInbox(inbox *model.Inbox) map[string]any { case "Channel::Instagram": payload["instagram_id"] = configValue(config, "instagram_id") payload["reauthorization_required"] = configValue(config, "reauthorization_required") + case "Channel::TwitterProfile": + payload["tweets_enabled"] = configBoolDefault(config, "tweets_enabled", true) case "Channel::Tiktok": payload["reauthorization_required"] = configValue(config, "reauthorization_required") case "Channel::TwilioSms": @@ -79,39 +87,54 @@ func serializeInbox(inbox *model.Inbox) map[string]any { payload["phone_number"] = configValue(config, "phone_number") payload["medium"] = configValue(config, "medium") payload["content_templates"] = configValue(config, "content_templates") - payload["auth_token"] = configValue(config, "auth_token") - payload["account_sid"] = configValue(config, "account_sid") - payload["api_key_sid"] = configValue(config, "api_key_sid") + if isAdmin { + payload["auth_token"] = configValue(config, "auth_token") + payload["account_sid"] = configValue(config, "account_sid") + payload["api_key_sid"] = configValue(config, "api_key_sid") + } payload["voice_enabled"] = configValue(config, "voice_enabled") - payload["voice_configured"] = configValue(config, "twiml_app_sid") != nil - payload["has_api_key_secret"] = configValue(config, "api_key_secret") != nil - payload["voice_call_webhook_url"] = configValue(config, "voice_call_webhook_url") - payload["voice_status_webhook_url"] = configValue(config, "voice_status_webhook_url") + voiceConfigured := configStringPresent(config, "twiml_app_sid") + payload["voice_configured"] = voiceConfigured + payload["has_api_key_secret"] = configStringPresent(config, "api_key_secret") + if voiceConfigured { + payload["voice_call_webhook_url"] = configValue(config, "voice_call_webhook_url") + payload["voice_status_webhook_url"] = configValue(config, "voice_status_webhook_url") + } case "Channel::Email": payload["email"] = configValue(config, "email") - payload["forwarding_enabled"] = configValue(config, "forward_to_email") != nil - payload["forward_to_email"] = configValue(config, "forward_to_email") - payload["imap_login"] = configValue(config, "imap_login") - payload["imap_password"] = configValue(config, "imap_password") - payload["imap_address"] = configValue(config, "imap_address") - payload["imap_port"] = configValue(config, "imap_port") - payload["imap_enabled"] = configValue(config, "imap_enabled") - payload["imap_enable_ssl"] = configValue(config, "imap_enable_ssl") - payload["imap_authentication"] = configValue(config, "imap_authentication") - payload["smtp_login"] = configValue(config, "smtp_login") - payload["smtp_password"] = configValue(config, "smtp_password") - payload["smtp_address"] = configValue(config, "smtp_address") - payload["smtp_port"] = configValue(config, "smtp_port") - payload["smtp_enabled"] = configValue(config, "smtp_enabled") - payload["smtp_domain"] = configValue(config, "smtp_domain") - payload["smtp_enable_ssl_tls"] = configValue(config, "smtp_enable_ssl_tls") - payload["smtp_enable_starttls_auto"] = configValue(config, "smtp_enable_starttls_auto") - payload["smtp_openssl_verify_mode"] = configValue(config, "smtp_openssl_verify_mode") - payload["smtp_authentication"] = configValue(config, "smtp_authentication") + forwardingEnabled := strings.TrimSpace(os.Getenv("MAILER_INBOUND_EMAIL_DOMAIN")) != "" + payload["forwarding_enabled"] = forwardingEnabled + if forwardingEnabled { + payload["forward_to_email"] = configValue(config, "forward_to_email") + } + if isAdmin { + payload["imap_login"] = configValue(config, "imap_login") + payload["imap_password"] = configValue(config, "imap_password") + payload["imap_address"] = configValue(config, "imap_address") + payload["imap_port"] = configValue(config, "imap_port") + payload["imap_enabled"] = configValue(config, "imap_enabled") + payload["imap_enable_ssl"] = configValue(config, "imap_enable_ssl") + payload["imap_authentication"] = configValue(config, "imap_authentication") + payload["smtp_login"] = configValue(config, "smtp_login") + payload["smtp_password"] = configValue(config, "smtp_password") + payload["smtp_address"] = configValue(config, "smtp_address") + payload["smtp_port"] = configValue(config, "smtp_port") + payload["smtp_enabled"] = configValue(config, "smtp_enabled") + payload["smtp_domain"] = configValue(config, "smtp_domain") + payload["smtp_enable_ssl_tls"] = configValue(config, "smtp_enable_ssl_tls") + payload["smtp_enable_starttls_auto"] = configValue(config, "smtp_enable_starttls_auto") + payload["smtp_openssl_verify_mode"] = configValue(config, "smtp_openssl_verify_mode") + payload["smtp_authentication"] = configValue(config, "smtp_authentication") + if emailOauthProvider(config) { + payload["reauthorization_required"] = configObjectEmpty(config, "provider_config") || configBoolDefault(config, "reauthorization_required", false) + } + } case "Channel::Whatsapp": payload["phone_number"] = configValue(config, "phone_number") payload["message_templates"] = configValue(config, "message_templates") - payload["provider_config"] = configValue(config, "provider_config") + if isAdmin { + payload["provider_config"] = configValue(config, "provider_config") + } payload["reauthorization_required"] = configValue(config, "reauthorization_required") payload["voice_enabled"] = configValue(config, "voice_enabled") case "Channel::Line": @@ -123,6 +146,16 @@ func serializeInbox(inbox *model.Inbox) map[string]any { payload["provider_config"] = configValue(config, "provider_config") } + if db != nil && inbox.PortalID != nil && *inbox.PortalID != 0 { + var portal model.Portal + if err := db.Where("id = ?", *inbox.PortalID).First(&portal).Error; err == nil { + payload["help_center"] = map[string]any{ + "name": portal.Name, + "slug": portal.Slug, + } + } + } + return payload } @@ -169,6 +202,34 @@ func chatwootChannelType(channelType string) string { return channelType } +func chatwootSenderNameType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "friendly", "friendly_name": + return "friendly" + case "professional", "business_name": + return "professional" + default: + return value + } +} + +func callbackWebhookURL(inbox *model.Inbox, config map[string]any) any { + baseURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/") + switch chatwootChannelType(inbox.ChannelType) { + case "Channel::TwilioSms": + return baseURL + "/twilio/callback" + case "Channel::Sms": + phoneNumber := strings.TrimPrefix(inboxConfigString(config, "phone_number"), "+") + return baseURL + "/webhooks/sms/" + phoneNumber + case "Channel::Line": + return baseURL + "/webhooks/line/" + inboxConfigString(config, "line_channel_id") + case "Channel::Whatsapp": + return baseURL + "/webhooks/whatsapp/" + inboxConfigString(config, "phone_number") + default: + return nil + } +} + func parseInboxChannelConfig(raw string) map[string]any { if raw == "" { return map[string]any{} @@ -187,6 +248,17 @@ func configValue(config map[string]any, key string) any { return nil } +func inboxConfigString(config map[string]any, key string) string { + value, ok := config[key] + if !ok || value == nil { + return "" + } + if s, ok := value.(string); ok { + return s + } + return "" +} + func firstConfigValue(config map[string]any, keys ...string) any { for _, key := range keys { if value, ok := config[key]; ok { @@ -196,6 +268,49 @@ func firstConfigValue(config map[string]any, keys ...string) any { return nil } +func configBoolDefault(config map[string]any, key string, fallback bool) bool { + value, ok := config[key] + if !ok || value == nil { + return fallback + } + if parsed, ok := value.(bool); ok { + return parsed + } + return fallback +} + +func configStringPresent(config map[string]any, key string) bool { + value, ok := config[key] + if !ok || value == nil { + return false + } + if s, ok := value.(string); ok { + return strings.TrimSpace(s) != "" + } + return true +} + +func configObjectEmpty(config map[string]any, key string) bool { + value, ok := config[key] + if !ok || value == nil { + return true + } + if object, ok := value.(map[string]any); ok { + return len(object) == 0 + } + return false +} + +func emailOauthProvider(config map[string]any) bool { + provider, _ := config["provider"].(string) + switch strings.ToLower(strings.TrimSpace(provider)) { + case "microsoft", "google", "legacy_google": + return true + default: + return false + } +} + func configArray(config map[string]any, key string) any { if value, ok := config[key]; ok { return value diff --git a/internal/handler/api/v1/line_channel_handler.go b/internal/handler/api/v1/line_channel_handler.go index 93a48162..0a0d4a2a 100644 --- a/internal/handler/api/v1/line_channel_handler.go +++ b/internal/handler/api/v1/line_channel_handler.go @@ -129,7 +129,7 @@ func (h *LINEChannelHandler) Create(c *gin.Context) { applogger.L().Warnf("Failed to bind LINE inbox channel config: %v", err) } - c.JSON(http.StatusOK, serializeInbox(inbox)) + c.JSON(http.StatusOK, serializeInbox(inbox, nil, inboxSerializationAdmin(c))) } // === Get === @@ -219,7 +219,7 @@ func (h *LINEChannelHandler) Update(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeInbox(inbox)) + c.JSON(http.StatusOK, serializeInbox(inbox, nil, inboxSerializationAdmin(c))) } // === Delete === @@ -298,7 +298,7 @@ func (h *LINEChannelHandler) List(c *gin.Context) { func (h *LINEChannelHandler) serializeInboxForLINEChannel(c *gin.Context, ch *channelmodel.ChannelLINE) map[string]any { if ch != nil && ch.InboxID > 0 { if inbox, err := h.inboxSvc.GetByAccountAndID(c.Request.Context(), ch.AccountID, ch.InboxID); err == nil { - return serializeInbox(inbox) + return serializeInbox(inbox, nil, inboxSerializationAdmin(c)) } } return gin.H{"id": ch.ID, "account_id": ch.AccountID, "inbox_id": ch.InboxID, "channel_id": ch.ChannelID, "name": ch.Name} diff --git a/internal/handler/api/v1/message_handler.go b/internal/handler/api/v1/message_handler.go index 254f604f..69394364 100644 --- a/internal/handler/api/v1/message_handler.go +++ b/internal/handler/api/v1/message_handler.go @@ -111,27 +111,23 @@ func (h *MessageHandler) Create(c *gin.Context) { return } - // If conversation_id is not in the request body, use the URL param - if req.ConversationID == 0 { - conversationID, urlErr := parseUintParam(c, "conversation_id") - if urlErr != nil { - response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "conversation_id is required") - return - } - conversation, resolveErr := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, conversationID) - if resolveErr != nil { - handleServiceError(c, resolveErr) - return - } - req.ConversationID = conversation.ID + conversationID, urlErr := parseUintParam(c, "conversation_id") + if urlErr != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "conversation_id is required") + return } + conversation, resolveErr := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, conversationID) + if resolveErr != nil { + handleServiceError(c, resolveErr) + return + } + req.ConversationID = conversation.ID message, svcErr := h.svc.Create(c.Request.Context(), accountID, userID, req) if svcErr != nil { handleServiceError(c, svcErr) return } - conversation, _ := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, req.ConversationID) c.JSON(http.StatusOK, serializeMessage(c.Request.Context(), h.svc.DB(), message, conversation)) } @@ -164,13 +160,22 @@ func (h *MessageHandler) Get(c *gin.Context) { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid message id") return } + conversationID, err := parseUintParam(c, "conversation_id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id") + return + } - message, svcErr := h.svc.GetByAccountAndID(c.Request.Context(), accountID, messageID) + conversation, svcErr := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, conversationID) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + message, svcErr := h.svc.GetByAccountConversationAndID(c.Request.Context(), accountID, conversation.ID, messageID) if svcErr != nil { handleServiceError(c, svcErr) return } - conversation, _ := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, message.ConversationID) c.JSON(http.StatusOK, serializeMessage(c.Request.Context(), h.svc.DB(), message, conversation)) } @@ -187,6 +192,11 @@ func (h *MessageHandler) Update(c *gin.Context) { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid message id") return } + conversationID, err := parseUintParam(c, "conversation_id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id") + return + } var req service.UpdateMessageRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -194,12 +204,16 @@ func (h *MessageHandler) Update(c *gin.Context) { return } - message, svcErr := h.svc.Update(c.Request.Context(), accountID, messageID, req) + conversation, svcErr := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, conversationID) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + message, svcErr := h.svc.UpdateInConversation(c.Request.Context(), accountID, conversation.ID, messageID, req) if svcErr != nil { handleServiceError(c, svcErr) return } - conversation, _ := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, message.ConversationID) c.JSON(http.StatusOK, serializeMessage(c.Request.Context(), h.svc.DB(), message, conversation)) } @@ -231,13 +245,22 @@ func (h *MessageHandler) Delete(c *gin.Context) { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid message id") return } + conversationID, err := parseUintParam(c, "conversation_id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id") + return + } - message, svcErr := h.svc.Delete(c.Request.Context(), accountID, messageID) + conversation, svcErr := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, conversationID) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + message, svcErr := h.svc.DeleteInConversation(c.Request.Context(), accountID, conversation.ID, messageID) if svcErr != nil { handleServiceError(c, svcErr) return } - conversation, _ := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, message.ConversationID) c.JSON(http.StatusOK, serializeMessage(c.Request.Context(), h.svc.DB(), message, conversation)) } @@ -282,13 +305,24 @@ func (h *MessageHandler) Retry(c *gin.Context) { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid message id") return } + conversationID, err := parseUintParam(c, "conversation_id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id") + return + } - message, svcErr := h.svc.Retry(c.Request.Context(), accountID, messageID) + conversation, svcErr := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, conversationID) if svcErr != nil { - handleServiceError(c, svcErr) + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, svcErr.Error()) + return + } + message, svcErr := h.svc.RetryInConversation(c.Request.Context(), accountID, conversation.ID, messageID) + if svcErr != nil { + // Chatwoot retry rescues creation/status-update errors through render_could_not_create_error, + // so a missing message on this action returns 422 instead of the generic show/delete 404. + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, svcErr.Error()) return } - conversation, _ := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, message.ConversationID) c.JSON(http.StatusOK, serializeMessage(c.Request.Context(), h.svc.DB(), message, conversation)) } @@ -306,6 +340,11 @@ func (h *MessageHandler) Translate(c *gin.Context) { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid message id") return } + conversationID, err := parseUintParam(c, "conversation_id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation id") + return + } var req service.TranslateMessageRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -313,7 +352,12 @@ func (h *MessageHandler) Translate(c *gin.Context) { return } - result, svcErr := h.svc.Translate(c.Request.Context(), accountID, messageID, req) + conversation, svcErr := h.svc.ResolveConversationForRoute(c.Request.Context(), accountID, conversationID) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + result, svcErr := h.svc.TranslateInConversation(c.Request.Context(), accountID, conversation.ID, messageID, req) if svcErr != nil { lower := strings.ToLower(svcErr.Error()) if strings.Contains(lower, "not found") { @@ -327,7 +371,11 @@ func (h *MessageHandler) Translate(c *gin.Context) { response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrInternal, svcErr.Error()) return } - response.OK(c, result) + if result.AlreadyTranslated { + c.Status(http.StatusOK) + return + } + c.JSON(http.StatusOK, gin.H{"content": result.TranslatedContent}) } func bindCreateMessageRequest(c *gin.Context, req *service.CreateMessageRequest) error { @@ -338,12 +386,26 @@ func bindCreateMessageRequest(c *gin.Context, req *service.CreateMessageRequest) req.Content = c.PostForm("content") req.MessageType = c.PostForm("message_type") req.ContentType = c.PostForm("content_type") + if senderID, err := strconv.ParseUint(c.PostForm("sender_id"), 10, 64); err == nil { + req.SenderID = uint(senderID) + } + req.SenderType = c.PostForm("sender_type") req.SourceID = c.PostForm("source_id") req.EchoID = c.PostForm("echo_id") + req.ExternalCreatedAt = c.PostForm("external_created_at") + req.EmailHTMLContent = c.PostForm("email_html_content") + req.CCEmails = c.PostForm("cc_emails") + req.BCCEmails = c.PostForm("bcc_emails") + req.ToEmails = c.PostForm("to_emails") req.Private = strings.EqualFold(c.PostForm("private"), "true") || c.PostForm("private") == "1" + req.IsVoiceMessage = strings.EqualFold(c.PostForm("is_voice_message"), "true") || c.PostForm("is_voice_message") == "1" + req.CampaignID = c.PostForm("campaign_id") if raw := c.PostForm("content_attributes"); raw != "" { req.ContentAttributes = []byte(raw) } + if raw := c.PostForm("template_params"); raw != "" { + req.TemplateParams = []byte(raw) + } if c.Request.MultipartForm != nil { for _, key := range []string{"attachments[]", "attachments"} { for _, file := range c.Request.MultipartForm.File[key] { @@ -369,5 +431,8 @@ func bindCreateMessageRequest(c *gin.Context, req *service.CreateMessageRequest) if value, ok := raw["content_attributes"]; ok && string(value) != "null" { req.ContentAttributes = datatypes.JSON(value) } + if value, ok := raw["template_params"]; ok && string(value) != "null" { + req.TemplateParams = datatypes.JSON(value) + } return nil } diff --git a/internal/handler/api/v1/message_handler_test.go b/internal/handler/api/v1/message_handler_test.go index 143dbcdc..3ed14fe2 100644 --- a/internal/handler/api/v1/message_handler_test.go +++ b/internal/handler/api/v1/message_handler_test.go @@ -31,9 +31,11 @@ import ( type mockMsgHandlerLLMProvider struct { chatResponse *llm.ChatResponse chatError error + chatCalls int } func (m *mockMsgHandlerLLMProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { + m.chatCalls++ if m.chatError != nil { return nil, m.chatError } @@ -152,8 +154,10 @@ func (s *MessageHandlerTestSuite) SetupTest() { s.Require().NoError(s.db.Create(contact).Error) s.testContact = contact + displayID := uint(4242) conv := &model.Conversation{ AccountID: account.ID, + DisplayID: &displayID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), @@ -185,6 +189,7 @@ func (s *MessageHandlerTestSuite) SetupTest() { }, } s.mockLLM.chatError = nil + s.mockLLM.chatCalls = 0 } func (s *MessageHandlerTestSuite) TearDownTest() { @@ -329,7 +334,38 @@ func (s *MessageHandlerTestSuite) TestCreate_Success() { assert.NotNil(s.T(), resp["id"]) assert.Equal(s.T(), "New message", resp["content"]) assert.Equal(s.T(), float64(1), resp["message_type"]) - assert.Equal(s.T(), float64(s.testConv.ID), resp["conversation_id"]) + assert.Equal(s.T(), float64(*s.testConv.DisplayID), resp["conversation_id"]) +} + +func (s *MessageHandlerTestSuite) TestCreate_UsesRouteConversationOverBodyConversationID() { + otherConv := &model.Conversation{ + AccountID: s.testAccount.ID, + InboxID: s.testInbox.ID, + ContactID: s.testContact.ID, + Status: string(model.ConversationStatusOpen), + Priority: string(model.ConversationPriorityMedium), + ChannelType: "web_widget", + Channel: "web_widget", + } + s.Require().NoError(s.db.Create(otherConv).Error) + + payload := map[string]interface{}{ + "conversation_id": otherConv.ID, + "content": "Route scoped message", + "message_type": "outgoing", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + url := msgListURL(s.testAccount.ID, *s.testConv.DisplayID) + req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var created model.Message + s.Require().NoError(s.db.Where("content = ?", "Route scoped message").First(&created).Error) + assert.Equal(s.T(), s.testConv.ID, created.ConversationID) } func (s *MessageHandlerTestSuite) TestCreate_ChatwootFrontendPayloadDefaultsOutgoing() { @@ -424,7 +460,7 @@ func (s *MessageHandlerTestSuite) TestCreate_InvalidConversationID() { func (s *MessageHandlerTestSuite) TestGet_Success() { w := httptest.NewRecorder() - url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) + url := msgDetailURL(s.testAccount.ID, *s.testConv.DisplayID, s.testMessage.ID) req, _ := http.NewRequest("GET", url, nil) s.router.ServeHTTP(w, req) @@ -433,6 +469,7 @@ func (s *MessageHandlerTestSuite) TestGet_Success() { json.Unmarshal(w.Body.Bytes(), &resp) assert.Equal(s.T(), float64(s.testMessage.ID), resp["id"]) assert.Equal(s.T(), "Hello world", resp["content"]) + assert.Equal(s.T(), float64(*s.testConv.DisplayID), resp["conversation_id"]) } func (s *MessageHandlerTestSuite) TestGet_NotFound() { @@ -455,13 +492,16 @@ func (s *MessageHandlerTestSuite) TestGet_InvalidID() { // --- Update Tests --- func (s *MessageHandlerTestSuite) TestUpdate_Success() { + s.Require().NoError(s.db.Model(s.testInbox).Update("channel_type", "api").Error) + s.testInbox.ChannelType = "api" + payload := map[string]interface{}{ - "content": "Updated content", + "status": "delivered", } body, _ := json.Marshal(payload) w := httptest.NewRecorder() - url := msgDetailURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) + url := msgDetailURL(s.testAccount.ID, *s.testConv.DisplayID, s.testMessage.ID) req, _ := http.NewRequest("PATCH", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) @@ -469,7 +509,8 @@ func (s *MessageHandlerTestSuite) TestUpdate_Success() { assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) - assert.Equal(s.T(), "Updated content", resp["content"]) + assert.Equal(s.T(), "Hello world", resp["content"]) + assert.Equal(s.T(), "delivered", resp["status"]) } func (s *MessageHandlerTestSuite) TestUpdate_StatusExternalError() { @@ -567,7 +608,7 @@ func (s *MessageHandlerTestSuite) TestRetry_Success() { }).Error) w := httptest.NewRecorder() - url := msgRetryURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) + url := msgRetryURL(s.testAccount.ID, *s.testConv.DisplayID, s.testMessage.ID) req, _ := http.NewRequest("POST", url, nil) s.router.ServeHTTP(w, req) @@ -602,8 +643,8 @@ func (s *MessageHandlerTestSuite) TestRetry_NotFound() { req, _ := http.NewRequest("POST", url, nil) s.router.ServeHTTP(w, req) - // Service returns "not found" error → handleServiceError → 404 - assert.Equal(s.T(), http.StatusNotFound, w.Code) + // Chatwoot retry rescues lookup/status-update failures as unprocessable entity. + assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code) } func (s *MessageHandlerTestSuite) TestRetry_AccountMismatch() { @@ -617,8 +658,7 @@ func (s *MessageHandlerTestSuite) TestRetry_AccountMismatch() { req, _ := http.NewRequest("POST", url, nil) s.router.ServeHTTP(w, req) - // The service's FindByAccountAndID won't find the message under account 2 → "not found" → 404 - assert.Equal(s.T(), http.StatusNotFound, w.Code) + assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code) } // --- Translate Tests --- @@ -630,7 +670,7 @@ func (s *MessageHandlerTestSuite) TestTranslate_Success() { body, _ := json.Marshal(payload) w := httptest.NewRecorder() - url := msgTranslateURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) + url := msgTranslateURL(s.testAccount.ID, *s.testConv.DisplayID, s.testMessage.ID) req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") s.router.ServeHTTP(w, req) @@ -638,11 +678,7 @@ func (s *MessageHandlerTestSuite) TestTranslate_Success() { assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) - assert.True(s.T(), resp["success"].(bool)) - data := resp["data"].(map[string]interface{}) - assert.Equal(s.T(), "Bonjour le monde", data["translated_content"]) - assert.Equal(s.T(), "Hello world", data["original_content"]) - assert.Equal(s.T(), "fr", data["target_language"]) + assert.Equal(s.T(), "Bonjour le monde", resp["content"]) } func (s *MessageHandlerTestSuite) TestTranslate_EmptyTargetLanguage() { @@ -756,10 +792,32 @@ func (s *MessageHandlerTestSuite) TestTranslate_EmptyChoices() { assert.Equal(s.T(), http.StatusOK, w.Code) var resp map[string]interface{} json.Unmarshal(w.Body.Bytes(), &resp) - assert.True(s.T(), resp["success"].(bool)) - data := resp["data"].(map[string]interface{}) - assert.Equal(s.T(), "", data["translated_content"]) - assert.Equal(s.T(), "Hello world", data["original_content"]) + assert.Equal(s.T(), "", resp["content"]) +} + +func (s *MessageHandlerTestSuite) TestTranslate_CachesSecondCall() { + payload := map[string]interface{}{ + "target_language": "fr", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + url := msgTranslateURL(s.testAccount.ID, s.testConv.ID, s.testMessage.ID) + req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Equal(s.T(), 1, s.mockLLM.chatCalls) + + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest("POST", url, bytes.NewReader(body)) + req2.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w2, req2) + + assert.Equal(s.T(), http.StatusOK, w2.Code) + assert.Equal(s.T(), 1, s.mockLLM.chatCalls) + assert.Empty(s.T(), w2.Body.String()) } // --- DirectUpload Handler Tests (upload_handler_test.go extension) --- diff --git a/internal/handler/api/v1/notification_handler.go b/internal/handler/api/v1/notification_handler.go index cd34215c..446a7225 100644 --- a/internal/handler/api/v1/notification_handler.go +++ b/internal/handler/api/v1/notification_handler.go @@ -1,6 +1,7 @@ package v1 import ( + "context" "encoding/json" "fmt" "net/http" @@ -9,6 +10,7 @@ import ( "time" "github.com/gin-gonic/gin" + "gorm.io/gorm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" @@ -56,7 +58,7 @@ func (h *NotificationHandler) List(c *gin.Context) { payload := make([]gin.H, 0, len(result.Notifications)) for i := range result.Notifications { - payload = append(payload, serializeNotification(&result.Notifications[i])) + payload = append(payload, h.serializeNotification(c.Request.Context(), &result.Notifications[i])) } c.JSON(http.StatusOK, gin.H{ "data": gin.H{ @@ -91,7 +93,7 @@ func (h *NotificationHandler) Get(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeNotification(notification)) + c.JSON(http.StatusOK, h.serializeNotification(c.Request.Context(), notification)) } // Update marks a notification as read. @@ -112,7 +114,7 @@ func (h *NotificationHandler) Update(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeNotification(notification)) + c.JSON(http.StatusOK, h.serializeNotification(c.Request.Context(), notification)) } // MarkAllRead marks all unread notifications as read for the current user in account. @@ -185,7 +187,7 @@ func (h *NotificationHandler) Snooze(c *gin.Context) { handleServiceError(c, svcErr) return } - c.JSON(http.StatusOK, serializeNotification(notification)) + c.JSON(http.StatusOK, h.serializeNotification(c.Request.Context(), notification)) return } @@ -201,7 +203,7 @@ func (h *NotificationHandler) Snooze(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeNotification(notification)) + c.JSON(http.StatusOK, h.serializeNotification(c.Request.Context(), notification)) } // Unread marks a notification as unread. @@ -223,7 +225,7 @@ func (h *NotificationHandler) Unread(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeNotification(notification)) + c.JSON(http.StatusOK, h.serializeNotification(c.Request.Context(), notification)) } // Destroy deletes a single notification. @@ -288,7 +290,19 @@ func notificationIncludes(c *gin.Context, value string) bool { return false } +func (h *NotificationHandler) serializeNotification(ctx context.Context, notification *model.Notification) gin.H { + var db *gorm.DB + if h != nil && h.notificationService != nil { + db = h.notificationService.DB() + } + return serializeNotificationWithDB(ctx, db, notification) +} + func serializeNotification(notification *model.Notification) gin.H { + return serializeNotificationWithDB(context.Background(), nil, notification) +} + +func serializeNotificationWithDB(ctx context.Context, db *gorm.DB, notification *model.Notification) gin.H { meta := gin.H{} if len(notification.AdditionalAttributes) > 0 { _ = json.Unmarshal(notification.AdditionalAttributes, &meta) @@ -300,10 +314,10 @@ func serializeNotification(notification *model.Notification) gin.H { "push_message_body": notificationBody(notification), "primary_actor_type": notification.PrimaryActorType, "primary_actor_id": notification.PrimaryActorID, - "primary_actor": notificationActor(notification.PrimaryActorType, notification.PrimaryActorID), + "primary_actor": notificationActor(ctx, db, notification.PrimaryActorType, notification.PrimaryActorID, notification.AccountID), "read_at": notification.ReadAt, - "secondary_actor": notificationActor(notification.SecondaryActorType, notification.SecondaryActorID), - "user": gin.H{"id": notification.UserID}, + "secondary_actor": notificationActor(ctx, db, notification.SecondaryActorType, notification.SecondaryActorID, notification.AccountID), + "user": notificationUser(ctx, db, notification.UserID, notification.AccountID), "created_at": notification.CreatedAt.Unix(), "last_activity_at": notification.UpdatedAt.Unix(), "snoozed_until": notification.SnoozedUntil, @@ -312,13 +326,138 @@ func serializeNotification(notification *model.Notification) gin.H { } } -func notificationActor(actorType string, actorID uint) interface{} { +func notificationActor(ctx context.Context, db *gorm.DB, actorType string, actorID uint, accountID *uint) interface{} { if actorType == "" || actorID == 0 { return nil } + if db == nil { + return gin.H{"id": actorID, "type": actorType, "meta": gin.H{}} + } + switch strings.ToLower(actorType) { + case "conversation": + var conversation model.Conversation + query := db.WithContext(ctx).Preload("Contact").Preload("Inbox").First(&conversation, actorID) + if query.Error == nil && notificationAccountMatches(accountID, conversation.AccountID) { + return serializeConversationForNotification(ctx, db, &conversation) + } + case "contact": + var contact model.Contact + query := db.WithContext(ctx).First(&contact, actorID) + if query.Error == nil && notificationAccountMatches(accountID, contact.AccountID) { + return serializeContactForNotification(&contact) + } + case "user": + var user model.User + query := db.WithContext(ctx).First(&user, actorID) + if query.Error == nil && notificationAccountMatches(accountID, user.AccountID) { + return serializeUserForNotification(&user) + } + case "agentbot", "agent_bot": + var bot model.AgentBot + query := db.WithContext(ctx).First(&bot, actorID) + if query.Error == nil && (bot.AccountID == nil || accountID == nil || *bot.AccountID == *accountID) { + return serializeAgentBotSender(&bot) + } + } return gin.H{"id": actorID, "type": actorType, "meta": gin.H{}} } +func notificationUser(ctx context.Context, db *gorm.DB, userID uint, accountID *uint) interface{} { + if db == nil || userID == 0 { + return gin.H{"id": userID} + } + var user model.User + query := db.WithContext(ctx).First(&user, userID) + if query.Error == nil && notificationAccountMatches(accountID, user.AccountID) { + return serializeUserForNotification(&user) + } + return gin.H{"id": userID} +} + +func notificationAccountMatches(accountID *uint, recordAccountID uint) bool { + return accountID == nil || *accountID == recordAccountID +} + +func serializeConversationForNotification(ctx context.Context, db *gorm.DB, conversation *model.Conversation) map[string]any { + payload := map[string]any{ + "additional_attributes": jsonObject(conversation.AdditionalAttributes), + "can_reply": conversationCanReply(ctx, db, conversation), + "channel": conversation.ChannelType, + "contact_inbox": nil, + "id": conversationDisplayID(conversation), + "inbox_id": conversation.InboxID, + "labels": labelList(conversation.Labels), + "meta": serializeConversationMeta(ctx, db, conversation), + "status": conversation.Status, + "custom_attributes": jsonObject(conversation.CustomAttributes), + "snoozed_until": conversation.SnoozedUntil, + "unread_count": unreadCount(ctx, db, conversation), + "first_reply_created_at": int64Value(conversation.FirstReplyCreatedAt), + "priority": conversation.Priority, + "waiting_since": int64Value(conversation.WaitingSince), + "agent_last_seen_at": int64Value(conversation.AgentLastSeenAt), + "contact_last_seen_at": int64Value(conversation.ContactLastSeenAt), + "last_activity_at": int64Value(conversation.LastActivityAt), + "timestamp": int64Value(conversation.LastActivityAt), + "created_at": conversation.CreatedAt.Unix(), + "updated_at": float64(conversation.UpdatedAt.UnixNano()) / float64(time.Second), + } + payload["contact_inbox"] = serializeConversationContactInboxForNotification(ctx, db, conversation) + if message := latestChatMessageForNotification(ctx, db, conversation); message != nil { + payload["messages"] = []any{serializeMessage(ctx, db, message, conversation)} + } else { + payload["messages"] = []any{} + } + return payload +} + +func serializeConversationContactInboxForNotification(ctx context.Context, db *gorm.DB, conversation *model.Conversation) map[string]any { + if db == nil || conversation.ContactInboxID == nil || *conversation.ContactInboxID == 0 { + return nil + } + var contactInbox model.ContactInbox + if err := db.WithContext(ctx).First(&contactInbox, *conversation.ContactInboxID).Error; err != nil { + return nil + } + return serializeContactInboxShell(ctx, db, &contactInbox) +} + +func latestChatMessageForNotification(ctx context.Context, db *gorm.DB, conversation *model.Conversation) *model.Message { + if db == nil || conversation == nil { + return nil + } + var message model.Message + query := db.WithContext(ctx). + Where("conversation_id = ? AND account_id = ? AND message_type != ?", conversation.ID, conversation.AccountID, "activity"). + Order("created_at DESC, id DESC"). + First(&message) + if query.Error != nil { + return nil + } + return &message +} + +func serializeContactForNotification(contact *model.Contact) map[string]any { + payload := serializeContact(contact) + payload["type"] = "contact" + delete(payload, "availability_status") + delete(payload, "last_activity_at") + delete(payload, "created_at") + return payload +} + +func serializeUserForNotification(user *model.User) map[string]any { + return map[string]any{ + "id": user.ID, + "name": user.Name, + "available_name": nonEmpty(user.DisplayName, user.Name), + "avatar_url": user.AvatarURL, + "type": "user", + "availability_status": availabilityStatus(user.Available), + "thumbnail": user.AvatarURL, + } +} + func notificationTitle(notification *model.Notification) string { if title := stringFromNotificationAttrs(notification.AdditionalAttributes, "push_message_title", "title"); title != "" { return title diff --git a/internal/handler/api/v1/notification_handler_test.go b/internal/handler/api/v1/notification_handler_test.go index 922e8b77..43e506c8 100644 --- a/internal/handler/api/v1/notification_handler_test.go +++ b/internal/handler/api/v1/notification_handler_test.go @@ -27,7 +27,18 @@ func setupNotificationDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) assert.NoError(t, err) - err = db.AutoMigrate(&model.Notification{}, &model.User{}, &model.Account{}, &model.NotificationPreference{}) + err = db.AutoMigrate( + &model.Notification{}, + &model.User{}, + &model.Account{}, + &model.NotificationPreference{}, + &model.Contact{}, + &model.Inbox{}, + &model.ContactInbox{}, + &model.Conversation{}, + &model.Message{}, + &model.AgentBot{}, + ) assert.NoError(t, err) return db } @@ -148,6 +159,112 @@ func TestNotificationHandler_List_ChatwootEnvelopeAndIncludes(t *testing.T) { sqlDB.Close() } +func TestNotificationHandler_List_SerializesPushEventActors(t *testing.T) { + db := setupNotificationDB(t) + handler := setupNotificationHandler(t, db) + router := setupNotificationRouter(handler) + + accountID := uint(1) + user := &model.User{Name: "Agent Smith", DisplayName: "Smith", Email: "smith@example.com", Password: "pass", AccountID: accountID, AvatarURL: "https://example.com/agent.png", Available: true} + contact := &model.Contact{AccountID: accountID, Name: "Alice", Email: "alice@example.com", PhoneNumber: "+15551234567", AvatarURL: "https://example.com/alice.png", Identifier: "alice-1", AdditionalAttributes: []byte(`{"city":"Paris"}`), CustomAttributes: []byte(`{"tier":"gold"}`), Blocked: true} + inbox := &model.Inbox{AccountID: accountID, Name: "Support", ChannelType: "Channel::Api", ChannelID: 1} + require.NoError(t, db.Create(user).Error) + require.NoError(t, db.Create(contact).Error) + require.NoError(t, db.Create(inbox).Error) + contactInbox := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "source-1", HMACVerified: true} + require.NoError(t, db.Create(contactInbox).Error) + now := time.Now().Unix() + displayID := uint(42) + conversation := &model.Conversation{AccountID: accountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, DisplayID: &displayID, ChannelType: "Channel::Api", Channel: "api", Status: "open", Priority: "urgent", LastActivityAt: &now} + require.NoError(t, db.Create(conversation).Error) + message := &model.Message{ConversationID: conversation.ID, AccountID: accountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "Hello", ContentType: "text", MessageType: "incoming", Status: "sent"} + require.NoError(t, db.Create(message).Error) + notification := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "assigned_conversation_new_message", PrimaryActorType: "Conversation", PrimaryActorID: conversation.ID, SecondaryActorType: "Contact", SecondaryActorID: contact.ID} + require.NoError(t, db.Create(notification).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/accounts/1/notifications", nil) + req.Header.Set("X-User-ID", strconv.FormatUint(uint64(user.ID), 10)) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + payload := body["data"].(map[string]any)["payload"].([]any) + require.Len(t, payload, 1) + item := payload[0].(map[string]any) + primary := item["primary_actor"].(map[string]any) + assert.Equal(t, float64(displayID), primary["id"]) + assert.Equal(t, "Channel::Api", primary["channel"]) + assert.Equal(t, float64(inbox.ID), primary["inbox_id"]) + assert.Equal(t, "open", primary["status"]) + assert.Equal(t, float64(now), primary["last_activity_at"]) + contactInboxPayload := primary["contact_inbox"].(map[string]any) + assert.Equal(t, "source-1", contactInboxPayload["source_id"]) + contactInboxInbox := contactInboxPayload["inbox"].(map[string]any) + assert.Equal(t, float64(inbox.ID), contactInboxInbox["id"]) + assert.Equal(t, "Support", contactInboxInbox["name"]) + assert.Equal(t, "Channel::Api", contactInboxInbox["channel_type"]) + assert.NotContains(t, contactInboxPayload, "contact_id") + assert.NotContains(t, contactInboxPayload, "hmac_token") + messages := primary["messages"].([]any) + require.Len(t, messages, 1) + assert.Equal(t, "Hello", messages[0].(map[string]any)["content"]) + meta := primary["meta"].(map[string]any) + sender := meta["sender"].(map[string]any) + assert.Equal(t, float64(contact.ID), sender["id"]) + assert.Equal(t, "alice@example.com", sender["email"]) + assert.Equal(t, "+15551234567", sender["phone_number"]) + secondary := item["secondary_actor"].(map[string]any) + assert.Equal(t, "contact", secondary["type"]) + assert.Equal(t, true, secondary["blocked"]) + assert.Equal(t, "https://example.com/alice.png", secondary["thumbnail"]) + serializedUser := item["user"].(map[string]any) + assert.Equal(t, float64(user.ID), serializedUser["id"]) + assert.Equal(t, "Agent Smith", serializedUser["name"]) + assert.Equal(t, "Smith", serializedUser["available_name"]) + assert.Equal(t, "https://example.com/agent.png", serializedUser["avatar_url"]) + assert.Equal(t, "user", serializedUser["type"]) + assert.Equal(t, "online", serializedUser["availability_status"]) + assert.Equal(t, "https://example.com/agent.png", serializedUser["thumbnail"]) + + sqlDB, _ := db.DB() + sqlDB.Close() +} + +func TestNotificationHandler_List_ActorSerializationIsAccountScoped(t *testing.T) { + db := setupNotificationDB(t) + handler := setupNotificationHandler(t, db) + router := setupNotificationRouter(handler) + + accountID := uint(1) + otherAccountID := uint(2) + user := &model.User{Name: "Scoped Agent", Email: "scoped-agent@example.com", Password: "pass", AccountID: accountID} + otherContact := &model.Contact{AccountID: otherAccountID, Name: "Other Contact", Email: "other@example.com"} + require.NoError(t, db.Create(user).Error) + require.NoError(t, db.Create(otherContact).Error) + notification := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "contact_created", PrimaryActorType: "Contact", PrimaryActorID: otherContact.ID} + require.NoError(t, db.Create(notification).Error) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/accounts/1/notifications", nil) + req.Header.Set("X-User-ID", strconv.FormatUint(uint64(user.ID), 10)) + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + payload := body["data"].(map[string]any)["payload"].([]any) + require.Len(t, payload, 1) + primary := payload[0].(map[string]any)["primary_actor"].(map[string]any) + assert.Equal(t, float64(otherContact.ID), primary["id"]) + assert.Equal(t, "Contact", primary["type"]) + assert.Equal(t, map[string]any{}, primary["meta"]) + + sqlDB, _ := db.DB() + sqlDB.Close() +} + func TestNotificationGet(t *testing.T) { db := setupNotificationDB(t) handler := setupNotificationHandler(t, db) diff --git a/internal/handler/api/v1/search_handler.go b/internal/handler/api/v1/search_handler.go index 3cad002b..d57a1bfa 100644 --- a/internal/handler/api/v1/search_handler.go +++ b/internal/handler/api/v1/search_handler.go @@ -616,6 +616,7 @@ func chatwootMessagePayloadMap(payload chatwootMessagePayload) map[string]any { "id": payload.ID, "content": payload.Content, "inbox_id": payload.InboxID, + "account_id": payload.AccountID, "conversation_id": payload.ConversationID, "message_type": payload.MessageType, "content_type": payload.ContentType, diff --git a/internal/handler/api/v1/search_handler_test.go b/internal/handler/api/v1/search_handler_test.go index 16b5e66a..6bc36cf8 100644 --- a/internal/handler/api/v1/search_handler_test.go +++ b/internal/handler/api/v1/search_handler_test.go @@ -282,6 +282,7 @@ func TestSearchHandler_SearchConversations_ChatwootPayloadShape(t *testing.T) { require.Len(t, results, 1) item := results[0].(map[string]any) assert.Equal(t, float64(42), item["id"]) + assert.Equal(t, float64(1), item["account_id"]) assert.Equal(t, float64(createdAt.Unix()), item["created_at"]) assert.Equal(t, "Need pricing", item["additional_attributes"].(map[string]any)["mail_subject"]) assert.Equal(t, "Ada", item["contact"].(map[string]any)["name"]) @@ -289,8 +290,10 @@ func TestSearchHandler_SearchConversations_ChatwootPayloadShape(t *testing.T) { assert.Equal(t, "Website", item["inbox"].(map[string]any)["name"]) assert.Equal(t, float64(11), item["inbox"].(map[string]any)["channel_id"]) assert.Equal(t, "Agent One", item["agent"].(map[string]any)["available_name"]) - assert.Equal(t, float64(13), item["message"].(map[string]any)["id"]) - assert.Equal(t, float64(0), item["message"].(map[string]any)["message_type"]) + message := item["message"].(map[string]any) + assert.Equal(t, float64(13), message["id"]) + assert.Equal(t, float64(1), message["account_id"]) + assert.Equal(t, float64(0), message["message_type"]) } func TestSearchHandler_SearchConversations_IgnoresUnsupportedSearchFilters(t *testing.T) { @@ -465,6 +468,7 @@ func TestSearchHandler_SearchMessages_MeiliHitPayloadShape(t *testing.T) { messages := payload["messages"].([]any) require.Len(t, messages, 1) message := messages[0].(map[string]any) + assert.Equal(t, float64(1), message["account_id"]) assert.Equal(t, float64(0), message["message_type"]) assert.Equal(t, float64(1700000000), message["created_at"]) assert.Equal(t, float64(42), message["conversation_id"]) @@ -521,6 +525,7 @@ func TestSearchHandler_SearchMessages_HydratesChatwootMessagePayload(t *testing. messages := payload["messages"].([]any) require.Len(t, messages, 1) got := messages[0].(map[string]any) + assert.Equal(t, float64(account.ID), got["account_id"]) assert.Equal(t, float64(displayID), got["conversation_id"]) sender := got["sender"].(map[string]any) assert.Equal(t, "Agent One", sender["name"]) diff --git a/internal/handler/api/v1/twilio_channel_handler.go b/internal/handler/api/v1/twilio_channel_handler.go index 71e6b9e9..63987d27 100644 --- a/internal/handler/api/v1/twilio_channel_handler.go +++ b/internal/handler/api/v1/twilio_channel_handler.go @@ -151,7 +151,7 @@ func (h *TwilioChannelHandler) Create(c *gin.Context) { applogger.L().Warnf("Failed to bind Twilio inbox channel config: %v", err) } - c.JSON(http.StatusOK, serializeInbox(inbox)) + c.JSON(http.StatusOK, serializeInbox(inbox, nil, inboxSerializationAdmin(c))) } func twilioPhoneNumberForMedium(phoneNumber, medium string) string { @@ -283,7 +283,7 @@ func (h *TwilioChannelHandler) Update(c *gin.Context) { return } - c.JSON(http.StatusOK, serializeInbox(inbox)) + c.JSON(http.StatusOK, serializeInbox(inbox, nil, inboxSerializationAdmin(c))) } // Delete removes a Twilio SMS channel and its associated inbox. @@ -360,7 +360,7 @@ func (h *TwilioChannelHandler) List(c *gin.Context) { func (h *TwilioChannelHandler) serializeInboxForTwilioChannel(c *gin.Context, ch *channelmodel.ChannelTwilioSMS) map[string]any { if ch != nil && ch.InboxID > 0 { if inbox, err := h.inboxSvc.GetByAccountAndID(c.Request.Context(), ch.AccountID, ch.InboxID); err == nil { - return serializeInbox(inbox) + return serializeInbox(inbox, nil, inboxSerializationAdmin(c)) } } return gin.H{"id": ch.ID, "account_id": ch.AccountID, "inbox_id": ch.InboxID, "account_sid": ch.AccountSID, "phone_number": ch.PhoneNumber, "messaging_service_sid": ch.MessagingServiceSID} diff --git a/internal/handler/api/v1/twilio_channel_handler_test.go b/internal/handler/api/v1/twilio_channel_handler_test.go index 738e89c8..64c24c98 100644 --- a/internal/handler/api/v1/twilio_channel_handler_test.go +++ b/internal/handler/api/v1/twilio_channel_handler_test.go @@ -78,6 +78,10 @@ func twilioAccountIDUint(db *gorm.DB) uint { func setupTwilioTestRouter(handler *TwilioChannelHandler) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "administrator") + c.Next() + }) ag := r.Group("/api/v1/accounts/:id") ag.POST("/channels/twilio_channel", handler.Create) ag.POST("/twilio_sms_channels", handler.Create) diff --git a/internal/handler/widget/widget_handler.go b/internal/handler/widget/widget_handler.go index 2b04021b..752bc79e 100644 --- a/internal/handler/widget/widget_handler.go +++ b/internal/handler/widget/widget_handler.go @@ -1245,6 +1245,7 @@ func widgetAttachmentPayloads(attachments []model.Attachment) []gin.H { payload = append(payload, gin.H{ "id": attachment.ID, "message_id": attachment.MessageID, + "account_id": attachment.AccountID, "thumb_url": attachment.ThumbURL, "data_url": attachment.FileURL, "file_size": attachment.FileSize, @@ -1252,7 +1253,6 @@ func widgetAttachmentPayloads(attachments []model.Attachment) []gin.H { "extension": strings.TrimPrefix(strings.ToLower(attachmentExtension(attachment.FileName)), "."), "width": attachment.Width, "height": attachment.Height, - "created_at": attachment.CreatedAt.Unix(), }) } return payload diff --git a/internal/handler/widget/widget_handler_test.go b/internal/handler/widget/widget_handler_test.go index 89bf5f5f..aedb9dad 100644 --- a/internal/handler/widget/widget_handler_test.go +++ b/internal/handler/widget/widget_handler_test.go @@ -723,6 +723,8 @@ func TestWidgetHandler_ChatwootMessageDirectUploadAttachment(t *testing.T) { attachmentPayload := attachments[0].(map[string]interface{}) assert.Equal(t, "/uploads/widget_direct/signed-widget-upload-1.png", attachmentPayload["data_url"]) assert.Equal(t, "image", attachmentPayload["file_type"]) + assert.Equal(t, float64(account.ID), attachmentPayload["account_id"]) + assert.NotContains(t, attachmentPayload, "created_at") var attachment model.Attachment require.NoError(t, db.Where("file_name = ?", "screenshot.png").First(&attachment).Error) @@ -743,7 +745,10 @@ func TestWidgetHandler_ChatwootMessageDirectUploadAttachment(t *testing.T) { indexedMessage := payload[0].(map[string]interface{}) indexedAttachments := indexedMessage["attachments"].([]interface{}) require.Len(t, indexedAttachments, 1) - assert.Equal(t, "/uploads/widget_direct/signed-widget-upload-1.png", indexedAttachments[0].(map[string]interface{})["data_url"]) + indexedAttachment := indexedAttachments[0].(map[string]interface{}) + assert.Equal(t, "/uploads/widget_direct/signed-widget-upload-1.png", indexedAttachment["data_url"]) + assert.Equal(t, float64(account.ID), indexedAttachment["account_id"]) + assert.NotContains(t, indexedAttachment, "created_at") } func TestWidgetHandler_ChatwootMessageReopensSnoozedConversationWithoutActivity(t *testing.T) { @@ -2369,6 +2374,8 @@ func TestWidgetHandler_PublicAPIMessageCreatesAttachmentFromSignedUpload(t *test require.Len(t, attachments, 1) assert.Equal(t, "image", attachments[0].(map[string]any)["file_type"]) assert.Equal(t, "/uploads/widget_direct/public-signed-upload-1.png", attachments[0].(map[string]any)["data_url"]) + assert.Equal(t, float64(account.ID), attachments[0].(map[string]any)["account_id"]) + assert.NotContains(t, attachments[0].(map[string]any), "created_at") var attachment model.Attachment require.NoError(t, db.Where("file_name = ?", "public.png").First(&attachment).Error) @@ -2386,6 +2393,8 @@ func TestWidgetHandler_PublicAPIMessageCreatesAttachmentFromSignedUpload(t *test listedAttachments := listResp[0]["attachments"].([]interface{}) require.Len(t, listedAttachments, 1) assert.Equal(t, "/uploads/widget_direct/public-signed-upload-1.png", listedAttachments[0].(map[string]any)["data_url"]) + assert.Equal(t, float64(account.ID), listedAttachments[0].(map[string]any)["account_id"]) + assert.NotContains(t, listedAttachments[0].(map[string]any), "created_at") show := httptest.NewRecorder() showReq, _ := http.NewRequest("GET", "/public/api/v1/inboxes/public-api-inbox/contacts/attachment-source/conversations/"+strconv.FormatUint(uint64(conversation.ID), 10), nil) diff --git a/internal/model/channel/twitter.go b/internal/model/channel/twitter.go index a0c8bbf1..cd9900ea 100644 --- a/internal/model/channel/twitter.go +++ b/internal/model/channel/twitter.go @@ -13,6 +13,7 @@ import "github.com/gochat/gochat/internal/model" // - name: Twitter display name // - screen_name: Twitter @handle // - profile_image_url: Twitter avatar URL +// - tweets_enabled: whether tweet webhooks should create conversations/messages // - reauthorization_required: Flag when token needs refresh // // gochat enhancements beyond Chatwoot: @@ -58,6 +59,9 @@ type ChannelTwitter struct { // Twitter Account Activity API environment name WebhookEnv string `gorm:"size:255" json:"webhook_env,omitempty"` + // Whether incoming tweets should be processed into conversations/messages + TweetsEnabled bool `gorm:"default:true" json:"tweets_enabled"` + // Flag indicating reauthorization is required (token expired/revoked) ReauthorizationRequired bool `gorm:"default:false" json:"reauthorization_required,omitempty"` } diff --git a/internal/model/company.go b/internal/model/company.go index b19bf509..a0998384 100644 --- a/internal/model/company.go +++ b/internal/model/company.go @@ -9,20 +9,21 @@ import ( // Company represents a company/organization for contacts. // Reference: Chatwoot Company model + P2B M1 spec type Company struct { - ID uint `gorm:"primaryKey" json:"id"` - AccountID uint `gorm:"not null;index" json:"account_id"` - Name string `gorm:"size:255;not null" json:"name"` - Description string `gorm:"type:text" json:"description"` - WebsiteURL string `gorm:"size:512" json:"website_url"` - FaviconURL string `gorm:"size:512" json:"favicon_url"` - Domain string `gorm:"size:255" json:"domain"` - LastActivityAt *time.Time `json:"last_activity_at,omitempty"` - CustomAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"custom_attributes,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + AccountID uint `gorm:"not null;index" json:"account_id"` + Name string `gorm:"size:255;not null" json:"name"` + Description string `gorm:"type:text" json:"description"` + WebsiteURL string `gorm:"size:512" json:"website_url"` + FaviconURL string `gorm:"size:512" json:"favicon_url"` + Domain string `gorm:"size:255" json:"domain"` + LastActivityAt *time.Time `json:"last_activity_at,omitempty"` + AdditionalAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"additional_attributes,omitempty"` + CustomAttributes datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"custom_attributes,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"` Contacts []Contact `gorm:"many2many:company_contacts" json:"contacts,omitempty"` } -func (Company) TableName() string { return "companies" } \ No newline at end of file +func (Company) TableName() string { return "companies" } diff --git a/internal/model/inbox.go b/internal/model/inbox.go index 59baec65..b4a34bc5 100644 --- a/internal/model/inbox.go +++ b/internal/model/inbox.go @@ -36,7 +36,7 @@ type Inbox struct { Timezone string `gorm:"size:100" json:"timezone,omitempty"` // e.g. "Asia/Kolkata" AllowMessagesAfterResolved bool `gorm:"default:true" json:"allow_messages_after_resolved"` LockToSingleConversation bool `gorm:"default:false" json:"lock_to_single_conversation"` - SenderNameType string `gorm:"size:50;default:friendly_name" json:"sender_name_type,omitempty"` // friendly_name, business_name, random + SenderNameType string `gorm:"size:50;default:friendly" json:"sender_name_type,omitempty"` // friendly, professional BusinessName string `gorm:"size:255" json:"business_name,omitempty"` CsatConfig string `gorm:"type:text" json:"csat_config,omitempty"` // JSON-encoded CSAT survey configuration AvatarURL string `gorm:"size:1024" json:"avatar_url,omitempty"` // URL to inbox avatar image diff --git a/internal/repository/message_repo.go b/internal/repository/message_repo.go index 1bcf15d6..133909bd 100644 --- a/internal/repository/message_repo.go +++ b/internal/repository/message_repo.go @@ -114,6 +114,16 @@ func (r *MessageRepo) FindByConversationAndID(ctx context.Context, conversationI return &message, nil } +// FindByAccountConversationAndID retrieves a message scoped to an account and conversation. +func (r *MessageRepo) FindByAccountConversationAndID(ctx context.Context, accountID, conversationID, id uint) (*model.Message, error) { + var message model.Message + err := r.db.WithContext(ctx).Where("account_id = ? AND conversation_id = ? AND id = ?", accountID, conversationID, id).First(&message).Error + if err != nil { + return nil, err + } + return &message, nil +} + // Search performs a full-text search on messages for an account. func (r *MessageRepo) Search(ctx context.Context, accountID uint, query string, offset, limit int, searchMode search.SearchMode) ([]model.Message, int64, error) { var messages []model.Message diff --git a/internal/service/captain_conversation_worker_test.go b/internal/service/captain_conversation_worker_test.go index 6f0a9206..8fa97487 100644 --- a/internal/service/captain_conversation_worker_test.go +++ b/internal/service/captain_conversation_worker_test.go @@ -29,7 +29,7 @@ func setupCaptainConversationWorkerTest(t *testing.T) (*gorm.DB, *CaptainConvers }) account := &model.Account{Name: "Captain Org", Active: true} require.NoError(t, db.Create(account).Error) - inbox := &model.Inbox{AccountID: account.ID, Name: "Web", ChannelType: "web_widget", ChannelID: 1} + inbox := &model.Inbox{AccountID: account.ID, Name: "API", ChannelType: "api", ChannelID: 1} require.NoError(t, db.Create(inbox).Error) contact := &model.Contact{AccountID: account.ID, Name: "Customer", Email: "customer@example.com"} require.NoError(t, db.Create(contact).Error) diff --git a/internal/service/company_service.go b/internal/service/company_service.go index 433e3e0b..7cda2b11 100644 --- a/internal/service/company_service.go +++ b/internal/service/company_service.go @@ -65,33 +65,36 @@ func (s *CompanyService) deleteCompanyIndex(ctx context.Context, accountID uint, // CreateCompanyRequest is the DTO for creating a company. type CreateCompanyRequest struct { - Name string `json:"name" validate:"required,min=1"` - Description string `json:"description,omitempty"` - WebsiteURL string `json:"website_url,omitempty"` - FaviconURL string `json:"favicon_url,omitempty"` - Domain string `json:"domain,omitempty"` - CustomAttributes datatypes.JSON `json:"custom_attributes,omitempty"` - Company *CompanyParams `json:"company,omitempty"` + Name string `json:"name" validate:"required,min=1"` + Description string `json:"description,omitempty"` + WebsiteURL string `json:"website_url,omitempty"` + FaviconURL string `json:"favicon_url,omitempty"` + Domain string `json:"domain,omitempty"` + AdditionalAttributes datatypes.JSON `json:"additional_attributes,omitempty"` + CustomAttributes datatypes.JSON `json:"custom_attributes,omitempty"` + Company *CompanyParams `json:"company,omitempty"` } type CompanyParams struct { - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - WebsiteURL string `json:"website_url,omitempty"` - FaviconURL string `json:"favicon_url,omitempty"` - Domain string `json:"domain,omitempty"` - CustomAttributes datatypes.JSON `json:"custom_attributes,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + WebsiteURL string `json:"website_url,omitempty"` + FaviconURL string `json:"favicon_url,omitempty"` + Domain string `json:"domain,omitempty"` + AdditionalAttributes datatypes.JSON `json:"additional_attributes,omitempty"` + CustomAttributes datatypes.JSON `json:"custom_attributes,omitempty"` } // UpdateCompanyRequest is the DTO for updating a company. type UpdateCompanyRequest struct { - Name string `json:"name,omitempty" validate:"omitempty,min=1"` - Description string `json:"description,omitempty"` - WebsiteURL string `json:"website_url,omitempty"` - FaviconURL string `json:"favicon_url,omitempty"` - Domain string `json:"domain,omitempty"` - CustomAttributes datatypes.JSON `json:"custom_attributes,omitempty"` - Company *CompanyParams `json:"company,omitempty"` + Name string `json:"name,omitempty" validate:"omitempty,min=1"` + Description string `json:"description,omitempty"` + WebsiteURL string `json:"website_url,omitempty"` + FaviconURL string `json:"favicon_url,omitempty"` + Domain string `json:"domain,omitempty"` + AdditionalAttributes datatypes.JSON `json:"additional_attributes,omitempty"` + CustomAttributes datatypes.JSON `json:"custom_attributes,omitempty"` + Company *CompanyParams `json:"company,omitempty"` } // CreateCompanyNoteRequest is the DTO for creating a company note. @@ -156,6 +159,7 @@ func (s *CompanyService) Create(ctx context.Context, accountID uint, req *Create req.WebsiteURL = req.Company.WebsiteURL req.FaviconURL = req.Company.FaviconURL req.Domain = req.Company.Domain + req.AdditionalAttributes = req.Company.AdditionalAttributes req.CustomAttributes = req.Company.CustomAttributes } if req.Name == "" { @@ -166,13 +170,14 @@ func (s *CompanyService) Create(ctx context.Context, accountID uint, req *Create } company := &model.Company{ - AccountID: accountID, - Name: req.Name, - Description: req.Description, - WebsiteURL: req.WebsiteURL, - FaviconURL: req.FaviconURL, - Domain: req.Domain, - CustomAttributes: req.CustomAttributes, + AccountID: accountID, + Name: req.Name, + Description: req.Description, + WebsiteURL: req.WebsiteURL, + FaviconURL: req.FaviconURL, + Domain: req.Domain, + AdditionalAttributes: req.AdditionalAttributes, + CustomAttributes: req.CustomAttributes, } if err := s.companyRepo.Create(ctx, company); err != nil { @@ -191,6 +196,7 @@ func (s *CompanyService) Update(ctx context.Context, id, accountID uint, req *Up req.WebsiteURL = req.Company.WebsiteURL req.FaviconURL = req.Company.FaviconURL req.Domain = req.Company.Domain + req.AdditionalAttributes = req.Company.AdditionalAttributes req.CustomAttributes = req.Company.CustomAttributes } company, err := s.companyRepo.FindByIDAndAccount(ctx, id, accountID) @@ -213,6 +219,9 @@ func (s *CompanyService) Update(ctx context.Context, id, accountID uint, req *Up if req.Domain != "" { company.Domain = req.Domain } + if req.AdditionalAttributes != nil { + company.AdditionalAttributes = mergeJSON(company.AdditionalAttributes, req.AdditionalAttributes) + } if req.CustomAttributes != nil { company.CustomAttributes = mergeJSON(company.CustomAttributes, req.CustomAttributes) } diff --git a/internal/service/contact_service.go b/internal/service/contact_service.go index f7193c48..4adc7e07 100644 --- a/internal/service/contact_service.go +++ b/internal/service/contact_service.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "regexp" "strconv" "strings" "time" @@ -25,6 +26,17 @@ import ( pkgvalidator "github.com/gochat/gochat/pkg/validator" ) +var chatwootContactPhoneRegexp = regexp.MustCompile(`^\+[1-9]\d{1,14}$`) + +type ContactValidationError struct { + Message string + Attributes []string +} + +func (e *ContactValidationError) Error() string { + return e.Message +} + // ContactService implements business logic for Contact operations. // Reference: Chatwoot app/controllers/api/v1/contacts_controller.rb type ContactService struct { @@ -403,6 +415,14 @@ func (s *ContactService) ListContactInboxes(ctx context.Context, contactID uint) return s.contactInboxSvc.ListByContact(ctx, contactID) } +// ListContactInboxesByAccount retrieves contact inboxes after account-scoping the parent contact. +func (s *ContactService) ListContactInboxesByAccount(ctx context.Context, accountID, contactID uint) ([]model.ContactInbox, error) { + if _, err := s.repo.FindByAccountAndID(ctx, accountID, contactID); err != nil { + return nil, err + } + return s.contactInboxSvc.ListByContact(ctx, contactID) +} + // CreateContactRequest is the DTO for creating a contact. // Reference: Chatwoot app/controllers/api/v1/contacts_controller.rb#create // When inbox_id is provided, a ContactInbox record is auto-created (Chatwoot pattern). @@ -410,6 +430,7 @@ type CreateContactRequest struct { Name string `json:"name" validate:"required,min=1"` Email string `json:"email,omitempty" validate:"omitempty,email"` Phone string `json:"phone,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` Identifier string `json:"identifier,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` InboxID *uint `json:"inbox_id,omitempty"` @@ -424,18 +445,28 @@ type CreateContactRequest struct { CompanyID *uint `json:"company_id,omitempty"` } +func (r CreateContactRequest) contactPhoneNumber() string { + if r.PhoneNumber != "" { + return r.PhoneNumber + } + return r.Phone +} + // Create creates a new contact and optionally auto-creates a ContactInbox when inbox_id is provided. // Reference: Chatwoot contacts_controller#create — auto-creates ContactInbox for channel source. func (s *ContactService) Create(ctx context.Context, accountID uint, req CreateContactRequest) (*model.Contact, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } + if validationErr := s.validateContactUniqueness(ctx, accountID, 0, req.Email, req.contactPhoneNumber(), req.Identifier); validationErr != nil { + return nil, validationErr + } contact := &model.Contact{ AccountID: accountID, Name: req.Name, Email: req.Email, - PhoneNumber: req.Phone, + PhoneNumber: req.contactPhoneNumber(), Identifier: req.Identifier, AvatarURL: req.AvatarURL, MiddleName: req.MiddleName, @@ -485,6 +516,7 @@ type UpdateContactRequest struct { Name string `json:"name,omitempty" validate:"omitempty,min=1"` Email string `json:"email,omitempty" validate:"omitempty,email"` Phone string `json:"phone,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` Identifier string `json:"identifier,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` MiddleName string `json:"middle_name,omitempty"` @@ -497,6 +529,13 @@ type UpdateContactRequest struct { CompanyID *uint `json:"company_id,omitempty"` } +func (r UpdateContactRequest) contactPhoneNumber() string { + if r.PhoneNumber != "" { + return r.PhoneNumber + } + return r.Phone +} + // Update modifies an existing contact. func (s *ContactService) Update(ctx context.Context, accountID, id uint, req UpdateContactRequest) (*model.Contact, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { @@ -507,6 +546,9 @@ func (s *ContactService) Update(ctx context.Context, accountID, id uint, req Upd if err != nil { return nil, err } + if validationErr := s.validateContactUniqueness(ctx, accountID, contact.ID, req.Email, req.contactPhoneNumber(), req.Identifier); validationErr != nil { + return nil, validationErr + } if req.Name != "" { contact.Name = req.Name @@ -514,8 +556,8 @@ func (s *ContactService) Update(ctx context.Context, accountID, id uint, req Upd if req.Email != "" { contact.Email = req.Email } - if req.Phone != "" { - contact.PhoneNumber = req.Phone + if phoneNumber := req.contactPhoneNumber(); phoneNumber != "" { + contact.PhoneNumber = phoneNumber } if req.Identifier != "" { contact.Identifier = req.Identifier @@ -539,10 +581,10 @@ func (s *ContactService) Update(ctx context.Context, accountID, id uint, req Upd contact.ContactType = req.ContactType } if req.AdditionalAttributes != nil { - contact.AdditionalAttributes = model.ToDatatypesJSON(req.AdditionalAttributes) + contact.AdditionalAttributes = mergeContactJSON(contact.AdditionalAttributes, model.ToDatatypesJSON(req.AdditionalAttributes)) } if req.CustomAttributes != nil { - contact.CustomAttributes = model.ToDatatypesJSON(req.CustomAttributes) + contact.CustomAttributes = mergeContactJSON(contact.CustomAttributes, model.ToDatatypesJSON(req.CustomAttributes)) } if req.CompanyID != nil { contact.CompanyID = req.CompanyID @@ -1499,6 +1541,31 @@ func mergeContactJSON(current datatypes.JSON, incoming datatypes.JSON) datatypes return datatypes.JSON(bytes) } +func (s *ContactService) validateContactUniqueness(ctx context.Context, accountID, excludeID uint, email, phoneNumber, identifier string) *ContactValidationError { + if strings.TrimSpace(email) != "" { + var existing model.Contact + if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND LOWER(email) = LOWER(?) AND id <> ?", accountID, strings.TrimSpace(email), excludeID).First(&existing).Error; err == nil { + return &ContactValidationError{Message: "Email has already been taken", Attributes: []string{"email"}} + } + } + if strings.TrimSpace(phoneNumber) != "" { + if !chatwootContactPhoneRegexp.MatchString(strings.TrimSpace(phoneNumber)) { + return &ContactValidationError{Message: "Phone number is not valid", Attributes: []string{"phone_number"}} + } + var existing model.Contact + if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND phone_number = ? AND id <> ?", accountID, strings.TrimSpace(phoneNumber), excludeID).First(&existing).Error; err == nil { + return &ContactValidationError{Message: "Phone number has already been taken", Attributes: []string{"phone_number"}} + } + } + if strings.TrimSpace(identifier) != "" { + var existing model.Contact + if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND identifier = ? AND id <> ?", accountID, strings.TrimSpace(identifier), excludeID).First(&existing).Error; err == nil { + return &ContactValidationError{Message: "Identifier has already been taken", Attributes: []string{"identifier"}} + } + } + return nil +} + // ContactableInbox represents an inbox that a contact can be associated with. // Reference: Chatwoot contacts#contactable_inboxes type ContactableInbox struct { diff --git a/internal/service/inbox_service.go b/internal/service/inbox_service.go index 21b0f91e..6d10e1a3 100644 --- a/internal/service/inbox_service.go +++ b/internal/service/inbox_service.go @@ -83,6 +83,13 @@ func (s *InboxService) Ready() bool { return s != nil && s.repo != nil } +func (s *InboxService) DB() *gorm.DB { + if s == nil || s.repo == nil { + return nil + } + return s.repo.DB() +} + func (s *InboxService) SetWorkerPool(wp *worker.WorkerPool) { s.worker = wp } @@ -191,7 +198,7 @@ func (s *InboxService) Create(ctx context.Context, accountID uint, req CreateInb EnableAutoAssignment: req.EnableAutoAssignment, EnableEmailCollect: true, AllowMessagesAfterResolved: true, - SenderNameType: "friendly_name", + SenderNameType: "friendly", Timezone: "UTC", ChannelConfig: string(channelConfigJSON), } @@ -418,7 +425,7 @@ func applyCreateInboxSettings(inbox *model.Inbox, req CreateInboxRequest) { inbox.PortalID = req.PortalID } if req.SenderNameType != nil && *req.SenderNameType != "" { - inbox.SenderNameType = *req.SenderNameType + inbox.SenderNameType = normalizeInboxSenderNameType(*req.SenderNameType) } if req.BusinessName != nil { inbox.BusinessName = *req.BusinessName @@ -460,7 +467,7 @@ func applyUpdateInboxSettings(inbox *model.Inbox, req UpdateInboxRequest) { inbox.PortalID = req.PortalID } if req.SenderNameType != nil { - inbox.SenderNameType = *req.SenderNameType + inbox.SenderNameType = normalizeInboxSenderNameType(*req.SenderNameType) } if req.BusinessName != nil { inbox.BusinessName = *req.BusinessName @@ -470,6 +477,17 @@ func applyUpdateInboxSettings(inbox *model.Inbox, req UpdateInboxRequest) { } } +func normalizeInboxSenderNameType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "friendly", "friendly_name": + return "friendly" + case "professional", "business_name": + return "professional" + default: + return value + } +} + func buildInitialInboxChannelConfig(channelType string, channel map[string]any) map[string]interface{} { config := map[string]interface{}{} mergeInboxChannelConfig(config, channel) diff --git a/internal/service/message_service.go b/internal/service/message_service.go index 1c088217..43cfd490 100644 --- a/internal/service/message_service.go +++ b/internal/service/message_service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strconv" "strings" "github.com/gochat/gochat/internal/channel" @@ -109,6 +110,11 @@ func (s *MessageService) GetByConversationAndID(ctx context.Context, conversatio return s.repo.FindByConversationAndID(ctx, conversationID, id) } +// GetByAccountConversationAndID retrieves a message scoped to an account and conversation. +func (s *MessageService) GetByAccountConversationAndID(ctx context.Context, accountID, conversationID, id uint) (*model.Message, error) { + return s.repo.FindByAccountConversationAndID(ctx, accountID, conversationID, id) +} + // Search searches messages by content within an account. func (s *MessageService) Search(ctx context.Context, accountID uint, query string, offset, limit int, searchMode search.SearchMode) ([]model.Message, int64, error) { return s.repo.Search(ctx, accountID, query, offset, limit, searchMode) @@ -122,9 +128,19 @@ type CreateMessageRequest struct { MessageType string `json:"message_type,omitempty"` ContentType string `json:"content_type,omitempty"` Private bool `json:"private,omitempty"` + SenderID uint `json:"sender_id,omitempty"` + SenderType string `json:"sender_type,omitempty"` SourceID string `json:"source_id,omitempty"` EchoID string `json:"echo_id,omitempty"` + ExternalCreatedAt string `json:"external_created_at,omitempty"` ContentAttributes datatypes.JSON `json:"content_attributes,omitempty"` + EmailHTMLContent string `json:"email_html_content,omitempty"` + CCEmails string `json:"cc_emails,omitempty"` + BCCEmails string `json:"bcc_emails,omitempty"` + ToEmails string `json:"to_emails,omitempty"` + CampaignID any `json:"campaign_id,omitempty"` + TemplateParams datatypes.JSON `json:"template_params,omitempty"` + IsVoiceMessage bool `json:"is_voice_message,omitempty"` Attachments []MessageAttachmentInput `json:"-"` } @@ -149,7 +165,7 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } - if strings.TrimSpace(req.Content) == "" && len(req.Attachments) == 0 { + if strings.TrimSpace(req.Content) == "" && len(req.Attachments) == 0 && !contentTypeAllowsEmptyContent(req.ContentType, req.ContentAttributes) { return nil, fmt.Errorf("content is required") } @@ -157,6 +173,54 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, req.ConversationID).First(&conversation).Error; err != nil { return nil, err } + var inbox *model.Inbox + loadInbox := func() *model.Inbox { + if inbox != nil { + return inbox + } + var loaded model.Inbox + if err := s.repo.DB().WithContext(ctx).Select("id", "channel_type").Where("id = ?", conversation.InboxID).First(&loaded).Error; err == nil { + inbox = &loaded + if strings.TrimSpace(conversation.ChannelType) == "" { + conversation.ChannelType = loaded.ChannelType + } + } + return inbox + } + if req.MessageType == "incoming" && !messageCreateAllowedIncoming(conversation.ChannelType) { + if loaded := loadInbox(); loaded != nil { + conversation.ChannelType = loaded.ChannelType + } + if !messageCreateAllowedIncoming(conversation.ChannelType) { + return nil, fmt.Errorf("Incoming messages are only allowed in Api inboxes") + } + } + contentAttributes := messageContentAttributes(req.ContentAttributes) + contentAttributes = mergeMessageContentAttributes(contentAttributes, map[string]any{ + "external_created_at": strings.TrimSpace(req.ExternalCreatedAt), + }) + contentAttributes = s.resolveInReplyToContentAttributes(ctx, req.ConversationID, contentAttributes) + if messageCreateEmailInbox(conversation.ChannelType) || (loadInbox() != nil && messageCreateEmailInbox(inbox.ChannelType)) { + contentAttributes = mergeMessageContentAttributes(contentAttributes, map[string]any{ + "cc_emails": parseEmailList(req.CCEmails), + "bcc_emails": parseEmailList(req.BCCEmails), + "to_emails": parseEmailList(req.ToEmails), + }) + if !req.Private && strings.TrimSpace(req.Content) != "" { + contentAttributes = mergeEmailContentAttributes(contentAttributes, req.Content, req.EmailHTMLContent) + } + } + senderID := userID + senderType := "user" + if strings.TrimSpace(req.SenderType) == string(model.SenderTypeAgentBot) && req.SenderID != 0 { + var bot model.AgentBot + if err := s.repo.DB().WithContext(ctx). + Where("id = ? AND (account_id IS NULL OR account_id = ?)", req.SenderID, accountID). + First(&bot).Error; err == nil { + senderID = bot.ID + senderType = string(model.SenderTypeAgentBot) + } + } message := &model.Message{ AccountID: accountID, @@ -165,13 +229,17 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint Content: req.Content, MessageType: req.MessageType, ContentType: req.ContentType, - SenderID: &userID, - SenderType: "user", + SenderID: &senderID, + SenderType: senderType, Private: req.Private, SourceID: req.SourceID, EchoID: req.EchoID, Status: "sent", - ContentAttributes: req.ContentAttributes, + ContentAttributes: contentAttributes, + AdditionalAttributes: messageAdditionalAttributes(map[string]any{ + "campaign_id": req.CampaignID, + "template_params": req.TemplateParams, + }), } // Chatwoot: when message_type is "private_note", force Private=true and ContentType="private_note" @@ -187,15 +255,19 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint return err } for _, input := range req.Attachments { + fileType := attachmentFileType(input.ContentType) attachment := &model.Attachment{ MessageID: message.ID, AccountID: accountID, - FileType: attachmentFileType(input.ContentType), + FileType: fileType, FileURL: attachmentDataURL(message.ID, input.FileName), ThumbURL: attachmentThumbURL(input.ContentType, message.ID, input.FileName), FileSize: input.FileSize, FileName: input.FileName, } + if req.IsVoiceMessage && fileType == "audio" { + attachment.Metadata = `{"is_voice_message":true}` + } if err := tx.Create(attachment).Error; err != nil { return err } @@ -231,6 +303,226 @@ func (s *MessageService) Create(ctx context.Context, accountID uint, userID uint return message, nil } +func messageCreateAllowedIncoming(channelType string) bool { + switch strings.TrimSpace(channelType) { + case "api", string(model.InboxChannelTypeAPI): + return true + default: + return false + } +} + +func messageCreateEmailInbox(channelType string) bool { + switch strings.TrimSpace(channelType) { + case "email", string(model.InboxChannelTypeEmail): + return true + default: + return false + } +} + +func messageContentAttributes(raw datatypes.JSON) datatypes.JSON { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + attrs := map[string]any{} + if err := json.Unmarshal(raw, &attrs); err != nil { + var encoded string + if stringErr := json.Unmarshal(raw, &encoded); stringErr != nil || strings.TrimSpace(encoded) == "" { + return nil + } + if err := json.Unmarshal([]byte(encoded), &attrs); err != nil { + return nil + } + } + if len(attrs) == 0 { + return nil + } + encoded, err := json.Marshal(attrs) + if err != nil { + return nil + } + return datatypes.JSON(encoded) +} + +func mergeMessageContentAttributes(raw datatypes.JSON, values map[string]any) datatypes.JSON { + attrs := map[string]any{} + if len(raw) > 0 && string(raw) != "null" { + _ = json.Unmarshal(raw, &attrs) + } + for key, value := range values { + if list, ok := value.([]string); ok && len(list) == 0 { + continue + } + attrs[key] = value + } + if len(attrs) == 0 { + return nil + } + encoded, err := json.Marshal(attrs) + if err != nil { + return nil + } + return datatypes.JSON(encoded) +} + +func mergeEmailContentAttributes(raw datatypes.JSON, content string, htmlContent string) datatypes.JSON { + attrs := map[string]any{} + if len(raw) > 0 && string(raw) != "null" { + _ = json.Unmarshal(raw, &attrs) + } + emailAttrs := map[string]any{} + if existing, ok := attrs["email"].(map[string]any); ok { + emailAttrs = existing + } + textAttrs := map[string]any{} + if existing, ok := emailAttrs["text_content"].(map[string]any); ok { + textAttrs = existing + } + textAttrs["full"] = content + textAttrs["reply"] = content + emailAttrs["text_content"] = textAttrs + + renderedHTML := strings.TrimSpace(htmlContent) + if renderedHTML == "" { + renderedHTML = content + } + htmlAttrs := map[string]any{} + if existing, ok := emailAttrs["html_content"].(map[string]any); ok { + htmlAttrs = existing + } + htmlAttrs["full"] = renderedHTML + htmlAttrs["reply"] = renderedHTML + emailAttrs["html_content"] = htmlAttrs + attrs["email"] = emailAttrs + + encoded, err := json.Marshal(attrs) + if err != nil { + return raw + } + return datatypes.JSON(encoded) +} + +func (s *MessageService) resolveInReplyToContentAttributes(ctx context.Context, conversationID uint, raw datatypes.JSON) datatypes.JSON { + attrs := map[string]any{} + if len(raw) > 0 && string(raw) != "null" { + _ = json.Unmarshal(raw, &attrs) + } + if _, hasReplyID := attrs["in_reply_to"]; !hasReplyID { + if _, hasExternalID := attrs["in_reply_to_external_id"]; !hasExternalID { + return raw + } + } + + var replyMessage model.Message + found := false + if replyID, ok := uintFromAny(attrs["in_reply_to"]); ok && replyID != 0 { + if err := s.repo.DB().WithContext(ctx).Where("conversation_id = ? AND id = ?", conversationID, replyID).First(&replyMessage).Error; err == nil { + found = true + } + } + if !found { + if externalID, ok := stringFromAny(attrs["in_reply_to_external_id"]); ok && strings.TrimSpace(externalID) != "" { + if err := s.repo.DB().WithContext(ctx).Where("conversation_id = ? AND source_id = ?", conversationID, strings.TrimSpace(externalID)).First(&replyMessage).Error; err == nil { + found = true + } + } + } + + if found { + attrs["in_reply_to"] = replyMessage.ID + attrs["in_reply_to_external_id"] = replyMessage.SourceID + } else { + attrs["in_reply_to"] = nil + attrs["in_reply_to_external_id"] = nil + } + encoded, err := json.Marshal(attrs) + if err != nil { + return raw + } + return datatypes.JSON(encoded) +} + +func uintFromAny(value any) (uint, bool) { + switch typed := value.(type) { + case float64: + if typed > 0 && typed == float64(uint(typed)) { + return uint(typed), true + } + case int: + if typed > 0 { + return uint(typed), true + } + case uint: + if typed > 0 { + return typed, true + } + case string: + parsed, err := strconv.ParseUint(strings.TrimSpace(typed), 10, 64) + if err == nil && parsed > 0 { + return uint(parsed), true + } + } + return 0, false +} + +func stringFromAny(value any) (string, bool) { + if value == nil { + return "", false + } + if typed, ok := value.(string); ok { + return typed, true + } + return fmt.Sprint(value), true +} + +func parseEmailList(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.Split(strings.ReplaceAll(value, " ", ""), ",") + emails := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + emails = append(emails, part) + } + } + return emails +} + +func messageAdditionalAttributes(values map[string]any) datatypes.JSON { + attrs := map[string]any{} + for key, value := range values { + switch v := value.(type) { + case nil: + continue + case string: + if strings.TrimSpace(v) == "" { + continue + } + attrs[key] = v + case datatypes.JSON: + if len(v) == 0 || string(v) == "null" { + continue + } + var parsed any + if err := json.Unmarshal(v, &parsed); err == nil { + attrs[key] = parsed + } + default: + attrs[key] = v + } + } + if len(attrs) == 0 { + return nil + } + encoded, err := json.Marshal(attrs) + if err != nil { + return nil + } + return datatypes.JSON(encoded) +} + func normalizeMessageType(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "", "1", "outgoing": @@ -259,43 +551,55 @@ func validMessageType(value string) bool { func validContentType(value string) bool { switch value { - case "text", "input_text", "input_email", "input_phone", "select", "card", "private_note", "input_csat", "file", "image", "audio", "video", "voice_call", "integrations": + case "text", "input_text", "input_textarea", "input_email", "input_select", "cards", "form", "article", "incoming_email", "input_csat", "integrations", "sticker", "voice_call", "input_phone", "select", "card", "private_note", "file", "image", "audio", "video": return true default: return false } } +func contentTypeAllowsEmptyContent(contentType string, contentAttributes datatypes.JSON) bool { + switch contentType { + case "input_text", "input_textarea", "input_email", "input_select", "cards", "form", "article", "input_csat", "integrations", "sticker", "voice_call", "select", "card": + return len(messageContentAttributes(contentAttributes)) > 0 + default: + return false + } +} + // UpdateMessageRequest is the DTO for updating a message. type UpdateMessageRequest struct { - Content string `json:"content,omitempty" validate:"omitempty,min=1"` Status string `json:"status,omitempty"` ExternalError string `json:"external_error,omitempty"` } // Update modifies an existing message. func (s *MessageService) Update(ctx context.Context, accountID, id uint, req UpdateMessageRequest) (*model.Message, error) { + return s.UpdateInConversation(ctx, accountID, 0, id, req) +} + +// UpdateInConversation modifies a message scoped to a conversation route. +func (s *MessageService) UpdateInConversation(ctx context.Context, accountID, conversationID, id uint, req UpdateMessageRequest) (*model.Message, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } - message, err := s.repo.FindByAccountAndID(ctx, accountID, id) + message, err := s.findMessageForConversationRoute(ctx, accountID, conversationID, id) if err != nil { return nil, err } - if req.Content != "" { - message.Content = req.Content + if !s.messageInboxIsAPI(ctx, message.InboxID) { + return nil, fmt.Errorf("Message status update is only allowed for API inboxes") } - if req.Status != "" && validMessageStatus(req.Status) { - if !s.messageInboxIsAPI(ctx, message.InboxID) { - return nil, fmt.Errorf("Message status update is only allowed for API inboxes") + if req.Status != "" { + if !validMessageStatus(req.Status) { + return nil, fmt.Errorf("invalid status") } - if message.Status == "read" && req.Status == "delivered" { - return message, nil + if !(message.Status == "read" && req.Status == "delivered") { + message.Status = req.Status + message.ContentAttributes = setMessageExternalError(message.ContentAttributes, req.Status, req.ExternalError) } - message.Status = req.Status - message.ContentAttributes = setMessageExternalError(message.ContentAttributes, req.Status, req.ExternalError) } if err := s.repo.Update(ctx, message); err != nil { @@ -324,7 +628,12 @@ func (s *MessageService) messageInboxIsAPI(ctx context.Context, inboxID uint) bo // Delete marks a message deleted using Chatwoot's visible tombstone payload. func (s *MessageService) Delete(ctx context.Context, accountID, id uint) (*model.Message, error) { - message, err := s.repo.FindByAccountAndID(ctx, accountID, id) + return s.DeleteInConversation(ctx, accountID, 0, id) +} + +// DeleteInConversation marks a message deleted using Chatwoot's conversation-scoped lookup. +func (s *MessageService) DeleteInConversation(ctx context.Context, accountID, conversationID, id uint) (*model.Message, error) { + message, err := s.findMessageForConversationRoute(ctx, accountID, conversationID, id) if err != nil { return nil, err } @@ -442,7 +751,12 @@ func attachmentThumbURL(contentType string, messageID uint, fileName string) str // Reference: Chatwoot MessagesController#retry sets status to sent, clears // content_attributes, and queues SendReplyJob. func (s *MessageService) Retry(ctx context.Context, accountID, id uint) (*model.Message, error) { - message, err := s.repo.FindByAccountAndID(ctx, accountID, id) + return s.RetryInConversation(ctx, accountID, 0, id) +} + +// RetryInConversation retries a failed message scoped to a conversation route. +func (s *MessageService) RetryInConversation(ctx context.Context, accountID, conversationID, id uint) (*model.Message, error) { + message, err := s.findMessageForConversationRoute(ctx, accountID, conversationID, id) if err != nil { return nil, err } @@ -475,6 +789,13 @@ func (s *MessageService) Retry(ctx context.Context, accountID, id uint) (*model. return message, nil } +func (s *MessageService) findMessageForConversationRoute(ctx context.Context, accountID, conversationID, id uint) (*model.Message, error) { + if conversationID == 0 { + return s.repo.FindByAccountAndID(ctx, accountID, id) + } + return s.repo.FindByAccountConversationAndID(ctx, accountID, conversationID, id) +} + // CountByConversation returns the total message count in a conversation. func (s *MessageService) CountByConversation(ctx context.Context, conversationID uint) (int64, error) { return s.repo.CountByConversation(ctx, conversationID) @@ -492,18 +813,33 @@ type TranslateMessageResult struct { TranslatedContent string `json:"translated_content"` SourceLanguage string `json:"source_language,omitempty"` TargetLanguage string `json:"target_language"` + AlreadyTranslated bool `json:"-"` } // Translate translates a message's content to the target language using LLM. func (s *MessageService) Translate(ctx context.Context, accountID, id uint, req TranslateMessageRequest) (*TranslateMessageResult, error) { + return s.TranslateInConversation(ctx, accountID, 0, id, req) +} + +// TranslateInConversation translates a message scoped to a conversation route and caches the result. +func (s *MessageService) TranslateInConversation(ctx context.Context, accountID, conversationID, id uint, req TranslateMessageRequest) (*TranslateMessageResult, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } - message, err := s.repo.FindByAccountAndID(ctx, accountID, id) + message, err := s.findMessageForConversationRoute(ctx, accountID, conversationID, id) if err != nil { return nil, err } + if translated, ok := messageTranslationForLanguage(message.ContentAttributes, req.TargetLanguage); ok { + return &TranslateMessageResult{ + ID: message.ID, + OriginalContent: message.Content, + TranslatedContent: translated, + TargetLanguage: req.TargetLanguage, + AlreadyTranslated: true, + }, nil + } llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{ Messages: []llm.ChatMessage{ @@ -520,6 +856,13 @@ func (s *MessageService) Translate(ctx context.Context, accountID, id uint, req if len(llmResp.Choices) > 0 { translated = llmResp.Choices[0].Message.Content } + if strings.TrimSpace(translated) != "" { + message.ContentAttributes = setMessageTranslation(message.ContentAttributes, req.TargetLanguage, translated) + if err := s.repo.Update(ctx, message); err != nil { + return nil, err + } + s.indexMessage(ctx, message) + } return &TranslateMessageResult{ ID: message.ID, @@ -529,6 +872,44 @@ func (s *MessageService) Translate(ctx context.Context, accountID, id uint, req }, nil } +func messageTranslationForLanguage(attrs datatypes.JSON, language string) (string, bool) { + language = strings.TrimSpace(language) + if language == "" { + return "", false + } + obj := map[string]any{} + if len(attrs) == 0 || string(attrs) == "null" { + return "", false + } + if err := json.Unmarshal(attrs, &obj); err != nil { + return "", false + } + translations, ok := obj["translations"].(map[string]any) + if !ok { + return "", false + } + value, ok := translations[language].(string) + return value, ok && strings.TrimSpace(value) != "" +} + +func setMessageTranslation(attrs datatypes.JSON, language string, translated string) datatypes.JSON { + obj := map[string]any{} + if len(attrs) > 0 && string(attrs) != "null" { + _ = json.Unmarshal(attrs, &obj) + } + translations := map[string]any{} + if existing, ok := obj["translations"].(map[string]any); ok { + translations = existing + } + translations[strings.TrimSpace(language)] = translated + obj["translations"] = translations + encoded, err := json.Marshal(obj) + if err != nil { + return attrs + } + return datatypes.JSON(encoded) +} + // ListAttachments returns paginated attachments for all messages in a conversation. // Reference: Chatwoot conversations_controller.rb #attachments (member route) func (s *MessageService) ListAttachments(ctx context.Context, accountID, conversationID uint, offset, limit int) ([]model.Attachment, int64, error) { diff --git a/internal/service/message_service_test.go b/internal/service/message_service_test.go index 912cc54b..463fca71 100644 --- a/internal/service/message_service_test.go +++ b/internal/service/message_service_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "testing" @@ -23,9 +24,11 @@ import ( type mockMessageLLMProvider struct { chatResponse *llm.ChatResponse chatErr error + chatCalls int } func (m *mockMessageLLMProvider) ChatCompletion(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { + m.chatCalls++ if m.chatErr != nil { return nil, m.chatErr } @@ -286,12 +289,191 @@ func TestMessageService_Create(t *testing.T) { reqNoContentType := CreateMessageRequest{ ConversationID: conv.ID, Content: "无ContentType", - MessageType: "incoming", + MessageType: "outgoing", } created2, err2 := svc.Create(ctx, account.ID, user.ID, reqNoContentType) assert.NoError(t, err2) assert.Equal(t, "text", created2.ContentType) + // Chatwoot parity: incoming messages are only allowed in API inboxes + reqIncomingNonAPI := CreateMessageRequest{ + ConversationID: conv.ID, + Content: "非 API 收件箱 incoming", + MessageType: "incoming", + } + _, errIncomingNonAPI := svc.Create(ctx, account.ID, user.ID, reqIncomingNonAPI) + assert.EqualError(t, errIncomingNonAPI, "Incoming messages are only allowed in Api inboxes") + + apiInbox := createTestInbox(t, db, account.ID, string(model.InboxChannelTypeAPI)) + apiConv := createTestConversation(t, db, account.ID, apiInbox.ID, contact.ID) + reqIncomingAPI := CreateMessageRequest{ + ConversationID: apiConv.ID, + Content: "API incoming", + MessageType: "incoming", + } + createdIncomingAPI, errIncomingAPI := svc.Create(ctx, account.ID, user.ID, reqIncomingAPI) + assert.NoError(t, errIncomingAPI) + assert.Equal(t, "incoming", createdIncomingAPI.MessageType) + + // Chatwoot parity: campaign_id and template_params are stored in additional_attributes + templateParams := datatypes.JSON([]byte(`{"name":"customer_name","processed_params":{"body":"Hi"}}`)) + reqWithAdditionalAttributes := CreateMessageRequest{ + ConversationID: conv.ID, + Content: "带模板参数", + MessageType: "outgoing", + CampaignID: float64(42), + TemplateParams: templateParams, + } + createdWithAdditionalAttributes, errWithAdditionalAttributes := svc.Create(ctx, account.ID, user.ID, reqWithAdditionalAttributes) + assert.NoError(t, errWithAdditionalAttributes) + var additionalAttributes map[string]any + require.NoError(t, json.Unmarshal(createdWithAdditionalAttributes.AdditionalAttributes, &additionalAttributes)) + assert.Equal(t, float64(42), additionalAttributes["campaign_id"]) + assert.Equal(t, "customer_name", additionalAttributes["template_params"].(map[string]any)["name"]) + + // Chatwoot parity: email inbox recipient params are stored in content_attributes + emailInbox := createTestInbox(t, db, account.ID, string(model.InboxChannelTypeEmail)) + emailConv := createTestConversation(t, db, account.ID, emailInbox.ID, contact.ID) + reqEmailRecipients := CreateMessageRequest{ + ConversationID: emailConv.ID, + Content: "Email reply", + MessageType: "outgoing", + EmailHTMLContent: "
Email reply
", + ContentAttributes: datatypes.JSON([]byte(`{"email":{"html_content":{"reply":"Hello
"}}}`)), + CCEmails: "agent_cc1@example.com, agent_cc2@example.com", + BCCEmails: "agent_bcc@example.com", + ToEmails: "customer@example.com", + } + createdEmailMessage, errEmailMessage := svc.Create(ctx, account.ID, user.ID, reqEmailRecipients) + assert.NoError(t, errEmailMessage) + var emailContentAttributes map[string]any + require.NoError(t, json.Unmarshal(createdEmailMessage.ContentAttributes, &emailContentAttributes)) + assert.Equal(t, []any{"agent_cc1@example.com", "agent_cc2@example.com"}, emailContentAttributes["cc_emails"]) + assert.Equal(t, []any{"agent_bcc@example.com"}, emailContentAttributes["bcc_emails"]) + assert.Equal(t, []any{"customer@example.com"}, emailContentAttributes["to_emails"]) + emailAttrs := emailContentAttributes["email"].(map[string]any) + assert.Equal(t, "Email reply", emailAttrs["text_content"].(map[string]any)["reply"]) + assert.Equal(t, "Email reply
", emailAttrs["html_content"].(map[string]any)["reply"]) + + // Chatwoot parity: stringified content_attributes are parsed as JSON objects + encodedAttrs, err := json.Marshal(`{"automation_rule_id":123,"items":[{"title":"Plan","value":"pro"}]}`) + require.NoError(t, err) + reqStringContentAttributes := CreateMessageRequest{ + ConversationID: conv.ID, + Content: "String attrs", + MessageType: "outgoing", + ExternalCreatedAt: "2026-06-09 10:30:00 UTC", + ContentAttributes: datatypes.JSON(encodedAttrs), + } + createdStringAttrsMessage, errStringAttrsMessage := svc.Create(ctx, account.ID, user.ID, reqStringContentAttributes) + assert.NoError(t, errStringAttrsMessage) + var stringContentAttributes map[string]any + require.NoError(t, json.Unmarshal(createdStringAttrsMessage.ContentAttributes, &stringContentAttributes)) + assert.Equal(t, float64(123), stringContentAttributes["automation_rule_id"]) + assert.Equal(t, "2026-06-09 10:30:00 UTC", stringContentAttributes["external_created_at"]) + require.Len(t, stringContentAttributes["items"], 1) + + // Chatwoot parity: in_reply_to is normalized to a message in the same conversation + quotedSource := "provider-reply-1" + quotedMessage := &model.Message{ + ConversationID: conv.ID, + AccountID: account.ID, + InboxID: inbox.ID, + Content: "Quoted", + MessageType: "incoming", + ContentType: "text", + SenderType: "contact", + SourceID: quotedSource, + } + require.NoError(t, db.Create(quotedMessage).Error) + reqReplyContentAttributes := CreateMessageRequest{ + ConversationID: conv.ID, + Content: "Reply", + MessageType: "outgoing", + ContentAttributes: datatypes.JSON([]byte(fmt.Sprintf(`{"in_reply_to":%d,"items":[{"title":"Plan"}]}`, quotedMessage.ID))), + } + createdReplyMessage, errReplyMessage := svc.Create(ctx, account.ID, user.ID, reqReplyContentAttributes) + assert.NoError(t, errReplyMessage) + var replyContentAttributes map[string]any + require.NoError(t, json.Unmarshal(createdReplyMessage.ContentAttributes, &replyContentAttributes)) + assert.Equal(t, float64(quotedMessage.ID), replyContentAttributes["in_reply_to"]) + assert.Equal(t, quotedSource, replyContentAttributes["in_reply_to_external_id"]) + require.Len(t, replyContentAttributes["items"], 1) + + otherConv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) + reqCrossConversationReply := CreateMessageRequest{ + ConversationID: otherConv.ID, + Content: "Cross reply", + MessageType: "outgoing", + ContentAttributes: datatypes.JSON([]byte(fmt.Sprintf(`{"in_reply_to":%d}`, quotedMessage.ID))), + } + createdCrossReplyMessage, errCrossReplyMessage := svc.Create(ctx, account.ID, user.ID, reqCrossConversationReply) + assert.NoError(t, errCrossReplyMessage) + var crossReplyContentAttributes map[string]any + require.NoError(t, json.Unmarshal(createdCrossReplyMessage.ContentAttributes, &crossReplyContentAttributes)) + assert.Nil(t, crossReplyContentAttributes["in_reply_to"]) + assert.Nil(t, crossReplyContentAttributes["in_reply_to_external_id"]) + + // Chatwoot parity: structured bot content types can be created without text content + for _, contentType := range []string{"input_select", "cards"} { + createdStructuredMessage, errStructuredMessage := svc.Create(ctx, account.ID, user.ID, CreateMessageRequest{ + ConversationID: conv.ID, + MessageType: "outgoing", + ContentType: contentType, + ContentAttributes: datatypes.JSON([]byte(`{"items":[{"title":"Plan","value":"pro"}]}`)), + }) + assert.NoError(t, errStructuredMessage) + assert.Equal(t, contentType, createdStructuredMessage.ContentType) + assert.Empty(t, createdStructuredMessage.Content) + } + + // Chatwoot parity: sender_type=AgentBot uses an accessible agent bot as sender + require.NoError(t, db.AutoMigrate(&model.AgentBot{})) + agentBot := &model.AgentBot{AccountID: &account.ID, Name: "Reply Bot", BotType: "webhook"} + require.NoError(t, db.Create(agentBot).Error) + reqAgentBotSender := CreateMessageRequest{ + ConversationID: conv.ID, + Content: "Agent bot reply", + MessageType: "outgoing", + SenderType: string(model.SenderTypeAgentBot), + SenderID: agentBot.ID, + } + createdAgentBotMessage, errAgentBotMessage := svc.Create(ctx, account.ID, user.ID, reqAgentBotSender) + assert.NoError(t, errAgentBotMessage) + assert.Equal(t, string(model.SenderTypeAgentBot), createdAgentBotMessage.SenderType) + assert.Equal(t, agentBot.ID, *createdAgentBotMessage.SenderID) + + reqMissingAgentBotSender := CreateMessageRequest{ + ConversationID: conv.ID, + Content: "Fallback reply", + MessageType: "outgoing", + SenderType: string(model.SenderTypeAgentBot), + SenderID: agentBot.ID + 999, + } + createdFallbackMessage, errFallbackMessage := svc.Create(ctx, account.ID, user.ID, reqMissingAgentBotSender) + assert.NoError(t, errFallbackMessage) + assert.Equal(t, "user", createdFallbackMessage.SenderType) + assert.Equal(t, user.ID, *createdFallbackMessage.SenderID) + + // Chatwoot parity: is_voice_message tags only audio attachments + reqVoiceMessage := CreateMessageRequest{ + ConversationID: conv.ID, + Content: "voice note", + MessageType: "outgoing", + IsVoiceMessage: true, + Attachments: []MessageAttachmentInput{ + {FileName: "voice.mp3", FileSize: 1234, ContentType: "audio/mpeg"}, + {FileName: "transcript.txt", FileSize: 12, ContentType: "text/plain"}, + }, + } + createdVoiceMessage, errVoiceMessage := svc.Create(ctx, account.ID, user.ID, reqVoiceMessage) + assert.NoError(t, errVoiceMessage) + var voiceAttachments []model.Attachment + require.NoError(t, db.Where("message_id = ?", createdVoiceMessage.ID).Order("id ASC").Find(&voiceAttachments).Error) + require.Len(t, voiceAttachments, 2) + assert.Equal(t, `{"is_voice_message":true}`, voiceAttachments[0].Metadata) + assert.Empty(t, voiceAttachments[1].Metadata) + // 正常路径:创建私密消息 reqPrivate := CreateMessageRequest{ ConversationID: conv.ID, @@ -340,6 +522,68 @@ func TestMessageService_Create(t *testing.T) { assert.Error(t, err7) } +func TestMessageService_ConversationScopedMessageActions(t *testing.T) { + db, _, _, svc := setupMessageServiceWithDefaultLLM(t) + ctx := context.Background() + account := createTestAccount(t, db) + user := createTestUser(t, db, account.ID) + inbox := createTestInbox(t, db, account.ID, "api") + contact := createTestContact(t, db, account.ID) + conversation := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) + otherConversation := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) + + message := &model.Message{ + ConversationID: conversation.ID, + AccountID: account.ID, + InboxID: inbox.ID, + SenderID: &user.ID, + SenderType: "user", + Content: "route scoped", + MessageType: "outgoing", + ContentType: "text", + Status: "failed", + } + require.NoError(t, db.Create(message).Error) + + found, err := svc.GetByAccountConversationAndID(ctx, account.ID, conversation.ID, message.ID) + require.NoError(t, err) + assert.Equal(t, message.ID, found.ID) + + _, err = svc.GetByAccountConversationAndID(ctx, account.ID, otherConversation.ID, message.ID) + assert.Error(t, err) + + _, err = svc.UpdateInConversation(ctx, account.ID, otherConversation.ID, message.ID, UpdateMessageRequest{Status: "delivered"}) + assert.Error(t, err) + require.NoError(t, db.First(message, message.ID).Error) + assert.Equal(t, "route scoped", message.Content) + assert.Equal(t, "failed", message.Status) + + updated, err := svc.UpdateInConversation(ctx, account.ID, conversation.ID, message.ID, UpdateMessageRequest{Status: "delivered"}) + require.NoError(t, err) + assert.Equal(t, "route scoped", updated.Content) + assert.Equal(t, "delivered", updated.Status) + require.NoError(t, db.Model(message).Update("status", "failed").Error) + + _, err = svc.RetryInConversation(ctx, account.ID, otherConversation.ID, message.ID) + assert.Error(t, err) + require.NoError(t, db.First(message, message.ID).Error) + assert.Equal(t, "failed", message.Status) + + retried, err := svc.RetryInConversation(ctx, account.ID, conversation.ID, message.ID) + require.NoError(t, err) + assert.Equal(t, "sent", retried.Status) + + _, err = svc.DeleteInConversation(ctx, account.ID, otherConversation.ID, message.ID) + assert.Error(t, err) + require.NoError(t, db.First(message, message.ID).Error) + assert.NotContains(t, string(message.ContentAttributes), "deleted") + + deleted, err := svc.DeleteInConversation(ctx, account.ID, conversation.ID, message.ID) + require.NoError(t, err) + assert.Equal(t, "This message was deleted", deleted.Content) + assert.JSONEq(t, `{"deleted":true}`, string(deleted.ContentAttributes)) +} + // ========== Update 测试 ========== func TestMessageService_Update(t *testing.T) { @@ -347,27 +591,40 @@ func TestMessageService_Update(t *testing.T) { ctx := context.Background() account := createTestAccount(t, db) - inbox := createTestInbox(t, db, account.ID, "web_widget") + inbox := createTestInbox(t, db, account.ID, "api") contact := createTestContact(t, db, account.ID) conv := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) msg := &model.Message{ ConversationID: conv.ID, AccountID: account.ID, InboxID: inbox.ID, - Content: "原始内容", MessageType: "outgoing", ContentType: "text", SenderType: "user", + Content: "原始内容", MessageType: "outgoing", ContentType: "text", SenderType: "user", Status: "sent", } require.NoError(t, db.Create(msg).Error) - // 正常路径:更新消息内容 - req := UpdateMessageRequest{Content: "更新内容"} + req := UpdateMessageRequest{Status: "delivered"} updated, err := svc.Update(ctx, account.ID, msg.ID, req) assert.NoError(t, err) - assert.Equal(t, "更新内容", updated.Content) + assert.Equal(t, "delivered", updated.Status) + assert.Equal(t, "原始内容", updated.Content) - // 正常路径:空Content字符串不触发更新(omitempty跳过验证) - reqEmpty := UpdateMessageRequest{Content: ""} + reqEmpty := UpdateMessageRequest{} updated2, err2 := svc.Update(ctx, account.ID, msg.ID, reqEmpty) assert.NoError(t, err2) - assert.Equal(t, "更新内容", updated2.Content) // Content保持不变 + assert.Equal(t, "delivered", updated2.Status) + assert.Equal(t, "原始内容", updated2.Content) + + _, err = svc.Update(ctx, account.ID, msg.ID, UpdateMessageRequest{Status: "invalid"}) + assert.Error(t, err) + + webInbox := createTestInbox(t, db, account.ID, "web_widget") + webConv := createTestConversation(t, db, account.ID, webInbox.ID, contact.ID) + webMessage := &model.Message{ + ConversationID: webConv.ID, AccountID: account.ID, InboxID: webInbox.ID, + Content: "web", MessageType: "outgoing", ContentType: "text", SenderType: "user", Status: "sent", + } + require.NoError(t, db.Create(webMessage).Error) + _, err = svc.Update(ctx, account.ID, webMessage.ID, UpdateMessageRequest{Status: "delivered"}) + assert.Error(t, err) // 错误路径:accountID不匹配 _, err = svc.Update(ctx, 9999, msg.ID, req) @@ -482,6 +739,12 @@ func TestMessageService_Translate(t *testing.T) { assert.Equal(t, "你好", result.OriginalContent) assert.Equal(t, "Hello", result.TranslatedContent) assert.Equal(t, "en", result.TargetLanguage) + assert.Equal(t, 1, mockLLM.chatCalls) + + resultCached, errCached := svc.Translate(ctx, account.ID, msg.ID, TranslateMessageRequest{TargetLanguage: "en"}) + assert.NoError(t, errCached) + assert.Equal(t, "Hello", resultCached.TranslatedContent) + assert.Equal(t, 1, mockLLM.chatCalls) }) t.Run("empty_choices", func(t *testing.T) { diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index 066a7a40..d7617b5c 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -43,6 +43,13 @@ func NewNotificationService( } } +func (s *NotificationService) DB() *gorm.DB { + if s == nil { + return nil + } + return s.db +} + // --- Notification CRUD operations --- // GetNotification retrieves a single notification by ID. diff --git a/internal/service/search_indexer_hooks_test.go b/internal/service/search_indexer_hooks_test.go index ac9797ca..c8aad51e 100644 --- a/internal/service/search_indexer_hooks_test.go +++ b/internal/service/search_indexer_hooks_test.go @@ -107,13 +107,13 @@ func TestMessageService_SearchIndexHooks(t *testing.T) { account := createTestAccount(t, db) user := createTestUser(t, db, account.ID) - inbox := createTestInbox(t, db, account.ID, "web_widget") + inbox := createTestInbox(t, db, account.ID, "api") contact := createTestContact(t, db, account.ID) conversation := createTestConversation(t, db, account.ID, inbox.ID, contact.ID) message, err := svc.Create(context.Background(), account.ID, user.ID, CreateMessageRequest{ConversationID: conversation.ID, Content: "hello", MessageType: "outgoing"}) require.NoError(t, err) - _, err = svc.Update(context.Background(), account.ID, message.ID, UpdateMessageRequest{Content: "updated"}) + _, err = svc.Update(context.Background(), account.ID, message.ID, UpdateMessageRequest{Status: "delivered"}) require.NoError(t, err) _, err = svc.Delete(context.Background(), account.ID, message.ID) require.NoError(t, err) diff --git a/internal/service/whatsapp_authorization_service.go b/internal/service/whatsapp_authorization_service.go index facb23a4..5f2cf134 100644 --- a/internal/service/whatsapp_authorization_service.go +++ b/internal/service/whatsapp_authorization_service.go @@ -103,7 +103,7 @@ func (s *InboxService) createWhatsAppEmbeddedSignupInbox(ctx context.Context, ac } else { inboxName += " WhatsApp" } - inbox := &model.Inbox{AccountID: accountID, Name: inboxName, ChannelType: "whatsapp", Enabled: true, EnableEmailCollect: true, AllowMessagesAfterResolved: true, SenderNameType: "friendly_name", Timezone: "UTC"} + inbox := &model.Inbox{AccountID: accountID, Name: inboxName, ChannelType: "whatsapp", Enabled: true, EnableEmailCollect: true, AllowMessagesAfterResolved: true, SenderNameType: "friendly", Timezone: "UTC"} if err := s.repo.Create(ctx, inbox); err != nil { return nil, err } diff --git a/internal/ws/presence.go b/internal/ws/presence.go index b435fa11..e5b99a9b 100644 --- a/internal/ws/presence.go +++ b/internal/ws/presence.go @@ -61,8 +61,8 @@ func (p *PresenceTracker) SetAgentOnline(ctx context.Context, agentID, accountID // Broadcast presence update event msg := &WSMessage{ - Event: EventAgentOnline, - Data: map[string]any{ + Event: EventAgentOnline, + Data: map[string]any{ "agent_id": agentID, "account_id": accountID, }, @@ -96,8 +96,8 @@ func (p *PresenceTracker) SetAgentOffline(ctx context.Context, agentID, accountI // Broadcast offline event msg := &WSMessage{ - Event: EventAgentOffline, - Data: map[string]any{ + Event: EventAgentOffline, + Data: map[string]any{ "agent_id": agentID, "account_id": accountID, }, @@ -131,8 +131,8 @@ func (p *PresenceTracker) SetAgentBusy(ctx context.Context, agentID, accountID u // Broadcast presence update msg := &WSMessage{ - Event: EventPresenceUpdate, - Data: map[string]any{ + Event: EventPresenceUpdate, + Data: map[string]any{ "agent_id": agentID, "account_id": accountID, "status": "busy", @@ -228,6 +228,23 @@ func (p *PresenceTracker) GetAgentStatus(ctx context.Context, agentID, accountID return status, nil } +// GetContactStatus returns whether a contact is currently present. +func (p *PresenceTracker) GetContactStatus(ctx context.Context, contactID, accountID uint) (string, error) { + member := presenceMember(contactID, accountID) + minScore := float64(time.Now().Unix() - PresenceDurationContactSec) + score, err := p.rdb.ZScore(ctx, RedisKeyPresenceContacts, member).Result() + if err != nil { + if err == redis.Nil { + return "offline", nil + } + return "", fmt.Errorf("failed to get contact presence: %w", err) + } + if score < minScore { + return "offline", nil + } + return "online", nil +} + // CleanupExpired removes stale presence entries whose timestamp score // has fallen below the expiry threshold. Should be called periodically // (e.g., every 30 seconds) via a background goroutine. @@ -305,4 +322,4 @@ func parsePresenceMember(member string) (uint, uint) { id, _ := strconv.ParseUint(parts[0], 10, 32) acct, _ := strconv.ParseUint(parts[1], 10, 32) return uint(id), uint(acct) -} \ No newline at end of file +} diff --git a/internal/ws/presence_test.go b/internal/ws/presence_test.go index 52bc715f..9d29bf71 100644 --- a/internal/ws/presence_test.go +++ b/internal/ws/presence_test.go @@ -32,7 +32,7 @@ func setupPresenceTest(t *testing.T) (*miniredis.Miniredis, *redis.Client, *Pres return mr, rdb, tracker, handler, cleanup } -// --- PresenceTracker 构造函数测试 --- +// --- PresenceTracker 构造函数测试 --- func TestNewPresenceTracker(t *testing.T) { _, rdb, _, _, cleanup := setupPresenceTest(t) @@ -47,7 +47,7 @@ func TestNewPresenceTracker(t *testing.T) { assert.Equal(t, relay, tracker.relay, "BroadcastRelay 应正确设置") } -// --- SetAgentOnline 测试 --- +// --- SetAgentOnline 测试 --- func TestSetAgentOnline(t *testing.T) { _, rdb, tracker, _, cleanup := setupPresenceTest(t) @@ -119,7 +119,7 @@ func TestSetAgentOnline_RefreshExisting(t *testing.T) { assert.InDelta(t, now, score, 2.0, "刷新后 score 应更新为当前时间戳") } -// --- SetAgentOffline 测试 --- +// --- SetAgentOffline 测试 --- func TestSetAgentOffline(t *testing.T) { _, rdb, tracker, _, cleanup := setupPresenceTest(t) @@ -159,7 +159,7 @@ func TestSetAgentOffline_NotOnline(t *testing.T) { require.NoError(t, err, "对不存在的 agent 设置离线不应报错") } -// --- SetAgentBusy 测试 --- +// --- SetAgentBusy 测试 --- func TestSetAgentBusy(t *testing.T) { _, rdb, tracker, _, cleanup := setupPresenceTest(t) @@ -187,7 +187,7 @@ func TestSetAgentBusy(t *testing.T) { assert.Greater(t, score, 0.0, "busy agent 应仍在 sorted set 中") } -// --- SetContactOnline / SetContactOffline 测试 --- +// --- SetContactOnline / SetContactOffline 测试 --- func TestSetContactOnline(t *testing.T) { _, rdb, tracker, _, cleanup := setupPresenceTest(t) @@ -230,7 +230,7 @@ func TestSetContactOffline(t *testing.T) { assert.Equal(t, redis.Nil, rdb.HGet(ctx, RedisKeyPresenceStatus, "100:2").Err(), "hash 中不应有该成员的状态") } -// --- GetOnlineAgentsForAccount 测试 --- +// --- GetOnlineAgentsForAccount 测试 --- func TestGetOnlineAgentsForAccount(t *testing.T) { _, _, tracker, _, cleanup := setupPresenceTest(t) @@ -300,7 +300,7 @@ func TestGetOnlineAgentsForAccount_Expired(t *testing.T) { assert.Empty(t, agents, "过期 agent 不应在在线列表中") } -// --- GetAgentStatus 测试 --- +// --- GetAgentStatus 测试 --- func TestGetAgentStatus(t *testing.T) { _, _, tracker, _, cleanup := setupPresenceTest(t) @@ -345,7 +345,7 @@ func TestGetAgentStatus_UnknownAgent(t *testing.T) { assert.Equal(t, "offline", status, "未知 agent 状态应为 offline") } -// --- RefreshAgentPresence / RefreshContactPresence 测试 --- +// --- RefreshAgentPresence / RefreshContactPresence 测试 --- func TestRefreshAgentPresence(t *testing.T) { mr, rdb, tracker, _, cleanup := setupPresenceTest(t) @@ -395,7 +395,28 @@ func TestRefreshContactPresence(t *testing.T) { assert.InDelta(t, now, score, 2.0, "刷新后 score 应更新") } -// --- CleanupExpired 测试 --- +func TestGetContactStatus(t *testing.T) { + _, rdb, tracker, _, cleanup := setupPresenceTest(t) + defer cleanup() + + ctx := context.Background() + status, err := tracker.GetContactStatus(ctx, 100, 2) + require.NoError(t, err) + assert.Equal(t, "offline", status) + + require.NoError(t, tracker.SetContactOnline(ctx, 100, 2)) + status, err = tracker.GetContactStatus(ctx, 100, 2) + require.NoError(t, err) + assert.Equal(t, "online", status) + + expiredContactScore := float64(time.Now().Unix() - PresenceDurationContactSec - 10) + require.NoError(t, rdb.ZAdd(ctx, RedisKeyPresenceContacts, redis.Z{Score: expiredContactScore, Member: "100:2"}).Err()) + status, err = tracker.GetContactStatus(ctx, 100, 2) + require.NoError(t, err) + assert.Equal(t, "offline", status) +} + +// --- CleanupExpired 测试 --- func TestCleanupExpired(t *testing.T) { _, rdb, tracker, _, cleanup := setupPresenceTest(t) @@ -460,7 +481,7 @@ func TestCleanupExpired_NoExpired(t *testing.T) { require.NoError(t, err, "没有过期数据时清理不应报错") } -// --- presenceMember / parsePresenceMember 辅助函数测试 --- +// --- presenceMember / parsePresenceMember 辅助函数测试 --- func TestPresenceMember(t *testing.T) { result := presenceMember(42, 1) @@ -480,4 +501,4 @@ func TestParsePresenceMember(t *testing.T) { // 缺少分隔符 id, acct = parsePresenceMember("42") assert.Equal(t, uint(0), id, "缺少分隔符应返回0") -} \ No newline at end of file +} diff --git a/migrations/000038_add_channel_twitter_tweets_enabled.down.sql b/migrations/000038_add_channel_twitter_tweets_enabled.down.sql new file mode 100644 index 00000000..8df69767 --- /dev/null +++ b/migrations/000038_add_channel_twitter_tweets_enabled.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE channel_twitter_profiles DROP COLUMN IF EXISTS tweets_enabled; +ALTER TABLE channel_twitters DROP COLUMN IF EXISTS tweets_enabled; diff --git a/migrations/000038_add_channel_twitter_tweets_enabled.up.sql b/migrations/000038_add_channel_twitter_tweets_enabled.up.sql new file mode 100644 index 00000000..659bfe1d --- /dev/null +++ b/migrations/000038_add_channel_twitter_tweets_enabled.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE channel_twitters ADD COLUMN IF NOT EXISTS tweets_enabled BOOLEAN DEFAULT TRUE; +ALTER TABLE channel_twitter_profiles ADD COLUMN IF NOT EXISTS tweets_enabled BOOLEAN DEFAULT TRUE;