From fed32d59645ba24dcb429984210c54b1fad932da Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Tue, 28 Apr 2026 17:20:29 +0530 Subject: [PATCH 01/42] fix(perf): force better index for pending_reponses longest first sort (#14291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fix a Postgres planner trap on the "Pending Response: Longest first" sort that causes the conversation list to hang on busy accounts. The current `sort_on_waiting_since` generates query with `ORDER BY waiting_since ASC NULLS LAST, created_at ASC`. That order-by is exactly the shape of the single-column `index_conversations_on_waiting_since` btree, so the planner picks a forward index walk thinking `LIMIT 25` will stop early. In practice the per-account matches are spread along the global waiting_since timeline, so the scan reads tens of millions of rows from other accounts and discards them via the filter before producing any results which in turn causes the requests to time out and the conversation list spinner never resolves. DESC direction and every other sort (`priority`, `created_at`, `last_activity_at`) are unaffected. They fall through to `conv_acid_inbid_stat_asgnid_idx` (account-scoped composite), which is the right index for this access pattern. This change leads the ORDER BY with the expression `(waiting_since IS NULL)`, which no column-only btree can satisfy. The planner falls back to the same account-scoped index used by every other sort, and sorts in memory. Same logical NULLS LAST output for both directions; no behavior change for users. Fixes CW-6965 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? - [x] Existing specs pass - [x] Added new specs to cover NULL case - [x] Verify results and order for old and new query in prod - [x] Tested in prod since staging data was not sufficient `EXPLAIN (ANALYZE, BUFFERS)` for the same query (status=0, ASC, LIMIT 25) on a representative production account, before vs after: | Metric | Before | After | | --- | --- | --- | | Execution time | 34,679 ms | 0.71 ms | | Rows discarded by filter | 36,906,962 | 0 | | Shared buffer hits | 12,699,924 | 11 | | Blocks read from disk | 851,226 | 112 | | I/O read time | 17,785 ms | 0.3 ms | | Pages dirtied / written | 98 / 31,043 | 0 / 0 | Verified on two production accounts: identical row IDs in identical order between the old and new ORDER BY for both ASC and DESC. A NULL-bucket regression spec was added covering ASC/DESC tail ordering when some conversations have a null `waiting_since`. Roughly `49,000×` faster on this query (34,679 ms → 0.71 ms), and trivially less I/O and buffer pressure on the cluster while it runs. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> --- app/models/concerns/sort_handler.rb | 2 +- spec/models/conversation_spec.rb | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/sort_handler.rb b/app/models/concerns/sort_handler.rb index 065fa7fea..519eccc8b 100644 --- a/app/models/concerns/sort_handler.rb +++ b/app/models/concerns/sort_handler.rb @@ -19,7 +19,7 @@ module SortHandler end def sort_on_waiting_since(sort_direction = :asc) - order(generate_sql_query("waiting_since #{sort_direction.to_s.upcase} NULLS LAST, created_at ASC")) + order(generate_sql_query("(waiting_since IS NULL), waiting_since #{sort_direction.to_s.upcase}, created_at ASC")) end def last_messaged_conversations diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index 89c090207..0bf90859e 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -879,6 +879,31 @@ RSpec.describe Conversation do conversation_4.id ] end + + context 'when some conversations have a null waiting_since' do + before do + # rubocop:disable Rails/SkipsModelValidations + conversation_5.update_column(:waiting_since, nil) + conversation_2.update_column(:waiting_since, nil) + # rubocop:enable Rails/SkipsModelValidations + end + + it 'places null waiting_since conversations at the end in ascending order' do + records = described_class.sort_on_waiting_since + expect(records.map(&:id)).to eq [ + conversation_4.id, conversation_6.id, conversation_7.id, conversation_3.id, conversation_1.id, + conversation_5.id, conversation_2.id + ] + end + + it 'places null waiting_since conversations at the end in descending order' do + records = described_class.sort_on_waiting_since(:desc) + expect(records.map(&:id)).to eq [ + conversation_1.id, conversation_3.id, conversation_7.id, conversation_6.id, conversation_4.id, + conversation_5.id, conversation_2.id + ] + end + end end end From 6aeda0ddf6d267c7a3807192ab5c75259384819b Mon Sep 17 00:00:00 2001 From: Ajith KV Date: Tue, 28 Apr 2026 18:21:05 +0530 Subject: [PATCH 02/42] feat: add Playwright setup and login flow test (#13578) ## Description Adds Playwright E2E testing infrastructure with project configuration and a login flow test. This is Phase 1 of the Playwright E2E suite, kept minimal with only the core setup and login component. Ref: Discussion #13500, PR #13067 ## Type of change Please delete options that are not relevant. - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Verified all imports resolve correctly (`login-flow-ui-validation.spec.ts` only imports `Login` from `@components/ui`) - Ran login flow test locally against a running Chatwoot instance ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works --------- Co-authored-by: Sony Mathew Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> --- tests/playwright/.env.example | 11 + tests/playwright/.gitignore | 54 + tests/playwright/DOCS.md | 146 ++ tests/playwright/README.md | 59 + tests/playwright/components/ui/index.ts | 1 + .../components/ui/login.component.ts | 55 + tests/playwright/eslint.config.mjs | 42 + tests/playwright/package.json | 35 + tests/playwright/playwright.config.ts | 36 + tests/playwright/pnpm-lock.yaml | 1247 +++++++++++++++++ .../e2e/ui/login-flow-ui-validation.spec.ts | 65 + tests/playwright/tsconfig.json | 25 + 12 files changed, 1776 insertions(+) create mode 100644 tests/playwright/.env.example create mode 100644 tests/playwright/.gitignore create mode 100644 tests/playwright/DOCS.md create mode 100644 tests/playwright/README.md create mode 100644 tests/playwright/components/ui/index.ts create mode 100644 tests/playwright/components/ui/login.component.ts create mode 100644 tests/playwright/eslint.config.mjs create mode 100644 tests/playwright/package.json create mode 100644 tests/playwright/playwright.config.ts create mode 100644 tests/playwright/pnpm-lock.yaml create mode 100644 tests/playwright/tests/e2e/ui/login-flow-ui-validation.spec.ts create mode 100644 tests/playwright/tsconfig.json diff --git a/tests/playwright/.env.example b/tests/playwright/.env.example new file mode 100644 index 000000000..c2c13abb1 --- /dev/null +++ b/tests/playwright/.env.example @@ -0,0 +1,11 @@ +# Playwright Test Configuration + +# Base URL for the Chatwoot instance under test +BASE_URL=http://localhost:3000 + +# Admin credentials +TEST_USER_EMAIL=admin@chatwoot.com +TEST_USER_PASSWORD="Password123@#" + +# Add additional variables as needed by specific test suites +# VARIABLE_NAME=value diff --git a/tests/playwright/.gitignore b/tests/playwright/.gitignore new file mode 100644 index 000000000..6cb6e772e --- /dev/null +++ b/tests/playwright/.gitignore @@ -0,0 +1,54 @@ +# Dependencies +node_modules/ +package-lock.json + +# Environment files +.env +.env.local +.env.*.local +.github/.env + +# Playwright test results +test-results/ +playwright-report/ +blob-report/ + +# Playwright cache and auth +playwright/.cache/ +playwright/.auth/ +.cache/ +.auth/ + +# Test artifacts +screenshots/ +videos/ +downloads/ +traces/ + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +.claude + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Generated schemas (if you want to version control them, remove this) +# response-schemas/ + +# OS files +Thumbs.db +.DS_Store + +# Temporary files +*.tmp +*.temp +.tmp/ \ No newline at end of file diff --git a/tests/playwright/DOCS.md b/tests/playwright/DOCS.md new file mode 100644 index 000000000..6de5ce6ca --- /dev/null +++ b/tests/playwright/DOCS.md @@ -0,0 +1,146 @@ +# Chatwoot E2E Testing - Documentation + +Complete guide for writing and maintaining E2E tests for Chatwoot. + +--- + +## Overview + +End-to-end testing suite for Chatwoot built with Playwright and TypeScript using the Component Object Model (COM) pattern. + +--- + +## Architecture + +``` +tests/playwright/ +├── components/ +│ ├── api/ # API interaction (auth.component.ts, inbox.component.ts ...) +│ └── ui/ # UI page objects (login.component.ts, agent-page.component.ts ...) +├── tests/e2e/ # Test specs (api/ and ui/) +├── utils/ # Shared utilities (fixture.ts, test-data.ts, db.ts) +├── response-schemas/ # API response schemas for validation +├── fixtures/ # Test fixtures +├── helpers/ # Helper functions +└── playwright.config.ts +``` + +--- + +## Configuration + +All configuration managed through `.env` file. Copy `.env.example` to `.env`: + +``` +BASE_URL=http://localhost:3000 +TEST_USER_EMAIL=admin@chatwoot.com +TEST_USER_PASSWORD="Password123@#" +ACCOUNT_ID=1 + +# Add additional variables as needed by specific test suites +# VARIABLE_NAME=value +``` + +> **Note:** `npx playwright install` is required after `pnpm install` to download browser binaries. + +--- + +## Testing Patterns + +### API Testing + +```typescript +test('API operation', async ({ api }) => { + const authHeaders = await authComponent.login(email, password); + const result = await component.create(api, authHeaders, data); + expect(result.id).toBeTruthy(); +}); +``` + +### UI Testing + +```typescript +test('UI interaction', async ({ page }) => { + const loginComponent = new Login(page); + await loginComponent.login(email, password); + await expect(page.getByText('Success')).toBeVisible(); +}); +``` + +### Hybrid Pattern + +```typescript +test('UI with API setup', async ({ page, api }) => { + // Fast: Create test data via API + const inbox = await inboxComponent.createApiInbox(api, authHeaders, data); + + // Test UI interactions + await page.goto(`/app/accounts/2/inbox/${inbox.id}`); + await expect(page.getByText(inbox.name)).toBeVisible(); +}); +``` + +--- + +## Request Handler + +```typescript +const data = await api + .path('/api/v1/accounts/2/agents') + .headers(authHeaders) + .body({ name: 'John', email: 'john@test.com' }) + .logs(true) + .postRequest(200); +``` + +**Methods:** `getRequest()`, `postRequest()`, `putRequest()`, `deleteRequest()` + +--- + +## Test Data Generation + +```typescript +import { fake } from '@utils/test-data'; + +const agent = fake.agent({ role: 'agent' }); +const inboxName = fake.inboxName(); +``` + +**Available:** `fake.fullName`, `fake.email`, `fake.phoneNumber`, `fake.password`, `fake.agent()`, `fake.inboxName()` + +--- + +## Best Practices + +**Do:** +- Use existing components +- Use `fake` for test data +- Use semantic selectors (`getByRole`, `getByLabel`) +- Clean up test data in `afterAll` +- Validate API schemas + +**Don't:** +- Use CSS selectors +- Hardcode wait times +- Skip cleanup +- Commit sensitive data + +--- + +## Troubleshooting + +**Authentication errors:** +- Verify `.env` credentials match Chatwoot +- Check for rate limiting (429 errors) + +**Database errors:** +- Verify database is running +- Check credentials in `.env` + +**Timeout errors:** +- Ensure Chatwoot is running at `BASE_URL` +- Increase timeout: `{ timeout: 60000 }` + +**Element not found:** +- Use `page.pause()` to inspect +- Check for timing issues diff --git a/tests/playwright/README.md b/tests/playwright/README.md new file mode 100644 index 000000000..1820975bd --- /dev/null +++ b/tests/playwright/README.md @@ -0,0 +1,59 @@ +# Chatwoot E2E Testing + +End-to-end testing framework for Chatwoot using Component Object Model pattern. + +## Setup + +```bash +# Install dependencies +pnpm install + +# Install Playwright browsers +npx playwright install + +# Configure environment +cp .env.example .env +``` + +Edit `.env` with your Chatwoot instance URL and credentials. + +## Usage + +```bash +# Run all tests +pnpm run playwright:run + +# Run tests in UI mode +pnpm run playwright:open + +# Lint tests and page objects +pnpm run lint + +# Generate test code +pnpm run playwright:codegen +``` + +## Project Structure + +``` +tests/playwright/ +├── components/ +│ ├── api/ # API interaction components +│ └── ui/ # UI page objects +├── tests/ +│ └── e2e/ +│ ├── api/ # Pure API tests +│ └── ui/ # UI tests +├── utils/ # Shared utilities and helpers +├── response-schemas/ # API response schemas for validation +├── fixtures/ # Test fixtures +└── helpers/ # Helper functions +``` + +## Documentation + +See [DOCS.md](./DOCS.md) for complete testing guide including patterns, conventions, and troubleshooting. + +## Configuration + +See `.env.example` for the full list of variables. diff --git a/tests/playwright/components/ui/index.ts b/tests/playwright/components/ui/index.ts new file mode 100644 index 000000000..82366bc05 --- /dev/null +++ b/tests/playwright/components/ui/index.ts @@ -0,0 +1 @@ +export { Login } from './login.component'; diff --git a/tests/playwright/components/ui/login.component.ts b/tests/playwright/components/ui/login.component.ts new file mode 100644 index 000000000..3113c8203 --- /dev/null +++ b/tests/playwright/components/ui/login.component.ts @@ -0,0 +1,55 @@ +import { Page } from '@playwright/test'; + +export class Login { + private page: Page; + + constructor(page: Page) { + this.page = page; + } + + async navigate() { + await this.page.goto('/'); + } + + async fillEmail(email: string) { + await this.page.getByTestId('email_input').fill(email); + } + + async fillPassword(password: string) { + await this.page.getByTestId('password_input').fill(password); + } + + async clickLoginButton() { + await this.page.getByTestId('submit_button').click(); + } + + async login(email: string, password: string) { + await this.fillEmail(email); + await this.fillPassword(password); + await this.clickLoginButton(); + } + + getEmailInput() { + return this.page.getByTestId('email_input'); + } + + getPasswordInput() { + return this.page.getByTestId('password_input'); + } + + getLoginButton() { + return this.page.getByTestId('submit_button'); + } + + getLoginHeading() { + return this.page.getByRole('heading', { name: 'Login to Chatwoot' }); + } + + getSSOLink() { + return this.page.getByRole('link', { name: 'Login via SSO' }); + } + + getForgotPasswordLink() { + return this.page.getByRole('link', { name: 'Forgot your password?' }); + } +} diff --git a/tests/playwright/eslint.config.mjs b/tests/playwright/eslint.config.mjs new file mode 100644 index 000000000..523981812 --- /dev/null +++ b/tests/playwright/eslint.config.mjs @@ -0,0 +1,42 @@ +import js from '@eslint/js'; +import playwright from 'eslint-plugin-playwright'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: [ + 'node_modules/', + 'dist/', + 'test-results/', + 'playwright-report/', + 'blob-report/', + '.cache/', + '.auth/', + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.{js,mjs,ts}'], + languageOptions: { + globals: { + __dirname: 'readonly', + console: 'readonly', + process: 'readonly', + }, + }, + }, + { + files: ['**/*.ts'], + languageOptions: { + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + ...playwright.configs['flat/recommended'], + files: ['tests/**/*.ts'], + } +); diff --git a/tests/playwright/package.json b/tests/playwright/package.json new file mode 100644 index 000000000..54ede221d --- /dev/null +++ b/tests/playwright/package.json @@ -0,0 +1,35 @@ +{ + "name": "playwright", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "playwright:open": "npx playwright test --ui", + "playwright:run": "npx playwright test", + "playwright:codegen": "npx playwright codegen" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@eslint/js": "^9.39.4", + "@faker-js/faker": "^9.9.0", + "@playwright/mcp": "^0.0.48", + "@playwright/test": "^1.56.1", + "@types/node": "^24.10.1", + "@types/uuid": "^10.0.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "dotenv": "^17.2.3", + "eslint": "^9.39.4", + "eslint-plugin-playwright": "^2.10.2", + "genson-js": "^0.0.8", + "pg": "^8.16.3", + "playwright": "^1.56.1", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.1", + "uuid": "^13.0.0" + } +} diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts new file mode 100644 index 000000000..de9fc1c56 --- /dev/null +++ b/tests/playwright/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from '@playwright/test'; + +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +export default defineConfig({ + testDir: './tests', + timeout: 60 * 1000, + expect: { + timeout: 30 * 1000, + }, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + actionTimeout: 30 * 1000, + navigationTimeout: 30 * 1000, + baseURL: process.env.BASE_URL || 'http://localhost:3000', + trace: 'retain-on-failure', + headless: false, + viewport: { + width: 1440, + height: 1080, + }, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/tests/playwright/pnpm-lock.yaml b/tests/playwright/pnpm-lock.yaml new file mode 100644 index 000000000..af47702e8 --- /dev/null +++ b/tests/playwright/pnpm-lock.yaml @@ -0,0 +1,1247 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 + '@faker-js/faker': + specifier: ^9.9.0 + version: 9.9.0 + '@playwright/mcp': + specifier: ^0.0.48 + version: 0.0.48 + '@playwright/test': + specifier: ^1.56.1 + version: 1.59.1 + '@types/node': + specifier: ^24.10.1 + version: 24.12.2 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + ajv: + specifier: ^8.17.1 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) + dotenv: + specifier: ^17.2.3 + version: 17.4.2 + eslint: + specifier: ^9.39.4 + version: 9.39.4 + eslint-plugin-playwright: + specifier: ^2.10.2 + version: 2.10.2(eslint@9.39.4) + genson-js: + specifier: ^0.0.8 + version: 0.0.8 + pg: + specifier: ^8.16.3 + version: 8.20.0 + playwright: + specifier: ^1.56.1 + version: 1.59.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + typescript-eslint: + specifier: ^8.59.1 + version: 8.59.1(eslint@9.39.4)(typescript@6.0.3) + uuid: + specifier: ^13.0.0 + version: 13.0.0 + +packages: + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@faker-js/faker@9.9.0': + resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@playwright/mcp@0.0.48': + resolution: {integrity: sha512-c4uJ/9NhTpB0+kh2OFx3MZfLlg05GbDVJcV1/hxAy4/7yuHTA/FcZ2XY8SkV98RMq18Rwt0cqoqPiRrzG2bbxQ==} + engines: {node: '>=18'} + hasBin: true + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.12.2': + resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@typescript-eslint/eslint-plugin@8.59.1': + resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.1': + resolution: {integrity: sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.1': + resolution: {integrity: sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.1': + resolution: {integrity: sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.1': + resolution: {integrity: sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.1': + resolution: {integrity: sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.1': + resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.1': + resolution: {integrity: sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.1': + resolution: {integrity: sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.1': + resolution: {integrity: sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-playwright@2.10.2: + resolution: {integrity: sha512-0N+2OWc3NZbOZ0gK8mp2TK6Qu3UWcJTQ9rqU0UM2yRJXgT758pvpY0lsOLIySfbyFrLqn3TcXjixbmcK90VnuQ==} + engines: {node: '>=16.9.0'} + peerDependencies: + eslint: '>=8.40.0' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + genson-js@0.0.8: + resolution: {integrity: sha512-4NUusDTwF+lzYh72uKV+Uvpky9iPO+YDIMpGImA5pbHfLV9HwgRCA4hYjGu78V4J4Cx2IZRTFfRERn9aUs74mw==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@17.5.0: + resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + playwright-core@1.58.0-alpha-1763757971000: + resolution: {integrity: sha512-4nN53aSvtlz05uFtHn4+PABtZba8Z+jJKLRbPF0+mmoLfD2n9hAGZbvvrVy+iKmoUEvsHbKOBZCJ2NlKjIzZ9w==} + engines: {node: '>=18'} + hasBin: true + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.0-alpha-1763757971000: + resolution: {integrity: sha512-Ck2UJWEaHULTN2hSjdcCpYF3wd5R8gde3IGRhms8Fb9YpqKHRiAzQqnlmouPlSh5pF+Mg9aZTtrtjq3NeIiXkQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.59.1: + resolution: {integrity: sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@faker-js/faker@9.9.0': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@playwright/mcp@0.0.48': + dependencies: + playwright: 1.58.0-alpha-1763757971000 + playwright-core: 1.58.0-alpha-1763757971000 + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.12.2': + dependencies: + undici-types: 7.16.0 + + '@types/uuid@10.0.0': {} + + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/type-utils': 8.59.1(eslint@9.39.4)(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.1 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@6.0.3) + '@typescript-eslint/types': 8.59.1 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + + '@typescript-eslint/tsconfig-utils@8.59.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.59.1(eslint@9.39.4)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4)(typescript@6.0.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.1': {} + + '@typescript-eslint/typescript-estree@8.59.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@6.0.3) + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/visitor-keys': 8.59.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.1(eslint@9.39.4)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.59.1 + '@typescript-eslint/types': 8.59.1 + '@typescript-eslint/typescript-estree': 8.59.1(typescript@6.0.3) + eslint: 9.39.4 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.1': + dependencies: + '@typescript-eslint/types': 8.59.1 + eslint-visitor-keys: 5.0.1 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + callsites@3.1.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + dotenv@17.4.2: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-playwright@2.10.2(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + globals: 17.5.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.2: + optional: true + + genson-js@0.0.8: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@17.5.0: {} + + has-flag@4.0.0: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + ms@2.1.3: {} + + natural-compare@1.4.0: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picomatch@4.0.4: {} + + playwright-core@1.58.0-alpha-1763757971000: {} + + playwright-core@1.59.1: {} + + playwright@1.58.0-alpha-1763757971000: + dependencies: + playwright-core: 1.58.0-alpha-1763757971000 + optionalDependencies: + fsevents: 2.3.2 + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prelude-ls@1.2.1: {} + + punycode@2.3.1: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + split2@4.2.0: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.59.1(eslint@9.39.4)(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@6.0.3) + '@typescript-eslint/parser': 8.59.1(eslint@9.39.4)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.59.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.1(eslint@9.39.4)(typescript@6.0.3) + eslint: 9.39.4 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + undici-types@7.16.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + uuid@13.0.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + xtend@4.0.2: {} + + yocto-queue@0.1.0: {} diff --git a/tests/playwright/tests/e2e/ui/login-flow-ui-validation.spec.ts b/tests/playwright/tests/e2e/ui/login-flow-ui-validation.spec.ts new file mode 100644 index 000000000..1b732726a --- /dev/null +++ b/tests/playwright/tests/e2e/ui/login-flow-ui-validation.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; +import { Login } from '@components/ui'; + +const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'admin@chatwoot.com'; +const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Password123@#'; +const INVALID_PASSWORD = 'Password'; + +test.describe('Login page', () => { + let loginComponent: Login; + + test.beforeEach(async ({ page }) => { + loginComponent = new Login(page); + await loginComponent.navigate(); + }); + + test('should allow user to login with valid credentials', async ({ + page, + }) => { + await loginComponent.login(TEST_EMAIL, TEST_PASSWORD); + await expect(page).toHaveURL(/\/app\/accounts\/\d+\/dashboard/); + }); + + test('renders all critical components', async ({ page }) => { + await expect(page).toHaveTitle('Chatwoot'); + await expect(loginComponent.getLoginHeading()).toBeVisible(); + + const emailInput = loginComponent.getEmailInput(); + await expect(emailInput).toBeVisible(); + await expect(emailInput).toHaveAttribute('name', 'email_address'); + await expect(page.getByText('Email')).toBeVisible(); + + const passwordInput = loginComponent.getPasswordInput(); + await expect(passwordInput).toBeVisible(); + await expect(passwordInput).toHaveAttribute('type', 'password'); + await expect(page.getByText('PasswordForgot your password?')).toBeVisible(); + + const togglePasswordButton = page.getByRole('button', { + name: 'Show password', + }); + await expect(togglePasswordButton).toBeVisible(); + await expect(togglePasswordButton).toHaveAttribute('type', 'button'); + await expect(togglePasswordButton.locator('.i-lucide-eye')).toBeVisible(); + + const forgotPasswordLink = loginComponent.getForgotPasswordLink(); + await expect(forgotPasswordLink).toBeVisible(); + await expect(forgotPasswordLink).toHaveAttribute( + 'href', + '/app/auth/reset/password' + ); + + const loginButton = loginComponent.getLoginButton(); + await expect(loginButton).toBeVisible(); + await expect(loginButton).toHaveAttribute('type', 'submit'); + }); + + test('should show error for invalid password', async ({ page }) => { + await loginComponent.login(TEST_EMAIL, INVALID_PASSWORD); + + await expect( + page.getByText('Invalid login credentials. Please try again.') + ).toBeVisible(); + + await expect(page).toHaveURL(/\/app\/login/); + }); +}); diff --git a/tests/playwright/tsconfig.json b/tests/playwright/tsconfig.json new file mode 100644 index 000000000..e4bb2f8d4 --- /dev/null +++ b/tests/playwright/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": ".", + "baseUrl": ".", + "paths": { + "@components/*": ["components/*"], + "@utils/*": ["utils/*"], + "@fixtures/*": ["fixtures/*"], + "@helpers/*": ["helpers/*"], + "@tests/*": ["tests/*"] + }, + "types": ["node", "@playwright/test"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} From f8f0caf4438b2e9f0c479e87e94cc4f926156ca5 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 28 Apr 2026 21:57:30 +0400 Subject: [PATCH 03/42] feat(campaigns): Add variable support to WhatsApp campaigns (#13649) Fixes https://linear.app/chatwoot/issue/CW-5641/add-the-support-for-variables-in-whatsapp-campaign-templates This PR adds liquid variable support to WhatsApp campaigns, enabling dynamic per-contact personalization. It supports the same liquid variables as SMS campaigns ({{contact.name}}, {{contact.email}}, etc.). Variables are processed per-contact when the campaign executes, allowing personalized messages at scale. --------- Co-authored-by: Sojan Jose Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Sony Mathew --- .../liquid_template_processor_service.rb | 66 ++++ .../whatsapp/oneoff_campaign_service.rb | 21 +- .../liquid_template_processor_service_spec.rb | 347 ++++++++++++++++++ .../whatsapp/oneoff_campaign_service_spec.rb | 67 ++++ 4 files changed, 498 insertions(+), 3 deletions(-) create mode 100644 app/services/whatsapp/liquid_template_processor_service.rb create mode 100644 spec/services/whatsapp/liquid_template_processor_service_spec.rb diff --git a/app/services/whatsapp/liquid_template_processor_service.rb b/app/services/whatsapp/liquid_template_processor_service.rb new file mode 100644 index 000000000..6703b206c --- /dev/null +++ b/app/services/whatsapp/liquid_template_processor_service.rb @@ -0,0 +1,66 @@ +class Whatsapp::LiquidTemplateProcessorService + LIQUID_EXPRESSION = /\{\{\s*(.+?)\s*\}\}/ + + module JsonEscapeFilter + def json_escape(input) + input.to_s.to_json[1..-2] + end + end + + pattr_initialize [:campaign!, :contact!] + + def process_template_params(template_params) + return template_params if template_params.blank? + + template_params_copy = template_params.deep_dup + processed_params = template_params_copy['processed_params'] + + return template_params_copy if processed_params.blank? + + rendered_params = render_liquid(processed_params) + return nil if blank_render?(processed_params, rendered_params) + + template_params_copy.merge('processed_params' => rendered_params) + end + + private + + def render_liquid(processed_params) + raw = processed_params.to_json + rewritten = raw.gsub(LIQUID_EXPRESSION) { "{{ #{Regexp.last_match(1)} | json_escape }}" } + rendered = Liquid::Template.parse(rewritten).render!(drops, filters: [JsonEscapeFilter]) + JSON.parse(rendered) + rescue Liquid::Error, JSON::ParserError + processed_params + end + + def drops + { + 'contact' => ContactDrop.new(contact), + 'agent' => UserDrop.new(campaign.sender), + 'inbox' => InboxDrop.new(campaign.inbox), + 'account' => AccountDrop.new(campaign.account) + } + end + + def blank_render?(original, rendered) + case original + when Hash then blank_render_in_hash?(original, rendered) + when Array then blank_render_in_array?(original, rendered) + when String then original.match?(LIQUID_EXPRESSION) && rendered.to_s.blank? + else false + end + end + + def blank_render_in_hash?(original, rendered) + return false unless rendered.is_a?(Hash) + + original.any? { |key, value| blank_render?(value, rendered[key]) } + end + + def blank_render_in_array?(original, rendered) + return false unless rendered.is_a?(Array) + + original.each_with_index.any? { |value, index| blank_render?(value, rendered[index]) } + end +end diff --git a/app/services/whatsapp/oneoff_campaign_service.rb b/app/services/whatsapp/oneoff_campaign_service.rb index 5917838cb..7826f71fd 100644 --- a/app/services/whatsapp/oneoff_campaign_service.rb +++ b/app/services/whatsapp/oneoff_campaign_service.rb @@ -58,7 +58,10 @@ class Whatsapp::OneoffCampaignService return end - send_whatsapp_template_message(to: contact.phone_number) + processed_template_params = process_liquid_template_params(contact) + return if processed_template_params.nil? + + send_whatsapp_template_message(to: contact.phone_number, template_params: processed_template_params) end def process_audience(audience_labels) @@ -70,10 +73,22 @@ class Whatsapp::OneoffCampaignService Rails.logger.info "Campaign #{campaign.id} processing completed" end - def send_whatsapp_template_message(to:) + def process_liquid_template_params(contact) + liquid_processor = Whatsapp::LiquidTemplateProcessorService.new(campaign: campaign, contact: contact) + processed_template_params = liquid_processor.process_template_params(campaign.template_params) + + Rails.logger.info "Skipping contact #{contact.name} - liquid variables resolved to blank values" if processed_template_params.nil? + + processed_template_params + rescue StandardError => e + Rails.logger.error "Failed to process liquid template params for contact #{contact.name}: #{e.message}" + nil + end + + def send_whatsapp_template_message(to:, template_params:) processor = Whatsapp::TemplateProcessorService.new( channel: channel, - template_params: campaign.template_params + template_params: template_params ) name, namespace, lang_code, processed_parameters = processor.call diff --git a/spec/services/whatsapp/liquid_template_processor_service_spec.rb b/spec/services/whatsapp/liquid_template_processor_service_spec.rb new file mode 100644 index 000000000..5b11b7409 --- /dev/null +++ b/spec/services/whatsapp/liquid_template_processor_service_spec.rb @@ -0,0 +1,347 @@ +require 'rails_helper' + +describe Whatsapp::LiquidTemplateProcessorService do + let(:account) { create(:account) } + let(:agent) { create(:user, account: account, name: 'Agent Smith') } + let(:inbox) { create(:inbox, account: account, name: 'Support Inbox') } + let(:contact) { create(:contact, account: account, name: 'John Doe', email: 'john@example.com', phone_number: '+1234567890') } + let(:campaign) { create(:campaign, account: account, inbox: inbox, sender: agent, message: 'Test message') } + let(:service) { described_class.new(campaign: campaign, contact: contact) } + + describe '#process_template_params' do + context 'when template_params is blank' do + it 'returns the original template_params' do + result = service.process_template_params(nil) + expect(result).to be_nil + end + end + + context 'when processed_params is blank' do + let(:template_params) { { 'name' => 'test_template' } } + + it 'returns the original template_params' do + result = service.process_template_params(template_params) + expect(result).to eq(template_params) + end + end + + context 'with body parameters containing liquid variables' do + let(:template_params) do + { + 'name' => 'test_template', + 'namespace' => 'test_namespace', + 'language' => 'en', + 'processed_params' => { + 'body' => { + 'name' => '{{contact.name}}', + 'email' => '{{contact.email}}', + 'static_text' => 'Hello World' + } + } + } + end + + it 'processes liquid variables in body parameters' do + result = service.process_template_params(template_params) + contact_drop_name = ContactDrop.new(contact).name + + expect(result['processed_params']['body']['name']).to eq(contact_drop_name) + expect(result['processed_params']['body']['email']).to eq(contact.email) + expect(result['processed_params']['body']['static_text']).to eq('Hello World') + end + + it 'does not modify the original template_params' do + original_name_value = template_params['processed_params']['body']['name'] + service.process_template_params(template_params) + + expect(template_params['processed_params']['body']['name']).to eq(original_name_value) + end + end + + context 'with header parameters containing liquid variables' do + let(:template_params) do + { + 'name' => 'test_template', + 'processed_params' => { + 'header' => { + 'media_url' => 'https://example.com/{{contact.name}}.jpg', + 'media_name' => '{{contact.name}}_document.pdf' + } + } + } + end + + it 'processes liquid variables in header parameters' do + result = service.process_template_params(template_params) + contact_drop_name = ContactDrop.new(contact).name + + expect(result['processed_params']['header']['media_url']).to eq("https://example.com/#{contact_drop_name}.jpg") + expect(result['processed_params']['header']['media_name']).to eq("#{contact_drop_name}_document.pdf") + end + end + + context 'with button parameters containing liquid variables' do + let(:template_params) do + { + 'name' => 'test_template', + 'processed_params' => { + 'buttons' => [ + { 'type' => 'url', 'parameter' => '{{contact.email}}' }, + { 'type' => 'copy_code', 'parameter' => 'CODE-{{contact.name}}' } + ] + } + } + end + + it 'processes liquid variables in button parameters' do + result = service.process_template_params(template_params) + contact_drop_name = ContactDrop.new(contact).name + + expect(result['processed_params']['buttons'][0]['parameter']).to eq(contact.email) + expect(result['processed_params']['buttons'][1]['parameter']).to eq("CODE-#{contact_drop_name}") + end + end + + context 'with footer parameters containing liquid variables' do + let(:template_params) do + { + 'name' => 'test_template', + 'processed_params' => { + 'footer' => { + 'text' => 'From {{agent.name}} at {{account.name}}' + } + } + } + end + + it 'processes liquid variables in footer parameters' do + result = service.process_template_params(template_params) + agent_drop_name = UserDrop.new(agent).name + + expect(result['processed_params']['footer']['text']).to eq("From #{agent_drop_name} at #{account.name}") + end + end + + context 'with multiple liquid variables across different sections' do + let(:template_params) do + { + 'name' => 'test_template', + 'processed_params' => { + 'body' => { + 'greeting' => 'Hello {{contact.name}}', + 'agent' => 'Your agent is {{agent.name}}' + }, + 'header' => { + 'media_name' => '{{contact.name}}_file.pdf' + }, + 'buttons' => [ + { 'parameter' => '{{contact.email}}' } + ], + 'footer' => { + 'text' => '{{inbox.name}}' + } + } + } + end + + it 'processes all liquid variables correctly' do + result = service.process_template_params(template_params) + contact_drop_name = ContactDrop.new(contact).name + agent_drop_name = UserDrop.new(agent).name + + expect(result['processed_params']['body']['greeting']).to eq("Hello #{contact_drop_name}") + expect(result['processed_params']['body']['agent']).to eq("Your agent is #{agent_drop_name}") + expect(result['processed_params']['header']['media_name']).to eq("#{contact_drop_name}_file.pdf") + expect(result['processed_params']['buttons'][0]['parameter']).to eq(contact.email) + expect(result['processed_params']['footer']['text']).to eq(inbox.name) + end + end + + context 'with blank or nil values' do + let(:template_params) do + { + 'name' => 'test_template', + 'processed_params' => { + 'body' => { + 'name' => nil, + 'email' => '', + 'valid' => '{{contact.name}}' + } + } + } + end + + it 'handles blank values gracefully' do + result = service.process_template_params(template_params) + contact_drop_name = ContactDrop.new(contact).name + + expect(result['processed_params']['body']['name']).to be_nil + expect(result['processed_params']['body']['email']).to eq('') + expect(result['processed_params']['body']['valid']).to eq(contact_drop_name) + end + end + + context 'when liquid variable resolves to blank' do + let(:contact) { create(:contact, account: account, name: 'John', email: nil, phone_number: '+1234567890') } + + it 'returns nil for enhanced params with blank rendered values' do + template_params = { + 'name' => 'test_template', + 'processed_params' => { + 'body' => { + 'email' => '{{contact.email}}' + } + } + } + + result = service.process_template_params(template_params) + expect(result).to be_nil + end + + it 'returns nil for legacy hash params with blank rendered values' do + template_params = { + 'name' => 'test_template', + 'processed_params' => { + '1' => '{{contact.email}}' + } + } + + result = service.process_template_params(template_params) + expect(result).to be_nil + end + + it 'returns nil for legacy array params with blank rendered values' do + template_params = { + 'name' => 'test_template', + 'processed_params' => ['{{contact.email}}'] + } + + result = service.process_template_params(template_params) + expect(result).to be_nil + end + + it 'returns nil for button params with blank rendered values' do + template_params = { + 'name' => 'test_template', + 'processed_params' => { + 'buttons' => [ + { 'type' => 'url', 'parameter' => '{{contact.email}}' } + ] + } + } + + result = service.process_template_params(template_params) + expect(result).to be_nil + end + + it 'returns processed params when all variables resolve to non-blank values' do + template_params = { + 'name' => 'test_template', + 'processed_params' => { + 'body' => { + 'name' => '{{contact.name}}' + } + } + } + + result = service.process_template_params(template_params) + expect(result).not_to be_nil + expect(result['processed_params']['body']['name']).to eq(ContactDrop.new(contact).name) + end + end + + context 'with custom attributes' do + let(:contact) do + create(:contact, account: account, name: 'John Doe', + custom_attributes: { 'company' => 'Acme Inc', 'plan' => 'Premium' }) + end + let(:template_params) do + { + 'name' => 'test_template', + 'processed_params' => { + 'body' => { + 'company' => '{{contact.custom_attribute.company}}', + 'plan' => '{{contact.custom_attribute.plan}}' + } + } + } + end + + it 'processes custom attribute liquid variables' do + result = service.process_template_params(template_params) + + expect(result['processed_params']['body']['company']).to eq('Acme Inc') + expect(result['processed_params']['body']['plan']).to eq('Premium') + end + end + + context 'with invalid liquid syntax' do + let(:template_params) do + { + 'name' => 'test_template', + 'processed_params' => { + 'body' => { + 'invalid' => '{{contact.name missing braces' + } + } + } + end + + it 'returns original value when liquid parsing fails' do + result = service.process_template_params(template_params) + + expect(result['processed_params']['body']['invalid']).to eq('{{contact.name missing braces') + end + end + + context 'with legacy flat hash processed_params' do + let(:template_params) do + { + 'name' => 'legacy_template', + 'processed_params' => { + '1' => '{{contact.name}}', + '2' => '{{contact.email}}', + '3' => 'Hello World' + } + } + end + + it 'processes liquid variables in legacy hash values' do + result = service.process_template_params(template_params) + contact_drop_name = ContactDrop.new(contact).name + + expect(result['processed_params']['1']).to eq(contact_drop_name) + expect(result['processed_params']['2']).to eq(contact.email) + expect(result['processed_params']['3']).to eq('Hello World') + end + + it 'treats component-named string values as legacy params' do + params_with_component_named_key = { + 'name' => 'legacy_template', + 'processed_params' => { 'body' => '{{contact.name}}' } + } + + result = service.process_template_params(params_with_component_named_key) + contact_drop_name = ContactDrop.new(contact).name + + expect(result['processed_params']['body']).to eq(contact_drop_name) + end + end + + context 'with legacy array processed_params' do + let(:template_params) do + { + 'name' => 'legacy_template', + 'processed_params' => ['{{contact.name}}', '{{contact.email}}', 'Hello World'] + } + end + + it 'processes liquid variables in legacy array values' do + result = service.process_template_params(template_params) + contact_drop_name = ContactDrop.new(contact).name + + expect(result['processed_params']).to eq([contact_drop_name, contact.email, 'Hello World']) + end + end + end +end diff --git a/spec/services/whatsapp/oneoff_campaign_service_spec.rb b/spec/services/whatsapp/oneoff_campaign_service_spec.rb index dd8d51c54..00d7fdd3b 100644 --- a/spec/services/whatsapp/oneoff_campaign_service_spec.rb +++ b/spec/services/whatsapp/oneoff_campaign_service_spec.rb @@ -139,6 +139,73 @@ describe Whatsapp::OneoffCampaignService do described_class.new(campaign: campaign).perform end + + it 'processes liquid variables in template parameters' do + contact = create(:contact, :with_phone_number, account: account, name: 'Jane Smith', email: 'jane@example.com') + contact.update_labels([label1.title]) + + campaign_with_liquid = create(:campaign, inbox: whatsapp_inbox, account: account, + audience: [{ type: 'Label', id: label1.id }], + template_params: { + 'name' => 'ticket_status_updated', + 'namespace' => '23423423_2342423_324234234_2343224', + 'category' => 'UTILITY', + 'language' => 'en', + 'processed_params' => { + 'body' => { + 'name' => '{{contact.name}}', + 'ticket_id' => '{{contact.email}}' + } + } + }) + + contact_drop_name = ContactDrop.new(contact).name + + expect(whatsapp_channel).to receive(:send_template).with( + contact.phone_number, + hash_including( + name: 'ticket_status_updated', + namespace: '23423423_2342423_324234234_2343224', + lang_code: 'en', + parameters: array_including( + hash_including( + type: 'body', + parameters: array_including( + hash_including(type: 'text', parameter_name: 'name', text: contact_drop_name), + hash_including(type: 'text', parameter_name: 'ticket_id', text: contact.email) + ) + ) + ) + ), + nil + ) + + described_class.new(campaign: campaign_with_liquid).perform + end + + it 'skips contacts when liquid variables resolve to blank values' do + contact = create(:contact, :with_phone_number, account: account, name: 'Jane', email: nil) + contact.update_labels([label1.title]) + + campaign_with_blank_liquid = create(:campaign, inbox: whatsapp_inbox, account: account, + audience: [{ type: 'Label', id: label1.id }], + template_params: { + 'name' => 'test_template', + 'namespace' => 'test_namespace', + 'language' => 'en', + 'processed_params' => { + 'body' => { + 'email' => '{{contact.email}}' + } + } + }) + + expect(whatsapp_channel).not_to receive(:send_template) + expect(Rails.logger).to receive(:info).with("Skipping contact #{contact.name} - liquid variables resolved to blank values") + allow(Rails.logger).to receive(:info) + + described_class.new(campaign: campaign_with_blank_liquid).perform + end end context 'when template_params is missing' do From 0e122188e975e4f98c18c83f10dba29510c247e6 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 29 Apr 2026 11:32:19 +0400 Subject: [PATCH 04/42] feat: Add voice calling as a capability on Twilio SMS channel(Enterprise) (#13963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voice calling is now a capability on the existing TwilioSms rather than a separate Voice model. A single Twilio phone number handles both SMS and voice calls through one inbox. Fixes https://linear.app/chatwoot/issue/CW-6683/add-voice-calling-as-a-capability-on-twilio-sms-channel and https://linear.app/chatwoot/issue/PLA-120/add-the-support-for-sms **What changed** - Replaced Channel::Voice with voice_enabled flag on Channel::TwilioSms - Added voice_enabled, twiml_app_sid, api_key_secret columns to channel_twilio_sms table - Dropped channel_voice table (no production data) - All voice logic lives in Enterprise layer via prepend_mod_with('Channel::TwilioSms') - Added Voice settings tab on Twilio SMS inbox settings to enable/disable voice - Validates Twilio number voice capability before provisioning - Teardown service cleans up TwiML app and credentials when voice is disabled - Frontend voice detection uses isVoiceCallEnabled() / getVoiceCallProvider() helpers — extensible to future providers - Gated by channel_voice feature flag **How to test** 1. Enable feature flag: Account.find().enable_features('channel_voice') 2. Create voice inbox: Inboxes → Voice tile → enter Twilio credentials → verify incoming/outgoing calls and SMS work 3. Enable voice on existing SMS inbox: Inboxes → select Twilio SMS inbox → Voice tab → toggle on → provide API key credentials → verify calls work 4. Disable voice: Voice tab → toggle off → verify TwiML app is deleted, credentials cleared, SMS still works 5. Re-enable voice: Toggle on again → must provide api_key_secret again → new TwiML app provisioned --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- .../Contacts/VoiceCallButton.vue | 6 +- .../components/ActionButtons.vue | 9 +- .../components/ComposeNewConversationForm.vue | 5 +- .../components-next/icon/provider.js | 7 +- .../icon/specs/provider.spec.js | 7 +- .../composables/spec/useInbox.spec.js | 13 +- .../dashboard/composables/useInbox.js | 15 +- app/javascript/dashboard/constants/editor.js | 5 - app/javascript/dashboard/featureFlags.js | 1 + app/javascript/dashboard/helper/inbox.js | 31 ++-- .../dashboard/i18n/locale/en/inboxMgmt.json | 12 +- .../routes/dashboard/settings/inbox/Index.vue | 1 + .../dashboard/settings/inbox/Settings.vue | 52 ++++-- .../settings/inbox/components/ChannelName.vue | 8 +- .../inbox/settingsPage/ConfigurationPage.vue | 18 -- .../settingsPage/VoiceConfigurationPage.vue | 156 ++++++++++++++++++ app/javascript/shared/mixins/inboxMixin.js | 6 +- app/models/channel/twilio_sms.rb | 9 +- app/views/api/v1/models/_inbox.json.jbuilder | 14 +- ...6120000_add_voice_to_channel_twilio_sms.rb | 7 + .../20260326120001_drop_channel_voice.rb | 20 +++ db/schema.rb | 15 +- .../enterprise/contact_inbox_builder.rb | 10 +- .../enterprise/messages/message_builder.rb | 7 +- .../v1/accounts/contacts/calls_controller.rb | 13 +- .../api/v1/accounts/inboxes_controller.rb | 50 ++++-- .../controllers/twilio/voice_controller.rb | 6 +- enterprise/app/models/channel/voice.rb | 122 -------------- .../models/enterprise/channel/twilio_sms.rb | 81 +++++++++ .../app/models/enterprise/concerns/account.rb | 1 - .../contacts/contactable_inboxes_service.rb | 4 +- .../services/twilio/voice_teardown_service.rb | 33 ++++ .../twilio/voice_webhook_setup_service.rb | 40 ++--- .../services/voice/provider/twilio/adapter.rb | 6 +- .../provider/twilio/conference_service.rb | 19 +-- .../voice/provider/twilio/token_service.rb | 22 +-- .../v1/accounts/conference_controller_spec.rb | 2 +- .../v1/accounts/inboxes_controller_spec.rb | 8 +- .../twilio/voice_controller_spec.rb | 2 +- .../models/channel/twilio_sms_voice_spec.rb | 103 ++++++++++++ spec/enterprise/models/channel/voice_spec.rb | 79 --------- .../voice_webhook_setup_service_spec.rb | 14 +- .../voice/inbound_call_builder_spec.rb | 2 +- .../voice/outbound_call_builder_spec.rb | 2 +- .../voice/provider/twilio/adapter_spec.rb | 4 +- .../twilio/conference_service_spec.rb | 5 +- .../provider/twilio/token_service_spec.rb | 2 +- .../voice/status_update_service_spec.rb | 2 +- spec/factories/channel/channel_voice.rb | 21 --- spec/factories/channel/twilio_sms.rb | 8 + 50 files changed, 667 insertions(+), 418 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/VoiceConfigurationPage.vue create mode 100644 db/migrate/20260326120000_add_voice_to_channel_twilio_sms.rb create mode 100644 db/migrate/20260326120001_drop_channel_voice.rb delete mode 100644 enterprise/app/models/channel/voice.rb create mode 100644 enterprise/app/models/enterprise/channel/twilio_sms.rb create mode 100644 enterprise/app/services/twilio/voice_teardown_service.rb create mode 100644 spec/enterprise/models/channel/twilio_sms_voice_spec.rb delete mode 100644 spec/enterprise/models/channel/voice_spec.rb delete mode 100644 spec/factories/channel/channel_voice.rb diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index e9183ce9b..b258dc763 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -3,7 +3,7 @@ import { computed, ref, useAttrs } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRoute, useRouter } from 'vue-router'; import { useMapGetter, useStore } from 'dashboard/composables/store'; -import { INBOX_TYPES } from 'dashboard/helper/inbox'; +import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; import { useAlert } from 'dashboard/composables'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { useCallsStore } from 'dashboard/stores/calls'; @@ -34,9 +34,7 @@ const inboxesList = useMapGetter('inboxes/getInboxes'); const contactsUiFlags = useMapGetter('contacts/getUIFlags'); const voiceInboxes = computed(() => - (inboxesList.value || []).filter( - inbox => inbox.channel_type === INBOX_TYPES.VOICE - ) + (inboxesList.value || []).filter(isVoiceCallEnabled) ); const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0); diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue index e8f484997..22c142322 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue @@ -8,8 +8,6 @@ import { useEventListener } from '@vueuse/core'; import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import FileUpload from 'vue-upload-component'; -import { INBOX_TYPES } from 'dashboard/helper/inbox'; - import Button from 'dashboard/components-next/button/Button.vue'; import WhatsAppOptions from './WhatsAppOptions.vue'; import ContentTemplateSelector from './ContentTemplateSelector.vue'; @@ -30,6 +28,7 @@ const props = defineProps({ isDropdownActive: { type: Boolean, default: false }, messageSignature: { type: String, default: '' }, inboxId: { type: Number, default: null }, + voiceEnabled: { type: Boolean, default: false }, }); const emit = defineEmits([ @@ -82,11 +81,9 @@ const isRegularMessageMode = computed(() => { return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox; }); -const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE); - const shouldShowSignatureButton = computed(() => { return ( - props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value + props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled ); }); @@ -111,7 +108,7 @@ watch( () => props.hasSelectedInbox, newValue => { nextTick(() => { - if (newValue && !isVoiceInbox.value) setSignature(); + if (newValue && !props.voiceEnabled) setSignature(); }); }, { immediate: true } diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index c4111b481..f7813332f 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -2,7 +2,7 @@ import { ref, computed } from 'vue'; import { useVuelidate } from '@vuelidate/core'; import { required, requiredIf } from '@vuelidate/validators'; -import { INBOX_TYPES } from 'dashboard/helper/inbox'; +import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox'; import { appendSignature, removeSignature, @@ -100,6 +100,8 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || ''); const inboxMedium = computed(() => props.targetInbox?.medium || ''); +const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox)); + const effectiveChannelType = computed(() => getEffectiveChannelType(inboxChannelType.value, inboxMedium.value) ); @@ -442,6 +444,7 @@ useKeyboardEvents({ :is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp" :message-templates="whatsappMessageTemplates" :channel-type="inboxChannelType" + :voice-enabled="voiceCallEnabled" :is-loading="isCreating" :disable-send-button="isCreating" :has-selected-inbox="!!targetInbox" diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js index a5b99f84f..d7a9c93ad 100644 --- a/app/javascript/dashboard/components-next/icon/provider.js +++ b/app/javascript/dashboard/components-next/icon/provider.js @@ -1,4 +1,5 @@ import { computed } from 'vue'; +import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; export function useChannelIcon(inbox) { const channelTypeIconMap = { @@ -14,7 +15,6 @@ export function useChannelIcon(inbox) { 'Channel::Whatsapp': 'i-woot-whatsapp', 'Channel::Instagram': 'i-woot-instagram', 'Channel::Tiktok': 'i-woot-tiktok', - 'Channel::Voice': 'i-woot-voice', }; const providerIconMap = { @@ -38,6 +38,11 @@ export function useChannelIcon(inbox) { icon = 'i-woot-whatsapp'; } + // Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.) + if (isVoiceCallEnabled(inboxDetails)) { + icon = 'i-woot-voice'; + } + return icon ?? 'i-ri-global-fill'; }); diff --git a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js index 8bccd84cc..38c001fae 100644 --- a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js +++ b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js @@ -19,8 +19,11 @@ describe('useChannelIcon', () => { expect(icon).toBe('i-woot-whatsapp'); }); - it('returns correct icon for Voice channel', () => { - const inbox = { channel_type: 'Channel::Voice' }; + it('returns correct icon for voice-enabled Twilio channel', () => { + const inbox = { + channel_type: 'Channel::TwilioSms', + voice_enabled: true, + }; const { value: icon } = useChannelIcon(inbox); expect(icon).toBe('i-woot-voice'); }); diff --git a/app/javascript/dashboard/composables/spec/useInbox.spec.js b/app/javascript/dashboard/composables/spec/useInbox.spec.js index 71472a35b..1c01032d1 100644 --- a/app/javascript/dashboard/composables/spec/useInbox.spec.js +++ b/app/javascript/dashboard/composables/spec/useInbox.spec.js @@ -47,7 +47,11 @@ const mockStore = createStore({ 11: { id: 11, channel_type: INBOX_TYPES.API }, 12: { id: 12, channel_type: INBOX_TYPES.SMS }, 13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM }, - 14: { id: 14, channel_type: INBOX_TYPES.VOICE }, + 14: { + id: 14, + channel_type: INBOX_TYPES.TWILIO, + voice_enabled: true, + }, 15: { id: 15, channel_type: INBOX_TYPES.TIKTOK }, }; return inboxes[id] || null; @@ -211,11 +215,11 @@ describe('useInbox', () => { }); expect(wrapper.vm.isAnInstagramChannel).toBe(true); - // Test Voice + // Test Voice (Twilio with voice_enabled) wrapper = mount(createTestComponent(14), { global: { plugins: [mockStore] }, }); - expect(wrapper.vm.isAVoiceChannel).toBe(true); + expect(wrapper.vm.voiceCallEnabled).toBe(true); // Test Tiktok wrapper = mount(createTestComponent(15), { @@ -274,7 +278,8 @@ describe('useInbox', () => { 'isAnEmailChannel', 'isAnInstagramChannel', 'isATiktokChannel', - 'isAVoiceChannel', + 'voiceCallEnabled', + 'voiceCallProvider', ]; expectedProperties.forEach(prop => { diff --git a/app/javascript/dashboard/composables/useInbox.js b/app/javascript/dashboard/composables/useInbox.js index 632d3556b..6e8d0a52a 100644 --- a/app/javascript/dashboard/composables/useInbox.js +++ b/app/javascript/dashboard/composables/useInbox.js @@ -1,7 +1,11 @@ import { computed } from 'vue'; import { useMapGetter } from 'dashboard/composables/store'; import { useCamelCase } from 'dashboard/composables/useTransformKeys'; -import { INBOX_TYPES } from 'dashboard/helper/inbox'; +import { + INBOX_TYPES, + isVoiceCallEnabled, + getVoiceCallProvider, +} from 'dashboard/helper/inbox'; export const INBOX_FEATURES = { REPLY_TO: 'replyTo', @@ -134,9 +138,9 @@ export const useInbox = (inboxId = null) => { return channelType.value === INBOX_TYPES.TIKTOK; }); - const isAVoiceChannel = computed(() => { - return channelType.value === INBOX_TYPES.VOICE; - }); + const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value)); + + const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value)); return { inbox, @@ -156,6 +160,7 @@ export const useInbox = (inboxId = null) => { isAnEmailChannel, isAnInstagramChannel, isATiktokChannel, - isAVoiceChannel, + voiceCallEnabled, + voiceCallProvider, }; }; diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index fd02461a8..378d303b4 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -109,11 +109,6 @@ export const FORMATTING = { 'redo', ], }, - 'Channel::Voice': { - marks: [], - nodes: [], - menu: [], - }, 'Channel::Tiktok': { marks: [], nodes: [], diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 0cc67db67..1f9632425 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -36,6 +36,7 @@ export const FEATURE_FLAGS = { CHATWOOT_V4: 'chatwoot_v4', CHANNEL_INSTAGRAM: 'channel_instagram', CHANNEL_TIKTOK: 'channel_tiktok', + CHANNEL_VOICE: 'channel_voice', CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team', CAPTAIN_CUSTOM_TOOLS: 'custom_tools', CAPTAIN_V2: 'captain_integration_v2', diff --git a/app/javascript/dashboard/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js index 401739706..4039a07d1 100644 --- a/app/javascript/dashboard/helper/inbox.js +++ b/app/javascript/dashboard/helper/inbox.js @@ -11,9 +11,29 @@ export const INBOX_TYPES = { SMS: 'Channel::Sms', INSTAGRAM: 'Channel::Instagram', TIKTOK: 'Channel::Tiktok', - VOICE: 'Channel::Voice', }; +// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp) +export const VOICE_CALL_PROVIDERS = { + TWILIO: 'twilio', +}; + +export const getVoiceCallProvider = inbox => { + if (!inbox) return null; + + // Callers pass either snake_case (raw API) or camelCase (after camelcaseKeys) shapes. + const channelType = inbox.channel_type || inbox.channelType; + const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled; + + if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) { + return VOICE_CALL_PROVIDERS.TWILIO; + } + + return null; +}; + +export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null; + export const TWILIO_CHANNEL_MEDIUM = { WHATSAPP: 'whatsapp', SMS: 'sms', @@ -30,7 +50,6 @@ const INBOX_ICON_MAP_FILL = { [INBOX_TYPES.LINE]: 'i-ri-line-fill', [INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill', [INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill', - [INBOX_TYPES.VOICE]: 'i-ri-phone-fill', }; const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill'; @@ -45,7 +64,6 @@ const INBOX_ICON_MAP_LINE = { [INBOX_TYPES.TELEGRAM]: 'i-woot-telegram', [INBOX_TYPES.LINE]: 'i-woot-line', [INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram', - [INBOX_TYPES.VOICE]: 'i-woot-voice', [INBOX_TYPES.TIKTOK]: 'i-woot-tiktok', }; @@ -58,7 +76,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => { case INBOX_TYPES.TWILIO: case INBOX_TYPES.WHATSAPP: - case INBOX_TYPES.VOICE: return phoneNumber || ''; case INBOX_TYPES.EMAIL: @@ -97,9 +114,6 @@ export const getReadableInboxByType = (type, phoneNumber) => { case INBOX_TYPES.LINE: return 'line'; - case INBOX_TYPES.VOICE: - return 'voice'; - default: return 'chat'; } @@ -142,9 +156,6 @@ export const getInboxClassByType = (type, phoneNumber) => { case INBOX_TYPES.TIKTOK: return 'brand-tiktok'; - case INBOX_TYPES.VOICE: - return 'phone'; - default: return 'chat'; } diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 6b1ff20b1..24207cef2 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -636,7 +636,17 @@ "WIDGET_BUILDER": "Widget Builder", "BOT_CONFIGURATION": "Bot Configuration", "ACCOUNT_HEALTH": "Account Health", - "CSAT": "CSAT" + "CSAT": "CSAT", + "VOICE": "Voice" + }, + "VOICE_CONFIGURATION": { + "ENABLE_VOICE": { + "LABEL": "Enable Voice Calling", + "DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls." + }, + "CREDENTIALS": { + "DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections." + } }, "CHANNEL_PREFERENCES": "Channel Preferences", "WIDGET_FEATURES": "Widget features", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue index 838d3ac0b..884c198c4 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue @@ -145,6 +145,7 @@ const openDelete = inbox => { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 11eb1f6aa..a26eb0e18 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -21,6 +21,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue'; import WeeklyAvailability from './components/WeeklyAvailability.vue'; import GreetingsEditor from 'shared/components/GreetingsEditor.vue'; import ConfigurationPage from './settingsPage/ConfigurationPage.vue'; +import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue'; import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue'; import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue'; import BotConfiguration from './components/BotConfiguration.vue'; @@ -46,6 +47,7 @@ export default { BotConfiguration, CollaboratorsPage, ConfigurationPage, + VoiceConfigurationPage, CustomerSatisfactionPage, FacebookReauthorize, GreetingsEditor, @@ -169,19 +171,17 @@ export default { }, ]; - if (!this.isAVoiceChannel) { - visibleToAllChannelTabs = [ - ...visibleToAllChannelTabs, - { - key: 'business-hours', - name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'), - }, - { - key: 'csat', - name: this.$t('INBOX_MGMT.TABS.CSAT'), - }, - ]; - } + visibleToAllChannelTabs = [ + ...visibleToAllChannelTabs, + { + key: 'business-hours', + name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'), + }, + { + key: 'csat', + name: this.$t('INBOX_MGMT.TABS.CSAT'), + }, + ]; if (this.isAWebWidgetInbox) { visibleToAllChannelTabs = [ @@ -197,7 +197,6 @@ export default { this.isATwilioChannel || this.isALineChannel || this.isAPIInbox || - this.isAVoiceChannel || (this.isAnEmailChannel && !this.inbox.provider) || this.shouldShowWhatsAppConfiguration || this.isAWebWidgetInbox @@ -232,6 +231,24 @@ export default { ]; } + if ( + this.isATwilioChannel && + this.inbox.phone_number && + this.inbox.medium === 'sms' && + this.isFeatureEnabledonAccount( + this.accountId, + FEATURE_FLAGS.CHANNEL_VOICE + ) + ) { + visibleToAllChannelTabs = [ + ...visibleToAllChannelTabs, + { + key: 'voice-configuration', + name: this.$t('INBOX_MGMT.TABS.VOICE'), + }, + ]; + } + return visibleToAllChannelTabs; }, currentInboxId() { @@ -812,7 +829,6 @@ export default { @@ -1240,6 +1256,12 @@ export default { > +
+ +
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue index 1804e1224..711217f32 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue @@ -12,6 +12,10 @@ const props = defineProps({ type: String, default: '', }, + voiceEnabled: { + type: Boolean, + default: false, + }, }); const getters = useStoreGetters(); const { t } = useI18n(); @@ -30,7 +34,6 @@ const i18nMap = { 'Channel::Api': 'API', 'Channel::Instagram': 'INSTAGRAM', 'Channel::Tiktok': 'TIKTOK', - 'Channel::Voice': 'VOICE', }; const twilioChannelName = () => { @@ -45,6 +48,9 @@ const readableChannelName = computed(() => { return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API'); } if (props.channelType === 'Channel::TwilioSms') { + if (props.voiceEnabled) { + return t('INBOX_MGMT.CHANNELS.VOICE'); + } return twilioChannelName(); } return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`); diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue index 0bfabb7b6..9cd1665bf 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue @@ -208,24 +208,6 @@ export default {
-
- - - - - - -
+import { useAlert } from 'dashboard/composables'; +import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue'; +import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue'; +import NextInput from 'dashboard/components-next/input/Input.vue'; +import NextButton from 'dashboard/components-next/button/Button.vue'; + +export default { + components: { + SettingsFieldSection, + SettingsToggleSection, + NextInput, + NextButton, + }, + props: { + inbox: { + type: Object, + default: () => ({}), + }, + }, + data() { + return { + voiceEnabled: this.inbox.voice_enabled || false, + apiKeySid: this.inbox.api_key_sid || '', + apiKeySecret: '', + isUpdating: false, + }; + }, + computed: { + isVoiceConfigured() { + return !!this.inbox.voice_configured; + }, + hasApiKeySid() { + return !!this.inbox.api_key_sid; + }, + hasExistingCredentials() { + return this.hasApiKeySid && !!this.inbox.has_api_key_secret; + }, + needsCredentials() { + return ( + this.voiceEnabled && + !this.isVoiceConfigured && + !this.hasExistingCredentials + ); + }, + needsApiKeySid() { + return this.needsCredentials && !this.hasApiKeySid; + }, + isSubmitDisabled() { + if (!this.voiceEnabled) return false; + if (this.needsCredentials) { + if (this.needsApiKeySid && !this.apiKeySid) return true; + return !this.apiKeySecret; + } + return false; + }, + }, + watch: { + 'inbox.voice_enabled'(val) { + this.voiceEnabled = val || false; + }, + 'inbox.api_key_sid'(val) { + this.apiKeySid = val || ''; + }, + }, + methods: { + async updateVoiceSettings() { + this.isUpdating = true; + try { + const channelPayload = { voice_enabled: this.voiceEnabled }; + + if (this.needsCredentials) { + if (this.needsApiKeySid) { + channelPayload.api_key_sid = this.apiKeySid; + } + channelPayload.api_key_secret = this.apiKeySecret; + } + + await this.$store.dispatch('inboxes/updateInbox', { + id: this.inbox.id, + formData: false, + channel: channelPayload, + }); + this.apiKeySecret = ''; + useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE')); + } catch (error) { + useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')); + } finally { + this.isUpdating = false; + } + }, + }, +}; + + + diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js index 3fe04dc11..40579125b 100644 --- a/app/javascript/shared/mixins/inboxMixin.js +++ b/app/javascript/shared/mixins/inboxMixin.js @@ -1,4 +1,4 @@ -import { INBOX_TYPES } from 'dashboard/helper/inbox'; +import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox'; export const INBOX_FEATURES = { REPLY_TO: 'replyTo', @@ -59,8 +59,8 @@ export default { isALineChannel() { return this.channelType === INBOX_TYPES.LINE; }, - isAVoiceChannel() { - return this.channelType === INBOX_TYPES.VOICE; + voiceCallEnabled() { + return isVoiceCallEnabled(this.inbox); }, isAnEmailChannel() { return this.channelType === INBOX_TYPES.EMAIL; diff --git a/app/models/channel/twilio_sms.rb b/app/models/channel/twilio_sms.rb index 2f9130cbb..e288361ea 100644 --- a/app/models/channel/twilio_sms.rb +++ b/app/models/channel/twilio_sms.rb @@ -4,6 +4,7 @@ # # id :bigint not null, primary key # account_sid :string not null +# api_key_secret :string # api_key_sid :string # auth_token :string not null # content_templates :jsonb @@ -11,6 +12,8 @@ # medium :integer default("sms") # messaging_service_sid :string # phone_number :string +# twiml_app_sid :string +# voice_enabled :boolean default(FALSE), not null # created_at :datetime not null # updated_at :datetime not null # account_id :integer not null @@ -58,8 +61,6 @@ class Channel::TwilioSms < ApplicationRecord client.messages.create(**params) end - private - def client if api_key_sid.present? Twilio::REST::Client.new(api_key_sid, auth_token, account_sid) @@ -68,6 +69,8 @@ class Channel::TwilioSms < ApplicationRecord end end + private + def send_message_from if messaging_service_sid? { messaging_service_sid: messaging_service_sid } @@ -76,3 +79,5 @@ class Channel::TwilioSms < ApplicationRecord end end end + +Channel::TwilioSms.prepend_mod_with('Channel::TwilioSms') diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 619e6a28c..e4c19fea0 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -72,6 +72,7 @@ if resource.twilio? if Current.account_user&.administrator? json.auth_token resource.channel.try(:auth_token) json.account_sid resource.channel.try(:account_sid) + json.api_key_sid resource.channel.try(:api_key_sid) end end @@ -131,8 +132,13 @@ if resource.whatsapp? json.reauthorization_required resource.channel.try(:reauthorization_required?) end -## Voice Channel Attributes -if resource.channel_type == 'Channel::Voice' - json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url) - json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url) +## Voice attributes for TwilioSms +if resource.twilio? && resource.channel.respond_to?(:voice_enabled?) + json.voice_enabled resource.channel.voice_enabled? + json.voice_configured resource.channel.try(:twiml_app_sid).present? + json.has_api_key_secret resource.channel.try(:api_key_secret).present? + if resource.channel.try(:twiml_app_sid).present? + json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url) + json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url) + end end diff --git a/db/migrate/20260326120000_add_voice_to_channel_twilio_sms.rb b/db/migrate/20260326120000_add_voice_to_channel_twilio_sms.rb new file mode 100644 index 000000000..0051f7614 --- /dev/null +++ b/db/migrate/20260326120000_add_voice_to_channel_twilio_sms.rb @@ -0,0 +1,7 @@ +class AddVoiceToChannelTwilioSms < ActiveRecord::Migration[7.0] + def change + add_column :channel_twilio_sms, :voice_enabled, :boolean, default: false, null: false + add_column :channel_twilio_sms, :twiml_app_sid, :string + add_column :channel_twilio_sms, :api_key_secret, :string + end +end diff --git a/db/migrate/20260326120001_drop_channel_voice.rb b/db/migrate/20260326120001_drop_channel_voice.rb new file mode 100644 index 000000000..6da514226 --- /dev/null +++ b/db/migrate/20260326120001_drop_channel_voice.rb @@ -0,0 +1,20 @@ +class DropChannelVoice < ActiveRecord::Migration[7.0] + def up + drop_table :channel_voice, if_exists: true + end + + def down + create_table :channel_voice do |t| + t.string :phone_number, null: false + t.string :provider, null: false, default: 'twilio' + t.jsonb :provider_config, null: false + t.integer :account_id, null: false + t.jsonb :additional_attributes, default: {} + + t.timestamps + end + + add_index :channel_voice, :phone_number, unique: true + add_index :channel_voice, :account_id + end +end diff --git a/db/schema.rb b/db/schema.rb index bce190760..16be9e111 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -552,6 +552,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do t.string "api_key_sid" t.jsonb "content_templates", default: {} t.datetime "content_templates_last_updated" + t.boolean "voice_enabled", default: false, null: false + t.string "twiml_app_sid" + t.string "api_key_secret" t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true @@ -568,18 +571,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_04_27_094500) do t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true end - create_table "channel_voice", force: :cascade do |t| - t.string "phone_number", null: false - t.string "provider", default: "twilio", null: false - t.jsonb "provider_config", null: false - t.integer "account_id", null: false - t.jsonb "additional_attributes", default: {} - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["account_id"], name: "index_channel_voice_on_account_id" - t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true - end - create_table "channel_web_widgets", id: :serial, force: :cascade do |t| t.string "website_url" t.integer "account_id" diff --git a/enterprise/app/builders/enterprise/contact_inbox_builder.rb b/enterprise/app/builders/enterprise/contact_inbox_builder.rb index 043505f7f..e7ab48fd5 100644 --- a/enterprise/app/builders/enterprise/contact_inbox_builder.rb +++ b/enterprise/app/builders/enterprise/contact_inbox_builder.rb @@ -2,13 +2,13 @@ module Enterprise::ContactInboxBuilder private def generate_source_id - return super unless @inbox.channel_type == 'Channel::Voice' + return super unless twilio_voice_inbox? phone_source_id end def phone_source_id - return super unless @inbox.channel_type == 'Channel::Voice' + return super unless twilio_voice_inbox? return SecureRandom.uuid if @contact.phone_number.blank? @@ -16,6 +16,10 @@ module Enterprise::ContactInboxBuilder end def allowed_channels? - super || @inbox.channel_type == 'Channel::Voice' + super || twilio_voice_inbox? + end + + def twilio_voice_inbox? + @inbox.channel_type == 'Channel::TwilioSms' && @inbox.channel.voice_enabled? end end diff --git a/enterprise/app/builders/enterprise/messages/message_builder.rb b/enterprise/app/builders/enterprise/messages/message_builder.rb index 727248956..06bd0357c 100644 --- a/enterprise/app/builders/enterprise/messages/message_builder.rb +++ b/enterprise/app/builders/enterprise/messages/message_builder.rb @@ -2,8 +2,13 @@ module Enterprise::Messages::MessageBuilder private def message_type - return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice' + return @message_type if @message_type == 'incoming' && twilio_voice_inbox? && @params[:content_type] == 'voice_call' super end + + def twilio_voice_inbox? + inbox = @conversation.inbox + inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled? + end end diff --git a/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb b/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb index bcdd178fe..11a352325 100644 --- a/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/contacts/calls_controller.rb @@ -30,9 +30,14 @@ class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseCont end def voice_inbox - @voice_inbox ||= Current.user.assigned_inboxes.where( - account_id: Current.account.id, - channel_type: 'Channel::Voice' - ).find(params.require(:inbox_id)) + @voice_inbox ||= begin + inbox = Current.user.assigned_inboxes.where( + account_id: Current.account.id, + channel_type: 'Channel::TwilioSms' + ).find(params.require(:inbox_id)) + raise ActiveRecord::RecordNotFound, 'Voice not enabled' unless inbox.channel.voice_enabled? + + inbox + end end end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb index 396c3a91d..f9d828806 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb @@ -14,20 +14,46 @@ module Enterprise::Api::V1::Accounts::InboxesController end def channel_type_from_params - case permitted_params[:channel][:type] - when 'voice' - Channel::Voice - else - super - end + return Channel::TwilioSms if permitted_params[:channel][:type] == 'voice' + + super end def account_channels_method - case permitted_params[:channel][:type] - when 'voice' - Current.account.voice_channels - else - super - end + return Current.account.twilio_sms if permitted_params[:channel][:type] == 'voice' + + super + end + + def create_channel + return create_voice_channel if permitted_params[:channel][:type] == 'voice' + + super + end + + def get_channel_attributes(channel_type) + attrs = super + attrs += [:voice_enabled, :api_key_sid, :api_key_secret] if channel_type == 'Channel::TwilioSms' && @inbox&.channel&.medium == 'sms' + attrs + end + + def create_voice_channel + raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('channel_voice') + + voice_params = params.require(:channel).permit( + :phone_number, :provider, + provider_config: [:account_sid, :auth_token, :api_key_sid, :api_key_secret] + ) + config = voice_params[:provider_config] || {} + + Current.account.twilio_sms.create!( + phone_number: voice_params[:phone_number], + account_sid: config[:account_sid], + auth_token: config[:auth_token], + api_key_sid: config[:api_key_sid], + api_key_secret: config[:api_key_secret], + medium: :sms, + voice_enabled: true + ) end end diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb index aa2696b31..a8555b373 100644 --- a/enterprise/app/controllers/twilio/voice_controller.rb +++ b/enterprise/app/controllers/twilio/voice_controller.rb @@ -169,8 +169,10 @@ class Twilio::VoiceController < ApplicationController def set_inbox! digits = params[:phone].to_s.gsub(/\D/, '') - e164 = "+#{digits}" - channel = Channel::Voice.find_by!(phone_number: e164) + phone_number = "+#{digits}" + channel = Channel::TwilioSms.find_by!(phone_number: phone_number) + raise ActiveRecord::RecordNotFound, "Voice not enabled for #{phone_number}" unless channel.voice_enabled? + @inbox = channel.inbox end diff --git a/enterprise/app/models/channel/voice.rb b/enterprise/app/models/channel/voice.rb deleted file mode 100644 index dbb9931df..000000000 --- a/enterprise/app/models/channel/voice.rb +++ /dev/null @@ -1,122 +0,0 @@ -# == Schema Information -# -# Table name: channel_voice -# -# id :bigint not null, primary key -# additional_attributes :jsonb -# phone_number :string not null -# provider :string default("twilio"), not null -# provider_config :jsonb not null -# created_at :datetime not null -# updated_at :datetime not null -# account_id :integer not null -# -# Indexes -# -# index_channel_voice_on_account_id (account_id) -# index_channel_voice_on_phone_number (phone_number) UNIQUE -# -class Channel::Voice < ApplicationRecord - include Channelable - - self.table_name = 'channel_voice' - - validates :phone_number, presence: true, uniqueness: true - validates :provider, presence: true - validates :provider_config, presence: true - - # Validate phone number format (E.164 format) - validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ } - - # Provider-specific configs stored in JSON - validate :validate_provider_config - before_validation :provision_twilio_on_create, on: :create, if: :twilio? - - EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze - - def name - "Voice (#{phone_number})" - end - - def messaging_window_enabled? - false - end - - def initiate_call(to:, conference_sid: nil, agent_id: nil) - case provider - when 'twilio' - Voice::Provider::Twilio::Adapter.new(self).initiate_call( - to: to, - conference_sid: conference_sid, - agent_id: agent_id - ) - else - raise "Unsupported voice provider: #{provider}" - end - end - - # Public URLs used to configure Twilio webhooks - def voice_call_webhook_url - digits = phone_number.delete_prefix('+') - Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits) - end - - def voice_status_webhook_url - digits = phone_number.delete_prefix('+') - Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits) - end - - private - - def twilio? - provider == 'twilio' - end - - def validate_provider_config - return if provider_config.blank? - - case provider - when 'twilio' - validate_twilio_config - end - end - - def validate_twilio_config - config = provider_config.with_indifferent_access - # Require credentials and provisioned TwiML App SID - required_keys = %w[account_sid auth_token api_key_sid api_key_secret twiml_app_sid] - required_keys.each do |key| - errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank? - end - end - - def provider_config_hash - if provider_config.is_a?(Hash) - provider_config - else - JSON.parse(provider_config.to_s) - end - end - - def provision_twilio_on_create - service = ::Twilio::VoiceWebhookSetupService.new(channel: self) - app_sid = service.perform - return if app_sid.blank? - - cfg = provider_config.with_indifferent_access - cfg[:twiml_app_sid] = app_sid - self.provider_config = cfg - rescue StandardError => e - error_details = { - error_class: e.class.to_s, - message: e.message, - phone_number: phone_number, - account_id: account_id, - backtrace: e.backtrace&.first(5) - } - Rails.logger.error("TWILIO_VOICE_SETUP_ON_CREATE_ERROR: #{error_details}") - errors.add(:base, "Twilio setup failed: #{e.message}") - end - - public :provider_config_hash -end diff --git a/enterprise/app/models/enterprise/channel/twilio_sms.rb b/enterprise/app/models/enterprise/channel/twilio_sms.rb new file mode 100644 index 000000000..15c201795 --- /dev/null +++ b/enterprise/app/models/enterprise/channel/twilio_sms.rb @@ -0,0 +1,81 @@ +module Enterprise::Channel::TwilioSms + extend ActiveSupport::Concern + + def self.prepended(base) + base.class_eval do + encrypts :api_key_secret if Chatwoot.encryption_configured? + + validate :voice_requires_phone_number, if: :voice_enabled? + before_validation :provision_twiml_app, on: :create, if: :voice_enabled? + before_validation :provision_twiml_app_on_update, on: :update, if: :voice_enabled_changed_to_true? + after_commit :teardown_voice, on: :update, if: :voice_disabled? + end + end + + def initiate_call(to:, conference_sid: nil, agent_id: nil) + Voice::Provider::Twilio::Adapter.new(self).initiate_call( + to: to, + conference_sid: conference_sid, + agent_id: agent_id + ) + end + + def voice_call_webhook_url + digits = phone_number.delete_prefix('+') + Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits) + end + + def voice_status_webhook_url + digits = phone_number.delete_prefix('+') + Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits) + end + + # Voice channels store the secret in api_key_secret; SMS channels keep using auth_token via super. + def client + if api_key_sid.present? && api_key_secret.present? + Twilio::REST::Client.new(api_key_sid, api_key_secret, account_sid) + else + super + end + end + + private + + def voice_requires_phone_number + return if phone_number.present? + + errors.add(:base, 'Voice calling requires a phone number and cannot be used with messaging service SID') + end + + def voice_enabled_changed_to_true? + voice_enabled? && voice_enabled_changed? + end + + def voice_disabled? + !voice_enabled? && voice_enabled_previously_changed? + end + + def teardown_voice + Twilio::VoiceTeardownService.new(channel: self).perform + end + + def provision_twiml_app + return if twiml_app_sid.present? + return if phone_number.blank? + + validate_voice_capability! + service = ::Twilio::VoiceWebhookSetupService.new(channel: self) + self.twiml_app_sid = service.perform + rescue StandardError => e + Rails.logger.error("TWILIO_VOICE_SETUP_ERROR: #{e.class} #{e.message} phone=#{phone_number} account=#{account_id}") + errors.add(:base, "Twilio voice setup failed: #{e.message}") + end + + def validate_voice_capability! + number = client.incoming_phone_numbers.list(phone_number: phone_number).first + raise 'Phone number not found in Twilio account' unless number + raise 'This phone number does not support voice calls' unless number.capabilities['voice'] + end + + alias provision_twiml_app_on_update provision_twiml_app +end diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 427b5245b..1ef112fb5 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -16,7 +16,6 @@ module Enterprise::Concerns::Account has_many :copilot_threads, dependent: :destroy_async has_many :companies, dependent: :destroy_async - has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice' has_many :calls, dependent: :destroy_async has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings' diff --git a/enterprise/app/services/enterprise/contacts/contactable_inboxes_service.rb b/enterprise/app/services/enterprise/contacts/contactable_inboxes_service.rb index 546d5f4bc..ce6e2a64b 100644 --- a/enterprise/app/services/enterprise/contacts/contactable_inboxes_service.rb +++ b/enterprise/app/services/enterprise/contacts/contactable_inboxes_service.rb @@ -1,9 +1,9 @@ module Enterprise::Contacts::ContactableInboxesService private - # Extend base selection to include Voice inboxes + # Extend base selection to include voice-enabled TwilioSms inboxes def get_contactable_inbox(inbox) - return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Voice' + return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::TwilioSms' && inbox.channel.voice_enabled? super end diff --git a/enterprise/app/services/twilio/voice_teardown_service.rb b/enterprise/app/services/twilio/voice_teardown_service.rb new file mode 100644 index 000000000..dec365373 --- /dev/null +++ b/enterprise/app/services/twilio/voice_teardown_service.rb @@ -0,0 +1,33 @@ +class Twilio::VoiceTeardownService + pattr_initialize [:channel!] + + def perform + delete_twiml_app if channel.twiml_app_sid.present? + clear_number_webhooks + ensure + clear_voice_credentials + end + + private + + def delete_twiml_app + channel.client.applications(channel.twiml_app_sid).delete + rescue StandardError => e + Rails.logger.error("TWILIO_VOICE_TEARDOWN_ERROR: #{e.class} #{e.message} phone=#{channel.phone_number} account=#{channel.account_id}") + end + + def clear_number_webhooks + numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number) + return if numbers.empty? + + channel.client + .incoming_phone_numbers(numbers.first.sid) + .update(voice_url: '', status_callback: '') + rescue StandardError => e + Rails.logger.error("TWILIO_VOICE_TEARDOWN_WEBHOOK_ERROR: #{e.class} #{e.message} phone=#{channel.phone_number} account=#{channel.account_id}") + end + + def clear_voice_credentials + channel.update(twiml_app_sid: nil) + end +end diff --git a/enterprise/app/services/twilio/voice_webhook_setup_service.rb b/enterprise/app/services/twilio/voice_webhook_setup_service.rb index c1da2f24a..e9a679b36 100644 --- a/enterprise/app/services/twilio/voice_webhook_setup_service.rb +++ b/enterprise/app/services/twilio/voice_webhook_setup_service.rb @@ -17,8 +17,7 @@ class Twilio::VoiceWebhookSetupService private def validate_token_credentials! - # Only validate Account SID + Auth Token - token_client.incoming_phone_numbers.list(limit: 1) + channel.client.incoming_phone_numbers.list(limit: 1) rescue StandardError => e log_twilio_error('AUTH_VALIDATION_TOKEN', e) raise @@ -26,7 +25,7 @@ class Twilio::VoiceWebhookSetupService def create_twiml_app! friendly_name = "Chatwoot Voice #{channel.phone_number}" - app = api_key_client.applications.create( + app = channel.client.applications.create( friendly_name: friendly_name, voice_url: channel.voice_call_webhook_url, voice_method: HTTP_METHOD @@ -38,39 +37,25 @@ class Twilio::VoiceWebhookSetupService end def configure_number_webhooks! - numbers = api_key_client.incoming_phone_numbers.list(phone_number: channel.phone_number) + numbers = channel.client.incoming_phone_numbers.list(phone_number: channel.phone_number) if numbers.empty? Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}" return end - api_key_client - .incoming_phone_numbers(numbers.first.sid) - .update( - voice_url: channel.voice_call_webhook_url, - voice_method: HTTP_METHOD, - status_callback: channel.voice_status_webhook_url, - status_callback_method: HTTP_METHOD - ) + channel.client + .incoming_phone_numbers(numbers.first.sid) + .update( + voice_url: channel.voice_call_webhook_url, + voice_method: HTTP_METHOD, + status_callback: channel.voice_status_webhook_url, + status_callback_method: HTTP_METHOD + ) rescue StandardError => e log_twilio_error('NUMBER_WEBHOOKS_UPDATE', e) raise end - def api_key_client - @api_key_client ||= begin - cfg = channel.provider_config.with_indifferent_access - ::Twilio::REST::Client.new(cfg[:api_key_sid], cfg[:api_key_secret], cfg[:account_sid]) - end - end - - def token_client - @token_client ||= begin - cfg = channel.provider_config.with_indifferent_access - ::Twilio::REST::Client.new(cfg[:account_sid], cfg[:auth_token]) - end - end - def log_twilio_error(context, error) details = build_error_details(context, error) add_twilio_specific_details(details, error) @@ -80,11 +65,10 @@ class Twilio::VoiceWebhookSetupService end def build_error_details(context, error) - cfg = channel.provider_config.with_indifferent_access { context: context, phone_number: channel.phone_number, - account_sid: cfg[:account_sid], + account_sid: channel.account_sid, error_class: error.class.to_s, message: error.message } diff --git a/enterprise/app/services/voice/provider/twilio/adapter.rb b/enterprise/app/services/voice/provider/twilio/adapter.rb index 061143f03..b72ed3369 100644 --- a/enterprise/app/services/voice/provider/twilio/adapter.rb +++ b/enterprise/app/services/voice/provider/twilio/adapter.rb @@ -43,10 +43,6 @@ class Voice::Provider::Twilio::Adapter end def twilio_client - Twilio::REST::Client.new(config['account_sid'], config['auth_token']) - end - - def config - @config ||= @channel.provider_config_hash + Twilio::REST::Client.new(@channel.account_sid, @channel.auth_token) end end diff --git a/enterprise/app/services/voice/provider/twilio/conference_service.rb b/enterprise/app/services/voice/provider/twilio/conference_service.rb index 5daea8733..94e75d89a 100644 --- a/enterprise/app/services/voice/provider/twilio/conference_service.rb +++ b/enterprise/app/services/voice/provider/twilio/conference_service.rb @@ -1,5 +1,5 @@ class Voice::Provider::Twilio::ConferenceService - pattr_initialize [:conversation!, { twilio_client: nil }] + pattr_initialize [:conversation!] def ensure_conference_sid existing = conversation.additional_attributes&.dig('conference_sid') @@ -19,10 +19,11 @@ class Voice::Provider::Twilio::ConferenceService end def end_conference - twilio_client + client = conversation.inbox.channel.client + client .conferences .list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress') - .each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') } + .each { |conf| client.conferences(conf.sid).update(status: 'completed') } end private @@ -31,16 +32,4 @@ class Voice::Provider::Twilio::ConferenceService current = conversation.additional_attributes || {} conversation.update!(additional_attributes: current.merge(attrs)) end - - def twilio_client - @twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token) - end - - def account_sid - @account_sid ||= conversation.inbox.channel.provider_config_hash['account_sid'] - end - - def auth_token - @auth_token ||= conversation.inbox.channel.provider_config_hash['auth_token'] - end end diff --git a/enterprise/app/services/voice/provider/twilio/token_service.rb b/enterprise/app/services/voice/provider/twilio/token_service.rb index cee4c1887..a2769b587 100644 --- a/enterprise/app/services/voice/provider/twilio/token_service.rb +++ b/enterprise/app/services/voice/provider/twilio/token_service.rb @@ -6,20 +6,20 @@ class Voice::Provider::Twilio::TokenService token: access_token.to_jwt, identity: identity, voice_enabled: true, - account_sid: config['account_sid'], + account_sid: channel.account_sid, agent_id: user.id, account_id: account.id, inbox_id: inbox.id, - phone_number: inbox.channel.phone_number, + phone_number: channel.phone_number, twiml_endpoint: twiml_url, - has_twiml_app: config['twiml_app_sid'].present? + has_twiml_app: channel.twiml_app_sid.present? } end private - def config - @config ||= inbox.channel.provider_config_hash || {} + def channel + @channel ||= inbox.channel end def identity @@ -28,9 +28,9 @@ class Voice::Provider::Twilio::TokenService def access_token Twilio::JWT::AccessToken.new( - config['account_sid'], - config['api_key_sid'], - config['api_key_secret'], + channel.account_sid, + channel.api_key_sid, + channel.api_key_secret, identity: identity, ttl: 1.hour.to_i ).tap { |token| token.add_grant(voice_grant) } @@ -39,7 +39,7 @@ class Voice::Provider::Twilio::TokenService def voice_grant Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant| grant.incoming_allow = true - grant.outgoing_application_sid = config['twiml_app_sid'] + grant.outgoing_application_sid = channel.twiml_app_sid grant.outgoing_application_params = outgoing_params end end @@ -50,13 +50,13 @@ class Voice::Provider::Twilio::TokenService agent_id: user.id, identity: identity, client_name: identity, - accountSid: config['account_sid'], + accountSid: channel.account_sid, is_agent: 'true' } end def twiml_url - digits = inbox.channel.phone_number.delete_prefix('+') + digits = channel.phone_number.delete_prefix('+') Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits) end end diff --git a/spec/enterprise/controllers/api/v1/accounts/conference_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/conference_controller_spec.rb index f4949b9b8..f3019910a 100644 --- a/spec/enterprise/controllers/api/v1/accounts/conference_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/conference_controller_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do let(:account) { create(:account) } - let(:voice_channel) { create(:channel_voice, account: account) } + let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) } let(:voice_inbox) { voice_channel.inbox } let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) } let(:admin) { create(:user, :administrator, account: account) } diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb index 4291d5091..709a8da22 100644 --- a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb @@ -24,6 +24,11 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do end it 'creates a voice inbox when administrator' do + account.enable_features('channel_voice') + account.save! + stub_request(:get, %r{api\.twilio\.com/2010-04-01/Accounts/.*/IncomingPhoneNumbers\.json}) + .to_return(status: 200, body: { incoming_phone_numbers: [{ capabilities: { 'voice' => true } }] }.to_json, + headers: { 'Content-Type' => 'application/json' }) allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}")) @@ -34,8 +39,7 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do provider_config: { account_sid: "AC#{SecureRandom.hex(16)}", auth_token: SecureRandom.hex(16), api_key_sid: SecureRandom.hex(8), - api_key_secret: SecureRandom.hex(16), - twiml_app_sid: "AP#{SecureRandom.hex(16)}" } } }, + api_key_secret: SecureRandom.hex(16) } } }, as: :json expect(response).to have_http_status(:success) diff --git a/spec/enterprise/controllers/twilio/voice_controller_spec.rb b/spec/enterprise/controllers/twilio/voice_controller_spec.rb index 43141414e..c04f5ae41 100644 --- a/spec/enterprise/controllers/twilio/voice_controller_spec.rb +++ b/spec/enterprise/controllers/twilio/voice_controller_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' RSpec.describe 'Twilio::VoiceController', type: :request do let(:account) { create(:account) } - let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230003') } + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230003') } let(:inbox) { channel.inbox } let(:digits) { channel.phone_number.delete_prefix('+') } diff --git a/spec/enterprise/models/channel/twilio_sms_voice_spec.rb b/spec/enterprise/models/channel/twilio_sms_voice_spec.rb new file mode 100644 index 000000000..b2d5dba48 --- /dev/null +++ b/spec/enterprise/models/channel/twilio_sms_voice_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Channel::TwilioSms do + let(:account) { create(:account) } + let(:twiml_app_sid) { 'AP1234567890abcdef' } + + before do + allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: twiml_app_sid)) + end + + describe 'factory' do + it 'has a valid :with_voice factory' do + channel = create(:channel_twilio_sms, :with_voice, account: account) + expect(channel).to be_valid + expect(channel.voice_enabled?).to be true + end + end + + describe 'validations' do + it 'requires a phone number when voice is enabled' do + channel = build(:channel_twilio_sms, :with_voice, account: account, phone_number: nil) + channel.valid? + expect(channel.errors[:base]).to include('Voice calling requires a phone number and cannot be used with messaging service SID') + end + end + + describe '#voice_enabled?' do + it 'returns true when voice_enabled is set' do + channel = create(:channel_twilio_sms, :with_voice, account: account) + expect(channel.voice_enabled?).to be true + end + + it 'returns false by default' do + channel = create(:channel_twilio_sms, account: account) + expect(channel.voice_enabled?).to be false + end + end + + describe '#voice_call_webhook_url' do + it 'returns the webhook URL based on phone number' do + channel = create(:channel_twilio_sms, :with_voice) + digits = channel.phone_number.delete_prefix('+') + expect(channel.voice_call_webhook_url).to include(digits) + end + end + + describe '#voice_status_webhook_url' do + it 'returns the status webhook URL based on phone number' do + channel = create(:channel_twilio_sms, :with_voice) + digits = channel.phone_number.delete_prefix('+') + expect(channel.voice_status_webhook_url).to include(digits) + end + end + + describe 'provisioning on create' do + it 'stores twiml_app_sid from the webhook setup service' do + stub_request(:get, %r{api\.twilio\.com/2010-04-01/Accounts/.*/IncomingPhoneNumbers\.json}) + .to_return(status: 200, body: { incoming_phone_numbers: [{ capabilities: { 'voice' => true } }] }.to_json, + headers: { 'Content-Type' => 'application/json' }) + channel = create(:channel_twilio_sms, :with_voice, twiml_app_sid: nil) + expect(channel.twiml_app_sid).to eq(twiml_app_sid) + end + end + + describe 'teardown on disable' do + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) } + let(:app_context) { instance_double(Twilio::REST::Api::V2010::AccountContext::ApplicationContext) } + let(:twilio_client) { instance_double(Twilio::REST::Client) } + let(:numbers_list) { instance_double(Twilio::REST::Api::V2010::AccountContext::IncomingPhoneNumberList) } + + before do + allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client) + allow(twilio_client).to receive(:applications).with(channel.twiml_app_sid).and_return(app_context) + allow(app_context).to receive(:delete) + allow(twilio_client).to receive(:incoming_phone_numbers).and_return(numbers_list) + allow(numbers_list).to receive(:list).with(phone_number: channel.phone_number).and_return([]) + end + + it 'deletes the TwiML app and clears twiml_app_sid' do + original_twiml_sid = channel.twiml_app_sid + channel.update!(voice_enabled: false) + + expect(twilio_client).to have_received(:applications).with(original_twiml_sid) + expect(app_context).to have_received(:delete) + expect(channel.reload.twiml_app_sid).to be_nil + end + + it 'preserves api_key_sid and api_key_secret' do + channel.update!(voice_enabled: false) + expect(channel.reload.api_key_sid).to be_present + expect(channel.reload.api_key_secret).to be_present + end + + it 'does not fail if Twilio API errors' do + allow(app_context).to receive(:delete).and_raise(StandardError.new('Not found')) + + expect { channel.update!(voice_enabled: false) }.not_to raise_error + expect(channel.reload.twiml_app_sid).to be_nil + end + end +end diff --git a/spec/enterprise/models/channel/voice_spec.rb b/spec/enterprise/models/channel/voice_spec.rb deleted file mode 100644 index 01f4b4c5b..000000000 --- a/spec/enterprise/models/channel/voice_spec.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Channel::Voice do - let(:twiml_app_sid) { 'AP1234567890abcdef' } - let(:channel) { create(:channel_voice) } - - before do - allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: twiml_app_sid)) - end - - it 'has a valid factory' do - expect(channel).to be_valid - end - - describe 'validations' do - it 'validates presence of provider_config' do - channel.provider_config = nil - expect(channel).not_to be_valid - expect(channel.errors[:provider_config]).to include("can't be blank") - end - - it 'validates presence of account_sid in provider_config' do - channel.provider_config = { auth_token: 'token' } - expect(channel).not_to be_valid - expect(channel.errors[:provider_config]).to include('account_sid is required for Twilio provider') - end - - it 'validates presence of auth_token in provider_config' do - channel.provider_config = { account_sid: 'sid' } - expect(channel).not_to be_valid - expect(channel.errors[:provider_config]).to include('auth_token is required for Twilio provider') - end - - it 'validates presence of api_key_sid in provider_config' do - channel.provider_config = { account_sid: 'sid', auth_token: 'token' } - expect(channel).not_to be_valid - expect(channel.errors[:provider_config]).to include('api_key_sid is required for Twilio provider') - end - - it 'validates presence of api_key_secret in provider_config' do - channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key' } - expect(channel).not_to be_valid - expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider') - end - - it 'validates presence of twiml_app_sid in provider_config' do - channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key', api_key_secret: 'secret' } - expect(channel).not_to be_valid - expect(channel.errors[:provider_config]).to include('twiml_app_sid is required for Twilio provider') - end - - it 'is valid with all required provider_config fields' do - channel.provider_config = { - account_sid: 'test_sid', - auth_token: 'test_token', - api_key_sid: 'test_key', - api_key_secret: 'test_secret', - twiml_app_sid: 'test_app_sid' - } - expect(channel).to be_valid - end - end - - describe '#name' do - it 'returns Voice with phone number' do - expect(channel.name).to include('Voice') - expect(channel.name).to include(channel.phone_number) - end - end - - describe 'provisioning on create' do - it 'stores twiml_app_sid in provider_config' do - ch = create(:channel_voice) - expect(ch.provider_config.with_indifferent_access[:twiml_app_sid]).to eq(twiml_app_sid) - end - end -end diff --git a/spec/enterprise/services/twilio/voice_webhook_setup_service_spec.rb b/spec/enterprise/services/twilio/voice_webhook_setup_service_spec.rb index e31dfeb20..6c47fa325 100644 --- a/spec/enterprise/services/twilio/voice_webhook_setup_service_spec.rb +++ b/spec/enterprise/services/twilio/voice_webhook_setup_service_spec.rb @@ -9,14 +9,16 @@ RSpec.describe Twilio::VoiceWebhookSetupService do let(:api_key_secret) { 'api_key_secret_123' } let(:phone_number) { '+15551230001' } let(:frontend_url) { 'https://app.chatwoot.test' } + let(:account) { create(:account) } let(:channel) do - build(:channel_voice, phone_number: phone_number, provider_config: { - account_sid: account_sid, - auth_token: auth_token, - api_key_sid: api_key_sid, - api_key_secret: api_key_secret - }) + build(:channel_twilio_sms, :with_voice, + account: account, + phone_number: phone_number, + account_sid: account_sid, + auth_token: auth_token, + api_key_sid: api_key_sid, + api_key_secret: api_key_secret) end let(:twilio_base_url) { "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}" } diff --git a/spec/enterprise/services/voice/inbound_call_builder_spec.rb b/spec/enterprise/services/voice/inbound_call_builder_spec.rb index e8953da3c..a021c3a9c 100644 --- a/spec/enterprise/services/voice/inbound_call_builder_spec.rb +++ b/spec/enterprise/services/voice/inbound_call_builder_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' RSpec.describe Voice::InboundCallBuilder do let(:account) { create(:account) } - let(:channel) { create(:channel_voice, account: account, phone_number: '+15551239999') } + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239999') } let(:inbox) { channel.inbox } let(:from_number) { '+15550001111' } let(:to_number) { channel.phone_number } diff --git a/spec/enterprise/services/voice/outbound_call_builder_spec.rb b/spec/enterprise/services/voice/outbound_call_builder_spec.rb index fb75b404b..13d667925 100644 --- a/spec/enterprise/services/voice/outbound_call_builder_spec.rb +++ b/spec/enterprise/services/voice/outbound_call_builder_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' RSpec.describe Voice::OutboundCallBuilder do let(:account) { create(:account) } - let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230000') } + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230000') } let(:inbox) { channel.inbox } let(:user) { create(:user, account: account) } let(:contact) { create(:contact, account: account, phone_number: '+15550001111') } diff --git a/spec/enterprise/services/voice/provider/twilio/adapter_spec.rb b/spec/enterprise/services/voice/provider/twilio/adapter_spec.rb index 68157ac50..62513c2a3 100644 --- a/spec/enterprise/services/voice/provider/twilio/adapter_spec.rb +++ b/spec/enterprise/services/voice/provider/twilio/adapter_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' describe Voice::Provider::Twilio::Adapter do let(:account) { create(:account) } - let(:channel) { create(:channel_voice, account: account) } + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) } let(:adapter) { described_class.new(channel) } let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) } let(:calls_double) { instance_double(Twilio::REST::Api::V2010::AccountContext::CallList) } @@ -19,7 +19,7 @@ describe Voice::Provider::Twilio::Adapter do allow(calls_double).to receive(:create).and_return(call_instance) allow(Twilio::REST::Client).to receive(:new) - .with(channel.provider_config_hash['account_sid'], channel.provider_config_hash['auth_token']) + .with(channel.account_sid, channel.auth_token) .and_return(client_double) result = adapter.initiate_call(to: '+15550001111', conference_sid: 'CF999', agent_id: 42) diff --git a/spec/enterprise/services/voice/provider/twilio/conference_service_spec.rb b/spec/enterprise/services/voice/provider/twilio/conference_service_spec.rb index 9997280cb..54b22c542 100644 --- a/spec/enterprise/services/voice/provider/twilio/conference_service_spec.rb +++ b/spec/enterprise/services/voice/provider/twilio/conference_service_spec.rb @@ -2,14 +2,15 @@ require 'rails_helper' describe Voice::Provider::Twilio::ConferenceService do let(:account) { create(:account) } - let(:channel) { create(:channel_voice, account: account) } + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account) } let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) } let(:twilio_client) { instance_double(Twilio::REST::Client) } - let(:service) { described_class.new(conversation: conversation, twilio_client: twilio_client) } + let(:service) { described_class.new(conversation: conversation) } let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) } before do allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service) + allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client) end describe '#ensure_conference_sid' do diff --git a/spec/enterprise/services/voice/provider/twilio/token_service_spec.rb b/spec/enterprise/services/voice/provider/twilio/token_service_spec.rb index fe6aebe01..0c96dbe76 100644 --- a/spec/enterprise/services/voice/provider/twilio/token_service_spec.rb +++ b/spec/enterprise/services/voice/provider/twilio/token_service_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Voice::Provider::Twilio::TokenService do let(:account) { create(:account) } let(:user) { create(:user, :administrator, account: account) } - let(:voice_channel) { create(:channel_voice, account: account) } + let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) } let(:inbox) { voice_channel.inbox } let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) } diff --git a/spec/enterprise/services/voice/status_update_service_spec.rb b/spec/enterprise/services/voice/status_update_service_spec.rb index fab626fb6..6f88f51c6 100644 --- a/spec/enterprise/services/voice/status_update_service_spec.rb +++ b/spec/enterprise/services/voice/status_update_service_spec.rb @@ -27,7 +27,7 @@ RSpec.describe Voice::StatusUpdateService do content_attributes: { data: { call_sid: call_sid, status: 'ringing' } } ) end - let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230002') } + let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551230002') } let(:inbox) { channel.inbox } let(:from_number) { '+15550002222' } let(:call_sid) { 'CATESTSTATUS123' } diff --git a/spec/factories/channel/channel_voice.rb b/spec/factories/channel/channel_voice.rb deleted file mode 100644 index 81ad8caaa..000000000 --- a/spec/factories/channel/channel_voice.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -FactoryBot.define do - factory :channel_voice, class: 'Channel::Voice' do - sequence(:phone_number) { |n| "+155512345#{n.to_s.rjust(2, '0')}" } - provider_config do - { - account_sid: "AC#{SecureRandom.hex(16)}", - auth_token: SecureRandom.hex(16), - api_key_sid: SecureRandom.hex(8), - api_key_secret: SecureRandom.hex(16), - twiml_app_sid: "AP#{SecureRandom.hex(16)}" - } - end - account - - after(:create) do |channel_voice| - create(:inbox, channel: channel_voice, account: channel_voice.account) - end - end -end diff --git a/spec/factories/channel/twilio_sms.rb b/spec/factories/channel/twilio_sms.rb index 1963a4f28..68ac5a5d1 100644 --- a/spec/factories/channel/twilio_sms.rb +++ b/spec/factories/channel/twilio_sms.rb @@ -17,5 +17,13 @@ FactoryBot.define do trait :whatsapp do medium { :whatsapp } end + + trait :with_voice do + with_phone_number + voice_enabled { true } + api_key_sid { "SK#{SecureRandom.hex(16)}" } + api_key_secret { SecureRandom.hex(16) } + twiml_app_sid { "AP#{SecureRandom.hex(16)}" } + end end end From b058d840343b54ea7e70fa68143d03dbde67a21d Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:15:08 +0530 Subject: [PATCH 05/42] fix: prevent Escape from opening formatting toolbar in editor (#14133) --- .../components/widgets/WootWriter/Editor.vue | 14 +++++++- .../widgets/WootWriter/FullEditor.vue | 24 +++++++++++--- .../dashboard/helper/editorHelper.js | 13 ++++++++ .../helper/specs/editorHelper.spec.js | 32 +++++++++++++++++++ 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index e776ed913..c349468b8 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -51,6 +51,7 @@ import { import { appendSignature, + collapseSelection, findNodeToInsertImage, getContentNode, insertAtCursor, @@ -66,6 +67,7 @@ import { import { hasPressedEnterAndNotCmdOrShift, hasPressedCommandAndEnter, + isEscape, } from 'shared/helpers/KeyboardHelpers'; import { createTypingIndicator } from '@chatwoot/utils'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; @@ -515,7 +517,9 @@ function setMenubarPosition({ selection } = {}) { function checkSelection(editorState) { showSelectionMenu.value = false; - const hasSelection = editorState.selection.from !== editorState.selection.to; + const { selection } = editorState; + // Skip NodeSelection (from Esc -> selectParentNode); only text ranges count. + const hasSelection = !selection.empty && !selection.node; if (hasSelection === isTextSelected.value) return; isTextSelected.value = hasSelection; @@ -711,12 +715,17 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) { } function onKeydown(event) { + if (isEscape(event)) { + collapseSelection(editorView); + return true; + } if (isEnterToSendEnabled()) { handleLineBreakWhenEnterToSendEnabled(event); } if (isCmdPlusEnterToSendEnabled()) { handleLineBreakWhenCmdAndEnterToSendEnabled(event); } + return false; } function createEditorView() { @@ -744,6 +753,9 @@ function createEditorView() { blur: () => { if (props.disabled) return; typingIndicator.stop(); + // PM keeps its selection on blur — clear the menu flags manually. + isTextSelected.value = false; + editorRoot.value?.classList.remove('has-selection'); emit('blur'); }, paste: (view, event) => { diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue index 72104ff06..1e1ab9756 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue @@ -17,6 +17,8 @@ import { toggleMark } from 'prosemirror-commands'; import { wrapInList } from 'prosemirror-schema-list'; import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; +import { isEscape } from 'shared/helpers/KeyboardHelpers'; +import { collapseSelection } from 'dashboard/helper/editorHelper'; import { useAlert } from 'dashboard/composables'; import { useUISettings } from 'dashboard/composables/useUISettings'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; @@ -362,19 +364,33 @@ export default { onKeyup() { this.$emit('keyup'); }, - onKeydown() { + onKeydown(view, event) { this.$emit('keydown'); + if (isEscape(event)) { + if (this.showSlashMenu) { + this.showSlashMenu = false; + this.slashSearchTerm = ''; + this.slashMenuPosition = null; + return true; + } + collapseSelection(editorView); + return true; + } + return false; }, onBlur() { + // ProseMirror keeps its selection on blur — clear the menu flag manually. + this.isTextSelected = false; + this.$refs.editor?.classList.remove('has-selection'); this.$emit('blur'); }, onFocus() { this.$emit('focus'); }, checkSelection(editorState) { - const { from, to } = editorState.selection; - // Check if there's a selection (from and to are different) - const hasSelection = from !== to; + const { selection } = editorState; + // Skip NodeSelection (from Esc -> selectParentNode); only text ranges count. + const hasSelection = !selection.empty && !selection.node; // If the selection state is the same as the previous state, do nothing if (hasSelection === this.isTextSelected) return; // Update the selection state diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index b3d071ccd..9839a04e0 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -2,6 +2,7 @@ import { messageSchema, MessageMarkdownTransformer, MessageMarkdownSerializer, + Selection, } from '@chatwoot/prosemirror-schema'; import { replaceVariablesInMessage } from '@chatwoot/utils'; import * as Sentry from '@sentry/vue'; @@ -273,6 +274,18 @@ export const scrollCursorIntoView = view => { } }; +/** + * Collapse the current selection to a cursor near its head. Used to override + * the default Escape -> selectParentNode behavior which would otherwise keep + * the text highlight visible. + * + * @param {EditorView} view - The ProseMirror EditorView + */ +export const collapseSelection = view => { + const { tr, selection } = view.state; + view.dispatch(tr.setSelection(Selection.near(selection.$head))); +}; + /** * Returns a transaction that inserts a node into editor at the given position * Has an optional param 'content' to check if the diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index f558dd213..b06c36d42 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -16,6 +16,7 @@ import { calculateMenuPosition, stripUnsupportedFormatting, stripInlineBase64Images, + collapseSelection, } from '../editorHelper'; import { FORMATTING } from 'dashboard/constants/editor'; import { EditorState } from '@chatwoot/prosemirror-schema'; @@ -454,6 +455,37 @@ describe('stripInlineBase64Images', () => { }); }); +describe('collapseSelection', () => { + it('collapses a text range to a cursor at its head', () => { + const editorView = new EditorView(document.body, { + state: createEditorState('Hello world'), + }); + + // Build a TextSelection via the initial selection's constructor (avoids + // importing prosemirror-state, which isn't a direct dep). + const { doc, selection } = editorView.state; + editorView.dispatch( + editorView.state.tr.setSelection(selection.constructor.create(doc, 1, 6)) + ); + expect(editorView.state.selection.empty).toBe(false); + + collapseSelection(editorView); + + expect(editorView.state.selection.empty).toBe(true); + expect(editorView.state.selection.head).toBe(6); + }); + + it('leaves an already-collapsed selection as a cursor', () => { + const editorView = new EditorView(document.body, { + state: createEditorState('Hi'), + }); + + collapseSelection(editorView); + + expect(editorView.state.selection.empty).toBe(true); + }); +}); + describe('insertAtCursor', () => { it('should return undefined if editorView is not provided', () => { const result = insertAtCursor(undefined, schema.text('Hello'), 0); From 7c7d67fd066e49b61abb9156e76c91aee3ce164c Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:15:37 +0530 Subject: [PATCH 06/42] fix: show all matches when filtering by multiple labels (#14303) --- .../conversations/helpers/filterHelpers.js | 10 +++--- .../helpers/specs/filterHelpers.spec.js | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js index c7060bee3..63a592375 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js @@ -46,8 +46,8 @@ * 2. Nested properties in additional_attributes (browser_language, referer, etc.) * 3. Nested properties in custom_attributes (conversation_type, etc.) */ -import jsonLogic from 'json-logic-js'; import { coerceToDate } from '@chatwoot/utils'; +import jsonLogic from 'json-logic-js'; /** * Gets a value from a conversation based on the attribute key @@ -121,7 +121,8 @@ const resolveValue = candidate => { * @returns {Boolean} - Returns true if the values are considered equal according to filtering rules * * This function handles various equality scenarios: - * 1. When both values are arrays: checks if all items in filterValue exist in conversationValue + * 1. When both values are arrays (e.g. labels): matches if any filter value exists in the conversation array + * (mirrors the backend SQL `tag_id IN (...)` OR semantics) * 2. When filterValue is an array but conversationValue is not: checks if conversationValue is included in filterValue * 3. Otherwise: performs strict equality comparison */ @@ -131,8 +132,9 @@ const equalTo = (filterValue, conversationValue) => { if (filterValue === 'all') return true; if (Array.isArray(conversationValue)) { - // For array values like labels, check if any of the filter values exist in the array - return filterValue.every(val => conversationValue.includes(val)); + // For array values like labels, match if any filter value is present. + // Mirrors the backend SQL `tag_id IN (...)` (OR semantics). + return filterValue.some(val => conversationValue.includes(val)); } if (!Array.isArray(conversationValue)) { diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js index db1017407..adcf5c96f 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js @@ -416,6 +416,40 @@ describe('filterHelpers', () => { expect(matchesFilters(conversation, filters)).toBe(true); }); + // Multi-label equal_to uses OR semantics to mirror the backend SQL `tag_id IN (...)`: + // a conversation matches if ANY of the filter labels is on it. + it('should match conversation with equal_to operator when any of multiple filter labels is present', () => { + const conversation = { labels: ['support'] }; + const filters = [ + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: [ + { id: 'support', name: 'Support' }, + { id: 'urgent', name: 'Urgent' }, + ], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(true); + }); + + it('should not match conversation with equal_to operator when none of multiple filter labels is present', () => { + const conversation = { labels: ['new'] }; + const filters = [ + { + attribute_key: 'labels', + filter_operator: 'equal_to', + values: [ + { id: 'support', name: 'Support' }, + { id: 'urgent', name: 'Urgent' }, + ], + query_operator: 'and', + }, + ]; + expect(matchesFilters(conversation, filters)).toBe(false); + }); + it('should match conversation with is_present operator for labels', () => { const conversation = { labels: ['support', 'urgent', 'new'] }; const filters = [ From 2324a344dcd6950779a67fbdfd8b184d7c204a93 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:42:37 +0530 Subject: [PATCH 07/42] fix: keep agents action visible in teams edit/add view (#14304) --- .../routes/dashboard/settings/teams/AgentSelector.vue | 6 ++++-- .../routes/dashboard/settings/teams/Create/AddAgents.vue | 2 +- .../routes/dashboard/settings/teams/Edit/EditAgents.vue | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/javascript/dashboard/routes/dashboard/settings/teams/AgentSelector.vue b/app/javascript/dashboard/routes/dashboard/settings/teams/AgentSelector.vue index e1c24303f..9473d16d2 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/teams/AgentSelector.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/teams/AgentSelector.vue @@ -132,8 +132,10 @@ const headers = computed(() => [ -
-

+

+

{{ $t('TEAMS_SETTINGS.AGENTS.SELECTED_COUNT', { selected: selectedAgents.length, diff --git a/app/javascript/dashboard/routes/dashboard/settings/teams/Create/AddAgents.vue b/app/javascript/dashboard/routes/dashboard/settings/teams/Create/AddAgents.vue index ef1e14663..19a7a325d 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/teams/Create/AddAgents.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/teams/Create/AddAgents.vue @@ -88,7 +88,7 @@ export default {