codex/widget-select-contrast
340
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
379e28df1f |
fix: prevent bot metrics double-counting when handoff and resolution coexist [CW-6210] (#14032)
The bot metrics dashboard can show `handoff_rate + resolution_rate >
100%`. A single conversation can accumulate both
`conversation_bot_handoff` and `conversation_bot_resolved` events, and
the rate queries count them independently against a shared denominator.
## How it happens
```
Customer messages bot inbox
│
▼
┌──────────┐
│ pending │ (bot handling)
└────┬─────┘
│ bot can't help
▼
┌──────────┐
│ open │ (handed off → conversation_bot_handoff event created)
└────┬─────┘
│ agent clicks "Resolve" WITHOUT sending a message
▼
┌──────────┐
│ resolved │ conversation_resolved fires
└──────────┘
│
▼
create_bot_resolved_event guard checks:
✅ inbox.active_bot?
✅ no outgoing messages with sender_type: 'User' ← agent never messaged!
│
▼
conversation_bot_resolved event ALSO created ← BUG
│
▼
Same conversation counted in BOTH rates → sum exceeds 100%
```
## Why fix at the read path, not the write path
An earlier attempt added guards in the listener to make the two events
mutually exclusive per conversation — deleting `bot_resolved` when a
handoff fires, suppressing resolutions when a handoff exists. This was
rejected because conversations can be reopened across multiple cycles
(bot resolves on day 1, customer returns on day 5, bot hands off).
Deleting the day-1 resolution corrupts historical reports, and the async
event dispatcher makes listener-level guards vulnerable to race
conditions.
## What this PR does
Within a reporting window, if a conversation has both events, **handoff
wins** — the conversation is excluded from the resolution count. This is
applied via SQL subquery across all three read paths:
```
┌─────────────────────────┐
│ Reporting Events DB │
│ │
│ conv_bot_handoff: [A,B] │
│ conv_bot_resolved: [A,C]│
└────────┬────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
BotMetricsBuilder ReportHelper CountReportBuilder
(rate cards) (bot_summary) (timeseries charts)
│ │ │
▼ ▼ ▼
resolutions: resolutions: resolutions:
[A,C] minus [A,B] same logic same logic
= [C] only = [C] only = [C] only
Result: Conversation A → handoff only
Conversation B → handoff only
Conversation C → resolution only
```
For wide date ranges spanning multiple lifecycles, a conversation
bot-resolved in one cycle and handed off in a later cycle will only show
as a handoff. This is an acceptable tradeoff — the alternative (>100%
rates) is clearly worse, and narrow ranges handle this correctly since
the events fall into different windows. No reporting events are
modified, so historical data stays intact.
## Diagnostic tool
`rake bot_metrics:diagnose` — read-only task that prompts for account ID
and date range, shows a before/after rate comparison without modifying
data.
---------
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
|
||
|
|
6c67eb9ba0 |
fix(notifications): Respect conversation access when notifying agents (#14412)
Agents with limited custom roles were receiving notifications (creation, assignment, mentions, new messages, SLA) for conversations they couldn't actually open. For example, an agent whose custom role only grants `conversation_unassigned_manage` was getting notified about conversations assigned to other agents. Notifications now go through the same `ConversationPolicy#show?` check that gates the conversation view itself, so an agent only gets notified for conversations they're permitted to see. Administrators and agents without custom roles are unaffected. --------- Co-authored-by: Sojan Jose <sojan@pepalo.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> |
||
|
|
de696a55cb |
feat(voice): add WhatsApp inbound call webhook pipeline [3] (#14315)
Adds the server-side flow that turns Meta WhatsApp Cloud Calling webhooks into Chatwoot Calls, conversations, voice_call message bubbles, and ActionCable broadcasts. Stacked on top of #14312 (PR-2 — provider methods); intentionally does not include the HTTP controller, routes, or frontend (those land in PR-4 and PR-9). ## Closes - Part of the WhatsApp Cloud Calling rollout. Linear: TBD ## What changed **Webhook routing** - `app/jobs/webhooks/whatsapp_events_job.rb` — append `prepend_mod_with('Webhooks::WhatsappEventsJob')` so EE can extend it without forking. - `enterprise/app/jobs/enterprise/webhooks/whatsapp_events_job.rb` (new) — overlay that prepends `handle_message_events` to intercept `field: 'calls'` payloads (route to `Whatsapp::IncomingCallService`) and `interactive.call_permission_reply` messages (route to `Whatsapp::CallPermissionReplyService`); falls through with `super` for regular messages. **Services** - `enterprise/app/services/whatsapp/incoming_call_service.rb` (new) — gated on `provider_config['calling_enabled']`; processes `connect` (creates inbound call via `Voice::InboundCallBuilder` or transitions an existing outbound call to `in_progress`) and `terminate` events; updates conversation `additional_attributes` and broadcasts `voice_call.incoming`/`voice_call.outbound_connected`/`voice_call.ended`. - `enterprise/app/services/whatsapp/call_permission_reply_service.rb` (new) — handles WhatsApp interactive `call_permission_reply` replies; clears the conversation's `call_permission_requested_at` flag and broadcasts `voice_call.permission_granted` so the agent UI can re-enable the call button. **Builder/model adjustments** - `enterprise/app/services/voice/inbound_call_builder.rb` — provider-agnostic; accepts `provider:` and `extra_meta:` kwargs, drops `account:` (now derived from `inbox.account` to keep the param count under rubocop's ceiling without disabling cops), uses digits-only `source_id` for WhatsApp ContactInbox (validation requires `^\d{1,15}\z`), skips Twilio-only `conference_sid` for non-Twilio providers. - `enterprise/app/services/voice/call_message_builder.rb` — adds `create!`/`update_status!` API and `CALL_TO_VOICE_STATUS` map; uses direct `Message.create!` (bypasses `Messages::MessageBuilder`'s incoming-on-non-Api-inbox guard, which would otherwise reject the system bubble); content is `'WhatsApp Call'` for WhatsApp and `'Voice Call'` for Twilio. Backwards-compatible `perform!` retained for the existing Twilio call sites. - `enterprise/app/models/call.rb` — adds `default_ice_servers` (driven by `VOICE_CALL_STUN_URLS` env), `direction_label` alias for the `inbound`/`outbound` strings the FE expects, and `ringing?`/`in_progress?`/`terminal?` predicates used throughout the pipeline. **Outgoing-channel guard** - `app/services/base/send_on_channel_service.rb` — extends `invalid_message?` to skip messages with `content_type == 'voice_call'`. Without this, agent-initiated outbound calls (PR-4) would deliver \"WhatsApp Call\" as a text message to the contact every time. **Twilio call-site update** - `enterprise/app/controllers/twilio/voice_controller.rb` — drops the now-redundant `account: current_account` kwarg from the `Voice::InboundCallBuilder.perform!` call. **Tests** - New: `spec/enterprise/services/whatsapp/incoming_call_service_spec.rb` (5 examples — calling-disabled, inbound connect, outbound connect, terminate completed, terminate no-answer, unknown event). - New: `spec/enterprise/services/whatsapp/call_permission_reply_service_spec.rb` (3 examples — accept, reject, calling-disabled). - Updated: `spec/enterprise/services/voice/inbound_call_builder_spec.rb` and `spec/enterprise/controllers/twilio/voice_controller_spec.rb` to drop the `account:` kwarg from call expectations. ## How to test In `rails console` against an account with a WhatsApp inbox where `provider_config['calling_enabled']` is true: ```ruby inbox = Inbox.find(<id>) params = { calls: [{ id: 'wacid_test', from: '15550001111', event: 'connect', session: { sdp: 'v=0...', sdp_type: 'offer' } }] } Whatsapp::IncomingCallService.new(inbox: inbox, params: params).perform # => Conversation + Call (status: 'ringing', provider: 'whatsapp') + voice_call message bubble # => ActionCable broadcasts `voice_call.incoming` to the assignee or account-wide # Then terminate it: Whatsapp::IncomingCallService.new(inbox: inbox, params: { calls: [{ id: 'wacid_test', event: 'terminate', duration: 0, terminate_reason: 'no_answer' }] } ).perform # => Call status flips to 'no_answer', message bubble updates, `voice_call.ended` broadcast fires ``` End-to-end browser flow (Meta → cable → UI) requires the controller from PR-4 and the frontend from PR-9. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
bc768bf04f | chore: verbosely log errors for leadsquare activity failure (#14407) | ||
|
|
202403873d |
feat: Ability to specify the authentication type for imap server (#12306)
# Pull Request Template ## Description This PR adds IMAP authentication mechanism selection to Chatwoot's email inbox configuration. Users can now choose between 'plain', 'login', and 'cram-md5' authentication methods when configuring IMAP settings, providing flexibility for different email providers that require specific authentication types. https://github.com/chatwoot/chatwoot/issues/8867 The implementation includes: - Frontend dropdown with numeric keys (1, 2, 3) matching SMTP auth style - Backend API validation for allowed authentication mechanisms - Consistent 'cram-md5' format throughout the codebase - Updated IMAP service to handle different auth types properly This feature maintains consistency with existing SMTP authentication options and follows the established UI/UX patterns in the application. ## Type of change Please delete options that are not relevant. - [x] New feature (non-breaking change which adds functionality) - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? ### Manual Testing: - Tested in Docker environment - Verified IMAP auth dropdown appears in inbox settings - Confirmed all three auth mechanisms (plain, login, cram-md5) can be selected and saved - Tested API validation by attempting to save invalid auth mechanisms ### Automated Testing: - Updated existing IMAP service tests to use consistent lowercase values - Updated API controller tests for authentication parameter handling - All tests pass locally with the new changes ### Test Configuration: - Tested with both new and existing inbox configurations ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules ## Additional Notes - This feature is backward compatible and doesn't break existing IMAP configurations - The 'cram-md5' format is used consistently throughout (UI, API, storage, services) - Net::IMAP compatibility is maintained by converting to 'CRAM-MD5' internally - Follows the same pattern established by SMTP authentication configuration --------- Co-authored-by: João Santos <joao.santos@madigital.eu> Co-authored-by: Sony Mathew <sony@chatwoot.com> |
||
|
|
9c1d1c4070 |
feat(labels): remove label associations asynchronously on delete (#13531)
## Summary - Remove label deletion dependency on association cleanup by deleting immediately and enqueueing a background job. - Add `Labels::RemoveAssociationsJob` to strip deleted label references from tagged conversations and contacts. - Keep this version simple by removing the label count/prompt requirement requested. ## Implementation notes - Enqueue job from `Api::V1::Accounts::LabelsController#destroy` with label title + account id. - Background work performed in `Labels::DestroyService`. ## References - Linear issue: https://linear.app/chatwoot/issue/CW-4765/cw-2857-enhancement-removing-labels-is-inconsistent - GitHub issue: https://github.com/chatwoot/chatwoot/issues/1249 ## Testing - `bundle exec rspec spec/controllers/api/v1/accounts/labels_controller_spec.rb spec/services/labels/destroy_service_spec.rb spec/jobs/labels/remove_associations_job_spec.rb spec/services/labels/update_service_spec.rb` - `bundle exec rubocop app/controllers/api/v1/accounts/labels_controller.rb app/jobs/labels/remove_associations_job.rb spec/controllers/api/v1/accounts/labels_controller_spec.rb spec/jobs/labels/remove_associations_job_spec.rb spec/services/labels/destroy_service_spec.rb` --------- Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> |
||
|
|
b8108b71c1 |
fix(tiktok): Resolve media upload failures and gate attachments by conversation capability (#13643)
This PR fixes TikTok attachment send failures and adds a capability-based guard so attachments are only enabled for conversations that support media sending. - Fixed TikTok media upload request formatting so TikTok accepts image uploads reliably. - Added TikTok capability check (IMAGE_SEND) during conversation creation. - Stored capability in conversation.additional_attributes.tiktok_capabilities. - Updated reply composer UI to disable/hide attachment upload for TikTok conversations where image_send is false. Fixes https://linear.app/chatwoot/issue/CW-6532/enable-attachments-based-on-the-conversation-capability and https://linear.app/chatwoot/issue/CW-6996/unable-to-send-image-attachments-to-tiktok-customer-400-parsing-error --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> |
||
|
|
a01adf860a |
fix: [CW-7001] Limit emails fetch (#14354)
This PR limits IMAP email fetching to 500 messages per sync run to avoid expensive/long-running mailbox scans. It also filters out already-imported emails and Chatwoot-generated notification emails during the header fetch phase, before fetching full email bodies, reducing unnecessary IMAP work. Fixes #CW-7001 (issue) : https://linear.app/chatwoot/issue/CW-7001/emails-not-syncing |
||
|
|
cd9c8e3303 |
fix: skip self-mention notification in private notes (#14318)
When an agent mentions themselves in a private note, they no longer receive a redundant notification for their own mention. Closes: #4096 # Pull Request Template ## Description Agents who mention themselves in a private note no longer receive a conversation_mention notification. Previously, the mention service would generate a notification for every mentioned user without checking whether the sender and the mentioned user were the same person. |
||
|
|
f8f0caf443 |
feat(campaigns): Add variable support to WhatsApp campaigns (#13649)
Fixes https://linear.app/chatwoot/issue/CW-5641/add-the-support-for-variables-in-whatsapp-campaign-templates This PR adds liquid variable support to WhatsApp campaigns, enabling dynamic per-contact personalization. It supports the same liquid variables as SMS campaigns ({{contact.name}}, {{contact.email}}, etc.). Variables are processed per-contact when the campaign executes, allowing personalized messages at scale. --------- Co-authored-by: Sojan Jose <sojan@pepalo.com> Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Sony Mathew <sony@chatwoot.com> |
||
|
|
2266eb493b |
fix: Add validation to the name attribute in user (#10805)
With this change, the form will start displaying a required field. While validation is already enforced in APIs and other areas, the super_admin console—being autogenerated—will throw an error since this requirement isn’t explicitly defined in the model. <img width="670" alt="Screenshot 2025-01-30 at 2 12 43 PM" src="https://github.com/user-attachments/assets/e0ab3ace-3649-4ef2-bc94-8d4d80453dd1" /> Fixes https://github.com/chatwoot/chatwoot/issues/10754 --------- Co-authored-by: Shivam Mishra <scm.mymail@gmail.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Sony Mathew <ynos1234@gmail.com> Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> Co-authored-by: Sony Mathew <sony@chatwoot.com> |
||
|
|
6cbddbdb67 |
feat(rollup): report builder abstraction [2/3] (#13798)
## PR2: Report builder refactor — DataSource abstraction
The existing report builders (timeseries + summary) had their SQL
queries inlined — each builder constructed its own scopes, groupings,
and aggregations directly. This made it hard to swap the underlying data
source without duplicating builder logic.
This PR extracts all raw-event querying into a `Reports::RawDataSource`
behind a `Reports::DataSource` factory. Builders now call
`data_source.timeseries`, `.aggregate`, or `.summary` instead of
constructing queries themselves. Behavior is identical —
`DataSource.for(...)` returns `RawDataSource` in all cases today.
The timeseries path had two separate builders (`CountReportBuilder`,
`AverageReportBuilder`) that were selected via a metric-name case
statement in `Conversations::BaseReportBuilder`. These are replaced by a
single `ReportBuilder` that delegates to the data source. The metric
type (count vs average) is now decided inside the data source, not the
builder.
Summary builders similarly moved their inline SQL into
`RawDataSource#summary`, which returns a unified hash keyed by dimension
ID.
the rollup read path.
## Flow
### Before
```
ReportsController ──▶ case metric ──▶ AverageReportBuilder ──▶ inline SQL ──▶ DB
└──▶ CountReportBuilder ──▶ inline SQL ──▶ DB
SummaryController ──▶ AgentSummaryBuilder ──▶ inline SQL ──▶ DB
└──▶ InboxSummaryBuilder ──▶ inline SQL ──▶ DB
└──▶ TeamSummaryBuilder ──▶ inline SQL ──▶ DB
```
### After
```
ReportsController ──▶ ReportBuilder ──┐
├──▶ DataSource.for ──▶ RawDataSource ──▶ DB
SummaryController ──▶ SummaryBuilder ──┘
```
### Expected (after rollup read path)
```
ReportsController ──▶ ReportBuilder ──┐
├──▶ DataSource.for ──▶ RawDataSource ──▶ reporting_events
SummaryController ──▶ SummaryBuilder ──┘ └──▶ RollupDataSource ──▶ reporting_events_rollups
```
### What changed
- `Reports::DataSource` factory + `Reports::RawDataSource`
- `TimezoneHelper#timezone_name_from_params` — prefers IANA name, falls
back to offset
- Unified `Timeseries::ReportBuilder` replaces `CountReportBuilder` +
`AverageReportBuilder`
- Summary builders delegate to `DataSource` instead of querying directly
### How to test
This is a pure refactor — all existing report pages (Overview, Agent,
Inbox, Label, Team) should produce identical numbers. No feature flag or
new config needed.
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Tanmay Deep Sharma <tanmaydeepsharma21@gmail.com>
Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
|
||
|
|
135be52431 |
feat: Introduce last responding agent option to automation assign agent (#12326)
Introduce a `Last Responding Agent` options to assign_agents action in automations to cover the following use cases. - Assign conversations to first responding agent : ( automation message created at , if assignee is nil, assign last responding agent ) - Ensure conversations are not resolved with out an assignee : ( automation conversation resolved at : if assignee is nil, assign last responding agent ) and potential other cases. fixes: #1592 |
||
|
|
aee979ee0b |
fix: add explicit remove assignment actions to macros and automations (#12172)
This updates macros and automations so agents can explicitly remove assigned agents or teams, while keeping the existing `Assign -> None` flow working for backward compatibility. Fixes: #7551 Closes: #7551 ## Why The original macro change exposed unassignment only through `Assign -> None`, which made macros behave differently from automations and left the explicit remove actions inconsistent across the product. This keeps the lower-risk compatibility path and adds the explicit remove actions requested in review. ## What this change does - Adds `Remove Assigned Agent` and `Remove Assigned Team` as explicit actions in macros. - Adds the same explicit remove actions in automations. - Keeps `Assign Agent -> None` and `Assign Team -> None` working for existing behavior and stored payloads. - Preserves backward compatibility for existing macro and automation execution payloads. - Downmerges the latest `develop` and resolves the conflicts while keeping both the new remove actions and current `develop` behavior. ## Validation - Verified both remove actions are available and selectable in the macro editor. - Verified both remove actions are available and selectable in the automation builder. - Applied a disposable macro with `Remove Assigned Agent` and `Remove Assigned Team` on a real conversation and confirmed both fields were cleared. - Applied a disposable macro with `Assign Agent -> None` and `Assign Team -> None` on a real conversation and confirmed both fields were still cleared. |
||
|
|
45b6ea6b3f |
feat: add automation condition to filter private notes (#12102)
## Summary Adds a new automation condition to filter private notes. This allows automation rules to explicitly include or exclude private notes instead of relying on implicit behavior. Fixes: #11208 ## Preview https://github.com/user-attachments/assets/c40f6910-7bbf-4e59-aae5-ad408602927a |
||
|
|
e5107604a0 |
feat: account enrichment using context.dev [UPM-27] (#13978)
## Account branding enrichment during signup This PR does the following ### Replace Firecrawl with Context.dev Switches the enterprise brand lookup from Firecrawl to Context.dev for better data quality, built-in caching, and automatic filtering of free/disposable email providers. The service interface changes from URL to email input to match Context.dev's email endpoint. OSS still falls back to basic HTML scraping with a normalized output shape across both paths. The enterprise path intentionally does not fall back to HTML scraping on failure — speed matters more than completeness. We want the user on the editable onboarding form fast, and a slow fallback scrape is worse than letting them fill it in. Requires `CONTEXT_DEV_API_KEY` in Super Admin → App Config. Without it, falls back to OSS HTML scraping. ### Add job to enrich account details After account creation, `Account::BrandingEnrichmentJob` looks up the signup email and pre-fills the account name, colors, logos, social links, and industry into `custom_attributes['brand_info']`. The job signals completion via a short-lived Redis key (30s TTL) + an ActionCable broadcast (`account.enrichment_completed`). The Redis key lets the frontend distinguish "still running" from "finished with no results." |
||
|
|
4cce7f6ad8 |
fix(line): Use non-expiring URLs for image and video messages (#13949)
Images and videos sent from Chatwoot to LINE inboxes fail to display on the LINE mobile app — users see expired markers, broken thumbnails, or missing images. This happens because LINE mobile lazy-loads images rather than downloading them immediately, and the ActiveStorage signed URLs expire after 5 minutes. Closes https://linear.app/chatwoot/issue/CW-6696/line-messaging-with-image-or-video-may-not-show-when-client-inactive ## How to reproduce 1. Create a LINE inbox and start a chat from the LINE mobile app 2. Close the LINE mobile app 3. Send an image from Chatwoot to that chat 4. Wait 7-8 minutes (past the 5-minute URL expiration) 5. Open the LINE mobile app — the image is broken/expired ## What changed - **`originalContentUrl`**: switched from `download_url` (signed, 5-min expiry) to `file_url` (permanent redirect-based URL) - **`previewImageUrl`**: switched to `thumb_url` (250px resized thumbnail meeting LINE's 1MB/240x240 recommendation), with fallback to `file_url` for non-image attachments like video 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Sojan Jose <sojan@pepalo.com> |
||
|
|
7651c18b48 |
feat: firecrawl branding api [UPM-15] (#13903)
Adds `WebsiteBrandingService` (OSS) with an Enterprise override using
Firecrawl v2 to extract branding and business data from a URL for
onboarding auto-fill.
OSS version uses HTTParty + Nokogiri to extract:
- Business name (og:site_name or title)
- Language (html lang)
- Favicon
- Social links from `<a>` tags
Enterprise version makes a single Firecrawl call to fetch:
- Structured JSON (name, language, industry via LLM)
- Branding (favicon, primary color)
- Page links
Falls back to OSS if Firecrawl is unavailable or fails.
Social handles (WhatsApp, Facebook, Instagram, Telegram, TikTok, LINE)
are parsed deterministically via a shared `SocialLinkParser`.
> We use links for socials, since the LLM extraction was unreliable,
mostly returned empty, and hallucinated in some rare scenarios
## How to test
```ruby
# OSS (no Firecrawl key needed)
WebsiteBrandingService.new('chatwoot.com').perform
# Enterprise (requires CAPTAIN_FIRECRAWL_API_KEY)
WebsiteBrandingService.new('notion.so').perform
WebsiteBrandingService.new('postman.com').perform
```
Verify the returned hash includes business_name, language,
industry_category, social_handles, and branding with
favicon/primary_color.
<img width="908" height="393" alt="image"
src="https://github.com/user-attachments/assets/e3696887-d366-485a-89a0-8e1a9698a788"
/>
|
||
|
|
e4c3f0ac2f |
feat: fallback on phone number to update lead (#13910)
When syncing contacts to LeadSquared, the `Lead.CreateOrUpdate` API defaults to searching by email. If a contact has no email (or a different email) but a phone number matching an existing lead, the API fails with `MXDuplicateEntryException` instead of finding and updating the existing lead. This accounted for ~69% of all LeadSquared integration errors, and cascaded into "Lead not found" failures when posting transcript and conversation activities (~14% of errors). ## What changed - `LeadClient#create_or_update_lead` now catches `MXDuplicateEntryException` and retries the request once with `SearchBy=Phone` appended to the body, telling the API to match on phone number instead - Once the retry succeeds, the returned lead ID is stored on the contact (existing behavior), so all future events use the direct `update_lead` path and never hit the duplicate error again ## How to reproduce 1. Create a lead in LeadSquared with phone number `+91-75076767676` and email `a@example.com` 2. In Chatwoot, create a contact with the same phone number but a different email (or no email) 3. Trigger a contact sync (via conversation creation or contact update) 4. Before fix: `MXDuplicateEntryException` error in logs, contact fails to sync 5. After fix: retry with `SearchBy=Phone` finds and updates the existing lead, stores the lead ID on the contact |
||
|
|
9967101b48 |
feat(rollup): add models and write path [1/3] (#13796)
## PR#1: Reporting events rollup — model and write path Reporting queries currently hit the `reporting_events` table directly. This works, but the table grows linearly with event volume, and aggregation queries (counts, averages over date ranges) get progressively slower as accounts age. This PR introduces a pre-aggregated `reporting_events_rollups` table that stores daily per-metric, per-dimension (account/agent/inbox) totals. The write path is intentionally decoupled from the read path — rollup rows are written inline from the event listener via upsert, and a backfill service exists to rebuild historical data from raw events. Nothing reads from this table yet. The write path activates when an account has a `reporting_timezone` set (new account setting). The `reporting_events_rollup` feature flag controls only the future read path, not writes — so rollup data accumulates silently once timezone is configured. A `MetricRegistry` maps raw event names to rollup column semantics in one place, keeping the write and (future) read paths aligned. ### What changed - Migration for `reporting_events_rollups` with a unique composite index for upsert - `ReportingEventsRollup` model - `reporting_timezone` account setting with IANA timezone validation - `MetricRegistry` — single source of truth for event-to-metric mappings - `RollupService` — real-time upsert from event listener - `BackfillService` — rebuilds rollups for a given account + date from raw events - Rake tasks for interactive backfill and timezone setup - `reporting_events_rollup` feature flag (disabled by default) ### How to test 1. Set a `reporting_timezone` on an account (`Account.first.update!(reporting_timezone: 'Asia/Kolkata')`) 2. Resolve a conversation or trigger a first response 3. Check `ReportingEventsRollup.where(account_id: ...)` — rows should appear 4. Run backfill: `bundle exec rake reporting_events_rollup:backfill` and verify historical data populates --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> |
||
|
|
6e46be36c8 |
fix: Add fix to only allow confirmed agents to used in Agent Assingments at Macros/Automations (#13225)
# Pull Request Template ## Description Unconfirmed agents (pending email verification) were incorrectly appearing in the "assign agent" dropdown for macros and automations. This fix filters out unconfirmed agents from these dropdowns and adds backend validation to prevent assignment of unconfirmed agents. Fixes #13223 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? **Backend tests:** ```bash docker compose run --rm rails bundle exec rspec spec/services/action_service_spec.rb ``` - Added tests for confirmed agent assignment (should succeed) - Added tests for unconfirmed agent assignment (should be skipped) **Frontend tests:** ```bash docker compose run --rm rails pnpm test app/javascript/dashboard/composables/spec/useMacros.spec.js ``` - Updated mocks to use `getVerifiedAgents` getter **Manual testing:** 1. Create an unconfirmed agent via platform 2. Navigate to Settings → Macros → New Macro → Add "Assign Agent" action 3. Verify unconfirmed agent does NOT appear in dropdown 4. Navigate to Settings → Automations → New Automation → Add "Assign Agent" action 5. Verify unconfirmed agent does NOT appear in dropdown ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sojan Jose <sojan@pepalo.com> |
||
|
|
dc0e5eb465 |
fix: optimize message query with account_id filter (#13759)
## Description This PR optimizes message queries by explicitly filtering with `account_id` so the database can use the existing indexes more efficiently. Changes: - Add `account_id` to message query filters to improve index utilization. - Update `last_incoming_message` query to include `account_id`. - Avoid unnecessary preloading of `contact_inboxes` where it is not required. - Update specs to ensure `account_id` is set correctly in message-related tests. These changes reduce query cost and improve performance for message lookups, especially on large accounts. --------- Co-authored-by: Pranav <pranav@chatwoot.com> |
||
|
|
19683fae74 |
Merge branch 'hotfix/4.11.2' into develop
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
|
||
|
|
432462f967 | feat: harden filter service | ||
|
|
059506b1db |
feat: Add automatic favicon fetching for companies (#13013)
## Summary This Enterprise-only feature automatically fetches a favicon for companies created with a domain, and adds a batch task to backfill missing avatars for existing companies. The flow only targets companies that do not already have an attached avatar, so existing avatars are left untouched. ## Demo https://github.com/user-attachments/assets/d050334e-769f-4e46-b6e7-f7423727a192 ## What changed - Added `Avatar::AvatarFromFaviconJob` to build a Google favicon URL from the company domain and fetch it through `Avatar::AvatarFromUrlJob` - Triggered favicon fetching from `Company` with `after_create_commit` - Added `Companies::FetchAvatarsJob` to batch existing companies that are missing avatars - Added `companies:fetch_missing_avatars` under `enterprise/lib/tasks` - Kept the company-specific implementation inside the Enterprise boundary - Stubbed the new favicon request in unrelated specs that now hit this callback indirectly - Updated a couple of CI-sensitive specs that were failing due to callback side effects / reload-safe exception assertions ## How to verify 1. Create a company in Enterprise with a valid domain and no avatar. 2. Confirm that a favicon-based avatar gets attached shortly after creation. 3. Create another company with a domain and an avatar already attached. 4. Confirm that the existing avatar is not replaced. 5. Run `companies:fetch_missing_avatars`. 6. Confirm that existing companies without avatars get one, while companies that already have avatars remain unchanged. ## Notes - This change does not refresh or overwrite existing company avatars - Favicon fetching only runs for companies with a present domain - The branch includes the latest `develop` --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Sojan Jose <sojan@pepalo.com> |
||
|
|
9aacc0335b |
feat(facebook): use HUMAN_AGENT tag for Messenger replies when human-agent config is enabled (#13690)
This PR updates Facebook Messenger outbound tagging in Chatwoot to support Human Agent messaging when enabled. Previously, Facebook outbound text and attachment messages were always sent with: ``` messaging_type: MESSAGE_TAG tag: ACCOUNT_UPDATE ``` With this change, the tag is selected dynamically: ``` HUMAN_AGENT when ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT is enabled ACCOUNT_UPDATE as fallback when the flag is disabled ``` |
||
|
|
a44cb2c738 |
feat(inbox): Enable conversation continuity for social channels (#11079)
## Summary This PR enables and surfaces **conversation workflow** for social-style channels that should support either: - `Create new conversations` after resolve, or - `Reopen same conversation` ## What is included - Adds the conversation workflow setting UI as card-based options in Inbox Settings. - Expands channel availability in settings to include channels like: - Telegram - TikTok - Instagram - Line - WhatsApp - Facebook - Updates conversation selection behavior for Line incoming messages to respect the workflow (reopen vs create-new-after-resolved). - Updates TikTok conversation selection behavior to respect the workflow (reopen vs create-new-after-resolved). - Keeps email behavior unchanged (always starts a new thread). Fixes: https://github.com/chatwoot/chatwoot/issues/8426 ## Screenshot <img width="1400" height="900" alt="pr11079-workflow-sender-clear-tight" src="https://github.com/user-attachments/assets/9456821f-8d83-4924-8dcf-7503c811a7b1" /> ## How To Reproduce 1. Open `Settings -> Inboxes -> <Telegram/TikTok/Instagram/Line/Facebook/WhatsApp inbox> -> Settings`. 2. Verify **Conversation workflow** is visible with the two card options. 3. Toggle between both options and save. 4. For Line and TikTok, verify resolved-conversation behavior follows the selected workflow. ## Testing - `RAILS_ENV=test bundle exec rspec spec/builders/messages/instagram/message_builder_spec.rb:213 spec/builders/messages/instagram/message_builder_spec.rb:255 spec/builders/messages/instagram/messenger/message_builder_spec.rb:228 spec/builders/messages/instagram/messenger/message_builder_spec.rb:293 spec/services/tiktok/message_service_spec.rb` - Result: `16 examples, 0 failures` ## Follow-up - Migrate Website Live Chat workflow settings into this same conversation-workflow settings model. - Add Voice channel support for this workflow setting. --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> |
||
|
|
6be95e79f8 |
feat(csat): Add WhatsApp utility template analyzer with rewrite guidance (#13575)
CSAT templates for WhatsApp are submitted as Utility, but Meta may reclassify them as Marketing based on content, which can significantly increase messaging costs. This PR introduces a Captain-powered CSAT template analyzer for WhatsApp/Twilio WhatsApp that predicts utility fit, explains likely risks, and suggests safer rewrites before submission. The flow is manual (button-triggered), Captain-gated, and applies rewrites only on explicit user action. It also updates UX copy to clearly set expectations: the system submits as Utility, Meta makes the final categorization decision. Fixes https://linear.app/chatwoot/issue/CW-6424/ai-powered-whatsapp-template-classifier-for-csat-submissions https://github.com/user-attachments/assets/8fd1d6db-2f91-447c-9771-3de271b16fd9 |
||
|
|
b220663785 |
fix: Skip notifications for private notes (#13617)
When agents or integrations create private notes on a conversation, every note was sending a notification to the assigned agent and all conversation participants. The fix ensures that private notes no longer trigger new message notifications. If someone explicitly mentions a teammate in a private note, that person will still get notified as expected. |
||
|
|
957a1b17c9 |
perf: add default configs for assignment V2 (#13577)
## Description AutoAssignment::RateLimiter#within_limit? returned true early for inboxes without an AssignmentPolicy, bypassing fair distribution entirely and allowing unlimited conversation assignment. ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## Script - Script to enable users with V2 assignment: https://www.notion.so/chatwoot/Script-to-migrate-account-to-assignment-V2-30ca5f274c9280f5b8ecfd15e28eeb9c?source=copy_link ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Shivam Mishra <scm.mymail@gmail.com> |
||
|
|
39243b9e71 |
fix: duplicate message_created webhooks for WhatsApp messages (#13523)
Some customers using WhatsApp inboxes with account-level webhooks were
reporting receiving duplicate `message_created` webhook deliveries for
every incoming message. Upon inspection, here's what we found
- Both payloads are identical.
- No errors appear in the application logs
- Webhook URL is only configured in one place.
This meant, the system was sending the webhooks twice. For some context,
there's a know related issue... Meta's WhatsApp Business API can deliver
the same webhook notification multiple times for a single message. The
codebase already acknowledges this — there's a comment in
`IncomingMessageBaseService#process_messages` noting that "multiple
webhook events can be received against the same message due to
misconfigurations in the Meta business manager account." A deduplication
guard exists, but it doesn't actually work under concurrency.
### Rationale
The existing dedup was a three-step sequence: check Redis (`GET`), check
the database, then set a Redis flag (`SETEX`). Two Sidekiq workers
processing duplicate Meta webhooks simultaneously would both complete
the `GET` before either executed the `SETEX`, so both would proceed to
create a message. The `source_id` column has a non-unique index, so the
database wouldn't catch the duplicate either. Each message then
independently fires `after_create_commit`, dispatching two
`message_created` webhook events to the customer.
```
Worker A Worker B
│ │
▼ ▼
Redis GET key ──► nil Redis GET key ──► nil
│ │
│ ◄── both pass guard ──► │
│ │
▼ ▼
Redis SETEX key Redis SETEX key
│ │
▼ ▼
BEGIN transaction BEGIN transaction
INSERT message INSERT message
DELETE Redis key ◄─┐ │
COMMIT │ DELETE Redis key
│ COMMIT
│ │
└── key gone before ───┘
B's commit lands
▼ ▼
after_create_commit after_create_commit
dispatch MESSAGE_CREATED dispatch MESSAGE_CREATED
│ │
▼ ▼
WebhookJob ──► n8n WebhookJob ──► n8n
(duplicate!)
```
There was a second, subtler problem visible in the diagram: the Redis
key was cleared *inside* the database transaction, before the
transaction committed. This opened a window where neither the Redis
check nor the database check would see the in-flight message.
The fix collapses the check-and-set into a single `SET NX EX` call,
which is atomic in Redis. The key is no longer eagerly cleared — it
expires naturally after 24 hours. The database lookup
(`find_message_by_source_id`) remains as a fallback for messages that
were created before the lock expired.
```
Worker A Worker B
│ │
▼ ▼
Redis SET NX ──► OK Redis SET NX ──► nil
│ │
▼ ▼
proceeds to create returns early
message normally (lock already held)
```
### Implementation Notes
The lock logic is extracted into `Whatsapp::MessageDedupLock`, a small
class that wraps a single `Redis SET NX EX` call. This makes the
concurrency guarantee testable in isolation — the spec uses a
`CyclicBarrier` to race two threads against the same key and asserts
exactly one wins, without needing database writes,
`use_transactional_tests = false`, or monkey-patching.
Because the Redis lock now persists (instead of being cleared
mid-transaction), existing WhatsApp specs needed an `after` hook to
clean up `MESSAGE_SOURCE_KEY::*` keys between examples. Transactional
fixtures only roll back the database, not Redis.
|
||
|
|
f4538ae2c5 |
fix: Enforce team boundaries to prevent cross-team assignments (#13353)
## Description Fixes a critical bug where conversations assigned to a team could be auto-assigned to agents outside that team when all team members were at capacity. ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes core assignment selection for both legacy and v2 flows; misconfiguration of `allow_auto_assign` or team membership could cause conversations to remain unassigned. > > **Overview** > Prevents auto-assignment from crossing team boundaries by filtering eligible agents to the conversation’s `team` members (and requiring `team.allow_auto_assign`) in both the legacy `AutoAssignmentHandler` path and the v2 `AutoAssignment::AssignmentService` (including the Enterprise override). > > Adds test coverage to ensure team-scoped conversations only assign to team members, and are skipped when team auto-assign is disabled or no team members are available; also updates the conversations controller spec setup to include team membership. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 67ed2bda0cd8ffd56c7e0253b86369dead2e6155. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> |
||
|
|
2c2f0547f7 |
fix: Captain not responding to campaign conversations (#13489)
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> |
||
|
|
7b512bd00e |
fix: V2 Assignment service enhancements (#13036)
## Linear Ticket: https://linear.app/chatwoot/issue/CW-6081/review-feedback ## Description Assignment V2 Service Enhancements - Enable Assignment V2 on plan upgrade - Fix UI issue with fair distribution policy display - Add advanced assignment feature flag and enhance Assignment V2 capabilities ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? This has been tested using the UI. ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes auto-assignment execution paths, rate limiting defaults, and feature-flag gating (including premium plan behavior), which could affect which conversations get assigned and when. UI rewires inbox settings and policy flows, so regressions are possible around navigation/linking and feature visibility. > > **Overview** > **Adds a new premium `advanced_assignment` feature flag** and uses it to gate capacity/balanced assignment features in the UI (sidebar entry, settings routes, assignment-policy landing cards) and backend (Enterprise balanced selector + capacity filtering). `advanced_assignment` is marked premium, included in Business plan entitlements, and auto-synced in Enterprise accounts when `assignment_v2` is toggled. > > **Improves Assignment V2 policy UX** by adding an inbox-level “Conversation Assignment” section (behind `assignment_v2`) that can link/unlink an assignment policy, navigate to create/edit policy flows with `inboxId` query context, and show an inbox-link prompt after creating a policy. The policy form now defaults to enabled, disables the `balanced` option with a premium badge/message when unavailable, and inbox lists support click-to-navigate. > > **Tightens/adjusts auto-assignment behavior**: bulk assignment now requires `inbox.enable_auto_assignment?`, conversation ordering uses the attached `assignment_policy` priority, and rate limiting uses `assignment_policy` config with an infinite default limit while still tracking assignments. Tests and i18n strings are updated accordingly. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 23bc03bf75ee4376071e4d7fc7cd564c601d33d7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Pranav <pranav@chatwoot.com> Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Shivam Mishra <scm.mymail@gmail.com> |
||
|
|
f83415f299 |
fix(account-deletion): normalize deleted email suffix and handle collisions safely (#13472)
## Summary This PR fixes account deletion failures by changing how orphaned user emails are rewritten during `AccountDeletionService`. Ref: https://chatwoot-p3.sentry.io/issues/6715254765/events/e228a5d045ad47348d6c32448bc33b7a/ ## Changes (develop -> this branch) - Updated soft-delete email rewrite from: - `#{original_email}-deleted.com` - To deterministic value: - `#{user.id}@chatwoot-deleted.invalid` - Added reserved non-deliverable domain constant: - `@chatwoot-deleted.invalid` - Replaced the "other accounts" check from `count.zero?` to `exists?` (same behavior, cheaper query). - Updated service spec expectation to match deterministic email value and assert it differs from original email. ## Files changed - `app/services/account_deletion_service.rb` - `spec/services/account_deletion_service_spec.rb` ## How to verify - Run: `bundle exec rspec spec/services/account_deletion_service_spec.rb` - Run: `bundle exec rubocop app/services/account_deletion_service.rb spec/services/account_deletion_service_spec.rb` |
||
|
|
6a7cbcf5ba |
fix: Fixes reply-to in WhatsApp Cloud API (#13467)
This change https://github.com/chatwoot/chatwoot/pull/13371 broke the functionality. When a user replies to a WhatsApp message, the reply context wasn't being properly stored in Chatwoot due to #13371 WhatsApp sends reply messages with a `context` field containing the original message ID: ```json { "messages": [{ "context": { "from": "phone_number", "id": "wamid.ORIGINAL_MESSAGE_ID" }, "from": "phone_number", "id": "wamid.REPLY_MESSAGE_ID", "text": { "body": "This is a reply" } }] } ``` However, the in_reply_to_external_id was being overridden when building the message because content_attributes was explicitly set to either { external_echo: true } or {}, which discarded the reply-to information. |
||
|
|
9eb3ee44a8 |
Revert "chore: Upgrade Rails to 7.2.2 and update Gemfile dependencies (#11037)"
This reverts commit
|
||
|
|
ef6ba8aabd |
chore: Upgrade Rails to 7.2.2 and update Gemfile dependencies (#11037)
Upgrade rails to 7.2.2 so that we can proceed with the rails 8 upgrade afterwards # Changelog - `.circleci/config.yml` — align CI DB setup with GitHub Actions (`db:create` + `db:schema:load`) to avoid trigger-dependent prep steps. - `.rubocop.yml` — add `rubocop-rspec_rails` and disable new cops that don't match existing spec style. - `AGENTS.md` — document that specs should run without `.env` (rename temporarily when present). - `Gemfile` — upgrade to Rails 7.2, switch Azure storage gem, pin `commonmarker`, bump `sidekiq-cron`, add `rubocop-rspec_rails`, and relax some gem pins. - `Gemfile.lock` — dependency lockfile updates from the Rails 7.2 and gem changes. - `app/controllers/api/v1/accounts/integrations/linear_controller.rb` — stringify params before passing to the Linear service to keep key types stable. - `app/controllers/super_admin/instance_statuses_controller.rb` — use `MigrationContext` API for migration status in Rails 7.2. - `app/models/installation_config.rb` — add commentary on YAML serialization and future JSONB migration (no behavior change). - `app/models/integrations/hook.rb` — ensure hook type is set on create only and guard against missing app. - `app/models/user.rb` — update enum syntax for Rails 7.2 deprecation, serialize OTP backup codes with JSON, and use Ruby `alias`. - `app/services/crm/leadsquared/setup_service.rb` — stringify hook settings keys before merge to keep JSON shape consistent. - `app/services/macros/execution_service.rb` — remove macro-specific assignee activity workaround; rely on standard assignment handlers. - `config/application.rb` — load Rails 7.2 defaults. - `config/storage.yml` — update Azure Active Storage service name to `AzureBlob`. - `db/migrate/20230515051424_update_article_image_keys.rb` — use credentials `secret_key_base` with fallback to legacy secrets. - `docker/Dockerfile` — add `yaml-dev` and `pkgconf` packages for native extensions (Ruby 3.4 / psych). - `lib/seeders/reports/message_creator.rb` — add parentheses for clarity in range calculation. - `package.json` — pin Vite version and bump `vite-plugin-ruby`. - `pnpm-lock.yaml` — lockfile changes from JS dependency updates. - `spec/builders/v2/report_builder_spec.rb` — disable transactional fixtures; truncate tables per example via Rails `truncate_tables` so after_commit callbacks run with clean isolation; keep builder spec metadata minimal. - `spec/builders/v2/reports/label_summary_builder_spec.rb` — disable transactional fixtures + truncate tables via Rails `truncate_tables`; revert to real `resolved!`/`open!`/`resolved!` flow for multiple resolution events; align date range to `Time.zone` to avoid offset gaps; keep builder spec metadata minimal. - `spec/controllers/api/v1/accounts/macros_controller_spec.rb` — assert `assignee_id` instead of activity message to avoid transaction-timing flakes. - `spec/services/telegram/incoming_message_service_spec.rb` — reference the contact tied to the created conversation instead of `Contact.all.first` to avoid order-dependent failures when other specs leave data behind. - `spec/mailers/administrator_notifications/shared/smtp_config_shared.rb` — use `with_modified_env` instead of stubbing mailer internals. - `spec/services/account/sign_up_email_validation_service_spec.rb` — compare error `class.name` for parallel/reload-safe assertions. |
||
|
|
c884cdefde |
feat: add per-account daily rate limit for outbound emails (#13411)
Introduce a daily cap on non-channel outbound emails to prevent abuse. Fixes https://linear.app/chatwoot/issue/CW-6418/ses-incident-jan-28 ## Type of change - [x] New feature (non-breaking change which adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality not to work as expected) ## Summary - Adds a Redis-based daily counter to rate limit outbound emails per account, preventing email abuse - Covers continuity emails (WebWidget/API), conversation transcripts, and agent notifications - Email channel replies are excluded (paid feature, not abusable) - Adds account suspension check in `ConversationReplyMailer` to block already-queued emails for suspended accounts ## Limit Resolution Hierarchy 1. Per-account override (`account.limits['emails']`) — SuperAdmin configurable 2. Enterprise plan-based (`ACCOUNT_EMAILS_PLAN_LIMITS` InstallationConfig) 3. Global default (`ACCOUNT_EMAILS_LIMIT` InstallationConfig, default: 100) 4. Fallback (`ChatwootApp.max_limit` — effectively unlimited) ## Enforcement Points | Path | Where | Behavior | |------|-------|----------| | WebWidget/API continuity | `SendEmailNotificationService#should_send_email_notification?` | Silently skipped | | Widget transcript | `Widget::ConversationsController#transcript` | Returns 429 | | API transcript | `ConversationsController#transcript` | Returns 429 | | Agent notifications | `Notification::EmailNotificationService#perform` | Silently skipped | | Email channel replies | Not rate limited | Paid feature | | Suspended accounts | `ConversationReplyMailer` | Blocked at mailer level | |
||
|
|
c77d935e38 |
fix: Subscribe app to WABA before overriding webhook callback URL (#13279)
#### Problem
Meta requires the app to be subscribed to the WABA before
`override_callback_uri` can be used. The current implementation tries to
use `override_callback_uri` directly, which fails with:
> Error 100: "Before override the current callback uri, your app must be
subscribed to receive messages for WhatsApp Business Account"
This causes embedded signup to fail silently, the inbox appears
connected but never receives messages.
#### Solution
Split `subscribe_waba_webhook` into two sequential API calls:
```ruby
def subscribe_waba_webhook(waba_id, callback_url, verify_token)
# Step 1: Subscribe app to WABA first (required before override)
subscribe_app_to_waba(waba_id)
# Step 2: Override callback URL for this specific WABA
override_waba_callback(waba_id, callback_url, verify_token)
end
```
#### References
- Subscribe app to WABA's webhooks: https://www.postman.com/meta/whatsapp-business-platform/request/ju40fld/subscribe-app-to-waba-s-webhooks
- Override Callback URL (Embedded Signup): https://www.postman.com/meta/whatsapp-business-platform/request/l6a09ow/override-callback-url
Co-authored-by: Sojan Jose <sojan@pepalo.com>
|
||
|
|
b686d14044 |
feat: Handle external echo messages from native apps (#13371)
When businesses use WhatsApp Business App (co-existence mode) or Instagram App or TikTok alongside Chatwoot, messages sent from the native apps were not synced properly back to Chatwoot. This left agents with an incomplete conversation history and no visibility into responses sent outside the dashboard. Additionally, if these echo messages did arrive, they appeared as "Sent by: Bot" in the UI since they had no sender, making it confusing for agents. This PR subscribes to WhatsApp `smb_message_echoes` webhook events and routes them through the existing service with an `outgoing_echo` flag, mirroring how Instagram already handles echoes. On the Instagram side, echo messages now also carry the `external_echo` content attribute and `delivered` status. On the frontend, messages with `externalEcho` are distinguished from bot messages showing a "Native app" avatar and an advisory note encouraging agents to reply from Chatwoot to maintain the service window. <img width="1518" height="524" alt="CleanShot 2026-01-29 at 13 37 57@2x" src="https://github.com/user-attachments/assets/5aa0b552-6382-441f-96aa-9a62ca716e4a" /> Fixes https://linear.app/chatwoot/issue/CW-4204/display-messages-not-sent-from-chatwoot-in-case-of-outgoing-echo Fixes https://linear.app/chatwoot/issue/PLA-33/incoming-from-me-messages-from-whatsapp-business-app-are-not-falling |
||
|
|
0d9c0b2ed2 |
fix: Force account_id in the query (#13388)
### What Forces `account_id` to be applied consistently in queries and message creation paths. ### Why Some queries were missing `account_id`, leading to cross-account scans and slow performance in large datasets. ### Changes * Added `account_id` to the relevant query columns. * Ensured messages are always created within the correct account scope. * Updated `created_at` handling where required for consistency. ### Impact * Prevents cross-account queries. * Improves query performance. * Reduces risk of incorrect data access across accounts. ### Notes No functional behavior change for end users. This is a performance and safety fix. |
||
|
|
9e97cc0cdd |
fix(whatsapp): Preserve ordered list numbering in messages (#13339)
- Fix ordered lists being sent as unordered lists in WhatsApp integrations - The WhatsApp markdown renderer was converting all lists to bullet points (-), ignoring numbered list formatting - Added ordered list support by tracking list_type and list_item_number from CommonMarker AST metadata Before: Input: "1. First\n2. Second\n3. Third" Output: "- First\n- Second\n- Third" After: Input: "1. First\n2. Second\n3. Third" Output: "1. First\n2. Second\n3. Third" |
||
|
|
457430e8d9 |
fix: Remove phone_number_id param from WhatsApp media retrieval for incoming messages (#13319)
Fixes https://github.com/chatwoot/chatwoot/issues/13317 Fixes an issue where WhatsApp attachment messages (images, audio, video, documents) were failing to download. Messages were being created but without attachments. The `phone_number_id` parameter was being passed to the `GET /<MEDIA_ID>` endpoint when downloading incoming media. According to Meta's documentation: > "Note that `phone_number_id` is optional. If included, the request will only be processed if the business phone number ID included in the query matches the ID of the business phone number **that the media was uploaded on**." For incoming messages, media is uploaded by the customer, not by the business phone number. Passing the business's `phone_number_id` causes validation to fail with error: `Param phone_number_id is not a valid whatsapp business phone number id ID` This PR removes the `phone_number_id` parameter from the media URL request for incoming messages. |
||
|
|
7e4d93f649 |
fix: Setup webhooks for manual WhatsApp Cloud channel creation (#13278)
Fixes https://github.com/chatwoot/chatwoot/issues/13097 ### Problem The PR #12176 removed the `before_save :setup_webhooks` callback to fix a race condition where Meta's webhook verification request arrived before the channel was saved to the database. This change broke manual WhatsApp Cloud channel setup. While embedded signup explicitly calls `channel.setup_webhooks` in `EmbeddedSignupService`, manual setup had no equivalent call - meaning the `subscribed_apps` endpoint was never invoked and Meta never sent webhook events to Chatwoot. ### Solution Added an `after_commit` callback that triggers webhook setup for manual WhatsApp Cloud channels |
||
|
|
96b5780ea7 |
fix: Respect survey label rules for WhatsApp CSAT template (#13285)
Ensure CSAT survey label rules are evaluated once in CsatSurveyService before any channel-specific sending (including WhatsApp/Twilio templates), remove the duplicated rule check from the template builder, and cover the blocking-label scenario in service specs while simplifying the template specs accordingly. Co-authored-by: Sojan Jose <sojan@pepalo.com> |
||
|
|
c483034a07 |
feat: Add support for sending CSAT surveys via templates (Whatsapp Twilio) (#13143)
Fixes https://linear.app/chatwoot/issue/CW-6189/support-for-sending-csat-surveys-via-approved-whatsapp --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> |
||
|
|
7b51939f07 |
fix: country_code should be checked against the contact (#13186)
|
||
|
|
59cbf57e20 |
feat: Advanced Search Backend (#12917)
## Description
Implements comprehensive search functionality with advanced filtering
capabilities for Chatwoot (Linear: CW-5956).
This PR adds:
1. **Time-based filtering** for contacts and conversations (SQL-based
search)
2. **Advanced message search** with multiple filters
(OpenSearch/Elasticsearch-based)
- **`from` filter**: Filter messages by sender (format: `contact:42` or
`agent:5`)
- **`inbox_id` filter**: Filter messages by specific inbox
- **Time range filters**: Filter messages using `since` and `until`
parameters (Unix timestamps in seconds)
- **90-day limit enforcement**: Automatically limits searches to the
last 90 days to prevent performance issues
The implementation extends the existing `Enterprise::SearchService`
module for advanced features and adds time filtering to the base
`SearchService` for SQL-based searches.
## API Documentation
### Base URL
All search endpoints follow this pattern:
```
GET /api/v1/accounts/{account_id}/search/{resource}
```
### Authentication
All requests require authentication headers:
```
api_access_token: YOUR_ACCESS_TOKEN
```
---
## 1. Search All Resources
**Endpoint:** `GET /api/v1/accounts/{account_id}/search`
Returns results from all searchable resources (contacts, conversations,
messages, articles).
### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `since` | integer | Unix timestamp (contacts/conversations only) | No
|
| `until` | integer | Unix timestamp (contacts/conversations only) | No
|
### Example Request
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search?q=customer" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
### Example Response
```json
{
"payload": {
"contacts": [...],
"conversations": [...],
"messages": [...],
"articles": [...]
}
}
```
---
## 2. Search Contacts
**Endpoint:** `GET /api/v1/accounts/{account_id}/search/contacts`
Search contacts by name, email, phone number, or identifier with
optional time filtering.
### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `since` | integer | Unix timestamp - filter by last_activity_at | No |
| `until` | integer | Unix timestamp - filter by last_activity_at | No |
### Example Requests
**Basic search:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/contacts?q=john" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search contacts active in the last 7 days:**
```bash
SINCE=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/contacts?q=john&since=${SINCE}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search contacts active between 30 and 7 days ago:**
```bash
SINCE=$(date -v-30d +%s)
UNTIL=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/contacts?q=john&since=${SINCE}&until=${UNTIL}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
### Example Response
```json
{
"payload": {
"contacts": [
{
"id": 42,
"email": "john@example.com",
"name": "John Doe",
"phone_number": "+1234567890",
"identifier": "user_123",
"additional_attributes": {},
"created_at": 1701234567
}
]
}
}
```
---
## 3. Search Conversations
**Endpoint:** `GET /api/v1/accounts/{account_id}/search/conversations`
Search conversations by display ID, contact name, email, phone number,
or identifier with optional time filtering.
### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `since` | integer | Unix timestamp - filter by last_activity_at | No |
| `until` | integer | Unix timestamp - filter by last_activity_at | No |
### Example Requests
**Basic search:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/conversations?q=billing" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search conversations active in the last 24 hours:**
```bash
SINCE=$(date -v-1d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/conversations?q=billing&since=${SINCE}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search conversations from last month:**
```bash
SINCE=$(date -v-30d +%s)
UNTIL=$(date +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/conversations?q=billing&since=${SINCE}&until=${UNTIL}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
### Example Response
```json
{
"payload": {
"conversations": [
{
"id": 123,
"display_id": 45,
"inbox_id": 1,
"status": "open",
"messages": [...],
"meta": {...}
}
]
}
}
```
---
## 4. Search Messages (Advanced)
**Endpoint:** `GET /api/v1/accounts/{account_id}/search/messages`
Advanced message search with multiple filters powered by
OpenSearch/Elasticsearch.
### Prerequisites
- OpenSearch/Elasticsearch must be running (`OPENSEARCH_URL` env var
configured)
- Account must have `advanced_search` feature flag enabled
- Messages must be indexed in OpenSearch
### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
| `from` | string | Filter by sender: `contact:{id}` or `agent:{id}` |
No |
| `inbox_id` | integer | Filter by specific inbox ID | No |
| `since` | integer | Unix timestamp - searches from this time (max 90
days ago) | No |
| `until` | integer | Unix timestamp - searches until this time | No |
### Important Notes
- **90-Day Limit**: If `since` is not provided, searches default to the
last 90 days
- If `since` exceeds 90 days, returns `422` error: "Search is limited to
the last 90 days"
- All time filters use message `created_at` timestamp
### Example Requests
**Basic message search:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search messages from a specific contact:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&from=contact:42" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search messages from a specific agent:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&from=agent:5" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search messages in a specific inbox:**
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&inbox_id=3" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search messages from the last 7 days:**
```bash
SINCE=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&since=${SINCE}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Search messages between specific dates:**
```bash
SINCE=$(date -v-30d +%s)
UNTIL=$(date -v-7d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&since=${SINCE}&until=${UNTIL}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Combine all filters:**
```bash
SINCE=$(date -v-14d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&from=contact:42&inbox_id=3&since=${SINCE}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
**Attempt to search beyond 90 days (returns error):**
```bash
SINCE=$(date -v-120d +%s)
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/messages?q=refund&since=${SINCE}" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
### Example Response (Success)
```json
{
"payload": {
"messages": [
{
"id": 789,
"content": "I need a refund for my purchase",
"message_type": "incoming",
"created_at": 1701234567,
"conversation_id": 123,
"inbox_id": 3,
"sender": {
"id": 42,
"type": "contact"
}
}
]
}
}
```
### Example Response (90-day limit exceeded)
```json
{
"error": "Search is limited to the last 90 days"
}
```
**Status Code:** `422 Unprocessable Entity`
---
## 5. Search Articles
**Endpoint:** `GET /api/v1/accounts/{account_id}/search/articles`
Search help center articles by title or content.
### Parameters
| Parameter | Type | Description | Required |
|-----------|------|-------------|----------|
| `q` | string | Search query | Yes |
| `page` | integer | Page number (15 items per page) | No |
### Example Request
```bash
curl -X GET "https://app.chatwoot.com/api/v1/accounts/1/search/articles?q=installation" \
-H "api_access_token: YOUR_ACCESS_TOKEN"
```
### Example Response
```json
{
"payload": {
"articles": [
{
"id": 456,
"title": "Installation Guide",
"slug": "installation-guide",
"portal_slug": "help",
"account_id": 1,
"category_name": "Getting Started",
"status": "published",
"updated_at": 1701234567
}
]
}
}
```
---
## Technical Implementation
### SQL-Based Search (Contacts, Conversations, Articles)
- Uses PostgreSQL `ILIKE` queries by default
- Optional GIN index support via `search_with_gin` feature flag for
better performance
- Time filtering uses `last_activity_at` for contacts/conversations
- Returns paginated results (15 per page)
### Advanced Search (Messages)
- Powered by OpenSearch/Elasticsearch via Searchkick gem
- Requires `OPENSEARCH_URL` environment variable
- Requires `advanced_search` account feature flag
- Enforces 90-day lookback limit via
`Limits::MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS`
- Validates inbox access permissions before filtering
- Returns paginated results (15 per page)
---
## Type of change
- [x] New feature (non-breaking change which adds functionality)
- [x] Enhancement (improves existing functionality)
---
## How Has This Been Tested?
### Unit Tests
- **Contact Search Tests**: 3 new test cases for time filtering
(`since`, `until`, combined)
- **Conversation Search Tests**: 3 new test cases for time filtering
- **Message Search Tests**: 10+ test cases covering:
- Individual filters (`from`, `inbox_id`, time range)
- Combined filters
- Permission validation for inbox access
- Feature flag checks
- 90-day limit enforcement
- Error handling for exceeded time limits
### Test Commands
```bash
# Run all search controller tests
bundle exec rspec spec/controllers/api/v1/accounts/search_controller_spec.rb
# Run search service tests (includes enterprise specs)
bundle exec rspec spec/services/search_service_spec.rb
```
### Manual Testing Setup
A rake task is provided to create 50,000 test messages across multiple
inboxes:
```bash
# 1. Create test data
bundle exec rake search:setup_test_data
# 2. Start OpenSearch
mise elasticsearch-start
# 3. Reindex messages
rails runner "Message.search_index.import Message.all"
# 4. Enable feature flag
rails runner "Account.first.enable_features('advanced_search')"
# 5. Test via API or Rails console
```
---
## Checklist
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation (this PR
description)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
## Additional Notes
### Requirements
- **OpenSearch/Elasticsearch**: Required for advanced message search
- Set `OPENSEARCH_URL` environment variable
- Example: `export OPENSEARCH_URL=http://localhost:9200`
- **Feature Flags**:
- `advanced_search`: Account-level flag for message advanced search
- `search_with_gin` (optional): Account-level flag for GIN-based SQL
search
### Performance Considerations
- 90-day limit prevents expensive long-range queries on large datasets
- GIN indexes recommended for high-volume search on SQL-based resources
- OpenSearch/Elasticsearch provides faster full-text search for messages
### Breaking Changes
- None. All new parameters are optional and backward compatible.
### Frontend Integration
- Frontend PR tracking advanced search UI will consume these endpoints
- Time range pickers should convert JavaScript `Date` to Unix timestamps
(seconds)
- Date conversion: `Math.floor(date.getTime() / 1000)`
### Error Handling
- Invalid `from` parameter format is silently ignored (filter not
applied)
- Time range exceeding 90 days returns `422` with error message
- Missing `q` parameter returns `422` (existing behavior)
- Unauthorized inbox access is filtered out (no error, just excluded
from results)
---------
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
|
||
|
|
3e5b2979eb |
feat: Add support for sending CSAT surveys via templates (Whatsapp Cloud) (#12787)
This PR enables sending CSAT surveys on WhatsApp using approved WhatsApp message templates, ensuring survey delivery even after the 24-hour session window. The system now automatically creates, updates, and monitors WhatsApp CSAT templates without manual intervention. <img width="1664" height="1792" alt="approved" src="https://github.com/user-attachments/assets/c6efd61e-1d01-4738-abb6-0afc0dace975" /> #### Why this change Previously, WhatsApp CSAT messages failed outside the 24-hour customer window. With this update: - CSAT surveys are delivered reliably using WhatsApp templates - Template creation happens automatically in the background - Users can modify survey content and recreate templates easily - Clear UI states show template approval status #### Screens & States <details> <summary>Default — No template configured yet</summary> <img width="1662" height="1788" alt="default" src="https://github.com/user-attachments/assets/ed26d71b-cf7c-4a26-a2af-da88772c847c" /> </details> <details> <summary>Pending — Template submitted, awaiting Meta approval</summary> <img width="1658" height="1816" alt="pending" src="https://github.com/user-attachments/assets/923b789b-d91b-4364-905d-e56a2b65331a" /> </details> <details> <summary>Approved — Survey will be sent when conversation resolves</summary> <img width="1664" height="1792" alt="approved" src="https://github.com/user-attachments/assets/c6efd61e-1d01-4738-abb6-0afc0dace975" /> </details> <details> <summary>Rejected — Template rejected by Meta</summary> <img width="1672" height="1776" alt="rejected" src="https://github.com/user-attachments/assets/f69a9b0e-be27-4e67-a993-7b8149502c4f" /> </details> <details> <summary>Not Found — Template missing in Meta Platform</summary> <img width="1660" height="1784" alt="not-exist" src="https://github.com/user-attachments/assets/a2a4b4f7-b01a-4424-8fcb-3ed84256e057" /> </details> <details> <summary>Edit Template — Delete & recreate template on change</summary> <img width="2342" height="1778" alt="edit-survey" src="https://github.com/user-attachments/assets/0f999285-0341-4226-84e9-31f0c6446924" /> </details> #### Test Cases **1. First-time CSAT setup on WhatsApp inbox** - Enable CSAT - Enter message + button text - Save - Expected: Template created automatically, UI shows pending state **2. CSAT toggle without changing text** - Existing approved template - Toggle CSAT OFF → ON (no text change) - Expected: No confirmation alert, no template recreation **3. Editing only survey rules** - Modify labels or rule conditions only - Expected: No confirmation alert, template remains unchanged **4. Template text change** - Change survey message or button text - Save - Expected: - Confirmation dialog shown - On confirm → previous template deleted, new one created - On cancel → revert to previous values **5. Language change** - Change template language (e.g., en → es) - Expected: Confirmation dialog + new template on confirm **6. Sending survey** - Template approved → always send template - Template pending → send free-form within 24 hours only - Template rejected/missing → fallback to free-form (if within window) - Outside 24 hours & no approved template → activity log only **7. Non-WhatsApp inbox** - Enable CSAT for email/web inbox - Expected: No template logic triggered Fixes https://linear.app/chatwoot/issue/CW-6188/support-for-sending-csat-surveys-via-approved-whatsapp --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> |