Compare commits

...
Author SHA1 Message Date
aakashb95 9b43d4f952 add: display_id for contact attributes for completeness 2026-01-09 17:24:13 +05:30
aakashb95 6f307c2fab add: audio transcription to rubyllm and instrumentation 2026-01-09 17:23:37 +05:30
PranavandGitHub ded2f2751a fix: Rendering of translations based on the user's locale (#13211)
Previously, translations were generated and resolved purely based on the
account locale. This caused issues in multi-team, multi-region setups
where agents often work in different languages than the account default.

For example, an account might be set to English, while an agent prefers
Spanish. In this setup:
- Translations were always created using the account locale.
- Agents could not view content in their preferred language.
- This did not scale well for global teams.

There was also an issue with locale resolution during rendering, where
the system would incorrectly default to the account locale even when a
more appropriate locale should have been used.

With this update, During rendering, the system first attempts to use the
agent’s locale. If a translation for that locale does not exist, it
falls back to the account locale.


**How to test:**

- Set agent locale to a specific language (e.g., zh_CN) and account
language to en.
  - Translate a message.
- Verify translated content displays correctly for the agent's selected
locale
  - Do the same for another locale for agent.
- With multiple translations on a message (e.g., zh_CN, es, ml), verify
the UI shows the one matching agent's locale
- Change agent locale and verify the displayed translation updates
accordingly
2026-01-08 18:37:42 -08:00
Sivin VargheseandGitHub ed0e87405c fix: Strip autolinks <...> when links are not supported (#13204)
# Pull Request Template

## Description

This PR fixes the crash that occurred when inserting canned responses
containing **autolinks** (e.g. `<https://example.com>`) into reply
channels that **do not support links**, such as **Twilio SMS**.

### Steps to reproduce

1. Create a canned response with an autolink, for example:
`<https://example.com>`.
2. Open a conversation in a channel that does not support links (e.g.
SMS).
3. Insert the canned response into the reply box.

### Cause

* Currently, only standard markdown links (`[text](url)`) are handled
when stripping unsupported formats from canned responses.
* Autolinks (`<https://example.com>`) are not handled during this
process.
* As a result, **Error: Token type link_open not supported by Markdown
parser**

### Solution

* Extended the markdown link parsing logic to explicitly handle
**autolinks** in addition to standard markdown links.
* When a canned response containing an autolink is inserted into a reply
box for a channel that does not support links (e.g. SMS), the angle
brackets (`< >`) are stripped.
* The autolink is safely pasted as **plain text URL**, preventing parser
errors and editor crashes.



Fixes
https://linear.app/chatwoot/issue/CW-6256/error-token-type-link-open-not-supported-by-markdown-parser
Sentry issues
[[1](https://chatwoot-p3.sentry.io/issues/7103543778/?environment=production&project=4507182691975168&query=is%3Aunresolved%20markdown&referrer=issue-stream)],
[[2](https://chatwoot-p3.sentry.io/issues/7104325962/?environment=production&project=4507182691975168&query=is%3Aunresolved%20markdown&referrer=issue-stream
)]


## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)


## 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
- [ ] 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
2026-01-08 18:37:52 +04:00
92422f979d chore: Replace plain editor with advanced editor (#13071)
# Pull Request Template

## Description

This PR reverts the plain text editor back to the **advanced editor**,
which was previously removed in
[https://github.com/chatwoot/chatwoot/pull/13058](https://github.com/chatwoot/chatwoot/pull/13058).
All channels now use the **ProseMirror editor**, with formatting applied
based on each channel’s configuration.

This PR also fixes issues where **new lines were not properly preserved
during Markdown serialization**, for both:

* `Enter or CMD/Ctrl+enter` (new paragraph)
* `Shift+Enter` (`hard_break`)

Additionally, it resolves related **[Sentry
issue](https://chatwoot-p3.sentry.io/issues/?environment=production&project=4507182691975168&query=is%3Aunresolved%20markdown&referrer=issue-list&statsPeriod=7d)**.

With these changes:

* Line breaks and spacing are now preserved correctly when saving canned
responses.
* When editing a canned response, the content retains the exact spacing
and formatting as saved in editor.
* Canned responses are now correctly converted to plain text where
required and displayed consistently in the canned response list.

### https://github.com/chatwoot/prosemirror-schema/pull/38

---

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?



## 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
- [ ] 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: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-08 15:17:54 +05:30
Aakash BakhleandGitHub dcc72ee63c fix: consistent instrumentation with conversation.display_id (#13194) 2026-01-08 12:27:44 +05:30
PranavandGitHub 7ba7bf842e fix: Use SignedId instead of regular ID in portal update (#13197)
The Active Storage blob ids were handled inconsistently across the API.

- When a new logo was uploaded, the upload controller returned a
signed_id
- When loading an existing portal that already had a logo, the portal
model returned a blob_id.

This caused the API portal API to fail. There are about 107 instances of
this in production, so this change will fix that.
2026-01-07 19:36:29 -08:00
PranavandGitHub 69be587729 chore: Update copyright year in README.md to 2026 (#13195)
Basically what the title says
2026-01-07 17:42:41 -08:00
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>
2026-01-07 15:30:49 +05:30
Shivam MishraandGitHub 566de02385 feat: allow agent bot and captain responses to reset waiting since (#13181)
When AgentBot responds to customer messages, the `waiting_since`
timestamp is not reset, causing inflated reply time metrics when a human
agent eventually responds. This results in inaccurate reporting that
incorrectly includes periods when customers were satisfied with bot
responses.

### Timeline from Production Data

```
Dec 12, 16:20:14 - Customer sends message (ID: 368451924)
                   ↓ waiting_since = Dec 12, 16:20:14

Dec 12, 16:20:17 - AgentBot replies (ID: 368451960)
                   ↓ waiting_since STILL = Dec 12, 16:20:14 ❌
                   ↓ (Bot response doesn't clear it)

14-day gap        - Customer satisfied, no messages
                   ↓ waiting_since STILL = Dec 12, 16:20:14 ❌

Dec 26, 22:25:45 - Customer sends new message (ID: 383522275)
                   ↓ waiting_since STILL = Dec 12, 16:20:14 ❌
                   ↓ (New message doesn't reset it)

Dec 26-27         - More AgentBot interactions
                   ↓ waiting_since STILL = Dec 12, 16:20:14 ❌

Dec 27, 07:36:53 - Human agent finally replies (ID: 383799517)
                   ↓ Reply time calculated: 1,268,404 seconds
                   ↓ = 14.7 DAYS ❌
```
## Root Cause

The core issues is in `app/models/message.rb`, where **AgentBot messages
does not clear `waiting_since`** - The `human_response?` method only
returns true for `User` senders, so bot replies never trigger the
clearing logic. This means once `waiting_since` is set, it stays set
even when customers send new messages after receiving bot responses.

The solution is to simply reset `waiting_since` **after a bot has
responded**. This ensures reply time metrics reflect actual human agent
response times, not bot-handled periods.

### What triggers the rest

This is an intentional "gotcha", that only `AgentBot` and
`Captain::Assistant` messages trigger the waiting time reset. Automation
and campaign messages maintain current behavior (no reset). This is
because interactive bot assistants provide conversational help that
might satisfy customers. Automation and campaigns are one-way
communications and shouldn't affect waiting time calculations.

## Related Work

Extends PR #11787 which fixed `waiting_since` clearing on conversation
resolution. This PR addresses the bot interaction scenario which was not
covered by that fix.

Scripts to clean data:
https://gist.github.com/scmmishra/bd133208e219d0ab52fbfdf03036c48a
2026-01-07 13:57:43 +05:30
Shivam MishraandGitHub 02ab856520 feat(CW-6187): include headers from incoming emails (#13139) 2026-01-07 12:45:54 +05:30
Tanmay Deep SharmaandGitHub e58600d1b9 fix: the webhook url to be text (#13157)
## Description
Change the url type from string to text, to support more than 255
characters

Fixes # (issue)
https://app.chatwoot.com/app/accounts/1/conversations/65240

## Type of change

Please delete options that are not relevant.

- [ ] 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
2026-01-06 15:23:54 +05:30
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>
2026-01-06 11:46:00 +04:00
Muhsin KelothandGitHub bd698cb12c feat: Add call-to-action template support for Twilio (#13179)
Fixes
https://linear.app/chatwoot/issue/CW-6228/add-call-to-action-template-support-for-twilio-whatsapp-templates

Adds support for Twilio WhatsApp call-to-action templates, enabling
customers to use URL button templates with variable inputs.

<img width="2982" height="1388" alt="CleanShot 2026-01-05 at 16 25
55@2x"
src="https://github.com/user-attachments/assets/7cf332f5-3f3e-4ffb-a461-71c60a0156c8"
/>
2026-01-06 10:38:36 +04:00
PranavandGitHub 79381a4c5f fix: Add code_block method to WhatsApp and Instagram markdown renderers (#13166)
Problem: SystemStackError: stack level too deep occurred when rendering
messages with indented content (4+ spaces) for WhatsApp, Instagram, and
Facebook channels.

Root Cause: CommonMarker::Renderer#code_block contains a self-recursive
placeholder that must be overridden:
```
  def code_block(node)
    code_block(node)  # calls itself infinitely
  end
```

WhatsAppRenderer and InstagramRenderer were missing this override,
causing infinite recursion when markdown with 4-space indentation
(interpreted as code blocks) was rendered.

Fix: Added code_block method to both renderers that outputs the node
content as plain text:
```
  def code_block(node)
    out(node.string_content)
  end
 ```

Fix https://linear.app/chatwoot/issue/CW-6217/systemstackerror-stack-level-too-deep-systemstackerror
2025-12-30 11:25:54 -08:00
d57170712d fix: increase the alfred connection pool size to 10 (#13138)
## Description
Increased the alfred pool size to 10, so that each worker has enough
redis connection thread pool to work

## Type of change

Please delete options that are not relevant.

- [ ] 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]
> Introduces configurability for the Redis pool used by `alfred`.
> 
> - `$alfred` `ConnectionPool` size now reads from
`ENV['REDIS_ALFRED_SIZE']` with a default of `5`
> - Adds `REDIS_ALFRED_SIZE=10` to `.env.example`
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
96cdff8c0ea40f82a57d70be053780e87384ed47. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: tanmay <tanmay-chatwoot@tanmays-MacBook-Pro.local>
2025-12-23 13:59:20 +07:00
Sojan Jose e7be8c14bd Merge branch 'release/4.9.1' into develop 2025-12-22 18:56:15 -08:00
102 changed files with 5129 additions and 712 deletions
+44
View File
@@ -218,6 +218,49 @@ jobs:
source ~/.rvm/scripts/rvm
bundle install
# Install and configure OpenSearch
- run:
name: Install OpenSearch
command: |
# Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients)
wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz
tar -xzf opensearch-2.11.0-linux-x64.tar.gz
sudo mv opensearch-2.11.0 /opt/opensearch
- run:
name: Configure and Start OpenSearch
command: |
# Configure OpenSearch for single-node testing
cat > /opt/opensearch/config/opensearch.yml \<< EOF
cluster.name: chatwoot-test
node.name: node-1
network.host: 0.0.0.0
http.port: 9200
discovery.type: single-node
plugins.security.disabled: true
EOF
# Set ownership and permissions
sudo chown -R $USER:$USER /opt/opensearch
# Start OpenSearch in background
/opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid
- run:
name: Wait for OpenSearch to be ready
command: |
echo "Waiting for OpenSearch to start..."
for i in {1..30}; do
if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then
echo "OpenSearch is ready!"
exit 0
fi
echo "Waiting... ($i/30)"
sleep 2
done
echo "OpenSearch failed to start"
exit 1
# Configure environment and database
- run:
name: Database Setup and Configure Environment Variables
@@ -234,6 +277,7 @@ jobs:
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
# Database setup
- run:
+2
View File
@@ -274,3 +274,5 @@ AZURE_APP_SECRET=
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
+2 -2
View File
@@ -191,10 +191,10 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.7.0'
gem 'ai-agents', '>= 0.8.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm', '>= 1.9.1'
gem 'ruby_llm-schema'
# OpenTelemetry for LLM observability
+18 -18
View File
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.7.0)
ruby_llm (~> 1.8.2)
ai-agents (0.8.0)
ruby_llm (~> 1.9.1)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -297,7 +297,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.13.1)
faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -306,14 +306,14 @@ GEM
faraday-mashify (1.0.0)
faraday (~> 2.0)
hashie
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.2)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.2.1)
faraday-retry (2.4.0)
faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0)
@@ -463,7 +463,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.13.2)
json (2.18.0)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -538,7 +538,7 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.0.4)
marcel (1.1.0)
maxminddb (0.1.22)
meta_request (0.8.3)
rack-contrib (>= 1.1, < 3)
@@ -556,12 +556,12 @@ GEM
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.6.0)
uri
net-http (0.9.1)
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.20)
@@ -822,7 +822,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.8.2)
ruby_llm (1.9.1)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -966,7 +966,7 @@ GEM
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.0.4)
uri (1.1.1)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@@ -1001,7 +1001,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.17)
zeitwerk (2.7.4)
PLATFORMS
arm64-darwin-20
@@ -1021,7 +1021,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.7.0)
ai-agents (>= 0.8.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1123,7 +1123,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm (>= 1.8.2)
ruby_llm (>= 1.9.1)
ruby_llm-schema
scout_apm
scss_lint
+1 -2
View File
@@ -8,7 +8,6 @@ ___
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
@@ -137,4 +136,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2025, Chatwoot Inc - Released under the MIT License.
*Chatwoot* &copy; 2017-2026, Chatwoot Inc - Released under the MIT License.
@@ -28,5 +28,7 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
search_type: search_type,
params: params
).perform
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
end
+10
View File
@@ -32,6 +32,16 @@ class Inboxes extends CacheEnabledApiClient {
syncTemplates(inboxId) {
return axios.post(`${this.url}/${inboxId}/sync_templates`);
}
createCSATTemplate(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template`, {
template,
});
}
getCSATTemplateStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/csat_template`);
}
}
export default new Inboxes();
+14 -4
View File
@@ -14,38 +14,48 @@ class SearchAPI extends ApiClient {
});
}
contacts({ q, page = 1 }) {
contacts({ q, page = 1, since, until }) {
return axios.get(`${this.url}/contacts`, {
params: {
q,
page: page,
since,
until,
},
});
}
conversations({ q, page = 1 }) {
conversations({ q, page = 1, since, until }) {
return axios.get(`${this.url}/conversations`, {
params: {
q,
page: page,
since,
until,
},
});
}
messages({ q, page = 1 }) {
messages({ q, page = 1, since, until, from, inboxId }) {
return axios.get(`${this.url}/messages`, {
params: {
q,
page: page,
since,
until,
from,
inbox_id: inboxId,
},
});
}
articles({ q, page = 1 }) {
articles({ q, page = 1, since, until }) {
return axios.get(`${this.url}/articles`, {
params: {
q,
page: page,
since,
until,
},
});
}
@@ -0,0 +1,134 @@
import searchAPI from '../search';
import ApiClient from '../ApiClient';
describe('#SearchAPI', () => {
it('creates correct instance', () => {
expect(searchAPI).toBeInstanceOf(ApiClient);
expect(searchAPI).toHaveProperty('get');
expect(searchAPI).toHaveProperty('contacts');
expect(searchAPI).toHaveProperty('conversations');
expect(searchAPI).toHaveProperty('messages');
expect(searchAPI).toHaveProperty('articles');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
get: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
vi.clearAllMocks();
});
it('#get', () => {
searchAPI.get({ q: 'test query' });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', {
params: { q: 'test query' },
});
});
it('#contacts', () => {
searchAPI.contacts({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
params: { q: 'test', page: 1, since: undefined, until: undefined },
});
});
it('#contacts with date filters', () => {
searchAPI.contacts({
q: 'test',
page: 2,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
});
});
it('#conversations', () => {
searchAPI.conversations({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/search/conversations',
{
params: { q: 'test', page: 1, since: undefined, until: undefined },
}
);
});
it('#conversations with date filters', () => {
searchAPI.conversations({
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/search/conversations',
{
params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 },
}
);
});
it('#messages', () => {
searchAPI.messages({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
params: {
q: 'test',
page: 1,
since: undefined,
until: undefined,
from: undefined,
inbox_id: undefined,
},
});
});
it('#messages with all filters', () => {
searchAPI.messages({
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
from: 'contact:42',
inboxId: 10,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
params: {
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
from: 'contact:42',
inbox_id: 10,
},
});
});
it('#articles', () => {
searchAPI.articles({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
params: { q: 'test', page: 1, since: undefined, until: undefined },
});
});
it('#articles with date filters', () => {
searchAPI.articles({
q: 'test',
page: 2,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
});
});
});
});
@@ -19,7 +19,7 @@ const handleClick = () => {
<template>
<div
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 py-5"
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
menuItems: {
@@ -37,9 +38,13 @@ const props = defineProps({
type: String,
default: '',
},
disableLocalFiltering: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action']);
const emit = defineEmits(['action', 'search']);
const { t } = useI18n();
@@ -57,6 +62,7 @@ const flattenedMenuItems = computed(() => {
});
const filteredMenuItems = computed(() => {
if (props.disableLocalFiltering) return props.menuItems;
if (!searchQuery.value) return flattenedMenuItems.value;
return flattenedMenuItems.value.filter(item =>
@@ -69,7 +75,7 @@ const filteredMenuSections = computed(() => {
return [];
}
if (!searchQuery.value) {
if (props.disableLocalFiltering || !searchQuery.value) {
return props.menuSections;
}
@@ -89,6 +95,12 @@ const filteredMenuSections = computed(() => {
.filter(section => section.items.length > 0);
});
const handleSearchInput = event => {
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
};
const handleAction = item => {
const { action, value, ...rest } = item;
emit('action', { action, value, ...rest });
@@ -118,7 +130,7 @@ onMounted(() => {
>
<div
v-if="showSearch"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2 z-20"
>
<div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
@@ -130,6 +142,7 @@ onMounted(() => {
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
@input="handleSearchInput"
/>
</div>
</div>
@@ -141,10 +154,23 @@ onMounted(() => {
>
<p
v-if="section.title"
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky z-10 bg-n-alpha-3 backdrop-blur-sm"
:class="showSearch ? 'top-10' : 'top-0'"
>
{{ section.title }}
</p>
<div
v-if="section.isLoading"
class="flex items-center justify-center py-2"
>
<Spinner :size="24" />
</div>
<div
v-else-if="!section.items.length && section.emptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ section.emptyState }}
</div>
<button
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
@@ -235,5 +261,6 @@ onMounted(() => {
: t('DROPDOWN_MENU.EMPTY_STATE')
}}
</div>
<slot name="footer" />
</div>
</template>
@@ -42,7 +42,10 @@ const props = defineProps({
const emit = defineEmits(['retry']);
const allMessages = computed(() => {
return useCamelCase(props.messages, { deep: true });
return useCamelCase(props.messages, {
deep: true,
stopPaths: ['content_attributes.translations'],
});
});
const currentChat = useMapGetter('getSelectedChat');
@@ -0,0 +1,28 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
buttonText: {
type: String,
required: true,
},
});
</script>
<template>
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
<div class="p-3 rounded-xl bg-n-alpha-2">
<span
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
<div class="flex gap-2">
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
</div>
</div>
</template>
@@ -17,6 +17,10 @@ const { attachment } = defineProps({
type: Object,
required: true,
},
showTranscribedText: {
type: Boolean,
default: true,
},
});
defineOptions({
@@ -182,7 +186,7 @@ const downloadAudio = async () => {
</div>
<div
v-if="attachment.transcribedText"
v-if="attachment.transcribedText && showTranscribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
>
{{ attachment.transcribedText }}
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, onMounted, nextTick } from 'vue';
import { computed, ref, onMounted, nextTick, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
const props = defineProps({
@@ -31,20 +31,24 @@ const enableTransition = ref(false);
const activeElement = computed(() => tabRefs.value[activeTab.value]);
const updateIndicator = () => {
if (!activeElement.value) return;
nextTick(() => {
if (!activeElement.value) return;
indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`,
};
indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`,
};
});
};
useResizeObserver(activeElement, () => {
if (enableTransition.value || !activeElement.value) updateIndicator();
useResizeObserver(activeElement, updateIndicator);
// Watch for prop/tabs changes to update indicator position
watch([() => props.initialActiveTab, () => props.tabs], updateIndicator, {
immediate: true,
});
onMounted(() => {
updateIndicator();
nextTick(() => {
enableTransition.value = true;
});
@@ -66,7 +70,7 @@ const showDivider = index => {
<template>
<div
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 dark:bg-n-solid-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
>
<div
class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0"
@@ -55,6 +55,7 @@ import {
getSelectionCoords,
calculateMenuPosition,
getEffectiveChannelType,
stripUnsupportedFormatting,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -131,8 +132,10 @@ const editorMenuOptions = computed(() => {
const createState = (content, placeholder, plugins = [], methods = {}) => {
const schema = editorSchema.value;
// Strip unsupported formatting before parsing to prevent "Token type not supported" errors
const sanitizedContent = stripUnsupportedFormatting(content, schema);
return EditorState.create({
doc: new MessageMarkdownTransformer(schema).parse(content),
doc: new MessageMarkdownTransformer(schema).parse(sanitizedContent),
plugins: buildEditor({
schema,
placeholder,
@@ -859,6 +862,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
max-height: none !important;
min-height: 0 !important;
padding: 0 !important;
display: none !important;
}
> .ProseMirror {
@@ -41,6 +41,9 @@ const getTemplateType = template => {
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
}
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION');
}
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
};
@@ -7,9 +7,7 @@ import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
@@ -46,7 +44,6 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -72,8 +69,6 @@ export default {
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
ResizableTextArea,
CannedResponse,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -114,8 +109,6 @@ export default {
recordingAudioState: '',
recordingAudioDurationText: '',
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '',
ccEmails: '',
toEmails: '',
@@ -148,9 +141,6 @@ export default {
if (!senderId) return {};
return this.$store.getters['contacts/getContact'](senderId);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
},
shouldShowReplyToMessage() {
return (
this.inReplyTo?.id &&
@@ -409,19 +399,6 @@ export default {
!!this.quotedEmailText
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
return false;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -464,25 +441,7 @@ export default {
this.resetRecorderAndClearAttachments();
}
},
message(updatedMessage) {
// Check if the message starts with a slash.
const bodyWithoutSignature = removeSignature(
updatedMessage,
this.signatureToApply
);
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Determine if the user is potentially typing a slash command.
// This is true if the message starts with a slash and the rich content editor is not active.
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
this.showMentions = this.hasSlashCommand;
// If a slash command is active, extract the command text after the slash.
// If not, reset the mentionSearchKey.
this.mentionSearchKey = this.hasSlashCommand
? bodyWithoutSignature.substring(1)
: '';
message() {
// Autosave the current message draft.
this.doAutoSaveDraft();
},
@@ -532,20 +491,14 @@ export default {
methods: {
handleInsert(article) {
const { url, title } = article;
if (this.isRichEditorEnabled) {
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
} else {
this.addIntoEditor(
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
@@ -614,26 +567,14 @@ export default {
if (this.isPrivate) {
return message;
}
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(
message,
this.messageSignature,
effectiveChannelType
)
: removeSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.signatureToApply);
? appendSignature(message, this.messageSignature, effectiveChannelType)
: removeSignature(message, this.messageSignature, effectiveChannelType);
},
removeFromDraft() {
if (this.conversationIdByRoute) {
@@ -649,7 +590,6 @@ export default {
Escape: {
action: () => {
this.hideEmojiPicker();
this.hideMentions();
},
allowOnFocusedInput: true,
},
@@ -694,11 +634,6 @@ export default {
// Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) return;
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput?.$el?.blur();
}
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
.filter(file => file.size > 0)
@@ -832,19 +767,15 @@ export default {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
} else {
message = appendSignature(message, this.signatureToApply);
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
const updatedMessage = replaceVariablesInMessage({
@@ -868,52 +799,30 @@ export default {
});
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode;
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
return;
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
this.$nextTick(() => this.$refs.messageInput.focus());
},
clearEditorSelection() {
this.updateEditorSelectionWith = '';
},
insertIntoTextEditor(text, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
text +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
addIntoEditor(content) {
if (this.showRichContentEditor) {
this.updateEditorSelectionWith = content;
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
this.updateEditorSelectionWith = content;
this.onFocus();
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
} else {
this.message = appendSignature(this.message, this.signatureToApply);
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
}
this.attachedFiles = [];
this.isRecordingAudio = false;
@@ -948,9 +857,6 @@ export default {
this.toggleEmojiPicker();
}
},
hideMentions() {
this.showMentions = false;
},
onTypingOn() {
this.toggleTyping('on');
},
@@ -1197,13 +1103,6 @@ export default {
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<CannedResponse
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey"
@replace="replaceText"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
@@ -1227,23 +1126,7 @@ export default {
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<ResizableTextArea
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="rounded-none input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
allow-signature
:send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
<WootMessageEditor
v-else
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
@@ -1369,10 +1252,6 @@ export default {
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
}
.emoji-dialog {
@@ -1392,9 +1271,4 @@ export default {
@apply ltr:left-1 rtl:right-1 -bottom-2;
}
}
.normal-editor__canned-box {
width: calc(100% - 2 * 1rem);
left: 1rem;
}
</style>
@@ -1,6 +1,7 @@
<script setup>
import { ref, watch, computed, nextTick } from 'vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({
items: {
@@ -15,6 +16,8 @@ const props = defineProps({
const emit = defineEmits(['mentionSelect']);
const { getPlainText } = useMessageFormatter();
const mentionsListContainerRef = ref(null);
const selectedIndex = ref(0);
@@ -94,7 +97,7 @@ const variableKey = (item = {}) => {
'text-n-slate-12': index === selectedIndex,
}"
>
{{ item.description }}
{{ getPlainText(item.description) }}
</p>
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-n-slate-11 group-hover:text-n-slate-12 text-ellipsis whitespace-nowrap"
@@ -1,39 +1,31 @@
import { ref } from 'vue';
import { useTranslations } from '../useTranslations';
import { selectTranslation } from '../useTranslations';
describe('useTranslations', () => {
it('returns false and null when contentAttributes is null', () => {
const contentAttributes = ref(null);
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
describe('selectTranslation', () => {
it('returns null when translations is null', () => {
expect(selectTranslation(null, 'en', 'en')).toBeNull();
});
it('returns false and null when translations are missing', () => {
const contentAttributes = ref({});
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
it('returns null when translations is empty', () => {
expect(selectTranslation({}, 'en', 'en')).toBeNull();
});
it('returns false and null when translations is an empty object', () => {
const contentAttributes = ref({ translations: {} });
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
it('returns first translation when no locale matches', () => {
const translations = { en: 'Hello', es: 'Hola' };
expect(selectTranslation(translations, 'fr', 'de')).toBe('Hello');
});
it('returns true and correct translation content when translations exist', () => {
const contentAttributes = ref({
translations: { en: 'Hello' },
});
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(true);
// Should return the first translation (en: 'Hello')
expect(translationContent.value).toBe('Hello');
it('returns translation matching agent locale', () => {
const translations = { en: 'Hello', es: 'Hola', zh_CN: '你好' };
expect(selectTranslation(translations, 'es', 'en')).toBe('Hola');
});
it('falls back to account locale when agent locale not found', () => {
const translations = { en: 'Hello', zh_CN: '你好' };
expect(selectTranslation(translations, 'fr', 'zh_CN')).toBe('你好');
});
it('returns first translation when both locales are undefined', () => {
const translations = { en: 'Hello', es: 'Hola' };
expect(selectTranslation(translations, undefined, undefined)).toBe('Hello');
});
});
@@ -1,4 +1,25 @@
import { computed } from 'vue';
import { useUISettings } from './useUISettings';
import { useAccount } from './useAccount';
/**
* Select translation based on locale priority.
* @param {Object} translations - Translations object with locale keys
* @param {string} agentLocale - Agent's preferred locale
* @param {string} accountLocale - Account's default locale
* @returns {string|null} Selected translation or null
*/
export function selectTranslation(translations, agentLocale, accountLocale) {
if (!translations || Object.keys(translations).length === 0) return null;
if (agentLocale && translations[agentLocale]) {
return translations[agentLocale];
}
if (accountLocale && translations[accountLocale]) {
return translations[accountLocale];
}
return translations[Object.keys(translations)[0]];
}
/**
* Composable to extract translation state/content from contentAttributes.
@@ -6,6 +27,9 @@ import { computed } from 'vue';
* @returns {Object} { hasTranslations, translationContent }
*/
export function useTranslations(contentAttributes) {
const { uiSettings } = useUISettings();
const { currentAccount } = useAccount();
const hasTranslations = computed(() => {
if (!contentAttributes.value) return false;
const { translations = {} } = contentAttributes.value;
@@ -14,8 +38,11 @@ export function useTranslations(contentAttributes) {
const translationContent = computed(() => {
if (!hasTranslations.value) return null;
const translations = contentAttributes.value.translations;
return translations[Object.keys(translations)[0]];
return selectTranslation(
contentAttributes.value.translations,
uiSettings.value?.locale,
currentAccount.value?.locale
);
});
return { hasTranslations, translationContent };
+10 -2
View File
@@ -140,6 +140,11 @@ export const FORMATTING = {
nodes: [],
menu: ['strong', 'em', 'link', 'undo', 'redo'],
},
'Context::Plain': {
marks: [],
nodes: [],
menu: [],
},
};
// Editor menu options for Full Editor
@@ -232,8 +237,11 @@ export const MARKDOWN_PATTERNS = [
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
},
{
type: 'link', // PM: link, eg: [text](url)
patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }],
type: 'link', // PM: link, eg: [text](url) or <url>
patterns: [
{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }, // [text](url) -> text
{ pattern: /<(https?:\/\/[^>]+)>/g, replacement: '$1' }, // <url> -> url (autolinks)
],
},
];
@@ -5,4 +5,5 @@ export const LOCAL_STORAGE_KEYS = {
COLOR_SCHEME: 'color_scheme',
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
MESSAGE_REPLY_TO: 'messageReplyTo',
RECENT_SEARCHES: 'recentSearches',
};
+1
View File
@@ -43,6 +43,7 @@ export const FEATURE_FLAGS = {
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
};
export const PREMIUM_FEATURES = [
+17 -45
View File
@@ -34,6 +34,7 @@ export function extractTextFromMarkdown(markdown) {
/**
* Strip unsupported markdown formatting based on channel capabilities.
* Uses MARKDOWN_PATTERNS from editor constants.
*
* @param {string} markdown - markdown text to process
* @param {string} channelType - The channel type to check supported formatting
@@ -43,35 +44,16 @@ export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
if (!markdown) return '';
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
const has = (arr, key) => arr.includes(key);
const supported = [...marks, ...nodes];
// Define stripping rules: [condition, pattern, replacement]
const rules = [
[!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
[!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
[!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
[!has(marks, 'code'), /`([^`]+)`/g, '$1'],
[!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
[!has(marks, 'strong'), /__([^_]+)__/g, '$1'],
[!has(marks, 'em'), /\*([^*]+)\*/g, '$1'],
// Match _text_ only at word boundaries (whitespace/string start/end)
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
[
!has(marks, 'em'),
/(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
'$1',
],
[!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'],
[!has(nodes, 'blockquote'), /^>\s?/gm, ''],
[!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''],
[!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''],
];
const result = rules.reduce(
(text, [shouldStrip, pattern, replacement]) =>
shouldStrip ? text.replace(pattern, replacement) : text,
markdown
);
// Apply patterns from MARKDOWN_PATTERNS for unsupported types
const result = MARKDOWN_PATTERNS.reduce((text, { type, patterns }) => {
if (supported.includes(type)) return text;
return patterns.reduce(
(t, { pattern, replacement }) => t.replace(pattern, replacement),
text
);
}, markdown);
return result
.split('\n')
@@ -186,27 +168,22 @@ export function appendSignature(body, signature, channelType) {
/**
* Removes the signature from the body, along with the signature delimiter.
* Tries to find both the original signature and the stripped version.
* Tries multiple signature variants: original, channel-stripped, and fully stripped.
*
* @param {string} body - The body to remove the signature from.
* @param {string} signature - The signature to remove.
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
* For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature removed.
*/
export function removeSignature(body, signature, channelType) {
// Build list of signatures to try: original, channel-stripped, and fully stripped
const cleanedSignature = cleanSignature(signature);
// Build unique list of signature variants to try
const channelStripped = channelType
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
: null;
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
// Try signatures in order: original → channel-specific → fully stripped
const signaturesToTry = [
cleanedSignature,
cleanSignature(signature),
channelStripped,
fullyStripped,
cleanSignature(extractTextFromMarkdown(signature)),
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
// Find the first matching signature
@@ -225,17 +202,12 @@ export function removeSignature(body, signature, channelType) {
newBody = newBody.substring(0, signatureIndex).trimEnd();
}
// let's find the delimiter and remove it
const delimiterIndex = newBody.lastIndexOf(SIGNATURE_DELIMITER);
if (
delimiterIndex !== -1 &&
delimiterIndex === newBody.length - SIGNATURE_DELIMITER.length // this will ensure the delimiter is at the end
) {
// Remove delimiter if it's at the end
if (newBody.endsWith(SIGNATURE_DELIMITER)) {
// if the delimiter is at the end, remove it
newBody = newBody.substring(0, delimiterIndex);
newBody = newBody.slice(0, -SIGNATURE_DELIMITER.length);
}
// return the value
return newBody;
}
@@ -896,6 +896,11 @@ describe('stripUnsupportedFormatting', () => {
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves autolinks when schema supports links', () => {
const content = 'Check out <https://cegrafic.com/catalogo/>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves lists when schema supports them', () => {
const content = '- item 1\n- item 2\n1. first\n2. second';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
@@ -967,6 +972,18 @@ describe('stripUnsupportedFormatting', () => {
).toBe('Check this link');
});
it('converts autolinks to plain URLs when schema does not support links', () => {
const content = 'Visit <https://cegrafic.com/catalogo/> for more info';
const expected = 'Visit https://cegrafic.com/catalogo/ for more info';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('handles multiple autolinks in content', () => {
const content = 'Check <https://example.com> and <https://test.com>';
const expected = 'Check https://example.com and https://test.com';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('strips bullet list markers', () => {
expect(
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
@@ -28,6 +28,7 @@
"TYPES": {
"MEDIA": "Media",
"QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text"
}
},
@@ -808,6 +808,35 @@
"LABEL": "Message",
"PLACEHOLDER": "Please enter a message to show users with the form"
},
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": {
"LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -819,6 +848,7 @@
"SELECT_PLACEHOLDER": "select labels"
},
"NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -1,7 +1,7 @@
{
"SEARCH": {
"TABS": {
"ALL": "All",
"ALL": "All results",
"CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages",
@@ -19,14 +19,50 @@
"LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/ to focus",
"PLACEHOLDER_KEYBINDING": "/to focus",
"INPUT_PLACEHOLDER": "Type 3 or more characters to search",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ",
"BOT_LABEL": "Bot",
"READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:",
"FROM": "from",
"EMAIL": "email",
"EMAIL_SUBJECT": "subject"
"FROM": "From",
"EMAIL": "Email",
"EMAIL_SUBJECT": "Subject",
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_60_DAYS": "Last 60 days",
"LAST_90_DAYS": "Last 90 days",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Apply",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Sender",
"IN": "Inbox",
"AGENTS": "Agents",
"CONTACTS": "Contacts",
"INBOXES": "Inboxes",
"NO_AGENTS": "No agents found",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
}
}
@@ -62,6 +62,7 @@ export default {
...mapGetters({
getAccount: 'accounts/getAccount',
currentAccountId: 'getCurrentAccountId',
getUISettings: 'getUISettings',
}),
plainTextContent() {
return this.getPlainText(this.messageContent);
@@ -117,11 +118,13 @@ export default {
this.$emit('close', e);
},
handleTranslate() {
const { locale } = this.getAccount(this.currentAccountId);
const { locale: accountLocale } = this.getAccount(this.currentAccountId);
const agentLocale = this.getUISettings?.locale;
const targetLanguage = agentLocale || accountLocale || 'en';
this.$store.dispatch('translateMessage', {
conversationId: this.conversationId,
messageId: this.messageId,
targetLanguage: locale || 'en',
targetLanguage,
});
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
this.handleClose();
@@ -1,7 +1,8 @@
<script setup>
import { ref, useTemplateRef, onMounted, watch, nextTick, computed } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import ReadMore from './ReadMore.vue';
import { useExpandableContent } from 'shared/composables/useExpandableContent';
const props = defineProps({
author: {
@@ -18,6 +19,12 @@ const props = defineProps({
},
});
const { t } = useI18n();
const { highlightContent } = useMessageFormatter();
const { contentElement, showReadMore, showReadLess, toggleExpanded } =
useExpandableContent();
const messageContent = computed(() => {
// We perform search on either content or email subject or transcribed text
if (props.message.content) {
@@ -30,33 +37,19 @@ const messageContent = computed(() => {
return email.subject;
}
const audioAttachment = props.message.attachments.find(
const audioAttachment = props.message.attachments?.find(
attachment => attachment.file_type === 'audio'
);
return audioAttachment?.transcribed_text || '';
});
const { highlightContent } = useMessageFormatter();
const messageContainer = useTemplateRef('messageContainer');
const isOverflowing = ref(false);
const setOverflow = () => {
const wrap = messageContainer.value;
if (wrap) {
const message = wrap.querySelector('.message-content');
isOverflowing.value = message.offsetHeight > 150;
}
};
const escapeHtml = html => {
var text = document.createTextNode(html);
var p = document.createElement('p');
p.appendChild(text);
return p.innerText;
const wrapper = document.createElement('p');
wrapper.textContent = html;
return wrapper.textContent;
};
const prepareContent = () => {
const highlightedContent = computed(() => {
const content = messageContent.value || '';
const escapedText = escapeHtml(content);
return highlightContent(
@@ -64,50 +57,59 @@ const prepareContent = () => {
props.searchTerm,
'searchkey--highlight'
);
};
});
onMounted(() => {
watch(() => {
return messageContainer.value;
}, setOverflow);
nextTick(setOverflow);
const authorText = computed(() => {
const author = props.author || '';
const wroteText = t('SEARCH.WROTE');
return author ? `${author} ${wroteText} ` : '';
});
</script>
<template>
<blockquote ref="messageContainer" class="message border-l-2 border-n-weak">
<p class="header">
<strong class="text-n-slate-11">
{{ author }}
</strong>
{{ $t('SEARCH.WROTE') }}
</p>
<ReadMore :shrink="isOverflowing" @expand="isOverflowing = false">
<div v-dompurify-html="prepareContent()" class="message-content" />
</ReadMore>
</blockquote>
<div
ref="contentElement"
class="break-words grid items-center text-n-slate-11 text-sm leading-relaxed"
:class="showReadMore ? 'grid-cols-[1fr_auto]' : 'grid-cols-1'"
>
<div
class="min-w-0"
:class="{
'overflow-hidden whitespace-nowrap text-ellipsis': showReadMore,
}"
>
<span v-if="authorText" class="text-n-slate-11 font-medium leading-4">{{
authorText
}}</span>
<span
v-dompurify-html="highlightedContent"
class="message-content text-n-slate-12 [&_.searchkey--highlight]:text-n-slate-12 [&_.searchkey--highlight]:font-semibold"
/>
<button
v-if="showReadLess"
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium ltr:ml-0.5 rtl:mr-0.5"
@click.prevent="toggleExpanded(false)"
>
{{ t('SEARCH.READ_LESS') }}
</button>
</div>
<button
v-if="showReadMore"
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium justify-self-end ltr:ml-0.5 rtl:mr-0.5"
@click.prevent="toggleExpanded(true)"
>
{{ t('SEARCH.READ_MORE') }}
</button>
</div>
</template>
<style scoped lang="scss">
.message {
@apply py-0 px-2 mt-2;
.message-content::v-deep p {
@apply inline;
margin: 0;
}
.message-content::v-deep p,
.message-content::v-deep li::marker {
@apply text-n-slate-11 mb-1;
}
.header {
@apply text-n-slate-11 mb-1;
}
.message-content {
@apply break-words text-n-slate-11;
}
.message-content::v-deep .searchkey--highlight {
@apply text-n-slate-12 text-sm font-semibold;
.message-content::v-deep br {
display: none;
}
</style>
@@ -1,38 +0,0 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
shrink: {
type: Boolean,
default: false,
},
});
defineEmits(['expand']);
</script>
<template>
<div>
<div
:class="{
'max-h-[100px] overflow-hidden relative': shrink,
}"
>
<slot />
<div
v-if="shrink"
class="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t to-transparent from-n-background flex items-end justify-center pb-2"
>
<NextButton
:label="$t('SEARCH.READ_MORE')"
icon="i-lucide-chevrons-down"
blue
xs
faded
class="backdrop-filter backdrop-blur-[2px]"
@click.prevent="$emit('expand')"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,116 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['selectSearch', 'clearAll']);
const MAX_RECENT_SEARCHES = 3;
const recentSearches = ref([]);
const loadRecentSearches = () => {
const stored = LocalStorage.get(LOCAL_STORAGE_KEYS.RECENT_SEARCHES) || [];
recentSearches.value = Array.isArray(stored) ? stored : [];
};
const saveRecentSearches = () => {
LocalStorage.set(LOCAL_STORAGE_KEYS.RECENT_SEARCHES, recentSearches.value);
};
const addRecentSearch = query => {
if (!query || query.trim().length < 2) return;
const trimmedQuery = query.trim();
const existingIndex = recentSearches.value.findIndex(
search => search.toLowerCase() === trimmedQuery.toLowerCase()
);
if (existingIndex !== -1) {
recentSearches.value.splice(existingIndex, 1);
}
recentSearches.value.unshift(trimmedQuery);
if (recentSearches.value.length > MAX_RECENT_SEARCHES) {
recentSearches.value = recentSearches.value.slice(0, MAX_RECENT_SEARCHES);
}
saveRecentSearches();
};
const clearRecentSearches = () => {
recentSearches.value = [];
LocalStorage.remove(LOCAL_STORAGE_KEYS.RECENT_SEARCHES);
};
const hasRecentSearches = computed(() => recentSearches.value?.length > 0);
const onSelectSearch = query => {
emit('selectSearch', query);
};
const onClearAll = () => {
clearRecentSearches();
emit('clearAll');
};
defineExpose({
addRecentSearch,
loadRecentSearches,
});
onMounted(() => {
loadRecentSearches();
});
</script>
<template>
<div v-if="hasRecentSearches" class="px-4 pb-4 w-full pt-2">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2.5">
<Icon icon="i-lucide-rotate-ccw" class="text-n-slate-10 size-4" />
<h3 class="text-base font-medium text-n-slate-10">
{{ $t('SEARCH.RECENT_SEARCHES') }}
</h3>
</div>
<Button
type="button"
xs
slate
ghost
class="!text-n-slate-10 hover:!text-n-slate-12"
@mousedown.prevent
@click="onClearAll"
>
{{ $t('SEARCH.CLEAR_ALL') }}
</Button>
</div>
<div class="flex flex-col gap-4 items-start">
<button
v-for="(search, index) in recentSearches"
:key="search"
type="button"
class="w-full flex items-center gap-2.5 text-left text-base text-n-slate-12 rounded-lg transition-all duration-150 group p-0"
@mousedown.prevent
@click="onSelectSearch(search)"
>
<Icon
icon="i-lucide-search"
class="text-n-slate-10 group-hover:text-n-slate-11 transition-colors duration-150 size-4"
/>
<span class="flex-1 truncate">{{ search }}</span>
<span
class="text-xs text-n-slate-8 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
>
{{ index === 0 ? $t('SEARCH.MOST_RECENT') : '' }}
</span>
</button>
</div>
</div>
<template v-else />
</template>
@@ -0,0 +1,239 @@
<script setup>
import { ref, computed, defineModel, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { debounce } from '@chatwoot/utils';
import { useMapGetter } from 'dashboard/composables/store.js';
import { searchContacts } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { fetchContactDetails } from '../helpers/searchHelper';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
label: { type: String, required: true },
});
const emit = defineEmits(['change']);
const FROM_TYPE = {
CONTACT: 'contact',
AGENT: 'agent',
};
const MENU_ACTIONS_SELECT = 'select';
const modelValue = defineModel({ type: String, default: null });
const { t } = useI18n();
const [showDropdown, toggleDropdown] = useToggle();
const searchQuery = ref('');
const searchedContacts = ref([]);
const isSearching = ref(false);
const selectedContact = ref(null);
const agentsList = useMapGetter('agents/getVerifiedAgents');
const createMenuItem = (item, type, isAgent = false) => {
const transformed = useCamelCase(item, { deep: true });
const value = `${type}:${transformed.id}`;
return {
label: transformed.name,
value,
action: MENU_ACTIONS_SELECT,
type,
thumbnail: {
name: transformed.name,
src: isAgent ? transformed.avatarUrl : transformed.thumbnail,
},
...(isAgent
? {}
: { description: transformed.email || transformed.phoneNumber }),
isSelected: modelValue.value === value,
};
};
const agentsSection = computed(() => {
const agents =
agentsList.value?.map(agent =>
createMenuItem(agent, FROM_TYPE.AGENT, true)
) || [];
return searchQuery.value
? agents.filter(agent =>
agent.label.toLowerCase().includes(searchQuery.value.toLowerCase())
)
: agents;
});
const contactsSection = computed(
() =>
searchedContacts.value?.map(contact =>
createMenuItem(contact, FROM_TYPE.CONTACT)
) || []
);
const menuSections = computed(() => [
{
title: t('SEARCH.FILTERS.CONTACTS'),
items: contactsSection.value,
isLoading: isSearching.value,
emptyState: t('SEARCH.FILTERS.NO_CONTACTS'),
},
{
title: t('SEARCH.FILTERS.AGENTS'),
items: agentsSection.value,
emptyState: t('SEARCH.FILTERS.NO_AGENTS'),
},
]);
const selectedLabel = computed(() => {
if (!modelValue.value) return props.label;
const [type, id] = modelValue.value.split(':');
const numericId = Number(id);
if (type === FROM_TYPE.CONTACT) {
if (selectedContact.value?.id === numericId) {
return `${props.label}: ${selectedContact.value.name}`;
}
const contact = searchedContacts.value?.find(c => c.id === numericId);
if (contact) return `${props.label}: ${contact.name}`;
} else if (type === FROM_TYPE.AGENT) {
const agent = agentsList.value?.find(a => a.id === numericId);
if (agent) return `${props.label}: ${agent.name}`;
}
return `${props.label}: ${numericId}`;
});
const debouncedSearch = debounce(async query => {
if (!query) {
searchedContacts.value = selectedContact.value
? [selectedContact.value]
: [];
isSearching.value = false;
return;
}
try {
const contacts = await searchContacts({
keys: ['name', 'email', 'phone_number'],
query,
});
// Add selected contact to top if not already in results
const allContacts = selectedContact.value
? [
selectedContact.value,
...contacts.filter(c => c.id !== selectedContact.value.id),
]
: contacts;
searchedContacts.value = allContacts;
} catch {
// Ignore error
} finally {
isSearching.value = false;
}
}, 300);
const performSearch = query => {
searchQuery.value = query;
if (query) {
searchedContacts.value = selectedContact.value
? [selectedContact.value]
: [];
isSearching.value = true;
}
debouncedSearch(query);
};
const onToggleDropdown = () => {
if (!showDropdown.value) {
// Reset search when opening dropdown
searchQuery.value = '';
searchedContacts.value = selectedContact.value
? [selectedContact.value]
: [];
}
toggleDropdown();
};
const handleAction = item => {
if (modelValue.value === item.value) {
modelValue.value = null;
selectedContact.value = null;
} else {
modelValue.value = item.value;
if (item.type === FROM_TYPE.CONTACT) {
const [, id] = item.value.split(':');
selectedContact.value = {
id: Number(id),
name: item.label,
thumbnail: item.thumbnail?.src,
};
} else {
selectedContact.value = null;
}
}
toggleDropdown(false);
emit('change');
};
const resolveContactName = async () => {
if (!modelValue.value) return;
const [type, id] = modelValue.value.split(':');
if (type !== FROM_TYPE.CONTACT) return;
const numericId = Number(id);
if (selectedContact.value?.id === numericId) return;
const contact = await fetchContactDetails(numericId);
if (contact) {
selectedContact.value = {
id: contact.id,
name: contact.name,
thumbnail: contact.thumbnail,
};
if (!searchedContacts.value.some(c => c.id === contact.id)) {
searchedContacts.value.push(selectedContact.value);
}
}
};
watch(() => modelValue.value, resolveContactName, { immediate: true });
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group min-w-0 max-w-full"
>
<Button
sm
:variant="showDropdown ? 'faded' : 'ghost'"
slate
:label="selectedLabel"
trailing-icon
icon="i-lucide-chevron-down"
class="!px-2 max-w-full"
@click="onToggleDropdown"
/>
<DropdownMenu
v-if="showDropdown"
:menu-sections="menuSections"
show-search
disable-local-filtering
:is-searching="isSearching"
class="mt-1 ltr:left-0 rtl:right-0 top-full w-64 max-h-80 overflow-y-auto"
@search="performSearch"
@action="handleAction"
/>
</div>
</template>
@@ -0,0 +1,271 @@
<script setup>
import { computed, ref, defineModel } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import {
subDays,
subMonths,
subYears,
startOfDay,
endOfDay,
format,
getUnixTime,
fromUnixTime,
} from 'date-fns';
import { DATE_RANGE_TYPES } from '../helpers/searchHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const emit = defineEmits(['change']);
const modelValue = defineModel({
type: Object,
default: () => ({ type: null, from: null, to: null }),
});
const { t } = useI18n();
const [showDropdown, toggleDropdown] = useToggle();
const customFrom = ref('');
const customTo = ref('');
const rangeType = ref(DATE_RANGE_TYPES.BETWEEN);
// Calculate min date (90 days ago) for date inputs
const minDate = computed(() => format(subDays(new Date(), 90), 'yyyy-MM-dd'));
const maxDate = computed(() => format(new Date(), 'yyyy-MM-dd'));
// Check if both custom date inputs have values
const hasCustomDates = computed(() => customFrom.value && customTo.value);
const DATE_FILTER_ACTIONS = {
PRESET: 'preset',
SELECT: 'select',
};
const PRESET_RANGES = computed(() => [
{
label: t('SEARCH.DATE_RANGE.LAST_7_DAYS'),
value: DATE_RANGE_TYPES.LAST_7_DAYS,
days: 7,
},
{
label: t('SEARCH.DATE_RANGE.LAST_30_DAYS'),
value: DATE_RANGE_TYPES.LAST_30_DAYS,
days: 30,
},
{
label: t('SEARCH.DATE_RANGE.LAST_60_DAYS'),
value: DATE_RANGE_TYPES.LAST_60_DAYS,
days: 60,
},
{
label: t('SEARCH.DATE_RANGE.LAST_90_DAYS'),
value: DATE_RANGE_TYPES.LAST_90_DAYS,
days: 90,
},
]);
const computeDateRange = config => {
const end = endOfDay(new Date());
let start;
if (config.days) {
start = startOfDay(subDays(end, config.days));
} else if (config.months) {
start = startOfDay(subMonths(end, config.months));
} else {
start = startOfDay(subYears(end, config.years));
}
return { type: config.value, from: getUnixTime(start), to: getUnixTime(end) };
};
const selectedValue = computed(() => {
const { from, to, type } = modelValue.value || {};
if (!from && !to && !type) return '';
return type || DATE_RANGE_TYPES.CUSTOM;
});
const menuItems = computed(() =>
PRESET_RANGES.value.map(item => ({
...item,
action: DATE_FILTER_ACTIONS.PRESET,
isSelected: selectedValue.value === item.value,
}))
);
const applySelection = ({ type, from, to }) => {
const newValue = { type, from, to };
modelValue.value = newValue;
emit('change', newValue);
};
const clearFilter = () => {
applySelection({ type: null, from: null, to: null });
customFrom.value = '';
customTo.value = '';
toggleDropdown(false);
};
const handlePresetAction = item => {
if (selectedValue.value === item.value) {
clearFilter();
return;
}
customFrom.value = '';
customTo.value = '';
applySelection(computeDateRange(item));
toggleDropdown(false);
};
const applyCustomRange = () => {
const customFromDate = customFrom.value
? startOfDay(new Date(customFrom.value))
: null;
const customToDate = customTo.value
? endOfDay(new Date(customTo.value))
: null;
// Only BETWEEN mode - require both dates
if (customFromDate && customToDate) {
applySelection({
type: DATE_RANGE_TYPES.BETWEEN,
from: getUnixTime(customFromDate),
to: getUnixTime(customToDate),
});
toggleDropdown(false);
}
};
const clearCustomRange = () => {
customFrom.value = '';
customTo.value = '';
};
const formatDate = timestamp => format(fromUnixTime(timestamp), 'MMM d, yyyy'); // (e.g., "Jan 15, 2024")
const selectedLabel = computed(() => {
const prefix = t('SEARCH.DATE_RANGE.TIME_RANGE');
if (!selectedValue.value) return prefix;
// Check if it's a preset
const preset = PRESET_RANGES.value.find(p => p.value === selectedValue.value);
if (preset) return `${prefix}: ${preset.label}`;
// Custom range - only BETWEEN mode with both dates
const { from, to } = modelValue.value;
if (from && to) return `${prefix}: ${formatDate(from)} - ${formatDate(to)}`;
return `${prefix}: ${t('SEARCH.DATE_RANGE.CUSTOM_RANGE')}`;
});
const CUSTOM_RANGE_TYPES = [DATE_RANGE_TYPES.BETWEEN, DATE_RANGE_TYPES.CUSTOM];
const onToggleDropdown = () => {
if (!showDropdown.value) {
const { type, from, to } = modelValue.value || {};
rangeType.value = CUSTOM_RANGE_TYPES.includes(type)
? type
: DATE_RANGE_TYPES.BETWEEN;
if (CUSTOM_RANGE_TYPES.includes(type)) {
try {
customFrom.value = from ? format(fromUnixTime(from), 'yyyy-MM-dd') : '';
customTo.value = to ? format(fromUnixTime(to), 'yyyy-MM-dd') : '';
} catch {
customFrom.value = '';
customTo.value = '';
}
} else {
customFrom.value = '';
customTo.value = '';
}
}
toggleDropdown();
};
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group min-w-0 max-w-full"
>
<Button
sm
slate
:variant="showDropdown ? 'faded' : 'solid'"
:label="selectedLabel"
class="group-hover:bg-n-alpha-2 max-w-full"
trailing-icon
icon="i-lucide-chevron-down"
@click="onToggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="menuItems"
class="mt-1 ltr:left-0 rtl:right-0 top-full w-64"
@action="handlePresetAction"
>
<template #footer>
<div class="h-px bg-n-strong" />
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-2 px-1 h-9">
<span class="text-sm text-n-slate-11">
{{ t('SEARCH.DATE_RANGE.CUSTOM_RANGE') }}
</span>
<span class="text-sm text-n-slate-12">
{{ t('SEARCH.DATE_RANGE.CREATED_BETWEEN') }}
</span>
</div>
<input
v-model="customFrom"
type="date"
:min="minDate"
:max="customTo || maxDate"
class="!w-full !mb-0 !rounded-lg !bg-n-alpha-black2 !outline-n-strong -outline-offset-1 !px-3 !py-2 !text-sm text-n-slate-12 !h-8"
/>
<div class="flex items-center gap-3 h-5 px-1">
<div class="flex-1 h-px bg-n-weak" />
<span class="text-sm text-n-slate-11">
{{ t('SEARCH.DATE_RANGE.AND') }}
</span>
<div class="flex-1 h-px bg-n-weak" />
</div>
<input
v-model="customTo"
type="date"
:min="customFrom || minDate"
:max="maxDate"
class="!w-full !mb-0 !rounded-lg !bg-n-alpha-black2 !outline-n-strong -outline-offset-1 !px-3 !py-2 !text-sm text-n-slate-12 !h-8"
/>
<div class="flex items-center gap-2 mt-2">
<Button
sm
slate
faded
:label="t('SEARCH.DATE_RANGE.CLEAR_FILTER')"
:disabled="!hasCustomDates"
class="flex-1 justify-center"
@click="clearCustomRange"
/>
<Button
sm
solid
color="blue"
:label="t('SEARCH.DATE_RANGE.APPLY')"
:disabled="!hasCustomDates"
class="flex-1 justify-center"
@click="applyCustomRange"
/>
</div>
</div>
</template>
</DropdownMenu>
</div>
</template>
@@ -0,0 +1,104 @@
<script setup>
import { computed, defineModel } from 'vue';
import { useI18n } from 'vue-i18n';
import SearchDateRangeSelector from './SearchDateRangeSelector.vue';
import SearchContactAgentSelector from './SearchContactAgentSelector.vue';
import SearchInboxSelector from './SearchInboxSelector.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['updateFilters']);
const { t } = useI18n();
const filters = defineModel({
type: Object,
default: () => ({
from: null, // Contact id and Agent id
in: null, // Inbox id
dateRange: { type: null, from: null, to: null },
}),
});
const hasActiveFilters = computed(
() =>
filters.value.from ||
filters.value.in ||
filters.value.dateRange?.type ||
filters.value.dateRange?.from ||
filters.value.dateRange?.to
);
const onFilterChange = () => {
emit('updateFilters', filters.value);
};
const clearAllFilters = () => {
filters.value = {
from: null,
in: null,
dateRange: { type: null, from: null, to: null },
};
onFilterChange();
};
</script>
<template>
<div
class="flex flex-col lg:flex-row items-start lg:items-center gap-3 p-4 w-full min-w-0"
>
<div class="flex items-center gap-3 min-w-0 max-w-full">
<Button
v-if="hasActiveFilters"
sm
slate
solid
:label="t('SEARCH.DATE_RANGE.CLEAR_FILTER')"
icon="i-lucide-x"
class="flex-shrink-0 lg:hidden"
@click="clearAllFilters"
/>
<SearchDateRangeSelector
v-model="filters.dateRange"
class="min-w-0 max-w-full"
@change="onFilterChange"
/>
</div>
<div class="w-px h-4 bg-n-weak flex-shrink-0 hidden lg:block" />
<div class="flex items-center gap-1.5 min-w-0 flex-1 max-w-full">
<span class="text-sm text-n-slate-10 flex-shrink-0 whitespace-nowrap">
{{ t('SEARCH.FILTERS.FILTER_MESSAGE') }}
</span>
<div class="min-w-0">
<SearchContactAgentSelector
v-model="filters.from"
:label="$t('SEARCH.FILTERS.FROM')"
@change="onFilterChange"
/>
</div>
<div class="w-px h-3 bg-n-weak rounded-lg flex-shrink-0" />
<div class="min-w-0">
<SearchInboxSelector
v-model="filters.in"
:label="$t('SEARCH.FILTERS.IN')"
@change="onFilterChange"
/>
</div>
</div>
<Button
v-if="hasActiveFilters"
sm
slate
solid
:label="t('SEARCH.DATE_RANGE.CLEAR_FILTER')"
icon="i-lucide-x"
class="flex-shrink-0 hidden lg:inline-flex"
@click="clearAllFilters"
/>
</div>
</template>
@@ -1,48 +1,34 @@
<script setup>
import { ref, useTemplateRef, onMounted, onUnmounted, watch } from 'vue';
import { debounce } from '@chatwoot/utils';
import { ref, watch, useTemplateRef, defineModel } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { ROLES } from 'dashboard/constants/permissions';
import SearchInput from './SearchInput.vue';
import SearchFilters from './SearchFilters.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
initialQuery: {
type: String,
default: '',
},
initialQuery: { type: String, default: '' },
});
const emit = defineEmits(['search']);
const emit = defineEmits(['search', 'filterChange']);
const filters = defineModel('filters', { type: Object, default: () => ({}) });
const searchInputRef = useTemplateRef('searchInputRef');
const searchQuery = ref(props.initialQuery);
const isInputFocused = ref(false);
const searchInput = useTemplateRef('searchInput');
const handler = e => {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
searchInput.value.focus();
} else if (e.key === 'Escape' && document.activeElement.tagName === 'INPUT') {
e.preventDefault();
searchInput.value.blur();
const onSearch = query => {
if (query?.trim() && searchInputRef.value) {
searchInputRef.value.addToRecentSearches(query.trim());
}
emit('search', query);
};
const debouncedEmit = debounce(
value =>
emit('search', value.length > 1 || value.match(/^[0-9]+$/) ? value : ''),
500
);
const onInput = e => {
searchQuery.value = e.target.value;
debouncedEmit(searchQuery.value);
};
const onFocus = () => {
isInputFocused.value = true;
};
const onBlur = () => {
isInputFocused.value = false;
const onSelectRecentSearch = query => {
searchQuery.value = query;
onSearch(query);
};
watch(
@@ -54,51 +40,30 @@ watch(
},
{ immediate: true }
);
onMounted(() => {
searchInput.value.focus();
document.addEventListener('keydown', handler);
});
onUnmounted(() => {
document.removeEventListener('keydown', handler);
});
</script>
<template>
<div
class="input-container rounded-xl transition-[border-bottom] duration-[0.2s] ease-[ease-in-out] relative flex items-center py-2 px-4 h-14 gap-2 border border-solid bg-n-alpha-black2"
:class="{
'border-n-brand': isInputFocused,
'border-n-weak': !isInputFocused,
}"
>
<div class="flex items-center">
<fluent-icon
icon="search"
class="icon"
aria-hidden="true"
:class="{
'text-n-blue-text': isInputFocused,
'text-n-slate-10': !isInputFocused,
}"
/>
</div>
<input
ref="searchInput"
type="search"
class="reset-base outline-none w-full m-0 bg-transparent border-transparent shadow-none text-n-slate-12 dark:text-n-slate-12 active:border-transparent active:shadow-none hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none"
:placeholder="$t('SEARCH.INPUT_PLACEHOLDER')"
:value="searchQuery"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
/>
<woot-label
:title="$t('SEARCH.PLACEHOLDER_KEYBINDING')"
:show-close="false"
small
class="!m-0 whitespace-nowrap !bg-n-slate-3 dark:!bg-n-solid-3 !border-n-weak dark:!border-n-strong"
/>
<div class="flex flex-col gap-2">
<SearchInput
ref="searchInputRef"
v-model="searchQuery"
@search="onSearch"
@select-recent-search="onSelectRecentSearch"
>
<Policy
:permissions="ROLES"
:installation-types="[
INSTALLATION_TYPES.ENTERPRISE,
INSTALLATION_TYPES.CLOUD,
]"
:feature-flag="FEATURE_FLAGS.ADVANCED_SEARCH"
class="w-full"
>
<SearchFilters
v-model="filters"
@update-filters="$emit('filterChange', $event)"
/>
</Policy>
</SearchInput>
</div>
</template>
@@ -0,0 +1,125 @@
<script setup>
import { ref, computed, defineModel } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
label: {
type: String,
required: true,
},
});
const emit = defineEmits(['change']);
const modelValue = defineModel({
type: [String, Number],
default: null,
});
const MENU_ITEM_TYPES = {
INBOX: 'inbox',
};
const MENU_ACTIONS = {
SELECT: 'select',
};
const { t } = useI18n();
const [showDropdown, toggleDropdown] = useToggle();
const searchQuery = ref('');
const inboxesList = useMapGetter('inboxes/getInboxes');
const inboxesSection = computed(() => {
const inboxes = inboxesList.value?.map(inbox => {
const transformedInbox = useCamelCase(inbox, { deep: true });
return {
label: transformedInbox.name,
value: transformedInbox.id,
action: MENU_ACTIONS.SELECT,
type: MENU_ITEM_TYPES.INBOX,
thumbnail: {
name: transformedInbox.name,
src: transformedInbox.avatarUrl,
},
isSelected: modelValue.value === transformedInbox.id,
};
});
if (!searchQuery.value) return inboxes;
return inboxes.filter(inbox =>
inbox.label.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const menuSections = computed(() => {
return [
{
title: t('SEARCH.FILTERS.INBOXES'),
items: inboxesSection.value,
emptyState: t('SEARCH.FILTERS.NO_INBOXES'),
},
];
});
const selectedLabel = computed(() => {
if (!modelValue.value) return props.label;
// Find the selected inbox
const inbox = inboxesList.value?.find(i => i.id === modelValue.value);
if (inbox) return `${props.label}: ${inbox.name}`;
return `${props.label}: ${modelValue.value}`;
});
const handleAction = item => {
if (modelValue.value === item.value) {
modelValue.value = null;
} else {
modelValue.value = item.value;
}
toggleDropdown(false);
emit('change');
};
const onToggleDropdown = () => {
if (!showDropdown.value) {
searchQuery.value = '';
}
toggleDropdown();
};
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group min-w-0 max-w-full"
>
<Button
sm
:variant="showDropdown ? 'faded' : 'ghost'"
slate
:label="selectedLabel"
trailing-icon
icon="i-lucide-chevron-down"
class="!px-2 max-w-full"
@click="onToggleDropdown"
/>
<DropdownMenu
v-if="showDropdown"
:menu-sections="menuSections"
show-search
disable-local-filtering
class="mt-1 ltr:right-0 rtl:left-0 top-full w-64 max-h-80 overflow-y-auto"
@search="searchQuery = $event"
@action="handleAction"
/>
</div>
</template>
@@ -0,0 +1,136 @@
<script setup>
import { ref, useTemplateRef, onMounted, onUnmounted } from 'vue';
import { debounce } from '@chatwoot/utils';
import RecentSearches from './RecentSearches.vue';
const emit = defineEmits(['search', 'selectRecentSearch']);
const searchQuery = defineModel({
type: String,
default: '',
});
const isInputFocused = ref(false);
const showRecentSearches = ref(false);
const searchInput = useTemplateRef('searchInput');
const recentSearchesRef = useTemplateRef('recentSearchesRef');
const handler = e => {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
searchInput.value.focus();
} else if (e.key === 'Escape' && document.activeElement.tagName === 'INPUT') {
e.preventDefault();
searchInput.value.blur();
}
};
const debouncedEmit = debounce(
value =>
emit('search', value.length > 1 || value.match(/^[0-9]+$/) ? value : ''),
500
);
const onInput = () => {
debouncedEmit(searchQuery.value);
if (searchQuery.value.trim()) {
showRecentSearches.value = false;
} else if (isInputFocused.value) {
showRecentSearches.value = true;
}
};
const onFocus = () => {
isInputFocused.value = true;
if (!searchQuery.value.trim()) {
showRecentSearches.value = true;
}
};
const onBlur = () => {
isInputFocused.value = false;
showRecentSearches.value = false;
};
const onSelectRecentSearch = query => {
searchQuery.value = query;
emit('selectRecentSearch', query);
showRecentSearches.value = false;
searchInput.value.focus();
};
const addToRecentSearches = query => {
if (recentSearchesRef.value) {
recentSearchesRef.value.addRecentSearch(query);
}
};
defineExpose({
addToRecentSearches,
});
onMounted(() => {
searchInput.value.focus();
document.addEventListener('keydown', handler);
});
onUnmounted(() => {
document.removeEventListener('keydown', handler);
});
</script>
<template>
<div
class="rounded-xl transition-[border-bottom] duration-[0.2s] ease-[ease-in-out] relative flex items-start flex-col border border-solid bg-n-solid-1 divide-y divide-n-strong"
:class="{
'border-n-brand': isInputFocused,
'border-n-strong': !isInputFocused,
}"
>
<div class="flex items-center w-full h-[3.25rem] px-4 gap-2">
<div class="flex items-center">
<fluent-icon
icon="search"
class="icon"
aria-hidden="true"
:class="{
'text-n-blue-text': isInputFocused,
'text-n-slate-10': !isInputFocused,
}"
/>
</div>
<input
ref="searchInput"
v-model="searchQuery"
type="search"
class="reset-base outline-none w-full m-0 bg-transparent border-transparent shadow-none text-n-slate-12 dark:text-n-slate-12 active:border-transparent active:shadow-none hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none placeholder:text-n-slate-10 text-base"
:placeholder="$t('SEARCH.INPUT_PLACEHOLDER')"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
/>
<span class="text-sm text-n-slate-10 flex-shrink-0">
{{ $t('SEARCH.PLACEHOLDER_KEYBINDING') }}
</span>
</div>
<slot />
<div
class="transition-all duration-200 ease-out grid overflow-hidden w-full !border-t-0"
:class="
showRecentSearches
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div class="overflow-hidden w-full">
<RecentSearches
ref="recentSearchesRef"
@select-search="onSelectRecentSearch"
@clear-all="showRecentSearches = false"
/>
</div>
</div>
</div>
</template>
@@ -1,7 +1,10 @@
<script setup>
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { ARTICLE_STATUSES } from 'dashboard/helper/portalHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import MessageFormatter from 'shared/helpers/MessageFormatter';
const props = defineProps({
@@ -13,6 +16,8 @@ const props = defineProps({
content: { type: String, default: '' },
portalSlug: { type: String, required: true },
accountId: { type: [String, Number], default: 0 },
status: { type: String, default: '' },
updatedAt: { type: Number, default: 0 },
});
const MAX_LENGTH = 300;
@@ -23,6 +28,11 @@ const navigateTo = computed(() => {
);
});
const updatedAtTime = computed(() => {
if (!props.updatedAt) return '';
return dynamicTime(props.updatedAt);
});
const truncatedContent = computed(() => {
if (!props.content) return props.description || '';
@@ -34,36 +44,62 @@ const truncatedContent = computed(() => {
? `${plainText.substring(0, MAX_LENGTH)}...`
: plainText;
});
const statusTextColor = computed(() => {
switch (props.status) {
case ARTICLE_STATUSES.ARCHIVED:
return 'text-n-slate-12';
case ARTICLE_STATUSES.DRAFT:
return 'text-n-amber-11';
default:
return 'text-n-teal-11';
}
});
</script>
<template>
<router-link
:to="navigateTo"
class="flex items-start p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
>
<div
class="flex items-center justify-center w-6 h-6 mt-0.5 rounded bg-n-slate-3"
<router-link :to="navigateTo">
<CardLayout
layout="col"
class="[&>div]:justify-start [&>div]:gap-2 [&>div]:px-4 [&>div]:pt-4 [&>div]:pb-5 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
>
<Icon icon="i-lucide-library-big" class="text-n-slate-10" />
</div>
<div class="ltr:ml-2 rtl:mr-2 min-w-0 flex-1">
<div class="flex items-center gap-2">
<h5 class="text-sm font-medium truncate min-w-0 text-n-slate-12">
{{ title }}
</h5>
<span
v-if="category"
class="text-xs font-medium whitespace-nowrap capitalize bg-n-slate-3 px-1 py-0.5 rounded text-n-slate-10"
<div class="min-w-0 flex-1 flex flex-col items-start gap-2 w-full">
<div class="flex items-center min-w-0 justify-between gap-2 w-full">
<div class="flex items-center gap-2">
<h5
class="text-sm font-medium leading-4 truncate min-w-0 text-n-slate-12"
>
{{ title }}
</h5>
<div v-if="category" class="w-px h-4 bg-n-strong mx-2" />
<span
v-if="category"
class="text-xs inline-flex items-center font-medium rounded-md whitespace-nowrap capitalize bg-n-alpha-2 px-1.5 h-6 text-n-slate-12"
>
{{ category }}
</span>
<span
v-if="status"
class="text-xs inline-flex items-center font-medium rounded-md whitespace-nowrap capitalize bg-n-alpha-2 px-2 h-6"
:class="statusTextColor"
>
{{ status }}
</span>
</div>
<span
v-if="updatedAtTime"
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
>
{{ updatedAtTime }}
</span>
</div>
<p
v-if="truncatedContent"
class="text-sm leading-6 text-n-slate-11 line-clamp-2"
>
{{ category }}
</span>
{{ truncatedContent }}
</p>
</div>
<p
v-if="truncatedContent"
class="mt-1 text-sm text-n-slate-11 line-clamp-2"
>
{{ truncatedContent }}
</p>
</div>
</CardLayout>
</router-link>
</template>
@@ -34,18 +34,19 @@ const accountId = useMapGetter('getCurrentAccountId');
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="articles.length" class="space-y-1.5 list-none">
<ul v-if="articles.length" class="space-y-3 list-none">
<li v-for="article in articles" :key="article.id">
<SearchResultArticleItem
:id="article.id"
:title="article.title"
:description="article.description"
:content="article.content"
:portal-slug="article.portal_slug"
:portal-slug="article.portalSlug"
:locale="article.locale"
:account-id="accountId"
:category="article.category_name"
:category="article.categoryName"
:status="article.status"
:updated-at="article.updatedAt"
/>
</li>
</ul>
@@ -1,8 +1,12 @@
<script setup>
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
import countries from 'shared/constants/countries';
import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
const props = defineProps({
id: {
@@ -29,38 +33,122 @@ const props = defineProps({
type: [String, Number],
default: 0,
},
additionalAttributes: {
type: Object,
default: () => ({}),
},
updatedAt: {
type: Number,
default: 0,
},
});
const navigateTo = computed(() => {
return frontendURL(`accounts/${props.accountId}/contacts/${props.id}`);
});
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
acc[country.id] = country;
return acc;
}, {});
});
const updatedAtTime = computed(() => {
if (!props.updatedAt) return '';
return dynamicTime(props.updatedAt);
});
const countryDetails = computed(() => {
const { country, countryCode, city } = props.additionalAttributes;
if (!country && !countryCode) return null;
const activeCountry =
countriesMap.value[country] || countriesMap.value[countryCode];
if (!activeCountry) return null;
return {
countryCode: activeCountry.id,
city: city ? `${city},` : null,
name: activeCountry.name,
};
});
const formattedLocation = computed(() => {
if (!countryDetails.value) return '';
return [countryDetails.value.city, countryDetails.value.name]
.filter(Boolean)
.join(' ');
});
</script>
<template>
<router-link
:to="navigateTo"
class="flex items-start p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
>
<Avatar
:name="name"
:src="thumbnail"
:size="24"
rounded-full
class="mt-0.5"
/>
<div class="ml-2 rtl:mr-2 min-w-0 rtl:ml-0">
<h5 class="text-sm name truncate min-w-0 text-n-slate-12">
{{ name }}
</h5>
<p
class="grid items-center m-0 gap-1 text-sm grid-cols-[minmax(0,1fr)_auto_auto]"
>
<span v-if="email" class="truncate text-n-slate-12" :title="email">
{{ email }}
</span>
<span v-if="phone" class="text-n-slate-10">•</span>
<span v-if="phone" class="text-n-slate-12">{{ phone }}</span>
</p>
</div>
<router-link :to="navigateTo">
<CardLayout
layout="row"
class="[&>div]:justify-start [&>div]:px-4 [&>div]:py-3 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
>
<Avatar
:name="name"
:src="thumbnail"
:size="24"
rounded-full
class="mt-1 flex-shrink-0"
/>
<div class="min-w-0 flex flex-col items-start gap-1.5 w-full">
<div class="flex items-center min-w-0 justify-between gap-2 w-full">
<h5 class="text-sm font-medium truncate min-w-0 text-n-slate-12 py-1">
{{ name }}
</h5>
<span
v-if="updatedAtTime"
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
>
{{ $t('SEARCH.UPDATED_AT', { time: updatedAtTime }) }}
</span>
</div>
<div
class="grid items-center gap-3 m-0 text-sm overflow-hidden min-w-0 grid-cols-[minmax(0,max-content)_auto_minmax(0,max-content)_auto_minmax(0,max-content)]"
>
<span
v-if="email"
class="truncate text-n-slate-11 min-w-0"
:title="email"
>
{{ email }}
</span>
<div v-if="email && phone" class="w-px h-3 bg-n-slate-6 rounded" />
<span
v-if="phone"
:title="phone"
class="truncate text-n-slate-11 min-w-0"
>
{{ phone }}
</span>
<div
v-if="(email || phone) && countryDetails"
class="w-px h-3 bg-n-slate-6 rounded"
/>
<span
v-if="countryDetails"
class="truncate text-n-slate-11 flex items-center gap-1 min-w-0"
>
<Flag
:country="countryDetails.countryCode"
class="size-3 shrink-0"
/>
<span class="truncate min-w-0">{{ formattedLocation }}</span>
</span>
</div>
</div>
</CardLayout>
</router-link>
</template>
@@ -34,15 +34,17 @@ const accountId = useMapGetter('getCurrentAccountId');
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="contacts.length" class="space-y-1.5 list-none">
<ul v-if="contacts.length" class="space-y-3 list-none">
<li v-for="contact in contacts" :key="contact.id">
<SearchResultContactItem
:id="contact.id"
:name="contact.name"
:email="contact.email"
:phone="contact.phone_number"
:phone="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:account-id="accountId"
:thumbnail="contact.thumbnail"
:updated-at="contact.lastActivityAt"
/>
</li>
</ul>
@@ -1,10 +1,12 @@
<script setup>
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper.js';
import { dynamicTime } from 'shared/helpers/timeHelper';
import InboxName from 'dashboard/components/widgets/InboxName.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useInbox } from 'dashboard/composables/useInbox';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
id: {
@@ -41,6 +43,8 @@ const props = defineProps({
},
});
const { inbox } = useInbox(props.inbox?.id);
const navigateTo = computed(() => {
const params = {};
if (props.messageId) {
@@ -52,7 +56,10 @@ const navigateTo = computed(() => {
);
});
const createdAtTime = dynamicTime(props.createdAt);
const createdAtTime = computed(() => {
if (!props.createdAt) return '';
return dynamicTime(props.createdAt);
});
const infoItems = computed(() => [
{
@@ -75,58 +82,76 @@ const infoItems = computed(() => [
const visibleInfoItems = computed(() =>
infoItems.value.filter(item => item.show)
);
const inboxName = computed(() => props.inbox?.name);
const inboxIcon = computed(() => {
if (!inbox.value) return null;
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
</script>
<template>
<router-link
:to="navigateTo"
class="flex p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
>
<Avatar
name="chats"
:size="24"
icon-name="i-lucide-messages-square"
class="[&>span]:rounded"
/>
<div class="flex-grow min-w-0 ml-2">
<div class="flex items-center min-w-0 justify-between gap-1 mb-1">
<div class="flex">
<woot-label
class="!bg-n-slate-3 dark:!bg-n-solid-3 !border-n-weak dark:!border-n-strong m-0"
:title="`#${id}`"
:show-close="false"
small
/>
<div
class="flex items-center justify-center h-5 ml-1 rounded bg-n-slate-3 dark:bg-n-solid-3 w-fit rtl:ml-0 rtl:mr-1"
>
<InboxName
:inbox="inbox"
class="mx-2 bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-11 dark:text-n-slate-11"
<router-link :to="navigateTo">
<CardLayout
layout="col"
class="[&>div]:justify-start [&>div]:gap-2 [&>div]:px-4 [&>div]:py-3 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
>
<div
class="flex items-center min-w-0 justify-between gap-2 w-full h-7 mb-1"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 flex-shrink-0">
<Icon
icon="i-lucide-hash"
class="flex-shrink-0 text-n-slate-11 size-4"
/>
<span class="text-n-slate-12 text-sm leading-4">
{{ id }}
</span>
</div>
<div v-if="inboxName" class="w-px h-3 bg-n-strong" />
<div v-if="inboxName" class="flex items-center gap-1.5 flex-shrink-0">
<div
v-if="inboxIcon"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-4"
>
<Icon
:icon="inboxIcon"
class="flex-shrink-0 text-n-slate-11 size-2.5"
/>
</div>
<span class="text-sm leading-4 text-n-slate-12">
{{ inboxName }}
</span>
</div>
</div>
<span
class="text-xs font-normal min-w-0 truncate text-n-slate-11 dark:text-n-slate-11"
v-if="createdAtTime"
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
>
{{ createdAtTime }}
</span>
</div>
<div class="flex flex-wrap gap-x-2 gap-y-1.5">
<h5
v-for="item in visibleInfoItems"
:key="item.label"
class="m-0 text-sm min-w-0 text-n-slate-12 dark:text-n-slate-12 truncate"
<div class="flex flex-wrap gap-x-2 gap-y-1.5 items-center">
<template
v-for="(item, index) in visibleInfoItems"
:key="`info-${index}`"
>
<span
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
>
{{ $t(item.label) }}:
</span>
{{ item.value }}
</h5>
<h5 class="m-0 text-sm min-w-0 text-n-slate-12 truncate">
<span class="text-sm leading-4 font-normal text-n-slate-11">
{{ $t(item.label) + ':' }}
</span>
{{ item.value }}
</h5>
<div
v-if="index < visibleInfoItems.length - 1"
class="w-px h-3 bg-n-strong"
/>
</template>
</div>
<slot />
</div>
</CardLayout>
</router-link>
</template>
@@ -1,6 +1,7 @@
<script setup>
import { defineProps, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultSection from './SearchResultSection.vue';
import SearchResultConversationItem from './SearchResultConversationItem.vue';
@@ -28,7 +29,7 @@ const accountId = useMapGetter('getCurrentAccountId');
const conversationsWithSubject = computed(() => {
return props.conversations.map(conversation => ({
...conversation,
mail_subject: conversation.additional_attributes?.mail_subject || '',
mailSubject: conversation.additionalAttributes?.mailSubject || '',
}));
});
</script>
@@ -41,7 +42,7 @@ const conversationsWithSubject = computed(() => {
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="conversations.length" class="space-y-1.5 list-none">
<ul v-if="conversations.length" class="space-y-3 list-none">
<li
v-for="conversation in conversationsWithSubject"
:key="conversation.id"
@@ -52,8 +53,8 @@ const conversationsWithSubject = computed(() => {
:email="conversation.contact.email"
:account-id="accountId"
:inbox="conversation.inbox"
:created-at="conversation.created_at"
:email-subject="conversation.mail_subject"
:created-at="conversation.createdAt"
:email-subject="conversation.mailSubject"
/>
</li>
</ul>
@@ -0,0 +1,169 @@
<script setup>
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper.js';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { useInbox } from 'dashboard/composables/useInbox';
import { ATTACHMENT_TYPES } from 'dashboard/components-next/message/constants.js';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import FileChip from 'next/message/chips/File.vue';
import AudioChip from 'next/message/chips/Audio.vue';
import TranscribedText from './TranscribedText.vue';
const props = defineProps({
id: {
type: Number,
default: 0,
},
inboxId: {
type: Number,
default: 0,
},
isPrivate: {
type: Boolean,
default: false,
},
accountId: {
type: [String, Number],
default: '',
},
createdAt: {
type: [String, Date, Number],
default: '',
},
messageId: {
type: Number,
default: 0,
},
attachments: {
type: Array,
default: () => [],
},
});
const { inbox } = useInbox(props.inboxId);
const navigateTo = computed(() => {
const params = {};
if (props.messageId) {
params.messageId = props.messageId;
}
return frontendURL(
`accounts/${props.accountId}/conversations/${props.id}`,
params
);
});
const createdAtTime = computed(() => {
if (!props.createdAt) return '';
return dynamicTime(props.createdAt);
});
const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
if (!inbox.value) return null;
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
const fileAttachments = computed(() => {
return props.attachments.filter(
attachment => attachment.fileType !== ATTACHMENT_TYPES.AUDIO
);
});
const audioAttachments = computed(() => {
return props.attachments.filter(
attachment => attachment.fileType === ATTACHMENT_TYPES.AUDIO
);
});
</script>
<template>
<router-link :to="navigateTo">
<CardLayout
layout="col"
class="[&>div]:justify-start [&>div]:gap-2 [&>div]:px-4 [&>div]:py-3 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
>
<div
class="flex items-center min-w-0 justify-between gap-2 w-full h-7 mb-1"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 flex-shrink-0">
<Icon
icon="i-lucide-hash"
class="flex-shrink-0 text-n-slate-11 size-4"
/>
<span class="text-n-slate-12 text-sm leading-4">
{{ id }}
</span>
</div>
<div v-if="inboxName" class="w-px h-3 bg-n-strong" />
<div v-if="inboxName" class="flex items-center gap-1.5 flex-shrink-0">
<div
v-if="inboxIcon"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-4"
>
<Icon
:icon="inboxIcon"
class="flex-shrink-0 text-n-slate-11 size-2.5"
/>
</div>
<span class="text-sm leading-4 text-n-slate-12">
{{ inboxName }}
</span>
</div>
<div v-if="isPrivate" class="w-px h-3 bg-n-strong" />
<div
v-if="isPrivate"
class="flex items-center text-n-amber-11 gap-1.5 flex-shrink-0"
>
<Icon icon="i-lucide-lock-keyhole" class="flex-shrink-0 size-3.5" />
<span class="text-sm leading-4">
{{ $t('SEARCH.PRIVATE') }}
</span>
</div>
</div>
<span
v-if="createdAtTime"
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
>
{{ createdAtTime }}
</span>
</div>
<slot />
<div v-if="audioAttachments.length" class="mt-1.5 space-y-4 w-full">
<div
v-for="attachment in audioAttachments"
:key="attachment.id"
class="w-full"
>
<AudioChip
class="bg-n-alpha-2 dark:bg-n-alpha-2 text-n-slate-12"
:attachment="attachment"
:show-transcribed-text="false"
@click.prevent
/>
<div v-if="attachment.transcribedText" class="pt-2">
<TranscribedText :text="attachment.transcribedText" />
</div>
</div>
</div>
<div
v-if="fileAttachments.length"
class="flex gap-2 flex-wrap items-center mt-1.5"
>
<FileChip
v-for="attachment in fileAttachments"
:key="attachment.id"
:attachment="attachment"
class="!h-8"
@click.stop
/>
</div>
</CardLayout>
</router-link>
</template>
@@ -2,7 +2,7 @@
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultConversationItem from './SearchResultConversationItem.vue';
import SearchResultMessageItem from './SearchResultMessageItem.vue';
import SearchResultSection from './SearchResultSection.vue';
import MessageContent from './MessageContent.vue';
@@ -43,21 +43,23 @@ const getName = message => {
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="messages.length" class="space-y-1.5 list-none">
<ul v-if="messages.length" class="space-y-3 list-none">
<li v-for="message in messages" :key="message.id">
<SearchResultConversationItem
:id="message.conversation_id"
<SearchResultMessageItem
:id="message.conversationId"
:account-id="accountId"
:inbox="message.inbox"
:created-at="message.created_at"
:inbox-id="message.inboxId"
:created-at="message.createdAt"
:message-id="message.id"
:is-private="message.private"
:attachments="message.attachments"
>
<MessageContent
:author="getName(message)"
:message="message"
:search-term="query"
/>
</SearchResultConversationItem>
</SearchResultMessageItem>
</li>
</ul>
</SearchResultSection>
@@ -1,6 +1,8 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
title: {
type: String,
@@ -28,9 +30,12 @@ const titleCase = computed(() => props.title.toLowerCase());
</script>
<template>
<section class="mx-0 my-2">
<div v-if="showTitle" class="sticky top-0 p-2 z-50 mb-0.5 bg-n-background">
<h3 class="text-sm text-n-slate-12">{{ title }}</h3>
<section class="mx-0 mb-3">
<div
v-if="showTitle"
class="sticky top-0 pt-2 py-3 z-50 bg-gradient-to-b from-n-background from-80% to-transparent mb-3 -mx-1.5 px-1.5"
>
<h3 class="text-sm text-n-slate-11">{{ title }}</h3>
</div>
<slot />
<woot-loading-state
@@ -39,9 +44,12 @@ const titleCase = computed(() => props.title.toLowerCase());
/>
<div
v-if="empty && !isFetching"
class="flex items-center justify-center px-4 py-6 m-2 rounded-xl bg-n-slate-2 dark:bg-n-solid-1"
class="flex items-start justify-center px-4 py-6 rounded-xl bg-n-slate-2 dark:bg-n-solid-1"
>
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
<Icon
icon="i-lucide-info"
class="text-n-slate-11 size-4 flex-shrink-0 mt-[3px]"
/>
<p class="mx-2 my-0 text-center text-n-slate-11">
{{ $t('SEARCH.EMPTY_STATE', { item: titleCase, query }) }}
</p>
@@ -1,5 +1,8 @@
<script setup>
import { ref, watch } from 'vue';
import { computed, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
tabs: {
@@ -14,6 +17,8 @@ const props = defineProps({
const emit = defineEmits(['tabChange']);
const { t } = useI18n();
const activeTab = ref(props.selectedTab);
watch(
@@ -25,24 +30,35 @@ watch(
}
);
const onTabChange = index => {
const tabBarTabs = computed(() => {
return props.tabs.map(tab => ({
label: tab.name,
count: tab.showBadge ? tab.count : null,
}));
});
const onTabChange = selectedTab => {
const index = props.tabs.findIndex(tab => tab.name === selectedTab.label);
activeTab.value = index;
emit('tabChange', props.tabs[index].key);
};
</script>
<template>
<div class="mt-1 border-b border-n-weak">
<woot-tabs :index="activeTab" :border="false" @change="onTabChange">
<woot-tabs-item
v-for="(item, index) in tabs"
:key="item.key"
:index="index"
:name="item.name"
:count="item.count"
:show-badge="item.showBadge"
is-compact
/>
</woot-tabs>
<div class="flex items-center justify-between mt-7 mb-4">
<TabBar
:tabs="tabBarTabs"
:initial-active-tab="activeTab"
@tab-changed="onTabChange"
/>
<Button
:label="t('SEARCH.SORT_BY.RELEVANCE')"
sm
link
slate
class="hover:!no-underline pointer-events-none lg:inline-flex hidden"
icon="i-lucide-arrow-up-down"
/>
</div>
</template>
@@ -4,6 +4,8 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useRouter, useRoute } from 'vue-router';
import { useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { generateURLParams, parseURLParams } from '../helpers/searchHelper';
import {
ROLES,
CONVERSATION_PERMISSIONS,
@@ -47,7 +49,7 @@ const articleRecords = useMapGetter('conversationSearch/getArticleRecords');
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
const addTypeToRecords = (records, type) =>
records.value.map(item => ({ ...item, type }));
records.value.map(item => ({ ...useCamelCase(item, { deep: true }), type }));
const mappedContacts = computed(() =>
addTypeToRecords(contactRecords, 'contact')
@@ -64,6 +66,11 @@ const mappedArticles = computed(() =>
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
const searchResultSectionClass = computed(() => ({
'mt-4': isSelectedTabAll.value,
'mt-0.5': !isSelectedTabAll.value,
}));
const sliceRecordsIfAllTab = items =>
isSelectedTabAll.value ? items.value.slice(0, 5) : items.value;
@@ -227,30 +234,55 @@ const showViewMore = computed(() => ({
articles: mappedArticles.value?.length > 5 && isSelectedTabAll.value,
}));
const filters = ref({
from: null,
in: null,
dateRange: { type: null, from: null, to: null },
});
const clearSearchResult = () => {
pages.value = { contacts: 1, conversations: 1, messages: 1, articles: 1 };
store.dispatch('conversationSearch/clearSearchResults');
};
const buildSearchPayload = (basePayload = {}, searchType = 'message') => {
const payload = { ...basePayload };
// Only include filters if advanced search is enabled
if (isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)) {
// Date filters apply to all search types
if (filters.value.dateRange.from) {
payload.since = filters.value.dateRange.from;
}
if (filters.value.dateRange.to) {
payload.until = filters.value.dateRange.to;
}
// Only messages support 'from' and 'inboxId' filters
if (searchType === 'message') {
if (filters.value.from) payload.from = filters.value.from;
if (filters.value.in) payload.inboxId = filters.value.in;
}
}
return payload;
};
const updateURL = () => {
// Update route with tab as URL parameter and query as query parameter
const params = { accountId: route.params.accountId };
const queryParams = {};
const params = {
accountId: route.params.accountId,
...(selectedTab.value !== 'all' && { tab: selectedTab.value }),
};
// Only add tab param if not 'all'
if (selectedTab.value !== 'all') {
params.tab = selectedTab.value;
}
const queryParams = {
...(query.value?.trim() && { q: query.value.trim() }),
...generateURLParams(
filters.value,
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
),
};
if (query.value?.trim()) {
queryParams.q = query.value.trim();
}
router.replace({
name: 'search',
params,
query: queryParams,
});
router.replace({ name: 'search', params, query: queryParams });
};
const onSearch = q => {
@@ -259,7 +291,13 @@ const onSearch = q => {
updateURL();
if (!q) return;
useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
store.dispatch('conversationSearch/fullSearch', { q, page: 1 });
const searchPayload = buildSearchPayload({ q, page: 1 });
store.dispatch('conversationSearch/fullSearch', searchPayload);
};
const onFilterChange = () => {
onSearch(query.value);
};
const onBack = () => {
@@ -280,12 +318,16 @@ const loadMore = () => {
};
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
const tab = selectedTab.value;
pages.value[tab] += 1;
store.dispatch(SEARCH_ACTIONS[tab], {
q: query.value,
page: pages.value[tab],
});
const payload = buildSearchPayload(
{ q: query.value, page: pages.value[tab] },
tab
);
store.dispatch(SEARCH_ACTIONS[tab], payload);
};
const onTabChange = tab => {
@@ -295,6 +337,13 @@ const onTabChange = tab => {
onMounted(() => {
store.dispatch('conversationSearch/clearSearchResults');
store.dispatch('agents/get');
const parsedFilters = parseURLParams(
route.query,
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
);
filters.value = parsedFilters;
// Auto-execute search if query parameter exists
if (route.query.q) {
@@ -321,9 +370,14 @@ onUnmounted(() => {
/>
</div>
<section class="flex flex-col flex-grow w-full h-full overflow-hidden">
<div class="w-full max-w-4xl mx-auto">
<div class="w-full max-w-5xl mx-auto z-[60]">
<div class="flex flex-col w-full px-4">
<SearchHeader :initial-query="query" @search="onSearch" />
<SearchHeader
v-model:filters="filters"
:initial-query="query"
@search="onSearch"
@filter-change="onFilterChange"
/>
<SearchTabs
v-if="query"
:tabs="tabs"
@@ -333,7 +387,7 @@ onUnmounted(() => {
</div>
</div>
<div class="flex-grow w-full h-full overflow-y-auto">
<div class="w-full max-w-4xl mx-auto px-4 pb-6">
<div class="w-full max-w-5xl mx-auto px-4 pb-6">
<div v-if="showResultsSection">
<Policy
:permissions="[...ROLES, CONTACT_PERMISSIONS]"
@@ -345,6 +399,7 @@ onUnmounted(() => {
:contacts="contacts"
:query="query"
:show-title="isSelectedTabAll"
class="mt-0.5"
/>
<NextButton
v-if="showViewMore.contacts"
@@ -367,6 +422,7 @@ onUnmounted(() => {
:messages="messages"
:query="query"
:show-title="isSelectedTabAll"
:class="searchResultSectionClass"
/>
<NextButton
v-if="showViewMore.messages"
@@ -389,6 +445,7 @@ onUnmounted(() => {
:conversations="conversations"
:query="query"
:show-title="isSelectedTabAll"
:class="searchResultSectionClass"
/>
<NextButton
v-if="showViewMore.conversations"
@@ -413,6 +470,7 @@ onUnmounted(() => {
:articles="articles"
:query="query"
:show-title="isSelectedTabAll"
:class="searchResultSectionClass"
/>
<NextButton
v-if="showViewMore.articles"
@@ -425,7 +483,7 @@ onUnmounted(() => {
/>
</Policy>
<div v-if="showLoadMore" class="flex justify-center mt-4 mb-6">
<div v-if="showLoadMore" class="flex justify-center mt-3 mb-6">
<NextButton
v-if="!isSelectedTabAll"
:label="t(`SEARCH.LOAD_MORE`)"
@@ -0,0 +1,45 @@
<script setup>
import { useExpandableContent } from 'shared/composables/useExpandableContent';
defineProps({
text: {
type: String,
default: '',
},
});
const { contentElement, showReadMore, showReadLess, toggleExpanded } =
useExpandableContent({ useResizeObserverForCheck: true });
</script>
<template>
<span class="py-2 text-xs font-medium">
{{ $t('SEARCH.TRANSCRIPT') }}
</span>
<div
class="text-n-slate-11 pt-1 text-sm rounded-lg w-full break-words grid items-center"
:class="showReadMore ? 'grid-cols-[1fr_auto]' : 'grid-cols-1'"
>
<div
ref="contentElement"
class="min-w-0"
:class="{ 'overflow-hidden line-clamp-1': showReadMore }"
>
{{ text }}
<button
v-if="showReadLess"
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium ltr:ml-0.5 rtl:mr-0.5"
@click.prevent.stop="toggleExpanded(false)"
>
{{ $t('SEARCH.READ_LESS') }}
</button>
</div>
<button
v-if="showReadMore"
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium justify-self-end ltr:ml-0.5 rtl:mr-0.5"
@click.prevent.stop="toggleExpanded(true)"
>
{{ $t('SEARCH.READ_MORE') }}
</button>
</div>
</template>
@@ -0,0 +1,75 @@
import ContactAPI from 'dashboard/api/contacts';
export const DATE_RANGE_TYPES = {
LAST_7_DAYS: 'last_7_days',
LAST_30_DAYS: 'last_30_days',
LAST_60_DAYS: 'last_60_days',
LAST_90_DAYS: 'last_90_days',
CUSTOM: 'custom',
BETWEEN: 'between',
};
export const generateURLParams = (
{ from, in: inboxId, dateRange },
isAdvancedSearchEnabled = true
) => {
const params = {};
// Only include filter params if advanced search is enabled
if (isAdvancedSearchEnabled) {
if (from) params.from = from;
if (inboxId) params.inbox_id = inboxId;
if (dateRange?.type) {
const { type, from: dateFrom, to: dateTo } = dateRange;
params.range = type;
if (dateFrom) params.since = dateFrom;
if (dateTo) params.until = dateTo;
}
}
return params;
};
export const parseURLParams = (query, isAdvancedSearchEnabled = true) => {
// If advanced search is disabled, return empty filters
if (!isAdvancedSearchEnabled) {
return {
from: null,
in: null,
dateRange: {
type: null,
from: null,
to: null,
},
};
}
const { from, inbox_id, since, until, range } = query;
let type = range;
if (!type && (since || until)) {
type = DATE_RANGE_TYPES.BETWEEN;
}
return {
from: from || null,
in: inbox_id ? Number(inbox_id) : null,
dateRange: {
type,
from: since ? Number(since) : null,
to: until ? Number(until) : null,
},
};
};
export const fetchContactDetails = async id => {
try {
const response = await ContactAPI.show(id);
return response.data.payload;
} catch (error) {
// error
return null;
}
};
@@ -0,0 +1,379 @@
import ContactAPI from 'dashboard/api/contacts';
import {
DATE_RANGE_TYPES,
generateURLParams,
parseURLParams,
fetchContactDetails,
} from '../searchHelper';
// Mock ContactAPI
vi.mock('dashboard/api/contacts', () => ({
default: {
show: vi.fn(),
},
}));
describe('#generateURLParams', () => {
it('returns empty object when no parameters provided', () => {
expect(generateURLParams({})).toEqual({});
});
it('generates params with from parameter', () => {
const result = generateURLParams({ from: 'agent:123' });
expect(result).toEqual({ from: 'agent:123' });
});
it('generates params with inbox_id parameter', () => {
const result = generateURLParams({ in: 456 });
expect(result).toEqual({ inbox_id: 456 });
});
it('generates params with all basic parameters', () => {
const result = generateURLParams({
from: 'contact:789',
in: 123,
});
expect(result).toEqual({
from: 'contact:789',
inbox_id: 123,
});
});
describe('with date range', () => {
it('generates params with date range type only', () => {
const result = generateURLParams({
dateRange: { type: DATE_RANGE_TYPES.LAST_7_DAYS },
});
expect(result).toEqual({
range: 'last_7_days',
});
});
it('generates params with BETWEEN date range', () => {
const result = generateURLParams({
dateRange: {
type: DATE_RANGE_TYPES.BETWEEN,
from: 1640995200,
to: 1672531199,
},
});
expect(result).toEqual({
range: 'between',
since: 1640995200,
until: 1672531199,
});
});
it('generates params with CUSTOM date range', () => {
const result = generateURLParams({
dateRange: {
type: DATE_RANGE_TYPES.CUSTOM,
from: 1640995200,
to: 1672531199,
},
});
expect(result).toEqual({
range: 'custom',
since: 1640995200,
until: 1672531199,
});
});
it('handles date range with missing from/to values', () => {
const result = generateURLParams({
dateRange: {
type: DATE_RANGE_TYPES.BETWEEN,
from: null,
to: undefined,
},
});
expect(result).toEqual({
range: 'between',
});
});
});
it('generates params with all parameters combined', () => {
const result = generateURLParams({
from: 'agent:456',
in: 789,
dateRange: {
type: DATE_RANGE_TYPES.BETWEEN,
from: 1640995200,
to: 1672531199,
},
});
expect(result).toEqual({
from: 'agent:456',
inbox_id: 789,
range: 'between',
since: 1640995200,
until: 1672531199,
});
});
describe('when advanced search is disabled', () => {
it('returns empty object when isAdvancedSearchEnabled is false', () => {
const result = generateURLParams(
{
from: 'agent:123',
in: 456,
dateRange: {
type: DATE_RANGE_TYPES.BETWEEN,
from: 1640995200,
to: 1672531199,
},
},
false
);
expect(result).toEqual({});
});
it('strips all filter params when feature flag is disabled', () => {
const result = generateURLParams(
{
from: 'contact:789',
in: 123,
},
false
);
expect(result).toEqual({});
});
it('strips date range params when feature flag is disabled', () => {
const result = generateURLParams(
{
dateRange: {
type: DATE_RANGE_TYPES.LAST_7_DAYS,
from: 1640995200,
to: 1672531199,
},
},
false
);
expect(result).toEqual({});
});
});
});
describe('#parseURLParams', () => {
it('returns default values for empty query', () => {
const result = parseURLParams({});
expect(result).toEqual({
from: null,
in: null,
dateRange: {
type: undefined,
from: null,
to: null,
},
});
});
it('parses from parameter', () => {
const result = parseURLParams({ from: 'agent:123' });
expect(result).toEqual({
from: 'agent:123',
in: null,
dateRange: {
type: undefined,
from: null,
to: null,
},
});
});
it('parses inbox_id parameter as number', () => {
const result = parseURLParams({ inbox_id: '456' });
expect(result).toEqual({
from: null,
in: 456,
dateRange: {
type: undefined,
from: null,
to: null,
},
});
});
it('parses explicit range parameter', () => {
const result = parseURLParams({
range: 'last_7_days',
since: '1640995200',
until: '1672531199',
});
expect(result).toEqual({
from: null,
in: null,
dateRange: {
type: 'last_7_days',
from: 1640995200,
to: 1672531199,
},
});
});
describe('inferred date range types', () => {
it('infers BETWEEN type when both since and until are present', () => {
const result = parseURLParams({
since: '1640995200',
until: '1672531199',
});
expect(result).toEqual({
from: null,
in: null,
dateRange: {
type: 'between',
from: 1640995200,
to: 1672531199,
},
});
});
it('prioritizes explicit range over inferred type', () => {
const result = parseURLParams({
range: 'custom',
since: '1640995200',
until: '1672531199',
});
expect(result).toEqual({
from: null,
in: null,
dateRange: {
type: 'custom',
from: 1640995200,
to: 1672531199,
},
});
});
});
it('parses all parameters combined', () => {
const result = parseURLParams({
from: 'contact:789',
inbox_id: '123',
range: 'between',
since: '1640995200',
until: '1672531199',
});
expect(result).toEqual({
from: 'contact:789',
in: 123,
dateRange: {
type: 'between',
from: 1640995200,
to: 1672531199,
},
});
});
describe('when advanced search is disabled', () => {
it('returns empty filters when isAdvancedSearchEnabled is false', () => {
const result = parseURLParams(
{
from: 'agent:123',
inbox_id: '456',
range: 'between',
since: '1640995200',
until: '1672531199',
},
false
);
expect(result).toEqual({
from: null,
in: null,
dateRange: {
type: null,
from: null,
to: null,
},
});
});
it('ignores all filter params from URL when feature flag is disabled', () => {
const result = parseURLParams(
{
from: 'contact:789',
inbox_id: '123',
},
false
);
expect(result).toEqual({
from: null,
in: null,
dateRange: {
type: null,
from: null,
to: null,
},
});
});
it('ignores date range params from URL when feature flag is disabled', () => {
const result = parseURLParams(
{
range: 'last_7_days',
since: '1640995200',
until: '1672531199',
},
false
);
expect(result).toEqual({
from: null,
in: null,
dateRange: {
type: null,
from: null,
to: null,
},
});
});
});
});
describe('#fetchContactDetails', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns contact data on successful API call', async () => {
const mockContactData = {
id: 123,
name: 'John Doe',
email: 'john@example.com',
};
ContactAPI.show.mockResolvedValue({
data: {
payload: mockContactData,
},
});
const result = await fetchContactDetails(123);
expect(result).toEqual(mockContactData);
expect(ContactAPI.show).toHaveBeenCalledWith(123);
});
it('returns null on API error', async () => {
ContactAPI.show.mockRejectedValue(new Error('API Error'));
const result = await fetchContactDetails(123);
expect(result).toBeNull();
expect(ContactAPI.show).toHaveBeenCalledWith(123);
});
it('handles different contact ID types', async () => {
const mockContactData = { id: 456, name: 'Jane Doe' };
ContactAPI.show.mockResolvedValue({
data: { payload: mockContactData },
});
// Test with string ID
await fetchContactDetails('456');
expect(ContactAPI.show).toHaveBeenCalledWith('456');
// Test with number ID
await fetchContactDetails(456);
expect(ContactAPI.show).toHaveBeenCalledWith(456);
});
});
@@ -6,6 +6,7 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import { computed, onMounted, ref, defineOptions } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -17,6 +18,8 @@ const getters = useStoreGetters();
const store = useStore();
const { t } = useI18n();
const { getPlainText } = useMessageFormatter();
const showAddPopup = ref(false);
const loading = ref({});
const showEditPopup = ref(false);
@@ -182,7 +185,7 @@ const tableHeaders = computed(() => {
{{ cannedItem.short_code }}
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4 md:break-all whitespace-normal">
{{ cannedItem.content }}
{{ getPlainText(cannedItem.content) }}
</td>
<td class="py-4 flex justify-end gap-1">
<Button
@@ -3,15 +3,22 @@ import { reactive, onMounted, ref, defineProps, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useInbox } from 'dashboard/composables/useInbox';
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import SectionLayout from 'dashboard/routes/dashboard/settings/account/components/SectionLayout.vue';
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
import CSATTemplate from 'dashboard/components-next/message/bubbles/Template/CSAT.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'next/switch/Switch.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
import ConfirmTemplateUpdateDialog from './components/ConfirmTemplateUpdateDialog.vue';
const props = defineProps({
inbox: { type: Object, required: true },
@@ -21,6 +28,10 @@ const { t } = useI18n();
const store = useStore();
const labels = useMapGetter('labels/getLabels');
const { isAWhatsAppCloudChannel: isWhatsAppChannel } = useInbox(
props.inbox?.id
);
const isUpdating = ref(false);
const selectedLabelValues = ref([]);
const currentLabel = ref('');
@@ -29,7 +40,19 @@ const state = reactive({
csatSurveyEnabled: false,
displayType: 'emoji',
message: '',
templateButtonText: 'Please rate us',
surveyRuleOperator: 'contains',
templateLanguage: '',
});
const templateStatus = ref(null);
const templateLoading = ref(false);
const confirmDialog = ref(null);
const originalTemplateValues = ref({
message: '',
templateButtonText: '',
templateLanguage: '',
});
const filterTypes = [
@@ -51,6 +74,59 @@ const labelOptions = computed(() =>
: []
);
const languageOptions = computed(() =>
languages.map(({ name, id }) => ({ label: `${name} (${id})`, value: id }))
);
const messagePreviewData = computed(() => ({
content: state.message || t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'),
}));
const shouldShowTemplateStatus = computed(
() => templateStatus.value && !templateLoading.value
);
const templateApprovalStatus = computed(() => {
const statusMap = {
APPROVED: {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.APPROVED'),
icon: 'i-lucide-circle-check',
color: 'text-n-teal-11',
},
PENDING: {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.PENDING'),
icon: 'i-lucide-clock',
color: 'text-n-amber-11',
},
REJECTED: {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.REJECTED'),
icon: 'i-lucide-circle-x',
color: 'text-n-ruby-10',
},
};
// Handle template not found case
if (templateStatus.value?.error === 'TEMPLATE_NOT_FOUND') {
return {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.NOT_FOUND'),
icon: 'i-lucide-alert-triangle',
color: 'text-n-ruby-10',
};
}
// Handle existing template with status
if (templateStatus.value?.template_exists && templateStatus.value.status) {
return statusMap[templateStatus.value.status] || statusMap.PENDING;
}
// Default case - no template exists
return {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.DEFAULT'),
icon: 'i-lucide-stamp',
color: 'text-n-slate-11',
};
});
const initializeState = () => {
if (!props.inbox) return;
@@ -63,21 +139,63 @@ const initializeState = () => {
const {
display_type: displayType = CSAT_DISPLAY_TYPES.EMOJI,
message = '',
button_text: buttonText = 'Please rate us',
language = 'en',
survey_rules: surveyRules = {},
} = csat_config;
state.displayType = displayType;
state.message = message;
state.templateButtonText = buttonText;
state.templateLanguage = language;
state.surveyRuleOperator = surveyRules.operator || 'contains';
selectedLabelValues.value = Array.isArray(surveyRules.values)
? [...surveyRules.values]
: [];
// Store original template values for change detection
if (isWhatsAppChannel.value) {
originalTemplateValues.value = {
message: state.message,
templateButtonText: state.templateButtonText,
templateLanguage: state.templateLanguage,
};
}
};
const checkTemplateStatus = async () => {
if (!isWhatsAppChannel.value) return;
try {
templateLoading.value = true;
const response = await store.dispatch('inboxes/getCSATTemplateStatus', {
inboxId: props.inbox.id,
});
// Handle case where template doesn't exist
if (!response.template_exists && response.error === 'Template not found') {
templateStatus.value = {
template_exists: false,
error: 'TEMPLATE_NOT_FOUND',
};
} else {
templateStatus.value = response;
}
} catch (error) {
templateStatus.value = {
template_exists: false,
error: 'API_ERROR',
};
} finally {
templateLoading.value = false;
}
};
onMounted(() => {
initializeState();
if (!labels.value?.length) store.dispatch('labels/get');
if (isWhatsAppChannel.value) checkTemplateStatus();
});
watch(() => props.inbox, initializeState, { immediate: true });
@@ -105,6 +223,49 @@ const removeLabel = label => {
}
};
// Check if template-related fields have changed
const hasTemplateChanges = () => {
if (!isWhatsAppChannel.value) return false;
const original = originalTemplateValues.value;
return (
original.message !== state.message ||
original.templateButtonText !== state.templateButtonText ||
original.templateLanguage !== state.templateLanguage
);
};
// Check if there's an existing template
const hasExistingTemplate = () => {
const { template_exists, error } = templateStatus.value || {};
return template_exists && !error;
};
// Check if we should create a template
const shouldCreateTemplate = () => {
// Create template if no existing template
if (!hasExistingTemplate()) {
return true;
}
// Create template if there are changes to template fields
return hasTemplateChanges();
};
// Build template config for saving
const buildTemplateConfig = () => {
if (!hasExistingTemplate()) return null;
const { template_name, template_id, template, status } =
templateStatus.value || {};
return {
name: template_name,
template_id,
language: template?.language || state.templateLanguage,
status,
};
};
const updateInbox = async attributes => {
const payload = {
id: props.inbox.id,
@@ -115,31 +276,103 @@ const updateInbox = async attributes => {
return store.dispatch('inboxes/updateInbox', payload);
};
const saveSettings = async () => {
const createTemplate = async () => {
if (!isWhatsAppChannel.value) return null;
const response = await store.dispatch('inboxes/createCSATTemplate', {
inboxId: props.inbox.id,
template: {
message: state.message,
button_text: state.templateButtonText,
language: state.templateLanguage,
},
});
useAlert(t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.SUCCESS_MESSAGE'));
return response.template;
};
const performSave = async () => {
try {
isUpdating.value = true;
let newTemplateData = null;
// For WhatsApp channels, create template first if needed
if (
isWhatsAppChannel.value &&
state.csatSurveyEnabled &&
shouldCreateTemplate()
) {
try {
newTemplateData = await createTemplate();
} catch (error) {
const errorMessage =
error.response?.data?.error ||
t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.ERROR_MESSAGE');
useAlert(errorMessage);
return;
}
}
const csatConfig = {
display_type: state.displayType,
message: state.message,
button_text: state.templateButtonText,
language: state.templateLanguage,
survey_rules: {
operator: state.surveyRuleOperator,
values: selectedLabelValues.value,
},
};
// Use new template data if created, otherwise preserve existing template information
if (newTemplateData) {
csatConfig.template = {
name: newTemplateData.name,
template_id: newTemplateData.template_id,
language: newTemplateData.language,
status: newTemplateData.status,
created_at: new Date().toISOString(),
};
} else {
const templateConfig = buildTemplateConfig();
if (templateConfig) {
csatConfig.template = templateConfig;
}
}
await updateInbox({
csat_survey_enabled: state.csatSurveyEnabled,
csat_config: csatConfig,
});
useAlert(t('INBOX_MGMT.CSAT.API.SUCCESS_MESSAGE'));
checkTemplateStatus();
} catch (error) {
useAlert(t('INBOX_MGMT.CSAT.API.ERROR_MESSAGE'));
} finally {
isUpdating.value = false;
}
};
const saveSettings = async () => {
// Check if we need to show confirmation dialog for WhatsApp template changes
if (
isWhatsAppChannel.value &&
state.csatSurveyEnabled &&
hasExistingTemplate() &&
hasTemplateChanges()
) {
confirmDialog.value?.open();
return;
}
await performSave();
};
const handleConfirmTemplateUpdate = async () => {
// We will delete the template before creating the template
await performSave();
};
</script>
<template>
@@ -155,7 +388,9 @@ const saveSettings = async () => {
</template>
<div class="grid gap-5">
<!-- Show display type only for non-WhatsApp channels -->
<WithLabel
v-if="!isWhatsAppChannel"
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
name="display_type"
>
@@ -165,14 +400,97 @@ const saveSettings = async () => {
/>
</WithLabel>
<WithLabel :label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')" name="message">
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
class="w-full"
/>
</WithLabel>
<template v-if="isWhatsAppChannel">
<div
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
>
<div class="flex flex-col gap-3 basis-3/5">
<WithLabel
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
name="message"
>
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
channel-type="Context::Plain"
class="w-full"
/>
</WithLabel>
<Input
v-model="state.templateButtonText"
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
:placeholder="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER')"
class="w-full"
/>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
name="language"
>
<ComboBox
v-model="state.templateLanguage"
:options="languageOptions"
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
/>
</WithLabel>
<div
v-if="shouldShowTemplateStatus"
class="flex gap-2 items-center mt-4"
>
<Icon
:icon="templateApprovalStatus.icon"
:class="templateApprovalStatus.color"
class="size-4"
/>
<span
:class="templateApprovalStatus.color"
class="text-sm font-medium"
>
{{ templateApprovalStatus.text }}
</span>
</div>
</div>
<div
class="flex flex-col flex-shrink-0 justify-start items-center p-6 mt-1 rounded-xl basis-2/5 bg-n-slate-2 outline outline-1 outline-n-weak"
>
<p
class="inline-flex items-center text-sm font-medium text-n-slate-11"
>
{{ $t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.LABEL') }}
<Icon
v-tooltip.top-end="
$t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.TOOLTIP')
"
icon="i-lucide-info"
class="flex-shrink-0 mx-1 size-4"
/>
</p>
<CSATTemplate
:message="messagePreviewData"
:button-text="state.templateButtonText"
class="pt-12"
/>
</div>
</div>
</template>
<!-- Non-WhatsApp channels layout -->
<template v-else>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
name="message"
>
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
class="w-full"
/>
</WithLabel>
</template>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
@@ -180,7 +498,7 @@ const saveSettings = async () => {
>
<div class="mb-4">
<span
class="inline-flex flex-wrap items-center gap-1.5 text-sm text-n-slate-12"
class="inline-flex flex-wrap gap-1.5 items-center text-sm text-n-slate-12"
>
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }}
<FilterSelect
@@ -217,7 +535,11 @@ const saveSettings = async () => {
</div>
</WithLabel>
<p class="text-sm italic text-n-slate-11">
{{ $t('INBOX_MGMT.CSAT.NOTE') }}
{{
isWhatsAppChannel
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
: $t('INBOX_MGMT.CSAT.NOTE')
}}
</p>
<div>
<NextButton
@@ -229,5 +551,11 @@ const saveSettings = async () => {
</div>
</div>
</SectionLayout>
<!-- Template Update Confirmation Dialog -->
<ConfirmTemplateUpdateDialog
ref="confirmDialog"
@confirm="handleConfirmTemplateUpdate"
/>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const emit = defineEmits(['confirm']);
const { t } = useI18n();
const dialogRef = ref(null);
const handleDialogConfirm = () => {
emit('confirm');
dialogRef.value?.close();
};
defineExpose({
dialogRef,
open: () => dialogRef.value?.open(),
close: () => dialogRef.value?.close(),
});
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.TITLE')"
:description="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.DESCRIPTION')"
:confirm-button-label="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.CONFIRM')"
:cancel-button-label="t('INBOX_MGMT.CSAT.TEMPLATE_UPDATE_DIALOG.CANCEL')"
@confirm="handleDialogConfirm"
/>
</template>
@@ -57,8 +57,9 @@ export const actions = {
});
}
},
async fullSearch({ commit, dispatch }, { q }) {
if (!q) {
async fullSearch({ commit, dispatch }, payload) {
const { q, ...filters } = payload;
if (!q && !Object.keys(filters).length) {
return;
}
commit(types.FULL_SEARCH_SET_UI_FLAG, {
@@ -67,10 +68,10 @@ export const actions = {
});
try {
await Promise.all([
dispatch('contactSearch', { q }),
dispatch('conversationSearch', { q }),
dispatch('messageSearch', { q }),
dispatch('articleSearch', { q }),
dispatch('contactSearch', { q, ...filters }),
dispatch('conversationSearch', { q, ...filters }),
dispatch('messageSearch', { q, ...filters }),
dispatch('articleSearch', { q, ...filters }),
]);
} catch (error) {
// Ignore error
@@ -81,10 +82,11 @@ export const actions = {
});
}
},
async contactSearch({ commit }, { q, page = 1 }) {
async contactSearch({ commit }, payload) {
const { page = 1, ...searchParams } = payload;
commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.contacts({ q, page });
const { data } = await SearchAPI.contacts({ ...searchParams, page });
commit(types.CONTACT_SEARCH_SET, data.payload.contacts);
} catch (error) {
// Ignore error
@@ -92,10 +94,11 @@ export const actions = {
commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async conversationSearch({ commit }, { q, page = 1 }) {
async conversationSearch({ commit }, payload) {
const { page = 1, ...searchParams } = payload;
commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.conversations({ q, page });
const { data } = await SearchAPI.conversations({ ...searchParams, page });
commit(types.CONVERSATION_SEARCH_SET, data.payload.conversations);
} catch (error) {
// Ignore error
@@ -103,10 +106,11 @@ export const actions = {
commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async messageSearch({ commit }, { q, page = 1 }) {
async messageSearch({ commit }, payload) {
const { page = 1, ...searchParams } = payload;
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.messages({ q, page });
const { data } = await SearchAPI.messages({ ...searchParams, page });
commit(types.MESSAGE_SEARCH_SET, data.payload.messages);
} catch (error) {
// Ignore error
@@ -114,10 +118,11 @@ export const actions = {
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async articleSearch({ commit }, { q, page = 1 }) {
async articleSearch({ commit }, payload) {
const { page = 1, ...searchParams } = payload;
commit(types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.articles({ q, page });
const { data } = await SearchAPI.articles({ ...searchParams, page });
commit(types.ARTICLE_SEARCH_SET, data.payload.articles);
} catch (error) {
// Ignore error
@@ -83,6 +83,14 @@ export const getters = {
return false;
}
// Filter out CSAT templates (customer_satisfaction_survey and its versions)
if (
template.name &&
template.name.startsWith('customer_satisfaction_survey')
) {
return false;
}
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
const hasUnsupportedComponents = template.components.some(
component =>
@@ -344,6 +352,14 @@ export const actions = {
throw new Error(error);
}
},
createCSATTemplate: async (_, { inboxId, template }) => {
const response = await InboxesAPI.createCSATTemplate(inboxId, template);
return response.data;
},
getCSATTemplateStatus: async (_, { inboxId }) => {
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
return response.data;
},
};
export const mutations = {
@@ -77,6 +77,16 @@ describe('#actions', () => {
expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' });
expect(dispatch).toHaveBeenCalledWith('articleSearch', { q: 'test' });
});
it('should pass filters to all search actions including articleSearch', async () => {
const payload = { q: 'test', since: 1700000000, until: 1732000000 };
await actions.fullSearch({ commit, dispatch }, payload);
expect(dispatch).toHaveBeenCalledWith('contactSearch', payload);
expect(dispatch).toHaveBeenCalledWith('conversationSearch', payload);
expect(dispatch).toHaveBeenCalledWith('messageSearch', payload);
expect(dispatch).toHaveBeenCalledWith('articleSearch', payload);
});
});
describe('#contactSearch', () => {
@@ -165,6 +175,22 @@ describe('#actions', () => {
]);
});
it('should handle article search with date filters', async () => {
axios.get.mockResolvedValue({
data: { payload: { articles: [{ id: 1 }] } },
});
await actions.articleSearch(
{ commit },
{ q: 'test', page: 1, since: 1700000000, until: 1732000000 }
);
expect(commit.mock.calls).toEqual([
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.ARTICLE_SEARCH_SET, [{ id: 1 }]],
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
it('should handle failed article search', async () => {
axios.get.mockRejectedValue({});
await actions.articleSearch({ commit }, { q: 'test' });
@@ -0,0 +1,130 @@
import { nextTick } from 'vue';
import { useExpandableContent } from '../useExpandableContent';
// Mock VueUse composables
vi.mock('@vueuse/core', () => ({
useToggle: vi.fn(initialValue => {
let value = initialValue;
const toggle = newValue => {
value = newValue !== undefined ? newValue : !value;
};
return [{ value }, toggle];
}),
useResizeObserver: vi.fn((element, callback) => {
// Store callback for manual triggering in tests
if (element.value) {
callback();
}
}),
}));
describe('useExpandableContent', () => {
let originalGetComputedStyle;
beforeEach(() => {
// Mock window.getComputedStyle
originalGetComputedStyle = window.getComputedStyle;
window.getComputedStyle = vi.fn(() => ({
lineHeight: '20px',
}));
});
afterEach(() => {
window.getComputedStyle = originalGetComputedStyle;
vi.clearAllMocks();
});
it('returns expected properties', () => {
const result = useExpandableContent();
expect(result).toHaveProperty('contentElement');
expect(result).toHaveProperty('isExpanded');
expect(result).toHaveProperty('needsToggle');
expect(result).toHaveProperty('toggleExpanded');
expect(result).toHaveProperty('checkOverflow');
});
it('initializes with default values', () => {
const { isExpanded, needsToggle } = useExpandableContent();
expect(isExpanded.value).toBe(false);
expect(needsToggle.value).toBe(false);
});
it('checkOverflow sets needsToggle to true when content overflows', async () => {
const { contentElement, needsToggle, checkOverflow } =
useExpandableContent();
// Mock element with overflow
contentElement.value = {
scrollHeight: 100, // Much larger than 2 lines (40px)
};
checkOverflow();
await nextTick();
expect(needsToggle.value).toBe(true);
});
it('checkOverflow sets needsToggle to false when content fits', async () => {
const { contentElement, needsToggle, checkOverflow } =
useExpandableContent();
// Mock element without overflow
contentElement.value = {
scrollHeight: 30, // Less than 2 lines (40px)
};
checkOverflow();
await nextTick();
expect(needsToggle.value).toBe(false);
});
it('respects custom maxLines option', async () => {
const { contentElement, needsToggle, checkOverflow } = useExpandableContent(
{
maxLines: 3,
}
);
// Mock element that fits in 3 lines but not 2
contentElement.value = {
scrollHeight: 50, // Fits in 3 lines (60px) but not 2 lines (40px)
};
checkOverflow();
await nextTick();
expect(needsToggle.value).toBe(false);
});
it('uses defaultLineHeight when computed style is unavailable', async () => {
window.getComputedStyle = vi.fn(() => ({
lineHeight: 'normal', // Not a valid number
}));
const { contentElement, needsToggle, checkOverflow } = useExpandableContent(
{
defaultLineHeight: 16,
}
);
// Mock element that overflows with 16px line height (32px max)
contentElement.value = {
scrollHeight: 40,
};
checkOverflow();
await nextTick();
expect(needsToggle.value).toBe(true);
});
it('handles null contentElement gracefully', () => {
const { checkOverflow } = useExpandableContent();
// Should not throw when contentElement is null
expect(() => checkOverflow()).not.toThrow();
});
});
@@ -0,0 +1,62 @@
import { ref, computed, nextTick, onMounted } from 'vue';
import { useToggle, useResizeObserver } from '@vueuse/core';
/**
* Composable for handling expandable content with "Read more / Read less" functionality.
* Detects content overflow and provides toggle state for expansion.
*
* @param {Object} options - Configuration options
* @param {number} [options.maxLines=2] - Maximum number of lines before showing toggle
* @param {number} [options.defaultLineHeight=20] - Fallback line height if computed style is unavailable
* @param {boolean} [options.useResizeObserverForCheck=false] - Use ResizeObserver for continuous overflow checking
* @returns {Object} - Composable state and methods
*/
export function useExpandableContent(options = {}) {
const {
maxLines = 2,
defaultLineHeight = 20,
useResizeObserverForCheck = false,
} = options;
const contentElement = ref(null);
const [isExpanded, toggleExpanded] = useToggle(false);
const needsToggle = ref(false);
const showReadMore = computed(() => needsToggle.value && !isExpanded.value);
const showReadLess = computed(() => needsToggle.value && isExpanded.value);
/**
* Checks if content overflows the maximum allowed height
* and updates needsToggle accordingly
*/
const checkOverflow = () => {
if (!contentElement.value) return;
const element = contentElement.value;
const computedStyle = window.getComputedStyle(element);
const lineHeight =
parseFloat(computedStyle.lineHeight) || defaultLineHeight;
const maxHeight = lineHeight * maxLines;
needsToggle.value = element.scrollHeight > maxHeight;
};
// Setup overflow checking based on configuration
if (useResizeObserverForCheck) {
useResizeObserver(contentElement, checkOverflow);
} else {
onMounted(() => {
nextTick(checkOverflow);
});
}
return {
contentElement,
isExpanded,
needsToggle,
showReadMore,
showReadLess,
toggleExpanded,
checkOverflow,
};
}
@@ -164,4 +164,5 @@ export const TWILIO_CONTENT_TEMPLATE_TYPES = {
TEXT: 'text',
MEDIA: 'media',
QUICK_REPLY: 'quick_reply',
CALL_TO_ACTION: 'call_to_action',
};
+19 -5
View File
@@ -322,12 +322,21 @@ class Message < ApplicationRecord
end
def update_waiting_since
if human_response? && !private && conversation.waiting_since.present?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
waiting_present = conversation.waiting_since.present?
if waiting_present && !private
if human_response?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
elsif bot_response?
# Bot responses also clear waiting_since (simpler than checking on next customer message)
conversation.update(waiting_since: nil)
end
end
# Set waiting_since when customer sends a message (if currently blank)
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
end
@@ -341,6 +350,11 @@ class Message < ApplicationRecord
sender.is_a?(User)
end
def bot_response?
# Check if this is a response from AgentBot or Captain::Assistant
outgoing? && sender_type.in?(['AgentBot', 'Captain::Assistant'])
end
def dispatch_create_events
Rails.configuration.dispatcher.dispatch(MESSAGE_CREATED, Time.zone.now, message: self, performed_by: Current.executed_by)
+1 -1
View File
@@ -53,7 +53,7 @@ class Portal < ApplicationRecord
file_type: logo.content_type,
account_id: account_id,
file_url: url_for(logo),
blob_id: logo.blob_id,
blob_id: logo.blob.signed_id,
filename: logo.filename.to_s
}
end
+1 -1
View File
@@ -5,7 +5,7 @@
# id :bigint not null, primary key
# name :string
# subscriptions :jsonb
# url :string
# url :text
# webhook_type :integer default("account_type")
# created_at :datetime not null
# updated_at :datetime not null
+11
View File
@@ -95,6 +95,7 @@ class MailPresenter < SimpleDelegator
content_type: content_type,
date: date,
from: from,
headers: headers_data,
html_content: html_content,
in_reply_to: in_reply_to,
message_id: message_id,
@@ -136,6 +137,16 @@ class MailPresenter < SimpleDelegator
from_email_address(@mail[:reply_to].try(:value)) || @mail['X-Original-Sender'].try(:value) || from_email_address(from.first)
end
def headers_data
headers = {
'x-original-from' => @mail['X-Original-From']&.value,
'x-original-sender' => @mail['X-Original-Sender']&.value,
'x-forwarded-for' => @mail['X-Forwarded-For']&.value
}.compact
headers.presence
end
def from_email_address(email)
Mail::Address.new(email).address
end
+61 -1
View File
@@ -4,7 +4,9 @@ class CsatSurveyService
def perform
return unless should_send_csat_survey?
if within_messaging_window?
if whatsapp_channel? && template_available_and_approved?
send_whatsapp_template_survey
elsif within_messaging_window?
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
else
create_csat_not_sent_activity_message
@@ -35,6 +37,64 @@ class CsatSurveyService
conversation.can_reply?
end
def whatsapp_channel?
inbox.channel_type == 'Channel::Whatsapp'
end
def template_available_and_approved?
template_config = inbox.csat_config&.dig('template')
return false unless template_config
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
status_result = inbox.channel.provider_service.get_template_status(template_name)
status_result[:success] && status_result[:template][:status] == 'APPROVED'
rescue StandardError => e
Rails.logger.error "Error checking CSAT template status: #{e.message}"
false
end
def send_whatsapp_template_survey
template_config = inbox.csat_config&.dig('template')
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
phone_number = conversation.contact_inbox.source_id
template_info = build_template_info(template_name, template_config)
message = build_csat_message
message_id = inbox.channel.provider_service.send_template(phone_number, template_info, message)
message.update!(source_id: message_id) if message_id.present?
rescue StandardError => e
Rails.logger.error "Error sending WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
end
def build_template_info(template_name, template_config)
{
name: template_name,
lang_code: template_config['language'] || 'en',
parameters: [
{
type: 'button',
sub_type: 'url',
index: '0',
parameters: [{ type: 'text', text: conversation.uuid }]
}
]
}
end
def build_csat_message
conversation.messages.build(
account: conversation.account,
inbox: inbox,
message_type: :outgoing,
content: inbox.csat_config&.dig('message') || 'Please rate this conversation',
content_type: :input_csat
)
end
def create_csat_not_sent_activity_message
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
activity_message_params = {
@@ -42,6 +42,10 @@ class Messages::MarkdownRenderers::InstagramRenderer < Messages::MarkdownRendere
cr
end
def code_block(node)
out(node.string_content)
end
def softbreak(_node)
out("\n")
end
@@ -30,6 +30,10 @@ class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderer
cr
end
def code_block(node)
out(node.string_content)
end
def softbreak(_node)
out("\n")
end
+99 -18
View File
@@ -31,25 +31,38 @@ class SearchService
end
def filter_conversations
@conversations = current_account.conversations.where(inbox_id: accessable_inbox_ids)
.joins('INNER JOIN contacts ON conversations.contact_id = contacts.id')
.where("cast(conversations.display_id as text) ILIKE :search OR contacts.name ILIKE :search OR contacts.email
conversations_query = current_account.conversations.where(inbox_id: accessable_inbox_ids)
.joins('INNER JOIN contacts ON conversations.contact_id = contacts.id')
.where("cast(conversations.display_id as text) ILIKE :search OR contacts.name ILIKE :search OR contacts.email
ILIKE :search OR contacts.phone_number ILIKE :search OR contacts.identifier ILIKE :search", search: "%#{search_query}%")
.order('conversations.created_at DESC')
.page(params[:page])
.per(15)
if current_account.feature_enabled?('advanced_search')
conversations_query = apply_time_filter(conversations_query,
'conversations.last_activity_at')
end
@conversations = conversations_query.order('conversations.created_at DESC')
.page(params[:page])
.per(15)
end
def filter_messages
@messages = if use_gin_search
filter_messages_with_gin
elsif should_run_advanced_search?
advanced_search
advanced_search_with_fallback
else
filter_messages_with_like
end
end
def advanced_search_with_fallback
advanced_search
rescue Faraday::ConnectionFailed, Searchkick::Error, Elasticsearch::Transport::Transport::Error => e
Rails.logger.warn("Elasticsearch unavailable, falling back to SQL search: #{e.message}")
use_gin_search ? filter_messages_with_gin : filter_messages_with_like
end
def should_run_advanced_search?
ChatwootApp.advanced_search_allowed? && current_account.feature_enabled?('advanced_search')
end
@@ -58,6 +71,7 @@ class SearchService
def filter_messages_with_gin
base_query = message_base_query
base_query = apply_message_filters(base_query)
if search_query.present?
# Use the @@ operator with to_tsquery for better GIN index utilization
@@ -82,11 +96,12 @@ class SearchService
end
def filter_messages_with_like
message_base_query
.where('messages.content ILIKE :search', search: "%#{search_query}%")
.reorder('created_at DESC')
.page(params[:page])
.per(15)
base_query = message_base_query
base_query = apply_message_filters(base_query)
base_query.where('messages.content ILIKE :search', search: "%#{search_query}%")
.reorder('created_at DESC')
.page(params[:page])
.per(15)
end
def message_base_query
@@ -95,6 +110,45 @@ class SearchService
query
end
def apply_message_filters(query)
return query unless current_account.feature_enabled?('advanced_search')
query = apply_time_filter(query, 'messages.created_at')
query = apply_sender_filter(query)
apply_inbox_id_filter(query)
end
def apply_sender_filter(query)
sender_type, sender_id = parse_from_param(params[:from])
return query unless sender_type && sender_id
query.where(sender_type: sender_type, sender_id: sender_id)
end
def parse_from_param(from_param)
return [nil, nil] unless from_param&.match?(/\A(contact|agent):\d+\z/)
type, id = from_param.split(':')
sender_type = type == 'agent' ? 'User' : 'Contact'
[sender_type, id.to_i]
end
def apply_inbox_id_filter(query)
return query if params[:inbox_id].blank?
inbox_id = params[:inbox_id].to_i
return query if inbox_id.zero?
return query unless validate_inbox_access(inbox_id)
query.where(inbox_id: inbox_id)
end
def validate_inbox_access(inbox_id)
return true if should_skip_inbox_filtering?
accessable_inbox_ids.include?(inbox_id)
end
def should_skip_inbox_filtering?
account_user.administrator? || user_has_access_to_all_inboxes?
end
@@ -108,19 +162,46 @@ class SearchService
end
def filter_contacts
@contacts = current_account.contacts.where(
contacts_query = current_account.contacts.where(
"name ILIKE :search OR email ILIKE :search OR phone_number
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%"
).resolved_contacts(
)
contacts_query = apply_time_filter(contacts_query, 'last_activity_at') if current_account.feature_enabled?('advanced_search')
@contacts = contacts_query.resolved_contacts(
use_crm_v2: current_account.feature_enabled?('crm_v2')
).order_on_last_activity_at('desc').page(params[:page]).per(15)
end
def filter_articles
@articles = current_account.articles
.text_search(search_query)
.page(params[:page])
.per(15)
articles_query = current_account.articles.text_search(search_query)
articles_query = apply_time_filter(articles_query, 'updated_at') if current_account.feature_enabled?('advanced_search')
@articles = articles_query.page(params[:page]).per(15)
end
def apply_time_filter(query, column_name)
return query if params[:since].blank? && params[:until].blank?
query = query.where("#{column_name} >= ?", cap_since_time(params[:since])) if params[:since].present?
query = query.where("#{column_name} <= ?", cap_until_time(params[:until])) if params[:until].present?
query
end
def cap_since_time(since_param)
max_lookback = 90.days.ago
requested_time = Time.zone.at(since_param.to_i)
# Silently cap to max_lookback if requested time is too far back
[requested_time, max_lookback].max
end
def cap_until_time(until_param)
max_future = 90.days.from_now
requested_time = Time.zone.at(until_param.to_i)
[requested_time, max_future].min
end
end
@@ -23,8 +23,8 @@ class Twilio::TemplateProcessorService
def build_content_variables(template)
case template['template_type']
when 'text', 'quick_reply'
convert_text_template(template_params) # Text and quick reply templates use body variables
when 'text', 'quick_reply', 'call_to_action'
convert_text_template(template_params) # Text, quick reply and call-to-action templates use body variables
when 'media'
convert_media_template(template_params)
else
@@ -63,6 +63,8 @@ class Twilio::TemplateSyncService
'media'
elsif template_types.include?('twilio/quick-reply')
'quick_reply'
elsif template_types.include?('twilio/call-to-action')
'call_to_action'
elsif template_types.include?('twilio/catalog')
'catalog'
else
@@ -107,6 +109,8 @@ class Twilio::TemplateSyncService
template_types['twilio/media']['body']
elsif template_types['twilio/quick-reply']
template_types['twilio/quick-reply']['body']
elsif template_types['twilio/call-to-action']
template_types['twilio/call-to-action']['body']
elsif template_types['twilio/catalog']
template_types['twilio/catalog']['body']
else
@@ -6,3 +6,5 @@ json.slug article.slug
json.portal_slug article.portal.slug
json.account_id article.account_id
json.category_name article.category&.name
json.status article.status
json.updated_at article.updated_at.to_i
@@ -3,3 +3,5 @@ json.id contact.id
json.name contact.name
json.phone_number contact.phone_number
json.identifier contact.identifier
json.additional_attributes contact.additional_attributes
json.last_activity_at contact.last_activity_at&.to_i
+2 -1
View File
@@ -5,7 +5,8 @@
# Alfred
# Add here as you use it for more features
# Used for Round Robin, Conversation Emails & Online Presence
$alfred = ConnectionPool.new(size: 5, timeout: 1) do
alfred_size = ENV.fetch('REDIS_ALFRED_SIZE', 5)
$alfred = ConnectionPool.new(size: alfred_size, timeout: 1) do
redis = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app)
Redis::Namespace.new('alfred', redis: redis, warning: true)
end
+3
View File
@@ -84,6 +84,9 @@ en:
invalid: must be a valid domain name
search:
query_missing: Specify search string with parameter q
messages:
search:
time_range_limit_exceeded: 'Search is limited to the last %{days} days'
categories:
locale:
unique: should be unique in the category and portal
@@ -0,0 +1,9 @@
class ChangeWebhookUrlToText < ActiveRecord::Migration[7.1]
def up
change_column :webhooks, :url, :text
end
def down
change_column :webhooks, :url, :string
end
end
+2 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do
ActiveRecord::Schema[7.1].define(version: 2025_12_29_081141) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -1239,7 +1239,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do
create_table "webhooks", force: :cascade do |t|
t.integer "account_id"
t.integer "inbox_id"
t.string "url"
t.text "url"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "webhook_type", default: 0
@@ -30,7 +30,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
delegate :account, :inbox, to: :@conversation
def generate_and_process_response
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation_id: @conversation.id).generate_response(
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation_id: @conversation.display_id).generate_response(
message_history: collect_previous_messages
)
process_response
@@ -36,6 +36,7 @@ class Captain::Llm::ContactAttributesService < Llm::BaseAiService
model: @model,
temperature: @temperature,
account_id: @conversation.account_id,
conversation_id: @conversation.display_id,
feature_name: 'contact_attributes',
messages: [
{ role: 'system', content: system_prompt },
@@ -37,6 +37,7 @@ class Captain::Llm::ContactNotesService < Llm::BaseAiService
model: @model,
temperature: @temperature,
account_id: @conversation.account_id,
conversation_id: @conversation.display_id,
feature_name: 'contact_notes',
messages: [
{ role: 'system', content: system_prompt },
@@ -100,7 +100,7 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
model: @model,
temperature: @temperature,
account_id: @conversation.account_id,
conversation_id: @conversation.id,
conversation_id: @conversation.display_id,
feature_name: 'conversation_faq',
messages: [
{ role: 'system', content: system_prompt },
@@ -1,7 +1,7 @@
module Enterprise::SearchService
def advanced_search
where_conditions = { account_id: current_account.id }
where_conditions[:inbox_id] = accessable_inbox_ids unless should_skip_inbox_filtering?
where_conditions = build_where_conditions
apply_filters(where_conditions)
Message.search(
search_query,
@@ -12,4 +12,77 @@ module Enterprise::SearchService
per_page: 15
)
end
private
def build_where_conditions
conditions = { account_id: current_account.id }
conditions[:inbox_id] = accessable_inbox_ids unless should_skip_inbox_filtering?
conditions
end
def apply_filters(where_conditions)
apply_from_filter(where_conditions)
apply_time_range_filter(where_conditions)
apply_inbox_filter(where_conditions)
end
def apply_from_filter(where_conditions)
sender_type, sender_id = parse_from_param(params[:from])
return unless sender_type && sender_id
where_conditions[:sender_type] = sender_type
where_conditions[:sender_id] = sender_id
end
def parse_from_param(from_param)
return [nil, nil] unless from_param&.match?(/\A(contact|agent):\d+\z/)
type, id = from_param.split(':')
sender_type = type == 'agent' ? 'User' : 'Contact'
[sender_type, id.to_i]
end
def apply_time_range_filter(where_conditions)
time_conditions = {}
time_conditions[:gte] = enforce_time_limit(params[:since])
time_conditions[:lte] = cap_until_time(params[:until]) if params[:until].present?
where_conditions[:created_at] = time_conditions if time_conditions.any?
end
def cap_until_time(until_param)
max_future = 90.days.from_now
requested_time = Time.zone.at(until_param.to_i)
[requested_time, max_future].min
end
def enforce_time_limit(since_param)
max_lookback = Limits::MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS.days.ago
if since_param.present?
requested_time = Time.zone.at(since_param.to_i)
# Silently cap to max_lookback if requested time is too far back
[requested_time, max_lookback].max
else
max_lookback
end
end
def apply_inbox_filter(where_conditions)
return if params[:inbox_id].blank?
inbox_id = params[:inbox_id].to_i
return if inbox_id.zero?
return unless validate_inbox_access(inbox_id)
where_conditions[:inbox_id] = inbox_id
end
def validate_inbox_access(inbox_id)
return true if should_skip_inbox_filtering?
accessable_inbox_ids.include?(inbox_id)
end
end
@@ -1,4 +1,4 @@
class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
class Messages::AudioTranscriptionService
include Integrations::LlmInstrumentation
WHISPER_MODEL = 'whisper-1'.freeze
@@ -6,7 +6,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
attr_reader :attachment, :message, :account
def initialize(attachment)
super()
Llm::Config.initialize!
@attachment = attachment
@message = attachment.message
@account = message.account
@@ -50,23 +50,18 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
temp_file_path = fetch_audio_file
transcribed_text = nil
File.open(temp_file_path, 'rb') do |file|
response = @client.audio.transcribe(
parameters: {
model: 'whisper-1',
file: file,
temperature: 0.4
}
)
transcribed_text = response['text']
transcription = instrument_audio_transcription(instrumentation_params(temp_file_path)) do
RubyLLM.transcribe(temp_file_path, model: WHISPER_MODEL)
end
FileUtils.rm_f(temp_file_path)
update_transcription(transcribed_text)
transcribed_text
update_transcription(transcription.text)
transcription.text
rescue RubyLLM::Error => e
FileUtils.rm_f(temp_file_path) if temp_file_path
Rails.logger.error "Audio transcription error: #{e.message}"
raise
end
def instrumentation_params(file_path)
@@ -74,6 +69,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
span_name: 'llm.messages.audio_transcription',
model: WHISPER_MODEL,
account_id: account&.id,
conversation_id: message.conversation&.display_id,
feature_name: 'audio_transcription',
file_path: file_path
}
@@ -11,7 +11,7 @@ class Integrations::GoogleTranslate::ProcessorService
response = client.translate_text(
contents: [content],
target_language_code: target_language,
target_language_code: bcp47_language_code,
parent: "projects/#{hook.settings['project_id']}",
mime_type: mime_type
)
@@ -23,6 +23,10 @@ class Integrations::GoogleTranslate::ProcessorService
private
def bcp47_language_code
target_language.tr('_', '-')
end
def email_channel?
message&.inbox&.email?
end
+1
View File
@@ -9,6 +9,7 @@ module Limits
COMPANY_NAME_LENGTH_LIMIT = 100
COMPANY_DESCRIPTION_LENGTH_LIMIT = 1000
MAX_CUSTOM_FILTERS_PER_USER = 1000
MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS = 90
def self.conversation_message_per_minute_limit
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
+183
View File
@@ -0,0 +1,183 @@
# rubocop:disable Metrics/BlockLength
namespace :search do
desc 'Create test messages for advanced search manual testing across multiple inboxes'
task setup_test_data: :environment do
puts '🔍 Setting up test data for advanced search...'
account = Account.first
unless account
puts '❌ No account found. Please create an account first.'
exit 1
end
agents = account.users.to_a
unless agents.any?
puts '❌ No agents found. Please create users first.'
exit 1
end
puts "✅ Using account: #{account.name} (ID: #{account.id})"
puts "✅ Found #{agents.count} agent(s)"
# Create missing inbox types for comprehensive testing
puts "\n📥 Checking and creating inboxes..."
# API inbox
unless account.inboxes.exists?(channel_type: 'Channel::Api')
puts ' Creating API inbox...'
account.inboxes.create!(
name: 'Search Test API',
channel: Channel::Api.create!(account: account)
)
end
# Web Widget inbox
unless account.inboxes.exists?(channel_type: 'Channel::WebWidget')
puts ' Creating WebWidget inbox...'
account.inboxes.create!(
name: 'Search Test WebWidget',
channel: Channel::WebWidget.create!(account: account, website_url: 'https://example.com')
)
end
# Email inbox
unless account.inboxes.exists?(channel_type: 'Channel::Email')
puts ' Creating Email inbox...'
account.inboxes.create!(
name: 'Search Test Email',
channel: Channel::Email.create!(
account: account,
email: 'search-test@example.com',
imap_enabled: false,
smtp_enabled: false
)
)
end
inboxes = account.inboxes.to_a
puts "✅ Using #{inboxes.count} inbox(es):"
inboxes.each { |i| puts " - #{i.name} (ID: #{i.id}, Type: #{i.channel_type})" }
# Create 10 test contacts
contacts = []
10.times do |i|
contacts << account.contacts.find_or_create_by!(
email: "test-customer-#{i}@example.com"
) do |c|
c.name = Faker::Name.name
end
end
puts "✅ Created/found #{contacts.count} test contacts"
target_messages = 50_000
messages_per_conversation = 100
total_conversations = target_messages / messages_per_conversation
puts "\n📝 Creating #{target_messages} messages across #{total_conversations} conversations..."
puts " Distribution: #{inboxes.count} inboxes × #{total_conversations / inboxes.count} conversations each"
start_time = 2.years.ago
end_time = Time.current
time_range = end_time - start_time
created_count = 0
failed_count = 0
conversations_per_inbox = total_conversations / inboxes.count
conversation_statuses = [:open, :resolved]
inboxes.each do |inbox|
conversations_per_inbox.times do
# Pick random contact and agent for this conversation
contact = contacts.sample
agent = agents.sample
# Create or find ContactInbox
contact_inbox = ContactInbox.find_or_create_by!(
contact: contact,
inbox: inbox
) do |ci|
ci.source_id = "test_#{SecureRandom.hex(8)}"
end
# Create conversation
conversation = inbox.conversations.create!(
account: account,
contact: contact,
inbox: inbox,
contact_inbox: contact_inbox,
status: conversation_statuses.sample
)
# Create messages for this conversation (50 incoming, 50 outgoing)
50.times do
random_time = start_time + (rand * time_range)
# Incoming message from contact
begin
Message.create!(
content: Faker::Movie.quote,
account: account,
inbox: inbox,
conversation: conversation,
message_type: :incoming,
sender: contact,
created_at: random_time,
updated_at: random_time
)
created_count += 1
rescue StandardError => e
failed_count += 1
puts "❌ Failed to create message: #{e.message}" if failed_count <= 5
end
# Outgoing message from agent
begin
Message.create!(
content: Faker::Movie.quote,
account: account,
inbox: inbox,
conversation: conversation,
message_type: :outgoing,
sender: agent,
created_at: random_time + rand(60..600),
updated_at: random_time + rand(60..600)
)
created_count += 1
rescue StandardError => e
failed_count += 1
puts "❌ Failed to create message: #{e.message}" if failed_count <= 5
end
print "\r🔄 Progress: #{created_count}/#{target_messages} messages created..." if (created_count % 500).zero?
end
end
end
puts "\n\n✅ Successfully created #{created_count} messages!"
puts "❌ Failed: #{failed_count}" if failed_count.positive?
puts "\n📊 Summary:"
puts " - Total messages: #{Message.where(account: account).count}"
puts " - Total conversations: #{Conversation.where(account: account).count}"
min_date = Message.where(account: account).minimum(:created_at)&.strftime('%Y-%m-%d')
max_date = Message.where(account: account).maximum(:created_at)&.strftime('%Y-%m-%d')
puts " - Date range: #{min_date} to #{max_date}"
puts "\nBreakdown by inbox:"
inboxes.each do |inbox|
msg_count = Message.where(inbox: inbox).count
conv_count = Conversation.where(inbox: inbox).count
puts " - #{inbox.name} (#{inbox.channel_type}): #{msg_count} messages, #{conv_count} conversations"
end
puts "\nBreakdown by sender type:"
puts " - Incoming (from contacts): #{Message.where(account: account, message_type: :incoming).count}"
puts " - Outgoing (from agents): #{Message.where(account: account, message_type: :outgoing).count}"
puts "\n🔧 Next steps:"
puts ' 1. Ensure OpenSearch is running: mise elasticsearch-start'
puts ' 2. Reindex messages: rails runner "Message.search_index.import Message.all"'
puts " 3. Enable feature: rails runner \"Account.find(#{account.id}).enable_features('advanced_search')\""
puts "\n💡 Then test the search with filters via API or Rails console!"
end
end
# rubocop:enable Metrics/BlockLength
+1 -1
View File
@@ -33,7 +33,7 @@
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.2.6",
"@chatwoot/prosemirror-schema": "1.3.4",
"@chatwoot/utils": "^0.0.51",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
+5 -5
View File
@@ -20,8 +20,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
specifier: 1.2.6
version: 1.2.6
specifier: 1.3.4
version: 1.3.4
'@chatwoot/utils':
specifier: ^0.0.51
version: 0.0.51
@@ -430,8 +430,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
'@chatwoot/prosemirror-schema@1.2.6':
resolution: {integrity: sha512-ej60kU3m/tP0VoGkOJhj0X+Mxt7fEX5DSEE4IibCZnTM4kMewkMxSYyPu0AXqaA4nKX1hTMrwcbv1t7gVQttWQ==}
'@chatwoot/prosemirror-schema@1.3.4':
resolution: {integrity: sha512-XY1UyG9topVy7fNCt45YgiU/QzlJJRhrcrSWcvAIIcDYKomnEcGOyeAfM3WeFD6GgOuLzRUyIHrW+WQGpHaZvA==}
'@chatwoot/utils@0.0.51':
resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==}
@@ -4909,7 +4909,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
'@chatwoot/prosemirror-schema@1.2.6':
'@chatwoot/prosemirror-schema@1.3.4':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
@@ -69,6 +69,80 @@ RSpec.describe 'Search', type: :request do
expect(response_data[:payload].keys).to contain_exactly(:contacts)
expect(response_data[:payload][:contacts].length).to eq 1
end
it 'returns last_activity_at in contact search results' do
contact = create(:contact, email: 'activity@test.com', account: account, last_activity_at: 3.days.ago)
get "/api/v1/accounts/#{account.id}/search/contacts",
headers: agent.create_new_auth_token,
params: { q: 'activity' },
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)
contact_result = response_data[:payload][:contacts].first
expect(contact_result[:last_activity_at]).to eq(contact.last_activity_at.to_i)
expect(contact_result).not_to have_key(:created_at)
end
context 'with advanced_search feature enabled', :opensearch do
before do
account.enable_features!('advanced_search')
end
it 'filters contacts by since parameter' do
create(:contact, email: 'old@test.com', account: account, last_activity_at: 10.days.ago)
create(:contact, email: 'recent@test.com', account: account, last_activity_at: 2.days.ago)
get "/api/v1/accounts/#{account.id}/search/contacts",
headers: agent.create_new_auth_token,
params: { q: 'test', since: 5.days.ago.to_i },
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)
contact_emails = response_data[:payload][:contacts].pluck(:email)
expect(contact_emails).to include('recent@test.com')
expect(contact_emails).not_to include('old@test.com')
end
it 'filters contacts by until parameter' do
create(:contact, email: 'old@test.com', account: account, last_activity_at: 10.days.ago)
create(:contact, email: 'recent@test.com', account: account, last_activity_at: 2.days.ago)
get "/api/v1/accounts/#{account.id}/search/contacts",
headers: agent.create_new_auth_token,
params: { q: 'test', until: 5.days.ago.to_i },
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)
contact_emails = response_data[:payload][:contacts].pluck(:email)
expect(contact_emails).to include('old@test.com')
expect(contact_emails).not_to include('recent@test.com')
end
it 'filters contacts by both since and until parameters' do
create(:contact, email: 'veryold@test.com', account: account, last_activity_at: 20.days.ago)
create(:contact, email: 'old@test.com', account: account, last_activity_at: 10.days.ago)
create(:contact, email: 'recent@test.com', account: account, last_activity_at: 2.days.ago)
get "/api/v1/accounts/#{account.id}/search/contacts",
headers: agent.create_new_auth_token,
params: { q: 'test', since: 15.days.ago.to_i, until: 5.days.ago.to_i },
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)
contact_emails = response_data[:payload][:contacts].pluck(:email)
expect(contact_emails).to include('old@test.com')
expect(contact_emails).not_to include('veryold@test.com', 'recent@test.com')
end
end
end
end
@@ -94,6 +168,104 @@ RSpec.describe 'Search', type: :request do
expect(response_data[:payload].keys).to contain_exactly(:conversations)
expect(response_data[:payload][:conversations].length).to eq 1
end
context 'with advanced_search feature enabled', :opensearch do
before do
account.enable_features!('advanced_search')
end
it 'filters conversations by since parameter' do
unique_id = SecureRandom.hex(8)
old_contact = create(:contact, email: "old-#{unique_id}@test.com", account: account)
recent_contact = create(:contact, email: "recent-#{unique_id}@test.com", account: account)
old_conversation = create(:conversation, account: account, contact: old_contact)
recent_conversation = create(:conversation, account: account, contact: recent_contact)
create(:message, conversation: old_conversation, account: account, content: 'message 1')
create(:message, conversation: recent_conversation, account: account, content: 'message 2')
create(:inbox_member, user: agent, inbox: old_conversation.inbox)
create(:inbox_member, user: agent, inbox: recent_conversation.inbox)
# Bypass CURRENT_TIMESTAMP default
# rubocop:disable Rails/SkipsModelValidations
Conversation.where(id: old_conversation.id).update_all(last_activity_at: 10.days.ago)
Conversation.where(id: recent_conversation.id).update_all(last_activity_at: 2.days.ago)
# rubocop:enable Rails/SkipsModelValidations
get "/api/v1/accounts/#{account.id}/search/conversations",
headers: agent.create_new_auth_token,
params: { q: unique_id, since: 5.days.ago.to_i },
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)
conversation_display_ids = response_data[:payload][:conversations].pluck(:id)
expect(conversation_display_ids).to eq([recent_conversation.display_id])
end
it 'filters conversations by until parameter' do
unique_id = SecureRandom.hex(8)
old_contact = create(:contact, email: "old-#{unique_id}@test.com", account: account)
recent_contact = create(:contact, email: "recent-#{unique_id}@test.com", account: account)
old_conversation = create(:conversation, account: account, contact: old_contact)
recent_conversation = create(:conversation, account: account, contact: recent_contact)
create(:message, conversation: old_conversation, account: account, content: 'message 1')
create(:message, conversation: recent_conversation, account: account, content: 'message 2')
create(:inbox_member, user: agent, inbox: old_conversation.inbox)
create(:inbox_member, user: agent, inbox: recent_conversation.inbox)
# Bypass CURRENT_TIMESTAMP default
# rubocop:disable Rails/SkipsModelValidations
Conversation.where(id: old_conversation.id).update_all(last_activity_at: 10.days.ago)
Conversation.where(id: recent_conversation.id).update_all(last_activity_at: 2.days.ago)
# rubocop:enable Rails/SkipsModelValidations
get "/api/v1/accounts/#{account.id}/search/conversations",
headers: agent.create_new_auth_token,
params: { q: unique_id, until: 5.days.ago.to_i },
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)
conversation_display_ids = response_data[:payload][:conversations].pluck(:id)
expect(conversation_display_ids).to eq([old_conversation.display_id])
end
it 'filters conversations by both since and until parameters' do
unique_id = SecureRandom.hex(8)
very_old_contact = create(:contact, email: "veryold-#{unique_id}@test.com", account: account)
old_contact = create(:contact, email: "old-#{unique_id}@test.com", account: account)
recent_contact = create(:contact, email: "recent-#{unique_id}@test.com", account: account)
very_old_conversation = create(:conversation, account: account, contact: very_old_contact)
old_conversation = create(:conversation, account: account, contact: old_contact)
recent_conversation = create(:conversation, account: account, contact: recent_contact)
create(:message, conversation: very_old_conversation, account: account, content: 'message 1')
create(:message, conversation: old_conversation, account: account, content: 'message 2')
create(:message, conversation: recent_conversation, account: account, content: 'message 3')
create(:inbox_member, user: agent, inbox: very_old_conversation.inbox)
create(:inbox_member, user: agent, inbox: old_conversation.inbox)
create(:inbox_member, user: agent, inbox: recent_conversation.inbox)
# Bypass CURRENT_TIMESTAMP default
# rubocop:disable Rails/SkipsModelValidations
Conversation.where(id: very_old_conversation.id).update_all(last_activity_at: 20.days.ago)
Conversation.where(id: old_conversation.id).update_all(last_activity_at: 10.days.ago)
Conversation.where(id: recent_conversation.id).update_all(last_activity_at: 2.days.ago)
# rubocop:enable Rails/SkipsModelValidations
get "/api/v1/accounts/#{account.id}/search/conversations",
headers: agent.create_new_auth_token,
params: { q: unique_id, since: 15.days.ago.to_i, until: 5.days.ago.to_i },
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)
conversation_display_ids = response_data[:payload][:conversations].pluck(:id)
expect(conversation_display_ids).to eq([old_conversation.display_id])
end
end
end
end
@@ -175,6 +347,74 @@ RSpec.describe 'Search', type: :request do
expect(response_data[:payload][:articles].length).to eq 15 # Default per_page is 15
end
context 'with advanced_search feature enabled', :opensearch do
before do
account.enable_features!('advanced_search')
end
it 'filters articles by since parameter' do
portal = create(:portal, account: account)
old_article = create(:article, title: 'Old Article test', account: account, portal: portal,
author: agent, status: 'published', updated_at: 10.days.ago)
recent_article = create(:article, title: 'Recent Article test', account: account, portal: portal,
author: agent, status: 'published', updated_at: 2.days.ago)
get "/api/v1/accounts/#{account.id}/search/articles",
headers: agent.create_new_auth_token,
params: { q: 'test', since: 5.days.ago.to_i },
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)
article_ids = response_data[:payload][:articles].pluck(:id)
expect(article_ids).to include(recent_article.id)
expect(article_ids).not_to include(old_article.id)
end
it 'filters articles by until parameter' do
portal = create(:portal, account: account)
old_article = create(:article, title: 'Old Article test', account: account, portal: portal,
author: agent, status: 'published', updated_at: 10.days.ago)
recent_article = create(:article, title: 'Recent Article test', account: account, portal: portal,
author: agent, status: 'published', updated_at: 2.days.ago)
get "/api/v1/accounts/#{account.id}/search/articles",
headers: agent.create_new_auth_token,
params: { q: 'test', until: 5.days.ago.to_i },
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)
article_ids = response_data[:payload][:articles].pluck(:id)
expect(article_ids).to include(old_article.id)
expect(article_ids).not_to include(recent_article.id)
end
it 'filters articles by both since and until parameters' do
portal = create(:portal, account: account)
very_old_article = create(:article, title: 'Very Old Article test', account: account, portal: portal,
author: agent, status: 'published', updated_at: 20.days.ago)
old_article = create(:article, title: 'Old Article test', account: account, portal: portal,
author: agent, status: 'published', updated_at: 10.days.ago)
recent_article = create(:article, title: 'Recent Article test', account: account, portal: portal,
author: agent, status: 'published', updated_at: 2.days.ago)
get "/api/v1/accounts/#{account.id}/search/articles",
headers: agent.create_new_auth_token,
params: { q: 'test', since: 15.days.ago.to_i, until: 5.days.ago.to_i },
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)
article_ids = response_data[:payload][:articles].pluck(:id)
expect(article_ids).to include(old_article.id)
expect(article_ids).not_to include(very_old_article.id, recent_article.id)
end
end
end
end
end
@@ -28,7 +28,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
end
it 'uses Captain::Llm::AssistantChatService' do
expect(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant, conversation_id: conversation.id)
expect(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant, conversation_id: conversation.display_id)
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
described_class.perform_now(conversation, assistant)
+2 -1
View File
@@ -14,8 +14,9 @@ RSpec.describe Message do
create(:message, message_type: :outgoing, conversation: conversation, sender: captain_assistant)
# Captain::Assistant responses clear waiting_since (like AgentBot)
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to be_within(0.000001.seconds).of(conversation.created_at)
expect(conversation.waiting_since).to be_nil
create(:message, message_type: :outgoing, conversation: conversation)
+2
View File
@@ -18,7 +18,9 @@ describe Webhooks::Trigger do
before do
ActiveJob::Base.queue_adapter = :test
allow(GlobalConfig).to receive(:get_value).and_call_original
allow(GlobalConfig).to receive(:get_value).with('WEBHOOK_TIMEOUT').and_return(webhook_timeout)
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return(nil)
end
after do
+3 -3
View File
@@ -12,8 +12,8 @@ RSpec.describe ReplyMailbox do
let(:conversation) { create(:conversation, assignee: agent, inbox: create(:inbox, account: account, greeting_enabled: false), account: account) }
let(:described_subject) { described_class.receive reply_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject text_content to
auto_reply]
%w[bcc cc content_type date from headers html_content in_reply_to message_id multipart number_of_attachments references subject text_content
to auto_reply]
end
context 'with reply uuid present' do
@@ -397,7 +397,7 @@ RSpec.describe ReplyMailbox do
let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') }
let(:described_subject) { described_class.receive support_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
%w[bcc cc content_type date from headers html_content in_reply_to message_id multipart number_of_attachments references subject
text_content to auto_reply]
end
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
+65
View File
@@ -978,5 +978,70 @@ RSpec.describe Conversation do
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(0)
end
context 'when AgentBot responds between customer messages' do
let(:agent_bot) { create(:agent_bot, account: account) }
def create_bot_message(conversation, created_at: Time.current)
message = nil
perform_enqueued_jobs do
message = create(:message,
message_type: 'outgoing',
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
sender: agent_bot,
created_at: created_at)
end
message
end
it 'calculates reply time from the most recent customer message after bot response' do
# Initial conversation: customer message -> agent first reply (to establish first_reply_created_at)
create_customer_message(conversation, created_at: 10.hours.ago)
create_agent_message(conversation, created_at: 9.hours.ago)
# Customer message 1
create_customer_message(conversation, created_at: 5.hours.ago)
# Bot responds
create_bot_message(conversation, created_at: 4.hours.ago)
# Customer message 2 (after bot response) - should reset waiting_since
create_customer_message(conversation, created_at: 2.hours.ago)
# Human agent replies - should create reply_time event from customer message 2
create_agent_message(conversation, created_at: 1.hour.ago)
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event
# Reply time should be 1 hour (from customer message 2 to agent reply)
expect(reply_events.first.value).to be_within(60).of(3600)
end
it 'handles multiple bot responses before customer messages again' do
# Initial conversation: customer message -> agent first reply
create_customer_message(conversation, created_at: 10.hours.ago)
create_agent_message(conversation, created_at: 9.hours.ago)
# Customer message 1
create_customer_message(conversation, created_at: 6.hours.ago)
# Bot responds multiple times
create_bot_message(conversation, created_at: 5.hours.ago)
create_bot_message(conversation, created_at: 4.hours.ago)
# Customer message 2 (after multiple bot responses) - should reset waiting_since
create_customer_message(conversation, created_at: 2.hours.ago)
# Human agent replies
create_agent_message(conversation, created_at: 1.hour.ago)
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event
# Reply time should be 1 hour (from customer message 2 to agent reply)
expect(reply_events.first.value).to be_within(60).of(3600)
end
end
end
end
+57
View File
@@ -300,6 +300,63 @@ RSpec.describe Message do
expect(conversation.waiting_since).to eq old_waiting_since
end
context 'when bot has responded to the conversation' do
let(:agent_bot) { create(:agent_bot, account: conversation.account) }
before do
# Create initial customer message
create(:message, conversation: conversation, message_type: :incoming,
created_at: 2.hours.ago)
conversation.update(waiting_since: 2.hours.ago)
# Bot responds
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent_bot, created_at: 1.hour.ago)
end
it 'resets waiting_since when customer sends a new message after bot response' do
new_message = build(:message, conversation: conversation, message_type: :incoming)
new_message.save!
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(new_message.created_at)
end
it 'does not reset waiting_since if last response was from human agent' do
# Human agent responds (clears waiting_since)
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent)
conversation.reload
expect(conversation.waiting_since).to be_nil
# Customer sends new message
new_message = build(:message, conversation: conversation, message_type: :incoming)
new_message.save!
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(new_message.created_at)
end
it 'clears waiting_since when bot responds' do
# After the bot response in before block, waiting_since should already be cleared
conversation.reload
expect(conversation.waiting_since).to be_nil
# Customer sends another message
create(:message, conversation: conversation, message_type: :incoming,
created_at: 30.minutes.ago)
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(30.minutes.ago)
# Another bot response should clear it again
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent_bot, created_at: 15.minutes.ago)
conversation.reload
expect(conversation.waiting_since).to be_nil
end
end
end
context 'with webhook_data' do
+34
View File
@@ -41,6 +41,7 @@ RSpec.describe MailPresenter do
:content_type,
:date,
:from,
:headers,
:html_content,
:in_reply_to,
:message_id,
@@ -60,6 +61,39 @@ RSpec.describe MailPresenter do
expect(data[:auto_reply]).to eq(decorated_mail.auto_reply?)
end
it 'includes forwarded headers in serialized_data' do
mail_with_headers = Mail.new do
from 'Sender <sender@example.com>'
to 'Inbox <inbox@example.com>'
subject :header
body 'Hi'
header['X-Original-From'] = 'Original <original@example.com>'
header['X-Original-Sender'] = 'original@example.com'
header['X-Forwarded-For'] = 'forwarder@example.com'
end
data = described_class.new(mail_with_headers).serialized_data
expect(data[:headers]).to eq(
'x-original-from' => 'Original <original@example.com>',
'x-original-sender' => 'original@example.com',
'x-forwarded-for' => 'forwarder@example.com'
)
end
it 'returns nil headers when forwarding headers are missing' do
mail_without_headers = Mail.new do
from 'Sender <sender@example.com>'
to 'Inbox <inbox@example.com>'
subject :header
body 'Hi'
end
data = described_class.new(mail_without_headers).serialized_data
expect(data[:headers]).to be_nil
end
it 'give email from in downcased format' do
expect(decorated_mail.from.first.eql?(mail.from.first.downcase)).to be true
end
+267 -1
View File
@@ -3,7 +3,9 @@ require 'rails_helper'
describe CsatSurveyService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, csat_survey_enabled: true) }
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :resolved) }
let(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '+1234567890') }
let(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: inbox, account: account, status: :resolved) }
let(:service) { described_class.new(conversation: conversation) }
describe '#perform' do
@@ -87,5 +89,269 @@ describe CsatSurveyService do
expect(Conversations::ActivityMessageJob).not_to have_received(:perform_later)
end
end
context 'when it is a WhatsApp channel' do
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
sync_templates: false, validate_provider_config: false)
end
let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account, csat_survey_enabled: true) }
let(:whatsapp_contact) { create(:contact, account: account) }
let(:whatsapp_contact_inbox) { create(:contact_inbox, contact: whatsapp_contact, inbox: whatsapp_inbox, source_id: '1234567890') }
let(:whatsapp_conversation) do
create(:conversation, contact_inbox: whatsapp_contact_inbox, inbox: whatsapp_inbox, account: account, status: :resolved)
end
let(:whatsapp_service) { described_class.new(conversation: whatsapp_conversation) }
let(:mock_provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
before do
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(mock_provider_service)
allow(whatsapp_conversation).to receive(:can_reply?).and_return(true)
end
context 'when template is available and approved' do
before do
setup_approved_template('customer_survey_template')
end
it 'sends WhatsApp template survey instead of regular survey' do
mock_successful_template_send('template_message_id_123')
whatsapp_service.perform
expect(mock_provider_service).to have_received(:send_template).with(
'1234567890',
hash_including(
name: 'customer_survey_template',
lang_code: 'en',
parameters: array_including(
hash_including(
type: 'button',
sub_type: 'url',
index: '0',
parameters: array_including(
hash_including(type: 'text', text: whatsapp_conversation.uuid)
)
)
)
),
instance_of(Message)
)
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
end
it 'updates message with returned message ID' do
mock_successful_template_send('template_message_id_123')
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message).to be_present
expect(csat_message.source_id).to eq('template_message_id_123')
end
it 'builds correct template info with default template name' do
expected_template_name = "customer_satisfaction_survey_#{whatsapp_inbox.id}"
whatsapp_inbox.update(csat_config: { 'template' => {}, 'message' => 'Rate us' })
allow(mock_provider_service).to receive(:get_template_status)
.with(expected_template_name)
.and_return({ success: true, template: { status: 'APPROVED' } })
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
'msg_id'
end
whatsapp_service.perform
expect(mock_provider_service).to have_received(:send_template).with(
'1234567890',
hash_including(
name: expected_template_name,
lang_code: 'en'
),
anything
)
end
it 'builds CSAT message with correct attributes' do
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
'msg_id'
end
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message.account).to eq(account)
expect(csat_message.inbox).to eq(whatsapp_inbox)
expect(csat_message.message_type).to eq('outgoing')
expect(csat_message.content).to eq('Please rate your experience')
expect(csat_message.content_type).to eq('input_csat')
end
it 'uses default message when not configured' do
setup_approved_template('test', { 'template' => { 'name' => 'test' } })
mock_successful_template_send('msg_id')
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message.content).to eq('Please rate this conversation')
end
end
context 'when template is not available or not approved' do
it 'falls back to regular survey when template is pending' do
setup_template_with_status('pending_template', 'PENDING')
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
it 'falls back to regular survey when template is rejected' do
setup_template_with_status('pending_template', 'REJECTED')
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
it 'falls back to regular survey when template API call fails' do
allow(mock_provider_service).to receive(:get_template_status)
.with('pending_template')
.and_return({ success: false, error: 'Template not found' })
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
it 'falls back to regular survey when template status check raises error' do
allow(mock_provider_service).to receive(:get_template_status)
.and_raise(StandardError, 'API connection failed')
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
end
context 'when no template is configured' do
it 'falls back to regular survey' do
whatsapp_service.perform
expect(MessageTemplates::Template::CsatSurvey).to have_received(:new).with(conversation: whatsapp_conversation)
expect(csat_template).to have_received(:perform)
end
end
context 'when template sending fails' do
before do
setup_approved_template('working_template', {
'template' => { 'name' => 'working_template' },
'message' => 'Rate us'
})
end
it 'handles template sending errors gracefully' do
mock_template_send_failure('Template send failed')
expect { whatsapp_service.perform }.not_to raise_error
# Should still create the CSAT message even if sending fails
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message).to be_present
expect(csat_message.source_id).to be_nil
end
it 'does not update message when send_template returns nil' do
mock_template_send_with_no_id
whatsapp_service.perform
csat_message = whatsapp_conversation.messages.where(content_type: :input_csat).last
expect(csat_message).to be_present
expect(csat_message.source_id).to be_nil
end
end
context 'when outside messaging window' do
before do
allow(whatsapp_conversation).to receive(:can_reply?).and_return(false)
end
it 'sends template survey even when outside messaging window if template is approved' do
setup_approved_template('approved_template', { 'template' => { 'name' => 'approved_template' } })
mock_successful_template_send('msg_id')
whatsapp_service.perform
expect(mock_provider_service).to have_received(:send_template)
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
# No activity message should be created when template is successfully sent
end
it 'creates activity message when template is not available and outside window' do
whatsapp_service.perform
expect(Conversations::ActivityMessageJob).to have_received(:perform_later).with(
whatsapp_conversation,
hash_including(content: I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window'))
)
expect(MessageTemplates::Template::CsatSurvey).not_to have_received(:new)
end
end
end
end
private
def setup_approved_template(template_name, config = nil)
template_config = config || {
'template' => {
'name' => template_name,
'language' => 'en'
},
'message' => 'Please rate your experience'
}
whatsapp_inbox.update(csat_config: template_config)
allow(mock_provider_service).to receive(:get_template_status)
.with(template_name)
.and_return({ success: true, template: { status: 'APPROVED' } })
end
def setup_template_with_status(template_name, status)
whatsapp_inbox.update(csat_config: {
'template' => { 'name' => template_name }
})
allow(mock_provider_service).to receive(:get_template_status)
.with(template_name)
.and_return({ success: true, template: { status: status } })
end
def mock_successful_template_send(message_id)
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
message_id
end
end
def mock_template_send_failure(error_message = 'Template send failed')
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
raise StandardError, error_message
end
end
def mock_template_send_with_no_id
allow(mock_provider_service).to receive(:send_template) do |_phone, _template, message|
message.save!
nil
end
end
end
@@ -74,6 +74,24 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
expect(result.scan("\n").count).to eq(4)
expect(result).to include("Para 1\n\n\n\nPara 2")
end
it 'renders code blocks as plain text' do
content = "```\ncode here\n```"
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code here')
end
it 'renders indented code blocks as plain text preserving exact content' do
content = ' indented code line'
result = described_class.new(content, channel_type).render
expect(result).to eq('indented code line')
end
it 'handles code blocks with emojis and special characters without stack overflow' do
content = " first line\n 🌐 second line\n"
result = described_class.new(content, channel_type).render
expect(result).to eq("first line\n🌐 second line")
end
end
context 'when channel is Channel::Instagram' do
@@ -130,6 +148,24 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
expect(result.scan("\n").count).to eq(4)
expect(result).to include("Para 1\n\n\n\nPara 2")
end
it 'renders code blocks as plain text' do
content = "```\ncode here\n```"
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code here')
end
it 'renders indented code blocks as plain text preserving exact content' do
content = ' indented code line'
result = described_class.new(content, channel_type).render
expect(result).to eq('indented code line')
end
it 'handles code blocks with emojis and special characters without stack overflow' do
content = " first line\n 🌐 second line\n"
result = described_class.new(content, channel_type).render
expect(result).to eq("first line\n🌐 second line")
end
end
context 'when channel is Channel::Line' do
@@ -358,6 +394,18 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
expect(result).to include('1. first step')
expect(result).to include('2. second step')
end
it 'renders code blocks as plain text' do
content = "```\ncode here\n```"
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code here')
end
it 'handles code blocks with emojis and special characters without stack overflow' do
content = " first line\n 🌐 second line\n"
result = described_class.new(content, channel_type).render
expect(result).to eq("first line\n🌐 second line")
end
end
context 'when channel is Channel::TwilioSms' do
+493
View File
@@ -95,6 +95,7 @@ describe SearchService do
let(:search_type) { 'Message' }
it 'uses LIKE search when search_with_gin feature is disabled' do
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
@@ -105,6 +106,7 @@ describe SearchService do
end
it 'uses GIN search when search_with_gin feature is enabled' do
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(true)
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
@@ -119,11 +121,13 @@ describe SearchService do
message3 = create(:message, account: account, inbox: inbox, content: 'Harry is a wizard apprentice')
# Test with GIN search
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(true)
gin_search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
gin_results = gin_search.perform[:messages].map(&:id)
# Test with LIKE search
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
like_search = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
like_results = like_search.perform[:messages].map(&:id)
@@ -133,6 +137,108 @@ describe SearchService do
expect(gin_results).to include(message.id, message2.id, message3.id)
end
end
# rubocop:disable RSpec/MultipleMemoizedHelpers
context 'when filtering messages with time, sender, and inbox', :opensearch do
let!(:agent) { create(:user, account: account) }
let!(:inbox2) { create(:inbox, account: account) }
let!(:old_message) do
create(:message, account: account, inbox: inbox, content: 'old wizard message', sender: harry, created_at: 80.days.ago)
end
let!(:recent_message) do
create(:message, account: account, inbox: inbox, content: 'recent wizard message', sender: harry, created_at: 1.day.ago)
end
let!(:agent_message) do
create(:message, account: account, inbox: inbox, content: 'wizard from agent', sender: agent, created_at: 1.day.ago)
end
let!(:inbox2_message) do
create(:message, account: account, inbox: inbox2, content: 'wizard in inbox2', sender: harry, created_at: 1.day.ago)
end
before do
account.enable_features!('advanced_search')
create(:inbox_member, inbox: inbox2, user: user)
end
it 'filters messages by time range with LIKE search' do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
params = { q: 'wizard', since: 50.days.ago.to_i, search_type: 'Message' }
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Message')
results = search.perform[:messages]
expect(results.map(&:id)).to include(recent_message.id, agent_message.id, inbox2_message.id)
expect(results.map(&:id)).not_to include(old_message.id)
end
it 'filters messages by time range with GIN search' do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(true)
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
params = { q: 'wizard', since: 50.days.ago.to_i, search_type: 'Message' }
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Message')
results = search.perform[:messages]
expect(results.map(&:id)).to include(recent_message.id, agent_message.id, inbox2_message.id)
expect(results.map(&:id)).not_to include(old_message.id)
end
it 'filters messages by sender (contact)' do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
params = { q: 'wizard', from: "contact:#{harry.id}", search_type: 'Message' }
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Message')
results = search.perform[:messages]
expect(results.map(&:id)).to include(recent_message.id, old_message.id, inbox2_message.id)
expect(results.map(&:id)).not_to include(agent_message.id)
end
it 'filters messages by sender (agent)' do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
params = { q: 'wizard', from: "agent:#{agent.id}", search_type: 'Message' }
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Message')
results = search.perform[:messages]
expect(results.map(&:id)).to include(agent_message.id)
expect(results.map(&:id)).not_to include(recent_message.id, old_message.id, inbox2_message.id)
end
it 'filters messages by inbox' do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
params = { q: 'wizard', inbox_id: inbox2.id, search_type: 'Message' }
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Message')
results = search.perform[:messages]
expect(results.map(&:id)).to include(inbox2_message.id)
expect(results.map(&:id)).not_to include(recent_message.id, old_message.id, agent_message.id)
end
it 'combines multiple filters' do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
params = { q: 'wizard', since: 50.days.ago.to_i, inbox_id: inbox.id, from: "contact:#{harry.id}", search_type: 'Message' }
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Message')
results = search.perform[:messages]
expect(results.map(&:id)).to include(recent_message.id)
expect(results.map(&:id)).not_to include(old_message.id, agent_message.id, inbox2_message.id)
end
end
# rubocop:enable RSpec/MultipleMemoizedHelpers
end
context 'when conversation search' do
@@ -183,6 +289,91 @@ describe SearchService do
expect(results.length).to eq(15) # Default per_page is 15
end
end
context 'when filtering contacts with time caps', :opensearch do
let!(:old_contact) { create(:contact, name: 'Old Potter', email: 'old@test.com', account: account, last_activity_at: 100.days.ago) }
let!(:recent_contact) { create(:contact, name: 'Recent Potter', email: 'recent@test.com', account: account, last_activity_at: 1.day.ago) }
before do
account.enable_features!('advanced_search')
end
it 'caps since to 90 days ago and excludes older contacts' do
params = { q: 'Potter', since: 100.days.ago.to_i, search_type: 'Contact' }
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Contact')
results = search.perform[:contacts]
expect(results.map(&:id)).not_to include(old_contact.id)
expect(results.map(&:id)).to include(recent_contact.id)
end
it 'caps until to 90 days from now' do
params = { q: 'Potter', until: 100.days.from_now.to_i, search_type: 'Contact' }
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Contact')
results = search.perform[:contacts]
# Both contacts should be included since their last_activity_at is before the capped time
expect(results.map(&:id)).to include(recent_contact.id)
end
end
context 'when filtering conversations with time caps', :opensearch do
let!(:old_conversation) { create(:conversation, contact: harry, inbox: inbox, account: account, last_activity_at: 100.days.ago) }
let!(:recent_conversation) { create(:conversation, contact: harry, inbox: inbox, account: account, last_activity_at: 1.day.ago) }
before do
account.enable_features!('advanced_search')
end
it 'caps since to 90 days ago and excludes older conversations' do
params = { q: 'Harry', since: 100.days.ago.to_i, search_type: 'Conversation' }
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Conversation')
results = search.perform[:conversations]
expect(results.map(&:id)).not_to include(old_conversation.id)
expect(results.map(&:id)).to include(recent_conversation.id)
end
it 'caps until to 90 days from now' do
params = { q: 'Harry', until: 100.days.from_now.to_i, search_type: 'Conversation' }
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Conversation')
results = search.perform[:conversations]
# Both conversations should be included since their last_activity_at is before the capped time
expect(results.map(&:id)).to include(recent_conversation.id)
end
end
context 'when filtering articles with time caps', :opensearch do
let!(:old_article) do
create(:article, title: 'Old Magic Guide', account: account, portal: portal, author: user, status: 'published', updated_at: 100.days.ago)
end
let!(:recent_article) do
create(:article, title: 'Recent Magic Guide', account: account, portal: portal, author: user, status: 'published', updated_at: 1.day.ago)
end
before do
account.enable_features!('advanced_search')
end
it 'caps since to 90 days ago and excludes older articles' do
params = { q: 'Magic', since: 100.days.ago.to_i, search_type: 'Article' }
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article')
results = search.perform[:articles]
expect(results.map(&:id)).not_to include(old_article.id)
expect(results.map(&:id)).to include(recent_article.id)
end
it 'caps until to 90 days from now' do
params = { q: 'Magic', until: 100.days.from_now.to_i, search_type: 'Article' }
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article')
results = search.perform[:articles]
# Both articles should be included since their updated_at is before the capped time
expect(results.map(&:id)).to include(recent_article.id)
end
end
end
describe '#message_base_query' do
@@ -260,4 +451,306 @@ describe SearchService do
expect(search.send(:use_gin_search)).to be false
end
end
describe '#advanced_search with filters', if: Message.respond_to?(:search) do
let(:params) { { q: 'test' } }
let(:search_type) { 'Message' }
before do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
allow(Message).to receive(:search).and_return([])
end
context 'when advanced_search feature flag is disabled' do
it 'ignores filters and falls back to standard search' do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
contact = create(:contact, account: account)
inbox2 = create(:inbox, account: account)
params = { q: 'test', from: "contact:#{contact.id}", inbox_id: inbox2.id, since: 3.days.ago.to_i }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(search_service).not_to receive(:advanced_search)
search_service.perform
end
end
context 'when filtering by from parameter' do
let(:contact) { create(:contact, account: account) }
let(:agent) { create(:user, account: account) }
it 'filters messages from specific contact' do
params = { q: 'test', from: "contact:#{contact.id}" }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(Message).to receive(:search).with(
'test',
hash_including(
where: hash_including(
sender_type: 'Contact',
sender_id: contact.id
)
)
).and_return([])
search_service.perform
end
it 'filters messages from specific agent' do
params = { q: 'test', from: "agent:#{agent.id}" }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(Message).to receive(:search).with(
'test',
hash_including(
where: hash_including(
sender_type: 'User',
sender_id: agent.id
)
)
).and_return([])
search_service.perform
end
it 'ignores invalid from parameter format' do
params = { q: 'test', from: 'invalid:format' }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(Message).to receive(:search).with(
'test',
hash_including(
where: hash_not_including(:sender_type, :sender_id)
)
).and_return([])
search_service.perform
end
end
context 'when filtering by time range' do
it 'defaults to 90 days ago when no since parameter is provided' do
params = { q: 'test' }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(Message).to receive(:search).with(
'test',
hash_including(
where: hash_including(
created_at: hash_including(gte: be_within(1.second).of(Limits::MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS.days.ago))
)
)
).and_return([])
search_service.perform
end
it 'silently caps since timestamp to 90 day limit when exceeded' do
since_timestamp = (Limits::MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS * 2).days.ago.to_i
params = { q: 'test', since: since_timestamp }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(Message).to receive(:search).with(
'test',
hash_including(
where: hash_including(
created_at: hash_including(gte: be_within(1.second).of(Limits::MESSAGE_SEARCH_TIME_RANGE_LIMIT_DAYS.days.ago))
)
)
).and_return([])
search_service.perform
end
it 'filters messages since timestamp when within 90 day limit' do
since_timestamp = 3.days.ago.to_i
params = { q: 'test', since: since_timestamp }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(Message).to receive(:search).with(
'test',
hash_including(
where: hash_including(
created_at: hash_including(gte: Time.zone.at(since_timestamp))
)
)
).and_return([])
search_service.perform
end
it 'filters messages until timestamp' do
until_timestamp = 5.days.ago.to_i
params = { q: 'test', until: until_timestamp }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(Message).to receive(:search).with(
'test',
hash_including(
where: hash_including(
created_at: hash_including(lte: Time.zone.at(until_timestamp))
)
)
).and_return([])
search_service.perform
end
it 'filters messages within time range' do
since_timestamp = 5.days.ago.to_i
until_timestamp = 12.hours.ago.to_i
params = { q: 'test', since: since_timestamp, until: until_timestamp }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(Message).to receive(:search).with(
'test',
hash_including(
where: hash_including(
created_at: hash_including(
gte: Time.zone.at(since_timestamp),
lte: Time.zone.at(until_timestamp)
)
)
)
).and_return([])
search_service.perform
end
it 'silently caps until timestamp to 90 days from now when exceeded' do
until_timestamp = 100.days.from_now.to_i
params = { q: 'test', until: until_timestamp }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(Message).to receive(:search).with(
'test',
hash_including(
where: hash_including(
created_at: hash_including(lte: be_within(1.second).of(90.days.from_now))
)
)
).and_return([])
search_service.perform
end
end
context 'when filtering by inbox_id' do
let!(:inbox2) { create(:inbox, account: account) }
before do
create(:inbox_member, user: user, inbox: inbox2)
end
it 'filters messages from specific inbox' do
params = { q: 'test', inbox_id: inbox2.id }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(Message).to receive(:search).with(
'test',
hash_including(
where: hash_including(inbox_id: inbox2.id)
)
).and_return([])
search_service.perform
end
it 'ignores inbox filter when user lacks access' do
restricted_inbox = create(:inbox, account: account)
params = { q: 'test', inbox_id: restricted_inbox.id }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(Message).to receive(:search).with(
'test',
hash_including(
where: hash_not_including(inbox_id: restricted_inbox.id)
)
).and_return([])
search_service.perform
end
end
context 'when combining multiple filters' do
it 'applies all filters together' do
test_contact = create(:contact, account: account)
test_inbox = create(:inbox, account: account)
create(:inbox_member, user: user, inbox: test_inbox)
since_timestamp = 3.days.ago.to_i
params = { q: 'test', from: "contact:#{test_contact.id}", inbox_id: test_inbox.id, since: since_timestamp }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
expect(Message).to receive(:search).with(
'test',
hash_including(
where: hash_including(
sender_type: 'Contact',
sender_id: test_contact.id,
inbox_id: test_inbox.id,
created_at: hash_including(gte: Time.zone.at(since_timestamp))
)
)
).and_return([])
search_service.perform
end
end
end
describe '#advanced_search_with_fallback' do
let(:params) { { q: 'test' } }
let(:search_type) { 'Message' }
context 'when Elasticsearch is unavailable' do
it 'falls back to LIKE search when Elasticsearch connection fails' do
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
params = { q: 'test' }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
allow(search_service).to receive(:advanced_search).and_raise(Faraday::ConnectionFailed.new('Connection refused'))
expect(search_service).to receive(:filter_messages_with_like).and_call_original
expect { search_service.perform }.not_to raise_error
end
it 'falls back to GIN search when Elasticsearch is unavailable and GIN is enabled' do
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(true)
params = { q: 'test' }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
allow(search_service).to receive(:advanced_search).and_raise(Searchkick::Error.new('Elasticsearch unavailable'))
expect(search_service).to receive(:filter_messages_with_gin).and_call_original
expect { search_service.perform }.not_to raise_error
end
it 'applies filters correctly in SQL fallback when Elasticsearch fails' do
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('advanced_search').and_return(true)
allow(account).to receive(:feature_enabled?).with('search_with_gin').and_return(false)
test_contact = create(:contact, account: account)
create(:message, account: account, inbox: inbox, content: 'test message', sender: test_contact, created_at: 1.day.ago)
params = { q: 'test', from: "contact:#{test_contact.id}", since: 2.days.ago.to_i }
search_service = described_class.new(current_user: user, current_account: account, params: params, search_type: search_type)
allow(search_service).to receive(:advanced_search).and_raise(Faraday::ConnectionFailed.new('Connection refused'))
results = search_service.perform[:messages]
expect(results).not_to be_empty
expect(results.first.sender_id).to eq(test_contact.id)
end
end
end
end

Some files were not shown because too many files have changed in this diff Show More