Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma 68973c3b7b Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-08 12:20:02 +05:30
Tanmay Deep Sharma 0875ae719e fix(voice): only reuse open conversations for Twilio outbound calls
Message#reopen_conversation bails on outgoing messages, so reusing a
resolved/snoozed/pending conversation hint left the call bubble inside
a thread that stays in its prior status. Restrict the existing-conversation
hint to open conversations and fall through to creating a fresh one
otherwise.
2026-05-08 12:19:52 +05:30
Tanmay Deep Sharma 872f09f720 Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 20:11:31 +07:00
Tanmay Deep Sharma 9e56a024c5 fix(voice): honor Meta failure reasons on terminate
Call::TERMINAL_STATUSES include 'failed' but handle_terminate only ever
chose between 'completed' and 'no_answer'. A network drop or any other
failure reason landing after status=ACCEPTED was being recorded as
'completed' purely because the call was already in_progress, surfacing
failed conversations as successful in the dashboard. Map known failure
reasons to 'failed' before falling back to the answered/no_answer
heuristic.
2026-05-07 20:11:17 +07:00
Tanmay Deep Sharma 66b0f51279 Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 20:00:57 +07:00
Tanmay Deep Sharma 7a7de71c39 Revert "test(telegram): fix business-chat send-attachments spec"
This reverts commit 461292fb2b.
2026-05-07 20:00:54 +07:00
Tanmay Deep Sharma 006d7afa44 Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 19:58:52 +07:00
Tanmay Deep Sharma 461292fb2b test(telegram): fix business-chat send-attachments spec
The let(:channel) and the service's message.inbox.channel were different
object instances, so the chat_id / business_connection_id stubs on the
test instance never applied to the service. Other examples masked it
because they only counted requests; the business-chat example matches
on body and tripped. Set the values via conversation.additional_attributes
(where the channel methods actually read from) instead of stubbing.
2026-05-07 19:58:41 +07:00
Tanmay Deep Sharma 12f29d0916 Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 19:57:36 +07:00
Tanmay Deep Sharma 3c33d912df fix(voice): persist late outbound SDP answers when ACCEPTED races connect
If status=ACCEPTED is processed before Meta's connect webhook, the call
is already in_progress when accept_outbound_call runs, and the previous
guard dropped the SDP answer — leaving the browser without the data it
needs to complete the WebRTC handshake. Gate on a stored sdp_answer
instead of in_progress so out-of-order delivery still persists and
broadcasts the answer (and connect retries stay idempotent).
2026-05-07 19:57:27 +07:00
Tanmay Deep SharmaandGitHub 9d889f691b Merge branch 'develop' into feat/whatsapp-call-incoming-pipeline 2026-05-07 19:38:02 +07:00
Tanmay Deep Sharma 7795bc9f1d Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 17:12:51 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 f6a88d7f12 test(voice): align WhatsApp call specs with feature gate and v22 calls path
- call_permission_reply specs: enable channel_voice and set
  provider_config['source'] = 'embedded_signup' so voice_enabled? returns
  true; without it the service short-circuits before any handling.
- whatsapp_cloud_service specs: stub /v22.0/<phone_id>/calls (and
  /messages) to match calls_phone_id_path, which uses
  WHATSAPP_API_VERSION (fallback v22.0). The OSS v13.0 path is locked
  for legacy /messages compatibility only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:12:39 +07:00
Tanmay Deep Sharma 5471e52335 Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 17:00:21 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 f0119b3f2b fix(voice): preserve duration when agents hang up active WhatsApp calls
finalize_call wasn't writing duration_seconds or end_reason, and the Meta
terminate webhook that would have filled them in is suppressed by the
terminal-state idempotency guard in IncomingCallService#handle_terminate.
The result was completed-call bubbles with no duration. Compute the
duration from started_at at agent-hangup time and stamp end_reason
locally instead of waiting for a webhook that won't apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:00:11 +07:00
Tanmay Deep Sharma 1610b9a4ba Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 16:27:55 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 2e6b415826 test(voice): align WhatsApp call specs with current voice_enabled gate and connect semantics
- Enable channel_voice and set provider_config['source'] = 'embedded_signup' in
  the before block so Channel::Whatsapp#voice_enabled? actually returns true;
  otherwise the service short-circuits before any handling runs.
- Outbound `connect` only stores the SDP answer and broadcasts
  voice_call.outbound_connected; the in_progress / started_at transition has
  moved to the separate status=ACCEPTED webhook. Update the existing-call spec
  to expect status: 'ringing' and started_at: nil instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:27:46 +07:00
Tanmay Deep Sharma ab8fefd3c9 Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge
# Conflicts:
#	enterprise/app/services/whatsapp/incoming_call_service.rb
2026-05-07 16:20:57 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 dfdadc470d fix(voice): lock webhook transitions and skip unknown terminates
- Wrap mark_outbound_accepted, accept_outbound_call, and handle_terminate in
  call.with_lock so they serialize against Whatsapp::CallService (which holds
  call.with_lock across the Meta API call). Without this, an ACCEPTED webhook
  arriving mid-terminate could overwrite the freshly-finalized terminal status
  back to in_progress and broadcast an erroneous outbound_accepted.

- Drop build_missed_inbound_call. An outbound terminate landing in the tiny
  window between provider_service.initiate_call and Call.create! had no local
  row, so the fallback materialised an inbound row with the same
  provider_call_id and tripped the unique (provider, provider_call_id) index
  on the controller's create. The "out-of-order webhook" case it protected is
  rare in practice; trade that for eliminating the false-positive collision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:20:07 +07:00
c5236ec67e feat(voice): bridge WhatsApp Cloud Calling to Chatwoot voice pipeline [4] (#14356)
# Pull Request Template

## Description

Bridges WhatsApp Cloud Calling onto Chatwoot's voice pipeline so agents
can answer and place calls on WhatsApp Cloud inboxes with the same UX as
Twilio voice. Browser ↔ Meta WebRTC is direct (no media-server hop);
Chatwoot owns signalling, recording upload, and `Call`/`Message`
lifecycle state.

Companion PRs:
- FE: #14346 (`feat/whatsapp-call-ui`) — UI consumer for these
endpoints.
- Specs: #14357 (`feat/whatsapp-call-meta-bridge-specs`) — backend test
coverage.

## Type of change

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

## What changed

- **API** — new `Api::V1::Accounts::WhatsappCallsController` with `show
/ accept / reject / terminate / initiate / upload_recording`.
Conversation-level Pundit auth. `initiate` is gated on the channel being
embedded-signup voice-enabled. Permission-blocked initiations (Meta
error 138006) return **422** with `{ status: 'permission_requested' |
'permission_pending' }` and trigger a throttled opt-in template send
under a conversation lock.
- **State machine** — `Whatsapp::CallService` handles accept / reject /
terminate with call-level locking. Meta failures are wrapped as
`Voice::CallErrors::CallFailed` so the controller renders 422 instead of
leaking 500s.
- **Webhooks** — `Whatsapp::IncomingCallService` processes Meta
connect/terminate events. Pins `setup:active` on outbound answers,
materialises a missed-call record when terminate arrives before connect
(out-of-order delivery), and broadcasts `voice_call.*` events to the
assignee's pubsub stream when assigned, otherwise account-wide.
- **Provider** — `Whatsapp::Providers::WhatsappCloudService` adds
`pre_accept_call / accept_call / reject_call / terminate_call /
initiate_call / send_call_permission_request`. Uses configurable
`WHATSAPP_API_VERSION` (default v22) since Calls API needs v17+ and the
OSS `phone_id_path` is locked at v13 for legacy `/messages`
compatibility.
- **Audio recordings** — `Attachment after_create_commit` enqueues
transcription and rebroadcasts the message so the FE bubble updates
immediately. Active Storage initializer allows audio MIME types to serve
inline.

## How to test

1. Set up a WhatsApp Cloud Embedded Signup inbox; flip
`provider_config.calling_enabled = true` (Calls tab in inbox settings —
ships with FE PR #14346).
2. Place a real call from a phone number that has previously messaged
the business → expect `voice_call.incoming` cable + `Call` row +
`voice_call` Message bubble.
3. Click Accept (FE PR) → `/whatsapp_calls/:id/accept` 200, audio flows
browser ↔ Meta, recording uploads on hangup, Whisper transcript appears
within ~10s.
4. From Chatwoot, click outbound → `/whatsapp_calls/initiate` 200 if the
contact is opted-in, otherwise **422** with `status:
'permission_requested'` and a template send (FE shows a banner instead
of an error toast).
5. Refresh during a ringing call → call survives on Meta, FE seeds it
back from the conversation cache, agent can still accept.
6. Refresh during an active call → `pagehide` beacon terminates the call
on Meta with `status: 'completed'` (no orphan).

## Checklist

- [x] Code follows project style (RuboCop / ESLint clean)
- [x] Self-review done
- [x] Hard-to-understand areas commented
- [ ] Documentation update (architecture doc lives in companion branch)
- [x] No new warnings
- [x] Tests live in companion PR #14357
- [x] Existing tests pass locally
- [x] Dependent changes merged downstream (FE #14346 consumes these
endpoints)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:09:36 +07:00
Tanmay Deep Sharma 02ac8f008f Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-07 15:39:42 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 29fd83c1ea fix(voice): don't mint inbound calls for outbound connect webhooks
If Meta delivered a connect for an outbound call before the controller
committed the Call row (race between Meta's API response and our INSERT),
the handler treated the unknown call_id as inbound and either tripped the
unique (provider, provider_call_id) index or broadcast voice_call.incoming
for an outbound. Use session.sdp_type to gate inbound creation on 'offer'
only; 'answer' with no row is logged and skipped — the next status webhook
finds the now-committed row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:39:29 +07:00
Tanmay Deep SharmaandGitHub 74f9cd764b Merge branch 'develop' into feat/whatsapp-call-incoming-pipeline 2026-05-07 13:13:27 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 553da80f80 fix(voice): retry ContactInbox lookup on inbound call race
A concurrent WhatsApp message webhook for the same wa_id could win the
(inbox_id, source_id) insert; the call path then re-raised RecordNotUnique
and the outer rescue mislabeled it as a duplicate provider_call_id,
silently dropping the connect. Handle the race inside ensure_contact_inbox!
and drop the now-redundant outer rescue so unrelated unique violations
surface instead of being swallowed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:19:13 +07:00
Tanmay Deep Sharma 7f59c8b771 Merge branch 'feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-06 16:43:44 +07:00
Tanmay Deep Sharma cb180dfc5f fix(voice): keep terminate handling idempotent for ended calls
A retried terminate webhook for an answered short call (duration=0)
would re-enter handle_terminate, find call.in_progress? false, fall
through to no_answer, and corrupt a completed call's status. Bail when
the call is already terminal so retries don't recompute the status.
2026-05-06 16:43:31 +07:00
Tanmay Deep Sharma 20af850432 fix(voice): normalize WhatsApp wa_id before ContactInbox lookup
Inbound WhatsApp calls were matching contacts by stripping `+` from the
raw from_number, bypassing the BR/AR wa_id normalization that
Whatsapp::IncomingMessageServiceHelpers#processed_waid runs on the
messaging path. A wa_id whose webhook variant differs from the stored
normalized source_id (notably Brazil's added "9") would miss the
existing ContactInbox and create a fresh contact/conversation,
fragmenting history. Route the voice path through the same
Whatsapp::PhoneNumberNormalizationService so the lookup is consistent.
2026-05-06 16:20:52 +07:00
Tanmay Deep Sharma 6d168f3c96 fix(voice): use action: 'connect' for outbound WhatsApp call initiate
Meta's Calls API documents `action: 'connect'` (alongside `pre_accept`,
`accept`, `reject`, `terminate`) as the required field on POST /calls
for business-initiated calls. The previous body used `type: 'audio'`,
which is the /messages media shape — Meta currently accepts it because
unknown fields are ignored, but that's an implicit contract that could
break the moment validation tightens.
2026-05-06 16:00:04 +07:00
Tanmay Deep Sharma 639d087e53 fix(messenger): re-broadcast audio message after blob attaches
Messenger ingest saves the Attachment row before attaching the blob, so
the after_create_commit broadcast bails on file.attached? and the FE
never gets the audio bubble until a refresh (or until Whisper finishes,
or never if transcription is disabled or returns blank). Re-fire the
message update after attach completes.
2026-05-06 15:25:16 +07:00
Tanmay Deep Sharma 40d3ec99dc fix(voice): require conversation to belong to dialed contact
Reject conversation_id hints that point to a different contact in the
same voice inbox, so reuse only happens when the agent is genuinely
inside the open thread for the dialed contact.
2026-05-06 15:25:16 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 2866b29f3a feat(voice): reuse open conversation for Twilio outbound calls
Accept an optional conversation_id in POST /contacts/:id/call. When the
hint matches the picked voice inbox, OutboundCallBuilder reuses that
conversation instead of creating a new one — mirroring the WhatsApp
calling flow so the agent stays in the same thread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:44:28 +07:00
Tanmay Deep Sharma 0b1caa7286 Merge remote-tracking branch 'origin/feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-05 18:14:30 +07:00
Tanmay Deep Sharma 23ccec3ebc test(whatsapp): update facebook_api_client spec for calls subscription
Mirrors the subscribed_fields change in 567631b — the webmock body matcher
needs `calls` so the override-failure stub actually fires.
2026-05-05 18:11:01 +07:00
Tanmay Deep Sharma 9e012e923b Merge branch 'develop' into feat/whatsapp-call-meta-bridge
Conflicts resolved:
- config/locales/en.yml — kept the new
  conversations.messages.voice_call.{twilio,whatsapp} keys this branch added.
- enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb
  — kept this branch's calls_phone_id_path helper that pins to
  WHATSAPP_API_VERSION (v22) for the Calls API endpoints. Develop's version
  from PR #14312 used the OSS phone_id_path (v13) which doesn't support the
  Calls API.
2026-05-05 18:09:33 +07:00
Tanmay Deep Sharma 567631b866 feat(voice): subscribe to calls webhook + gate WhatsApp calling on channel_voice flag
- Add 'calls' to the WhatsApp WABA override_callback subscribed_fields so a
  fresh embedded-signup connection (or a re-register) automatically receives
  call webhooks from Meta. Previously required a manual App Dashboard step.
- Gate Channel::Whatsapp#voice_enabled? on the account-level 'channel_voice'
  feature flag, matching the entitlement Twilio voice channel creation
  already requires. Cascades through the controller, webhook services, and
  inbox jbuilder voice_enabled serialization (FE button hides automatically).
2026-05-05 18:01:14 +07:00
Tanmay Deep Sharma f712796df3 Merge branch 'develop' into feat/whatsapp-call-incoming-pipeline
Resolves conflict in config/locales/en.yml — kept the new
conversations.messages.voice_call.{twilio,whatsapp} keys added in
this branch.
2026-05-05 17:45:52 +07:00
Tanmay Deep Sharma cb420d83f6 fix(voice): address PR #14356 review feedback
- Return 422 (not 200) from `WhatsappCallsController#initiate` permission
  branch so clients can't mistake the opt-in template path for a
  successful dial. Body shape unchanged (`{ status: ... }`).
- Drop the `file.attached?` guard from `enqueue_audio_transcription`.
  The social-media ingest path (Messages::Messenger::MessageBuilder)
  saves the Attachment before attaching the blob, and the guard was
  silently dropping transcription for those audio messages.
  AudioTranscriptionJob already has retry_on FileNotFoundError to ride
  out the create-then-attach race.
2026-05-05 17:30:03 +07:00
Tanmay Deep Sharma 61a8511f7a chore: revert audio_transcription_service temperature change (out of scope)
Whisper temperature tuning belongs with the transcription stack work,
not with WhatsApp call wiring. Restoring develop's value to keep the
PR focused.
2026-05-05 16:28:07 +07:00
Tanmay Deep Sharma 14a9b48d49 chore: drop Channel::Voice stub (out of scope for this PR)
The legacy-row stub for Channel::Voice belongs with the upstream
DropChannelVoice migration cleanup, not with WhatsApp call wiring.
Removing it keeps the PR's surface focused on the call pipeline.
2026-05-05 16:27:33 +07:00
Tanmay Deep Sharma 5086edefb3 fix(voice): use status=ACCEPTED as the real pickup signal for outbound calls
Meta delivers two payload shapes under field=calls: value.calls[] (event:
connect/terminate) and value.statuses[] (status: RINGING/ACCEPTED). The
dispatcher only forwarded the calls[] array, so status webhooks were
silently dropped.

Empirically `connect` for BUSINESS_INITIATED outbound fires when the
WebRTC tunnel is up, not on pickup — sometimes ~20s before the contact
actually answers. The terminate webhook's start_time aligns with the
ACCEPTED status timestamp, confirming ACCEPTED is the true pickup.

- whatsapp_events_job: route value.statuses[] (type=call) to
  IncomingCallService under the same per-call_id mutex.
- incoming_call_service: handle_status routes ACCEPTED to
  mark_outbound_accepted (flip in_progress, set started_at, broadcast
  voice_call.outbound_accepted). Strip the in_progress flip from the
  connect handler — connect now only stores the SDP answer + broadcasts
  voice_call.outbound_connected so the browser can complete the DTLS
  handshake during ringing.
2026-05-05 14:13:26 +07:00
Tanmay Deep Sharma 66d9ddb9c4 feat(voice): add activity messages + customizable body for call permission
Emit a conversation activity message when the call permission template is
sent (in the controller's render_permission_request) and another when the
contact accepts the prompt (in CallPermissionReplyService). Read an inbox
provider_config['call_permission_request_body'] override before falling
back to the i18n default — surfaced in the inbox UI in a separate change.
2026-05-05 14:13:10 +07:00
Tanmay Deep Sharma d693d468d8 feat(voice): bridge WhatsApp Cloud Calling to Chatwoot voice pipeline
Wires Meta's WhatsApp Cloud Calling APIs into the voice subsystem so
agents can answer / place calls on WhatsApp Cloud inboxes from the
existing Voice flow. Browser ↔ Meta WebRTC is direct (no media-server
hop); Chatwoot owns the signalling, recording upload, and lifecycle
state on the Call/Message records.

Backend
- API surface: new Api::V1::Accounts::WhatsappCallsController with
  show / accept / reject / terminate / initiate / upload_recording.
  Enforces conversation-level Pundit visibility, blocks initiate when
  the channel isn't embedded-signup voice-enabled, and surfaces the
  Meta 138006 (no-call-permission) flow as a throttled opt-in template
  send under a conversation lock so concurrent retries can't double-send.
- Whatsapp::CallService — accept/reject/terminate state machine with
  call-level locking; wraps Meta API failures (transport or business)
  as Voice::CallErrors::CallFailed so the controller renders 422.
- Whatsapp::IncomingCallService — handles Meta connect/terminate
  webhooks; pins setup:active on outbound answers, materialises a
  missed-call record when terminate arrives before connect, and
  broadcasts voice_call.* events to assignee or account streams.
- Whatsapp::Providers::WhatsappCloudService — adds pre_accept/accept/
  reject/terminate/initiate/permission-request endpoints. Uses
  configurable WHATSAPP_API_VERSION (default v22) since the Calls API
  needs v17+ and OSS phone_id_path is locked at v13.
- Audio recordings: Attachment after_create_commit enqueues
  transcription and rebroadcasts the message so the FE bubble updates
  immediately. Active Storage initializer allows audio MIME types to
  serve inline. Audio transcription drops Whisper temperature to 0 to
  suppress hallucinations on silence.
- Channel::Voice stub backs legacy inbox rows whose channel_type
  survived the DropChannelVoice migration so jbuilder .try chains
  short-circuit instead of raising.

Routes / config
- /api/v1/accounts/:id/whatsapp_calls/* (enterprise-only)
- en.yml error strings under errors.whatsapp.calls.*

The FE consumer of this API ships separately on feat/whatsapp-call-ui.
2026-05-04 15:38:28 +07:00
Tanmay Deep Sharma 555894793e fix(voice): match permission reply to originating conversation via context.id 2026-04-30 17:10:34 +07:00
Tanmay Deep Sharma e5a03d08ea fix(voice): resolve contact via source_id and i18n call message bubble 2026-04-30 16:51:29 +07:00
Tanmay Deep Sharma f32a7b3f77 refactor(voice): drop redundant CallMessageBuilder.update_status! class delegate 2026-04-30 16:13:53 +07:00
Tanmay Deep Sharma ba0fcbfc7f refactor(voice): tighten WhatsApp call pipeline and broadcast hyphenated terminate status 2026-04-30 16:04:39 +07:00
Tanmay Deep Sharma 546193de2a refactor(voice): tighten predicate and trim STUN comment 2026-04-30 15:35:58 +07:00
Tanmay Deep Sharma 67b522c433 refactor(voice): expose voice_enabled? on Channel::Whatsapp for duck-typed checks 2026-04-30 15:34:08 +07:00
Tanmay Deep Sharma f5eca9d1d0 refactor(voice): inline single-use helpers in WhatsApp call services 2026-04-30 15:28:41 +07:00
Tanmay Deep Sharma 5c4db3a6bc refactor(voice): extract finalize_status! helper to DRY incoming call lifecycle 2026-04-30 15:19:21 +07:00
Tanmay Deep Sharma 792dc359d4 fix(voice): tighten WhatsApp call lifecycle handling and per-call locking 2026-04-30 15:07:51 +07:00
Tanmay Deep Sharma 2b0aaa237f feat(voice): add WhatsApp inbound call webhook pipeline 2026-04-30 14:59:46 +07:00
Tanmay Deep Sharma 48022833ea refactor(voice): address review feedback on WhatsApp Cloud Calling provider methods 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma e2b3965a08 refactor(voice): raise typed Voice::CallErrors::CallFailed instead of StandardError 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma 17e25fa3c4 test(voice): cover WhatsApp Cloud Calling provider methods 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma 37963cc654 fix(voice): coerce non-Hash parsed_response in initiate_call error path 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma c7f5277277 feat(voice): add WhatsApp Cloud Calling provider methods 2026-04-30 14:43:44 +07:00
1718 changed files with 8315 additions and 93185 deletions
+4 -18
View File
@@ -2,22 +2,8 @@
ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
# Devise 5 is currently blocked by devise-secure_password/devise_token_auth/devise-two-factor.
# Chatwoot does not enable Timeoutable, so the timeout redirect path is not reachable.
- GHSA-jp94-3292-c3xv
# Rails 7.1 has no patched release for the Active Storage proxy range
# advisories. Chatwoot limits proxy range requests locally.
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
# storage setup uses local/cloud storage with optional direct uploads to the
# storage provider rather than Rails proxy mode. Revisit if we enable
# rails_storage_proxy or other app-served Active Storage proxy routes.
- CVE-2026-33658
# Rails 7.1 has no patched release for this Active Storage direct-upload
# advisory. Chatwoot filters internal metadata keys locally.
- CVE-2026-33173
- CVE-2026-33174
# Rails 7.1 has no patched release for these Rails advisories. These are not
# reachable through Chatwoot's current usage patterns and should be removed
# once we upgrade to Rails 7.2.3.1+.
- CVE-2026-33168
- CVE-2026-33169
- CVE-2026-33170
- CVE-2026-33176
- CVE-2026-33195
- CVE-2026-33202
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 20
parallelism: 18
steps:
- checkout
- node/install:
-6
View File
@@ -98,8 +98,6 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN=
# Maximum time in seconds to process a single IMAP email
# EMAIL_PROCESSING_TIMEOUT_SECONDS=60
# Set this to the appropriate ingress channel with regards to incoming emails
# Possible values are :
# relay for Exim, Postfix, Qmail
@@ -234,10 +232,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
## SafeFetch private network access
## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs.
# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false
## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false
+6 -8
View File
@@ -76,7 +76,7 @@ gem 'faraday_middleware-aws-sigv4'
##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman'
gem 'puma', '~> 7.2', '>= 7.2.1'
gem 'puma'
gem 'vite_rails'
# metrics on heroku
gem 'barnes'
@@ -89,7 +89,7 @@ gem 'rails-i18n', '~> 7.0'
# two-factor authentication
gem 'devise-two-factor', '>= 5.0.0'
# authorization
gem 'jwt', '~> 2.10', '>= 2.10.3'
gem 'jwt'
gem 'pundit'
# super admin
@@ -133,9 +133,9 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --##
gem 'sidekiq', '~> 7.3', '>= 7.3.1'
gem 'sidekiq', '>= 7.3.1'
# We want cron jobs
gem 'sidekiq-cron', '>= 2.4.0'
gem 'sidekiq-cron', '>= 1.12.0'
# for sidekiq healthcheck
gem 'sidekiq_alive'
@@ -195,10 +195,10 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.10.0'
gem 'ai-agents', '>= 0.9.1'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.14.1'
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
gem 'cld3', '~> 3.7'
@@ -209,8 +209,6 @@ gem 'opentelemetry-exporter-otlp'
gem 'shopify_api'
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
### Gems required only in specific deployment environments ###
##############################################################
+52 -72
View File
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.10.0)
ruby_llm (~> 1.14)
ai-agents (0.9.1)
ruby_llm (~> 1.9.1)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -136,8 +136,6 @@ GEM
audited (5.4.1)
activerecord (>= 5.0, < 7.7)
activesupport (>= 5.0, < 7.7)
auth-sanitizer (0.2.1)
version_gem (~> 1.1, >= 1.1.10)
aws-actionmailbox-ses (0.1.0)
actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0)
@@ -170,7 +168,7 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.4.1)
bigdecimal (3.3.1)
bigdecimal (3.2.2)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -186,7 +184,6 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
cgi (0.5.1)
childprocess (5.1.0)
logger (~> 1.5)
cld3 (3.7.0)
@@ -199,9 +196,6 @@ GEM
bigdecimal
rexml
crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
csv (3.3.0)
csv-safe (3.3.1)
csv (~> 3.0)
@@ -218,7 +212,7 @@ GEM
logger
msgpack
datadog-ruby_core_source (3.4.1)
date (3.5.1)
date (3.4.1)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@@ -304,7 +298,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.2)
faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -313,9 +307,9 @@ GEM
faraday-mashify (1.0.0)
faraday (~> 2.0)
hashie
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.4)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.4.2)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
@@ -342,7 +336,6 @@ GEM
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
firecrawl-sdk (1.4.1)
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.11.1)
@@ -438,8 +431,7 @@ GEM
hana (1.3.7)
hash_diff (1.1.1)
hashdiff (1.1.0)
hashie (5.1.0)
logger
hashie (5.0.0)
html2text (0.4.0)
nokogiri (>= 1.0, < 2.0)
http (5.1.1)
@@ -474,7 +466,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.8)
json (2.19.2)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -495,7 +487,7 @@ GEM
judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0)
jwt (2.10.3)
jwt (2.10.1)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
@@ -572,7 +564,7 @@ GEM
ruby2_keywords
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.9.1)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
mutex_m (0.3.0)
@@ -582,7 +574,7 @@ GEM
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.24)
net-imap (0.4.20)
date
net-protocol
net-pop (0.1.2)
@@ -597,36 +589,29 @@ GEM
sidekiq
newrelic_rpm (9.6.0)
base64
nio4r (2.7.5)
nokogiri (1.19.3)
nio4r (2.7.3)
nokogiri (1.19.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.3-arm64-darwin)
nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-darwin)
nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.6)
auth-sanitizer (~> 0.2, >= 0.2.1)
base64 (~> 0.1)
cgi
oauth-tty (~> 1.0, >= 1.0.8)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oauth-tty (1.0.8)
auth-sanitizer (~> 0.1, >= 0.1.3)
cgi
version_gem (~> 1.1, >= 1.1.9)
oauth2 (2.0.22)
auth-sanitizer (~> 0.2, >= 0.2.1)
faraday (>= 0.17.3, < 4.0)
jwt (>= 1.0, < 4.0)
logger (~> 1.2)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oauth-tty (1.0.5)
version_gem (~> 1.1, >= 1.1.1)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
@@ -693,7 +678,7 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (7.0.5)
puma (7.2.1)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
@@ -850,17 +835,17 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.15.0)
ruby_llm (1.9.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
faraday-multipart (>= 1)
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1)
ruby_llm-schema (~> 0)
marcel (~> 1.0)
ruby_llm-schema (~> 0.2.1)
zeitwerk (~> 2)
ruby_llm-schema (0.3.0)
ruby_llm-schema (0.2.5)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -917,11 +902,10 @@ GEM
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-cron (2.4.0)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
sidekiq-cron (1.12.0)
fugit (~> 1.8)
globalid (>= 1.0.1)
sidekiq (>= 6.5.0)
sidekiq (>= 6)
sidekiq_alive (2.5.0)
gserver (~> 0.0.1)
sidekiq (>= 5, < 9)
@@ -946,9 +930,9 @@ GEM
gli
hashie
logger
snaky_hash (2.0.5)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
sorbet-runtime (0.5.11934)
spring (4.1.1)
spring-watcher-listen (2.1.0)
@@ -977,7 +961,7 @@ GEM
time_diff (0.3.0)
activesupport
i18n
timeout (0.6.1)
timeout (0.4.3)
trailblazer-option (0.1.2)
twilio-ruby (7.6.0)
faraday (>= 0.9, < 3.0)
@@ -995,7 +979,6 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode (0.4.4.5)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
@@ -1006,14 +989,12 @@ GEM
valid_email2 (5.2.6)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.11)
vite_rails (3.10.0)
railties (>= 5.1, < 9)
version_gem (1.1.4)
vite_rails (3.0.17)
railties (>= 5.1, < 8)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.10.2)
vite_ruby (3.8.0)
dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2)
warden (1.2.9)
@@ -1038,7 +1019,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.7.5)
zeitwerk (2.7.4)
PLATFORMS
arm64-darwin-20
@@ -1058,7 +1039,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.10.0)
ai-agents (>= 0.9.1)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1093,7 +1074,6 @@ DEPENDENCIES
faker
faraday_middleware-aws-sigv4
fcm
firecrawl-sdk (~> 1.0)
flag_shih_tzu
foreman
gemoji
@@ -1115,7 +1095,7 @@ DEPENDENCIES
json_schemer
judoscale-rails
judoscale-sidekiq
jwt (~> 2.10, >= 2.10.3)
jwt
kaminari
koala
letter_opener
@@ -1143,7 +1123,7 @@ DEPENDENCIES
pgvector
procore-sift
pry-rails
puma (~> 7.2, >= 7.2.1)
puma
pundit
rack-attack (>= 6.7.0)
rack-cors (= 2.0.0)
@@ -1164,7 +1144,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm (>= 1.14.1)
ruby_llm (>= 1.8.2)
ruby_llm-schema
scout_apm
scss_lint
@@ -1175,8 +1155,8 @@ DEPENDENCIES
sentry-sidekiq (>= 5.19.0)
shopify_api
shoulda-matchers
sidekiq (~> 7.3, >= 7.3.1)
sidekiq-cron (>= 2.4.0)
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
+1 -1
View File
@@ -1 +1 @@
4.14.2
4.13.0
@@ -92,18 +92,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'],
external_url: attachment['url'] || attachment.dig('payload', 'url')
external_url: attachment['url']
}
end
# Facebook shared posts point to page URLs, not downloadable media URLs.
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
def normalize_file_type(type)
return :fallback if type.to_sym == :share
super
end
def conversation_params
{
account_id: @inbox.account_id,
+7 -17
View File
@@ -13,7 +13,6 @@ class Messages::MessageBuilder
@account = conversation.account
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message])
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@@ -57,25 +56,16 @@ class Messages::MessageBuilder
file: uploaded_attachment
)
attachment.file_type = attachment_file_type(uploaded_attachment)
tag_voice_message(attachment)
attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
end
end
def attachment_file_type(uploaded_attachment)
if uploaded_attachment.is_a?(String)
file_type_by_signed_id(uploaded_attachment)
else
file_type(uploaded_attachment&.content_type)
end
end
def tag_voice_message(attachment)
return unless @is_voice_message && attachment.file_type == 'audio'
attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true)
end
def process_emails
return unless @conversation.inbox&.inbox_type == 'Email'
-15
View File
@@ -27,8 +27,6 @@ class NotificationBuilder
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
# skip notifications for blocked conversations except for user mentions
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
# respect conversation access (inbox/team membership and custom-role permissions)
return unless user_can_access_conversation?
user.notifications.create!(
notification_type: notification_type,
@@ -38,17 +36,4 @@ class NotificationBuilder
secondary_actor: secondary_actor || current_user
)
end
def user_can_access_conversation?
conversation = primary_actor.is_a?(Conversation) ? primary_actor : primary_actor.try(:conversation)
return true if conversation.blank?
account_user = AccountUser.find_by(account_id: account.id, user_id: user.id)
return false if account_user.blank?
ConversationPolicy.new(
{ user: user, account: account, account_user: account_user },
conversation
).show?
end
end
+4 -15
View File
@@ -31,24 +31,13 @@ class V2::Reports::BotMetricsBuilder
end
def bot_resolutions_count
# Exclude conversations that also had a handoff in the same range — handoff wins
account.reporting_events.joins(:conversation).select(:conversation_id)
.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
.distinct.count
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range).distinct.count
end
def bot_handoffs_count
account.reporting_events.joins(:conversation).select(:conversation_id)
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
.distinct.count
end
def bot_handoff_conversation_ids_subquery
account.reporting_events
.where(name: :conversation_bot_handoff, created_at: range)
.where.not(conversation_id: nil)
.select(:conversation_id)
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
created_at: range).distinct.count
end
def bot_resolution_rate
@@ -1,7 +1,7 @@
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :set_articles, only: [:update_status, :update_category, :delete_articles]
before_action :set_articles, only: [:update_status, :delete_articles]
def translate
head :not_implemented
@@ -19,18 +19,6 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
render_could_not_create_error(e.message)
end
def update_category
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
return render_could_not_create_error(I18n.t('portals.articles.category_not_found')) unless category_valid?
ActiveRecord::Base.transaction do
@articles.find_each { |article| article.update!(category_id: params[:category_id]) }
end
head :ok
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.message)
end
def delete_articles
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
@@ -51,9 +39,5 @@ class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::Ba
def set_articles
@articles = @portal.articles.where(id: params[:ids])
end
def category_valid?
@portal.categories.exists?(id: params[:category_id])
end
end
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
@@ -1,18 +0,0 @@
class Api::V1::Accounts::Contacts::AttachmentsController < Api::V1::Accounts::Contacts::BaseController
RESULTS_PER_PAGE = 100
def index
conversations = Conversations::PermissionFilterService.new(
Current.account.conversations.where(contact_id: @contact.id),
Current.user,
Current.account
).perform
@attachments = Attachment.where(message_id: Message.where(conversation_id: conversations).select(:id))
.includes({ file_attachment: :blob }, message: [:conversation, :inbox, { sender: { avatar_attachment: :blob } }])
.order(created_at: :desc)
.page(params[:page])
.per(RESULTS_PER_PAGE)
@attachments_count = @attachments.total_count
end
end
@@ -1,16 +0,0 @@
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
before_action :ensure_unread_counts_enabled
def index
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
render json: { payload: counts }
end
private
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
end
end
@@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def attachments
@attachments_count = @conversation.attachments.count
@attachments = @conversation.attachments
.includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }])
.includes(:message)
.order(created_at: :desc)
.page(attachment_params[:page])
.per(ATTACHMENT_RESULTS_PER_PAGE)
@@ -162,8 +162,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_columns(updates)
# rubocop:enable Rails/SkipsModelValidations
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
end
def should_update_last_seen?
@@ -1,7 +1,6 @@
class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController
before_action :fetch_custom_attributes_definitions, except: [:create]
before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy]
before_action :check_authorization
DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def index; end
@@ -9,9 +9,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index
@inboxes = policy_scope(Current.account.inboxes)
.includes(:channel, :portal, :working_hours, { avatar_attachment: :blob })
.order_by_name
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
end
def show; end
@@ -87,7 +85,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def fetch_agent_bot
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[:agent_bot]
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end
def create_channel
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
enable_fb_login: '0',
force_authentication: '1',
response_type: 'code',
state: generate_instagram_token(Current.account.id, params[:return_to])
state: generate_instagram_token(Current.account.id)
}
)
if redirect_url
@@ -18,16 +18,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
end
def destroy
label_title = @label.title
account_id = Current.account.id
label_deleted_at = Time.current
@label.destroy!
Labels::RemoveAssociationsJob.perform_later(
label_title: label_title,
account_id: account_id,
label_deleted_at: label_deleted_at
)
head :ok
end
@@ -6,7 +6,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state
state: state,
prompt: 'consent'
}
)
if redirect_url
@@ -41,9 +41,9 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
def destroy_all
if params[:type] == 'read'
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read)
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
else
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all)
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
end
head :ok
end
@@ -69,7 +69,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
end
def fetch_notification
@notification = current_user.notifications.where(account_id: Current.account.id).find(params[:id])
@notification = current_user.notifications.find(params[:id])
end
def set_current_page
@@ -8,15 +8,7 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC
end
def state
# The sgid purpose doubles as a return hint: onboarding tags it so the callback
# can route the user back to inbox setup. The purpose is part of the signed
# payload (tamper-proof), and a non-onboarding request keeps the default
# purpose, leaving callers like Notion byte-identical.
Current.account.to_sgid(expires_in: 15.minutes, for: state_purpose).to_s
end
def state_purpose
params[:return_to] == 'onboarding' ? 'onboarding' : 'default'
Current.account.to_sgid(expires_in: 15.minutes).to_s
end
def base_url
@@ -1,51 +0,0 @@
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
def update
@account = Current.account
finalize = finalizing_account_details?
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
@account.custom_attributes.delete('onboarding_step') if finalize
@account.save!
# TODO: re-enable when the help center generation UI is ready to surface progress
# Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
render 'api/v1/accounts/update', format: :json
end
def help_center_generation
render json: help_center_generation_status
end
private
def finalizing_account_details?
@account.custom_attributes['onboarding_step'] == 'account_details'
end
def website
custom_attributes_params[:website]
end
def account_params
params.permit(:name, :locale)
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
end
def help_center_generation_status
{
generation_id: nil,
state: nil,
articles_count: 0,
categories_count: 0
}
end
end
Api::V1::Accounts::OnboardingsController.prepend_mod_with('Api::V1::Accounts::OnboardingsController')
@@ -18,7 +18,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
@portal.custom_domain = parsed_custom_domain
@portal.save!
process_attached_logo if params[:blob_id].present?
process_attached_logo
end
def update
@@ -78,23 +78,16 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
)
end
def locale_translation_keys
params.dig(:portal, :config, :locale_translations)&.keys || []
end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)
return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
inbox = Inbox.find(permitted_params[:inbox_id])
return {} unless inbox.web_widget?
{ channel_web_widget_id: inbox.channel.id }
@@ -105,8 +98,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def parsed_custom_domain
return @portal.custom_domain if @portal.custom_domain.blank?
domain = URI.parse(@portal.custom_domain)
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
end
@@ -3,7 +3,7 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O
def create
redirect_url = Tiktok::AuthClient.authorize_url(
state: generate_tiktok_token(Current.account.id, params[:return_to])
state: generate_tiktok_token(Current.account.id)
)
if redirect_url
@@ -0,0 +1,18 @@
class Api::V1::Accounts::WorkingHoursController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_webhook, only: [:update]
def update
@working_hour.update!(working_hour_params)
end
private
def working_hour_params
params.require(:working_hour).permit(:inbox_id, :open_hour, :open_minutes, :close_hour, :close_minutes, :closed_all_day)
end
def fetch_working_hour
@working_hour = Current.account.working_hours.find(params[:id])
end
end
@@ -58,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -110,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
end
def settings_params
@@ -8,8 +8,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
end
def destroy
notification_subscription = current_user.notification_subscriptions
.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription.destroy! if notification_subscription.present?
head :ok
end
@@ -83,10 +83,6 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def set_message
# `conversation.messages.find` would be simpler, but `conversation` is `conversations.last`,
# which means a visitor with more than one open thread could not edit a message in any
# but their most recent one. Scoping across all of the visitor's conversations keeps the
# happy path correct for that future multi-conversation widget flow.
@message = Message.where(conversation_id: conversations.select(:id)).find(permitted_params[:id])
@message = @web_widget.inbox.messages.find(permitted_params[:id])
end
end
@@ -1,9 +1,8 @@
module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[show toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create'],
'api/v1/accounts/conversations/labels' => %w[index create]
'api/v1/accounts/conversations/assignments' => ['create']
}.freeze
def ensure_access_token
@@ -28,8 +28,6 @@ class Instagram::CallbacksController < ApplicationController
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
inbox, already_exists = find_or_create_inbox
return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
if already_exists
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
@@ -151,10 +149,6 @@ class Instagram::CallbacksController < ApplicationController
verify_instagram_token(params[:state])
end
def return_to
instagram_token_return_to(params[:state])
end
def oauth_code
params[:code]
end
@@ -14,11 +14,4 @@ class Microsoft::CallbacksController < OauthCallbackController
def imap_address
'outlook.office365.com'
end
# Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field;
# it must match the token's UPN. `preferred_username` is the documented v2.0 claim;
# `upn` is the v1.0 fallback.
def imap_login_identity
users_data['preferred_username'] || users_data['upn'] || super
end
end
+2 -25
View File
@@ -16,8 +16,6 @@ class OauthCallbackController < ApplicationController
def handle_response
inbox, already_exists = find_or_create_inbox
return redirect_to app_onboarding_inbox_setup_url(account_id: account.id) if return_to == 'onboarding'
if already_exists
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else
@@ -46,7 +44,7 @@ class OauthCallbackController < ApplicationController
def update_channel(channel_email)
channel_email.update!({
imap_login: imap_login_identity, imap_address: imap_address,
imap_login: users_data['email'], imap_address: imap_address,
imap_port: '993', imap_enabled: true,
provider: provider_name,
provider_config: {
@@ -57,13 +55,6 @@ class OauthCallbackController < ApplicationController
})
end
# Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the
# id_token's email claim; providers override when their server requires a different
# claim (e.g. Microsoft SMTP requires UPN).
def imap_login_identity
users_data['email']
end
def provider_name
raise NotImplementedError
end
@@ -90,19 +81,10 @@ class OauthCallbackController < ApplicationController
decoded_token[0]
end
# The sgid purpose carries the onboarding return hint (see
# OauthAuthorizationController#state). Try the onboarding purpose first — a match
# both resolves the account and records the return target — then fall back to the
# default purpose used by every other caller.
def account_from_signed_id
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding'))
@return_to = 'onboarding'
else
account = GlobalID::Locator.locate_signed(params[:state])
end
account = GlobalID::Locator.locate_signed(params[:state])
raise 'Invalid or expired state' if account.nil?
account
@@ -112,11 +94,6 @@ class OauthCallbackController < ApplicationController
@account ||= account_from_signed_id
end
def return_to
account # resolving the sgid records which purpose matched
@return_to
end
# Fallback name, for when name field is missing from users_data
def fallback_name
users_data['email'].split('@').first.parameterize.titleize
@@ -24,10 +24,6 @@ class Public::Api::V1::InboxesController < PublicController
def set_conversation
return if params[:conversation_id].blank?
@conversation = if @contact_inbox.hmac_verified?
@contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
else
@contact_inbox.conversations.find_by!(display_id: params[:conversation_id])
end
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
end
end
@@ -1,11 +1,9 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index, :show_markdown]
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show, :show_markdown]
before_action :set_article, only: [:show]
layout 'portal'
def index
@@ -23,13 +21,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title)
@parsed_content = render_article_content(@article.content.to_s)
end
def show_markdown
return head :not_found unless @article&.published?
render plain: @article.content.to_s, content_type: 'text/markdown; charset=utf-8'
end
def tracking_pixel
@@ -71,6 +62,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@parsed_content = render_article_content(@article.content.to_s)
end
def set_category
@@ -7,8 +7,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
around_action :set_locale
after_action :allow_iframe_requests
PORTAL_LAYOUTS = %w[classic documentation].freeze
private
def show_plain_layout
@@ -19,14 +17,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
end
def set_portal_layout
@portal_layout = PORTAL_LAYOUTS.include?(@portal&.layout) ? @portal.layout : 'classic'
end
def set_view_variant
request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
end
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
end
@@ -52,7 +42,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
article_locale = if article.category.present?
article.category.locale
else
article.locale
article.portal.default_locale
end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
@@ -1,18 +1,12 @@
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :set_category, only: [:show]
before_action :load_category_articles, only: [:show], if: -> { @portal_layout == 'documentation' }
layout 'portal'
def index
respond_to do |format|
format.html { redirect_to public_portal_locale_path(@portal.slug, params[:locale]), status: :moved_permanently }
format.json { @categories = @portal.categories.order(position: :asc) }
end
@categories = @portal.categories.order(position: :asc)
end
def show
@@ -27,9 +21,4 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals:
Rails.logger.info "Category: not found for slug: #{params[:category_slug]}"
render_404 && return if @category.blank?
end
def load_category_articles
@articles = @category.articles.published.order(:position).includes(:author)
@category_authors = @articles.filter_map(&:author).uniq
end
end
@@ -1,31 +0,0 @@
class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:index]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
layout 'portal'
def index
@query = params[:query].to_s.strip
@articles = @portal.articles.published.includes(:category).where(locale: params[:locale])
search_articles
@articles = @articles.page(params[:page]).per(10)
end
private
def search_articles
@articles = @query.present? ? @articles.search(search_params) : @articles.none
end
def search_params
params.permit(:query, :locale, :sort, :status, :page).tap do |permitted|
permitted[:query] = @query
end
end
end
Public::Api::V1::Portals::SearchController.prepend_mod_with('Public::Api::V1::Portals::SearchController')
@@ -2,14 +2,11 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
layout 'portal'
def show
@og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale))
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
end
def sitemap
@@ -31,28 +28,4 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
@@ -27,7 +27,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
if errors.any?
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
else
redirect_to super_admin_settings_path, flash: success_flash
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
end
end
@@ -58,21 +58,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
)
end
def success_notice
message = "#{@config.titleize} settings updated successfully"
return message unless restart_required_config_saved?
"#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere."
end
def success_flash
restart_required_config_saved? ? { success: success_notice } : { notice: success_notice }
end
def restart_required_config_saved?
params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
end
end
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
@@ -25,29 +25,6 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
resource_class.editable
end
def create
resource = new_resource(resource_params)
authorize_resource(resource)
if resource.save
redirect_to after_resource_created_path(resource), flash: success_flash(resource)
else
render :new, locals: {
page: Administrate::Page::Form.new(dashboard, resource)
}, status: :unprocessable_entity
end
end
def update
if requested_resource.update(resource_params)
redirect_to after_resource_updated_path(requested_resource), flash: success_flash(requested_resource)
else
render :edit, locals: {
page: Administrate::Page::Form.new(dashboard, requested_resource)
}, status: :unprocessable_entity
end
end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
@@ -65,20 +42,6 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
.transform_values { |value| value == '' ? nil : value }.merge(locked: false)
end
private
def success_flash(resource)
message = translate_with_resource('update.success')
message = translate_with_resource('create.success') if action_name == 'create'
return { notice: message } unless restart_required_config?(resource)
{ success: "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." }
end
def restart_required_config?(resource)
resource.name.in?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
end
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information
end
+3 -8
View File
@@ -1,12 +1,7 @@
class SwaggerController < ApplicationController
def respond
if Rails.env.development? || Rails.env.test?
swagger_root = Rails.root.join('swagger')
file_path = swagger_root.join(derived_path).cleanpath
return head :not_found unless file_path.to_s.start_with?("#{swagger_root}/") && file_path.file?
render inline: file_path.read
render inline: Rails.root.join('swagger', derived_path).read
else
head :not_found
end
@@ -16,8 +11,8 @@ class SwaggerController < ApplicationController
def derived_path
params[:path] ||= 'index.html'
path = Rack::Utils.clean_path_info(params[:path]).delete_prefix('/')
path << ".#{Rack::Utils.clean_path_info(params[:format]).delete_prefix('/')}" unless path.ends_with?(params[:format].to_s)
path = Rack::Utils.clean_path_info(params[:path])
path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s)
path
end
end
@@ -20,8 +20,6 @@ class Tiktok::CallbacksController < ApplicationController
def process_successful_authorization
inbox, already_exists = find_or_create_inbox
return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
if already_exists
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
@@ -129,10 +127,6 @@ class Tiktok::CallbacksController < ApplicationController
@account_id ||= verify_tiktok_token(params[:state])
end
def return_to
tiktok_token_return_to(params[:state])
end
def account
@account ||= Account.find(account_id)
end
@@ -31,11 +31,7 @@ class Twilio::CallbackController < ApplicationController
:Latitude,
:Longitude,
:MessageType,
:ProfileName,
:ExternalUserId,
:ParentExternalUserId,
:ProfileUsername,
:Username
:ProfileName
)
end
end
-1
View File
@@ -17,7 +17,6 @@ class AsyncDispatcher < BaseDispatcher
InstallationWebhookListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
Conversations::UnreadCounts::Listener.instance,
ReportingEventListener.instance,
WebhookListener.instance
]
+1 -1
View File
@@ -12,7 +12,7 @@ class ContactDrop < BaseDrop
end
def first_name
@obj.try(:name).try(:split).try(:first).try(:capitalize)
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end
def last_name
+1 -1
View File
@@ -12,7 +12,7 @@ class UserDrop < BaseDrop
end
def first_name
@obj.try(:name).try(:split).try(:first).try(:capitalize)
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
end
def last_name
+10 -18
View File
@@ -17,12 +17,15 @@ module Api::V1::InboxesHelper
def validate_imap(channel_data)
return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled]
# Validate the user-selected auth mechanism before opening the connection.
authentication = Imap::Authentication.validate_user_configurable!(channel_data[:imap_authentication])
Mail.defaults do
retriever_method :imap, { address: channel_data[:imap_address],
port: channel_data[:imap_port],
user_name: channel_data[:imap_login],
password: channel_data[:imap_password],
enable_ssl: channel_data[:imap_enable_ssl] }
end
# Use the same auth adapter as the fetch service so LOGIN uses the IMAP LOGIN command,
# not SASL AUTH=LOGIN.
check_imap_connection(channel_data, authentication)
check_imap_connection(channel_data)
end
def validate_smtp(channel_data)
@@ -34,8 +37,8 @@ module Api::V1::InboxesHelper
check_smtp_connection(channel_data, smtp)
end
def check_imap_connection(channel_data, authentication)
imap = open_imap_connection(channel_data, authentication)
def check_imap_connection(channel_data)
Mail.connection {} # rubocop:disable:block
rescue SocketError => e
raise StandardError, I18n.t('errors.inboxes.imap.socket_error')
rescue Net::IMAP::NoResponseError => e
@@ -50,20 +53,9 @@ module Api::V1::InboxesHelper
rescue StandardError => e
raise StandardError, e.message
ensure
imap.disconnect if imap.present? && !imap.disconnected?
Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present?
end
def open_imap_connection(channel_data, authentication)
imap = build_imap_connection(channel_data)
Imap::Authentication.authenticate!(imap, authentication, channel_data[:imap_login], channel_data[:imap_password])
imap
end
def build_imap_connection(channel_data)
Net::IMAP.new(channel_data[:imap_address], port: channel_data[:imap_port], ssl: channel_data[:imap_enable_ssl])
end
def check_smtp_connection(channel_data, smtp)
smtp.open_timeout = 10
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
-1
View File
@@ -17,7 +17,6 @@ module FileTypeHelper
def image_file?(content_type)
[
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/bmp',
+9 -16
View File
@@ -4,21 +4,21 @@ module Instagram::IntegrationHelper
# Generates a signed JWT token for Instagram integration
#
# @param account_id [Integer] The account ID to encode in the token
# @param return_to [String, nil] Optional onboarding return hint
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_instagram_token(account_id, return_to = nil)
def generate_instagram_token(account_id)
return if client_secret.blank?
JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256')
JWT.encode(token_payload(account_id), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate Instagram token: #{e.message}")
nil
end
def token_payload(account_id, return_to = nil)
payload = { sub: account_id, iat: Time.current.to_i }
payload[:return_to] = return_to if return_to.present?
payload
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
# Verifies and decodes a Instagram JWT token
@@ -28,14 +28,7 @@ module Instagram::IntegrationHelper
def verify_instagram_token(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('sub')
end
# Reads the onboarding return hint from a Instagram JWT token, if present.
def instagram_token_return_to(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('return_to')
decode_token(token, client_secret)
end
private
@@ -48,7 +41,7 @@ module Instagram::IntegrationHelper
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first
}).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}")
nil
+3 -21
View File
@@ -45,16 +45,9 @@ module PortalHelper
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
end
def portal_query_string(theme, is_plain_layout_enabled)
query_params = {}
query_params[:theme] = theme if theme.present? && theme != 'system'
query_params[:show_plain_layout] = true if is_plain_layout_enabled
query_params.present? ? "?#{query_params.to_query}" : ''
end
def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{portal_locale}#{portal_query_string(theme, is_plain_layout_enabled)}"
"/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}"
else
"/hc/#{portal_slug}/#{portal_locale}"
end
@@ -68,7 +61,7 @@ module PortalHelper
is_plain_layout_enabled = params[:is_plain_layout_enabled]
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{portal_query_string(theme, is_plain_layout_enabled)}"
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}"
else
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
end
@@ -76,7 +69,7 @@ module PortalHelper
def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
"/hc/#{portal_slug}/articles/#{article_slug}#{portal_query_string(theme, is_plain_layout_enabled)}"
"/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
else
"/hc/#{portal_slug}/articles/#{article_slug}"
end
@@ -103,15 +96,4 @@ module PortalHelper
colors[username.length % colors.size]
end
def format_authors_label(authors)
return if authors.blank?
names = authors.map(&:available_name)
return names.to_sentence if names.size <= 3
I18n.t('public_portal.sidebar.authors_others',
names: names.first(2).join(', '),
count: authors.size - 2)
end
end
+4 -7
View File
@@ -53,12 +53,13 @@ module ReportHelper
end
def resolutions
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, created_at: range)
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved,
created_at: range)
end
def bot_resolutions
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range)
end
def bot_handoffs
@@ -66,10 +67,6 @@ module ReportHelper
created_at: range).distinct
end
def bot_handoff_conversation_ids_subquery
bot_handoffs
end
def avg_first_response_time
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
-6
View File
@@ -34,12 +34,6 @@ disable_branding:
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-sailbot-fill'
enterprise: true
voice_calls:
name: 'Voice Calls'
description: 'Enable voice calling capabilities for your agents and customers.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-voice-line'
enterprise: true
# ------- Product Features ------- #
help_center:
+9 -16
View File
@@ -2,12 +2,11 @@ module Tiktok::IntegrationHelper
# Generates a signed JWT token for Tiktok integration
#
# @param account_id [Integer] The account ID to encode in the token
# @param return_to [String, nil] Optional onboarding return hint
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_tiktok_token(account_id, return_to = nil)
def generate_tiktok_token(account_id)
return if client_secret.blank?
JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256')
JWT.encode(token_payload(account_id), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate TikTok token: #{e.message}")
nil
@@ -20,14 +19,7 @@ module Tiktok::IntegrationHelper
def verify_tiktok_token(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('sub')
end
# Reads the onboarding return hint from a Tiktok JWT token, if present.
def tiktok_token_return_to(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('return_to')
decode_token(token, client_secret)
end
private
@@ -36,17 +28,18 @@ module Tiktok::IntegrationHelper
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
end
def token_payload(account_id, return_to = nil)
payload = { sub: account_id, iat: Time.current.to_i }
payload[:return_to] = return_to if return_to.present?
payload
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
def decode_token(token, secret)
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first
}).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
nil
@@ -6,22 +6,15 @@ class CaptainDocument extends ApiClient {
super('captain/documents', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId, filter, source, sort } = {}) {
get({ page = 1, searchKey, assistantId } = {}) {
return axios.get(this.url, {
params: {
page,
search_key: searchKey,
searchKey,
assistant_id: assistantId,
filter,
source,
sort,
},
});
}
sync(id) {
return axios.post(`${this.url}/${id}/sync`);
}
}
export default new CaptainDocument();
@@ -48,12 +48,6 @@ class TwilioVoiceClient extends EventTarget {
return !!this.activeConnection;
}
setMuted(shouldMute) {
if (!this.activeConnection) return false;
this.activeConnection.mute(shouldMute);
return shouldMute;
}
endClientCall() {
if (this.activeConnection) {
this.activeConnection.disconnect();
@@ -1,45 +0,0 @@
/* global axios */
import ApiClient from '../../ApiClient';
class WhatsappCallsAPI extends ApiClient {
constructor() {
super('whatsapp_calls', { accountScoped: true });
}
show(callId) {
return axios.get(`${this.url}/${callId}`).then(r => r.data);
}
initiate(conversationId, sdpOffer) {
return axios
.post(`${this.url}/initiate`, {
conversation_id: conversationId,
sdp_offer: sdpOffer,
})
.then(r => r.data);
}
accept(callId, sdpAnswer) {
return axios
.post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer })
.then(r => r.data);
}
reject(callId) {
return axios.post(`${this.url}/${callId}/reject`).then(r => r.data);
}
terminate(callId) {
return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data);
}
uploadRecording(callId, blob, filename = 'call-recording.webm') {
const formData = new FormData();
formData.append('recording', blob, filename);
return axios
.post(`${this.url}/${callId}/upload_recording`, formData)
.then(r => r.data);
}
}
export default new WhatsappCallsAPI();
@@ -28,14 +28,6 @@ class CompanyAPI extends ApiClient {
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
}
listNotes(id) {
return axios.get(`${this.url}/${id}/notes`);
}
listConversations(id) {
return axios.get(`${this.url}/${id}/conversations`);
}
searchContacts(id, query = '', page = 1) {
const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`;
return axios.get(requestURL);
+3 -5
View File
@@ -35,9 +35,8 @@ class ContactAPI extends ApiClient {
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
}
getConversations(contactId, { inboxId } = {}) {
const params = inboxId ? { inbox_id: inboxId } : {};
return axios.get(`${this.url}/${contactId}/conversations`, { params });
getConversations(contactId) {
return axios.get(`${this.url}/${contactId}/conversations`);
}
getContactableInboxes(contactId) {
@@ -48,10 +47,9 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/labels`);
}
initiateCall(contactId, inboxId, conversationId = null) {
initiateCall(contactId, inboxId) {
return axios.post(`${this.url}/${contactId}/call`, {
inbox_id: inboxId,
conversation_id: conversationId,
});
}
@@ -13,10 +13,6 @@ class ConversationApi extends ApiClient {
updateLabels(conversationID, labels) {
return axios.post(`${this.url}/${conversationID}/labels`, { labels });
}
getUnreadCounts() {
return axios.get(`${this.url}/unread_counts`);
}
}
export default new ConversationApi();
@@ -87,13 +87,6 @@ class ArticlesAPI extends PortalsAPI {
);
}
bulkUpdateCategory({ portalSlug, articleIds, categoryId }) {
return axios.patch(
`${this.url}/${portalSlug}/articles/bulk_actions/update_category`,
{ ids: articleIds, category_id: categoryId }
);
}
bulkDelete({ portalSlug, articleIds }) {
return axios.delete(
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
@@ -12,7 +12,6 @@ export const buildCreatePayload = ({
bccEmails = '',
toEmails = '',
templateParams,
isVoiceMessage = false,
}) => {
let payload;
if (files && files.length !== 0) {
@@ -34,9 +33,6 @@ export const buildCreatePayload = ({
if (contentAttributes) {
payload.append('content_attributes', JSON.stringify(contentAttributes));
}
if (isVoiceMessage) {
payload.append('is_voice_message', true);
}
} else {
payload = {
content: message,
@@ -68,7 +64,6 @@ class MessageApi extends ApiClient {
bccEmails = '',
toEmails = '',
templateParams,
isVoiceMessage = false,
}) {
return axios({
method: 'post',
@@ -83,7 +78,6 @@ class MessageApi extends ApiClient {
bccEmails,
toEmails,
templateParams,
isVoiceMessage,
}),
});
}
-8
View File
@@ -52,14 +52,6 @@ class Inboxes extends CacheEnabledApiClient {
resetSecret(inboxId) {
return axios.post(`${this.url}/${inboxId}/reset_secret`);
}
enableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/enable_whatsapp_calling`);
}
disableWhatsappCalling(inboxId) {
return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`);
}
}
export default new Inboxes();
@@ -1,18 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class OnboardingAPI extends ApiClient {
constructor() {
super('onboarding', { accountScoped: true });
}
update(data) {
return axios.patch(this.url, data);
}
getHelpCenterGeneration() {
return axios.get(`${this.url}/help_center_generation`);
}
}
export default new OnboardingAPI();
@@ -153,33 +153,4 @@ describe('#PortalAPI', () => {
);
});
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('#bulkUpdateCategory', () => {
articlesAPI.bulkUpdateCategory({
portalSlug: 'room-rental',
articleIds: [1, 2, 3],
categoryId: 7,
});
expect(axiosMock.patch).toHaveBeenCalledWith(
'/api/v1/portals/room-rental/articles/bulk_actions/update_category',
{ ids: [1, 2, 3], category_id: 7 }
);
});
});
});
@@ -41,8 +41,7 @@ describe('#ContactsAPI', () => {
it('#getConversations', () => {
contactAPI.getConversations(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/1/conversations',
{ params: {} }
'/api/v1/contacts/1/conversations'
);
});
@@ -11,7 +11,6 @@ describe('#ConversationApi', () => {
expect(conversationsAPI).toHaveProperty('delete');
expect(conversationsAPI).toHaveProperty('getLabels');
expect(conversationsAPI).toHaveProperty('updateLabels');
expect(conversationsAPI).toHaveProperty('getUnreadCounts');
});
describe('API calls', () => {
@@ -48,12 +47,5 @@ describe('#ConversationApi', () => {
}
);
});
it('#getUnreadCounts', () => {
conversationsAPI.getUnreadCounts();
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/unread_counts'
);
});
});
});
@@ -83,29 +83,5 @@ describe('#ConversationAPI', () => {
template_params: undefined,
});
});
it('appends is_voice_message when isVoiceMessage is true', () => {
const formPayload = buildCreatePayload({
message: 'voice message',
echoId: 42,
isPrivate: false,
files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
isVoiceMessage: true,
});
expect(formPayload).toBeInstanceOf(FormData);
expect(formPayload.get('is_voice_message')).toEqual('true');
});
it('does not append is_voice_message when isVoiceMessage is false', () => {
const formPayload = buildCreatePayload({
message: 'regular audio',
echoId: 43,
isPrivate: false,
files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
isVoiceMessage: false,
});
expect(formPayload).toBeInstanceOf(FormData);
expect(formPayload.get('is_voice_message')).toBeNull();
});
});
});
@@ -1,11 +1,7 @@
// scss-lint:disable PropertySortOrder
@layer base {
// NEXT COLORS START
// `.light` mirrors `:root` so any subtree can be forced back to the light
// palette even when an ancestor (e.g. `body.dark`) sets the dark tokens —
// used to render the call window in the opposite theme of the product.
:root,
.light {
:root {
// slate
--slate-1: 252 252 253;
--slate-2: 249 249 251;
@@ -49,7 +49,6 @@ const emit = defineEmits(['edit', 'delete']);
const { t } = useI18n();
const STATUS_COMPLETED = 'completed';
const STATUS_PROCESSING = 'processing';
const { formatMessage } = useMessageFormatter();
@@ -69,15 +68,9 @@ const campaignStatus = computed(() => {
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
}
if (props.status === STATUS_COMPLETED) {
return t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED');
}
if (props.status === STATUS_PROCESSING) {
return t('CAMPAIGN.SMS.CARD.STATUS.PROCESSING');
}
return t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
return props.status === STATUS_COMPLETED
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
});
const inboxName = computed(() => props.inbox?.name || '');
@@ -46,8 +46,9 @@ const formattedLastActivityAt = computed(() => {
:src="avatarSource"
class="shrink-0"
:name="name"
:size="42"
:size="48"
hide-offline-status
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
@@ -56,14 +56,9 @@ const closeMobileSidebar = () => {
<div
v-if="slots.sidebar"
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
<slot name="sidebar" />
</div>
<div
@@ -110,14 +105,9 @@ const closeMobileSidebar = () => {
<div
v-if="isSidebarOpen"
id="details-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
<slot name="sidebar" />
</div>
</Transition>
</div>
@@ -2,7 +2,6 @@
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CompanySortMenu from './components/CompanySortMenu.vue';
import CompanyMoreActions from './components/CompanyMoreActions.vue';
defineProps({
showSearch: { type: Boolean, default: true },
@@ -12,7 +11,7 @@ defineProps({
activeOrdering: { type: String, default: '' },
});
const emit = defineEmits(['search', 'update:sort', 'create']);
const emit = defineEmits(['search', 'update:sort']);
</script>
<template>
@@ -49,7 +48,6 @@ const emit = defineEmits(['search', 'update:sort', 'create']);
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<CompanyMoreActions @create="emit('create')" />
</div>
</div>
</div>
@@ -1,45 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const emit = defineEmits(['create']);
const { t } = useI18n();
const showActionsDropdown = ref(false);
const menuItems = [
{
label: t('COMPANIES.ACTIONS.CREATE'),
action: 'create',
value: 'create',
icon: 'i-lucide-plus',
},
];
const handleAction = ({ action }) => {
if (action === 'create') emit('create');
showActionsDropdown.value = false;
};
</script>
<template>
<div v-on-clickaway="() => (showActionsDropdown = false)" class="relative">
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
variant="ghost"
size="sm"
:class="showActionsDropdown ? 'bg-n-alpha-2' : ''"
@click="showActionsDropdown = !showActionsDropdown"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
@action="handleAction($event)"
/>
</div>
</template>
@@ -12,12 +12,7 @@ defineProps({
showPaginationFooter: { type: Boolean, default: true },
});
const emit = defineEmits([
'update:currentPage',
'update:sort',
'search',
'create',
]);
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
const updateCurrentPage = page => {
emit('update:currentPage', page);
@@ -36,7 +31,6 @@ const updateCurrentPage = page => {
:active-ordering="activeOrdering"
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
@create="emit('create')"
/>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full mx-auto max-w-5xl py-4">
@@ -1,110 +0,0 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
defineProps({
isLoading: { type: Boolean, default: false },
});
const emit = defineEmits(['create']);
const { t } = useI18n();
const dialogRef = ref(null);
const form = reactive({ name: '', domain: '', description: '' });
const isFormInvalid = computed(() => !form.name.trim());
const resetForm = () => {
form.name = '';
form.domain = '';
form.description = '';
};
const handleConfirm = () => {
if (isFormInvalid.value) return;
emit('create', {
name: form.name.trim(),
domain: form.domain.trim() || null,
description: form.description.trim() || null,
});
};
const closeDialog = () => {
dialogRef.value?.close();
};
const onSuccess = () => {
resetForm();
closeDialog();
};
defineExpose({ dialogRef, onSuccess });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
overflow-y-auto
@confirm="handleConfirm"
@close="resetForm"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('COMPANIES.CREATE.TITLE') }}
</span>
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<Input
v-model="form.name"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
:disabled="isLoading"
custom-input-class="h-8 !pt-1 !pb-1 [&:not(.error,.focus)]:!outline-transparent"
autofocus
/>
<Input
v-model="form.domain"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
:disabled="isLoading"
custom-input-class="h-8 !pt-1 !pb-1 [&:not(.error,.focus)]:!outline-transparent"
/>
</div>
</div>
<TextArea
v-model="form.description"
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
:disabled="isLoading"
:max-length="280"
class="w-full"
show-character-count
auto-height
/>
</div>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="link"
type="reset"
class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog"
/>
<Button
:label="t('COMPANIES.CREATE.ACTIONS.SAVE')"
color="blue"
type="submit"
:disabled="isFormInvalid || isLoading"
:is-loading="isLoading"
/>
</div>
</template>
</Dialog>
</template>
@@ -169,7 +169,7 @@ const handleContactSelect = contactId => {
</script>
<template>
<div class="flex flex-col gap-6 px-6 pb-8">
<div class="flex flex-col gap-6 px-6 pb-6 pt-1">
<div v-if="!selectedContact" class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-base text-n-slate-12">
@@ -288,7 +288,7 @@ const handleContactSelect = contactId => {
<div
v-else-if="!hasContacts"
class="py-8 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
</div>
@@ -346,7 +346,7 @@ const handleContactSelect = contactId => {
:current-page="currentPage"
:total-items="totalContacts"
:items-per-page="15"
class="!px-0 before:hidden bg-transparent"
class="px-0 before:hidden"
@update:current-page="emit('update:currentPage', $event)"
/>
</div>
@@ -1,67 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
defineProps({
conversations: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const contactsById = useMapGetter('contacts/getContactById');
const stateInbox = useMapGetter('inboxes/getInboxById');
const accountLabels = useMapGetter('labels/getLabels');
const accountLabelsValue = computed(() => accountLabels.value);
const conversationContact = conversation => {
const sender = conversation.meta?.sender || {};
const contact = contactsById.value(sender.id);
return contact.id ? contact : sender;
};
const conversationInbox = conversation =>
stateInbox.value(conversation.inboxId) || {
name: '',
channelType: conversation.meta?.channel,
};
</script>
<template>
<div
v-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="conversations.length > 0"
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in conversations"
:key="conversation.id"
:conversation="conversation"
:contact="conversationContact(conversation)"
:state-inbox="conversationInbox(conversation)"
:account-labels="accountLabelsValue"
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
/>
</div>
<p
v-else
class="py-8 px-4 mx-6 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.HISTORY.EMPTY') }}
</p>
</template>
@@ -1,114 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
notes: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const currentUser = useMapGetter('getCurrentUser');
const hasNotes = computed(() => props.notes.length > 0);
const contactName = contact =>
contact?.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
const getWrittenBy = note => {
const isCurrentUser = note?.user?.id === currentUser.value.id;
return isCurrentUser
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: note?.user?.name || 'Bot';
};
const openContact = contactId => {
router.push({
name: 'contacts_edit',
params: {
accountId: route.params.accountId,
contactId,
},
});
};
</script>
<template>
<div v-if="hasNotes" class="flex flex-col px-6">
<div class="flex flex-col divide-y divide-n-strong">
<div
v-for="note in notes"
:key="note.id"
class="flex flex-col gap-2 py-4 group/note"
>
<div class="flex items-center gap-1.5 min-w-0">
<Avatar
:name="contactName(note.contact)"
:src="note.contact?.thumbnail"
:size="16"
rounded-full
hide-offline-status
/>
<div
class="flex items-center justify-between min-w-0 gap-1 w-full text-sm text-n-slate-11"
>
<button
type="button"
class="min-w-0 font-medium truncate text-start text-n-slate-12 hover:text-n-blue-11 p-0"
@click="openContact(note.contact.id)"
>
{{ contactName(note.contact) }}
</button>
<div class="min-w-0 truncate">
<span
class="inline-flex items-center gap-1 text-sm text-n-slate-10"
>
<span class="font-medium text-n-slate-11">
{{ getWrittenBy(note) }}
</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="font-medium text-n-slate-11">
{{ dynamicTime(note.createdAt) }}
</span>
</span>
</div>
</div>
</div>
<p
v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
/>
</div>
</div>
</div>
<div
v-else-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<p
v-else
class="py-8 mx-6 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.NOTES.EMPTY') }}
</p>
</template>
@@ -141,6 +141,7 @@ const handleUpdateCompany = async () => {
:src="avatarSource"
:size="72"
:allow-upload="!isAvatarBusy"
rounded-full
hide-offline-status
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
@@ -124,9 +124,10 @@ const handleAvatarHover = isHovered => {
<Avatar
:name="name"
:src="thumbnail"
:size="42"
:size="48"
:status="availabilityStatus"
hide-offline-status
rounded-full
>
<template v-if="selectable" #overlay="{ size }">
<label
@@ -32,7 +32,7 @@ const emit = defineEmits([
</script>
<template>
<header class="sticky top-0 z-20 px-6">
<header class="sticky top-0 z-10 px-6">
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
>
@@ -1,48 +1,34 @@
<script setup>
import { computed, ref } from 'vue';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
const emit = defineEmits(['add', 'import', 'export']);
const { t } = useI18n();
const { checkPermissions } = usePolicy();
const contactMenuItems = computed(() => [
const contactMenuItems = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
action: 'add',
value: 'add',
icon: 'i-lucide-plus',
},
...(checkPermissions(['administrator', 'contact_manage'])
? [
{
label: t(
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'
),
action: 'export',
value: 'export',
icon: 'i-lucide-upload',
},
]
: []),
...(checkPermissions(['administrator', 'contact_manage'])
? [
{
label: t(
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'
),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
]
: []),
]);
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
action: 'export',
value: 'export',
icon: 'i-lucide-upload',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
];
const showActionsDropdown = ref(false);
const handleContactAction = ({ action }) => {
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4 z-50"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -3,20 +3,10 @@ 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 {
isVoiceCallEnabled,
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import {
VOICE_CALL_DIRECTION,
VOICE_CALL_OUTBOUND_INIT_STATUS,
} from 'dashboard/components-next/message/constants';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -24,9 +14,6 @@ import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
phone: { type: String, default: '' },
contactId: { type: [String, Number], required: true },
// When set, the WhatsApp call continues in this conversation (matching the
// header button) instead of looking up the contact's most recent one.
conversationId: { type: [String, Number], default: null },
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
size: { type: String, default: 'sm' },
@@ -43,7 +30,6 @@ const { t } = useI18n();
const dialogRef = ref(null);
const callsStore = useCallsStore();
const inboxesList = useMapGetter('inboxes/getInboxes');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
@@ -52,22 +38,13 @@ const voiceInboxes = computed(() =>
);
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const isInitiatingCall = computed(() => {
return contactsUiFlags.value?.isInitiatingCall || false;
});
// Mirror the conversation-header button: block a new call whenever any provider
// call is already active or ringing, otherwise starting a WhatsApp call here
// would leave a still-live Twilio (or other) session with no visible control.
const isCallButtonDisabled = computed(
() =>
callsStore.hasActiveCall ||
callsStore.hasIncomingCall ||
isInitiatingCall.value
);
const navigateToConversation = conversationId => {
const accountId = route.params.accountId;
if (conversationId && accountId) {
@@ -81,96 +58,23 @@ const navigateToConversation = conversationId => {
}
};
const whatsappCallSession = useWhatsappCallSession();
// Find the most recent open conversation for this contact in the picked inbox.
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
// contacts whose latest WhatsApp conversation falls outside the 20 most recent
// across all inboxes would be treated as having no conversation.
const findWhatsappConversationId = async inboxId => {
const { data } = await ContactAPI.getConversations(props.contactId, {
inboxId,
});
const conversations = data?.payload || [];
const match = [...conversations].sort(
(a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
)[0];
return match?.id || null;
};
const startWhatsappCall = async (inboxId, conversationIdHint) => {
// WhatsApp /initiate is conversation-scoped, so we must hand it a
// conversation. Use the caller's hint when given (in-conversation flow);
// otherwise pick the most recent one in the inbox.
const conversationId =
conversationIdHint || (await findWhatsappConversationId(inboxId));
if (!conversationId) {
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
return;
}
const response =
await whatsappCallSession.initiateOutboundCall(conversationId);
// The composable returns { status: 'locked' } when an init is already in
// flight or a call is already active; treat that as a soft no-op rather than
// claiming success.
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
if (!response?.id) {
// Permission template path returns no call id. Mirror the header button and
// surface whether the request was just sent or is already pending instead of
// claiming the call started. The permission message lands in the
// conversation, so still navigate there.
const messageKey =
response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
? 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_PENDING'
: 'CONTACT_PANEL.WHATSAPP_CALL_PERMISSION_REQUESTED';
useAlert(t(messageKey));
navigateToConversation(conversationId);
return;
}
// Stay non-active until the connect cable event arrives — flipping to active
// here would start the duration timer before the contact picks up.
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId,
inboxId,
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
provider: VOICE_CALL_PROVIDERS.WHATSAPP,
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(conversationId);
};
const startCall = async (inboxId, conversationIdHint = null) => {
if (isCallButtonDisabled.value) return;
const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
try {
await startWhatsappCall(inboxId, conversationIdHint);
} catch (error) {
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
}
return;
}
const startCall = async inboxId => {
if (isInitiatingCall.value) return;
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: props.contactId,
inboxId,
conversationId: conversationIdHint,
});
const { call_sid: callSid, conversation_id: conversationId } = response;
// Add call to store immediately so widget shows
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
conversationId,
inboxId,
callDirection: VOICE_CALL_DIRECTION.OUTBOUND,
callDirection: 'outbound',
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
@@ -182,22 +86,6 @@ const startCall = async (inboxId, conversationIdHint = null) => {
};
const onClick = async () => {
// In conversation context, only stay in this conversation if its inbox is
// itself voice-capable (works the same for Twilio and WhatsApp). For
// non-voice channels (email, web, …) fall back to the picker so the call
// goes out via a voice inbox.
if (props.conversationId) {
const conversation = store.getters.getConversationById(
props.conversationId
);
const conversationInbox = (inboxesList.value || []).find(
i => i.id === conversation?.inbox_id
);
if (conversationInbox && isVoiceCallEnabled(conversationInbox)) {
await startCall(conversationInbox.id, props.conversationId);
return;
}
}
if (voiceInboxes.value.length > 1) {
dialogRef.value?.open();
return;
@@ -218,7 +106,7 @@ const onPickInbox = async inbox => {
v-if="shouldRender"
v-tooltip.top-end="tooltipLabel || null"
v-bind="attrs"
:disabled="isCallButtonDisabled"
:disabled="isInitiatingCall"
:is-loading="isInitiatingCall"
:label="label"
:icon="icon"
@@ -142,26 +142,28 @@ watch(
<style lang="scss" scoped>
.editor-wrapper {
:deep(.ProseMirror-menubar-wrapper) {
.ProseMirror.ProseMirror-woot-style {
p {
@apply first:mt-0 !important;
}
::v-deep {
.ProseMirror-menubar-wrapper {
.ProseMirror.ProseMirror-woot-style {
p {
@apply first:mt-0 !important;
}
.empty-node {
@apply m-0 !important;
.empty-node {
@apply m-0 !important;
&::before {
@apply text-n-slate-11 dark:text-n-slate-11;
&::before {
@apply text-n-slate-11 dark:text-n-slate-11;
}
}
}
}
.ProseMirror-menubar {
width: fit-content !important;
position: relative !important;
top: unset !important;
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
.ProseMirror-menubar {
width: fit-content !important;
position: relative !important;
top: unset !important;
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
}
}
}
}
@@ -53,9 +53,6 @@ const localeMenuLabels = computed(() => ({
'publish-locale': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
),
'customize-content': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
}));
@@ -131,7 +128,7 @@ const handleAction = ({ action, value }) => {
<DropdownMenu
v-if="showDropdownMenu"
:menu-items="localeMenuItems"
class="ltr:right-0 rtl:left-0 mt-1 top-full z-60 min-w-[150px]"
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
@action="handleAction"
/>
</div>
@@ -145,43 +145,45 @@ const handleCreateArticle = event => {
</template>
<style lang="scss" scoped>
:deep(.ProseMirror .empty-node::before) {
@apply text-n-slate-10 text-base;
}
:deep(.ProseMirror-menubar-wrapper) {
.ProseMirror-woot-style {
@apply min-h-[15rem] max-h-full;
::v-deep {
.ProseMirror .empty-node::before {
@apply text-n-slate-10 text-base;
}
}
:deep(.ProseMirror-menubar) {
display: none; // Hide by default
}
:deep(.editor-root .has-selection) {
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
.ProseMirror-menubar-wrapper {
.ProseMirror-woot-style {
@apply min-h-[15rem] max-h-full;
}
}
.ProseMirror-menubar {
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
width: fit-content !important;
position: absolute !important;
display: none; // Hide by default
}
.ProseMirror-menuitem {
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
}
.editor-root .has-selection {
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
.ProseMirror-menubar {
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
width: fit-content !important;
position: absolute !important;
.ProseMirror-menuitem {
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
}
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
}
}
}
}
@@ -84,20 +84,28 @@ const findCategoryFromSlug = slug => {
return categories.value?.find(category => category.slug === slug);
};
const assignCategoryFromSlug = slug => {
const categoryFromSlug = findCategoryFromSlug(slug);
if (categoryFromSlug) {
selectedCategoryId.value = categoryFromSlug.id;
return categoryFromSlug;
}
return null;
};
const selectedCategory = computed(() => {
if (isNewArticle.value) {
if (selectedCategoryId.value) {
return (
categories.value?.find(c => c.id === selectedCategoryId.value) || null
);
}
if (categorySlugFromRoute.value) {
const categoryFromSlug = findCategoryFromSlug(
const categoryFromSlug = assignCategoryFromSlug(
categorySlugFromRoute.value
);
if (categoryFromSlug) return categoryFromSlug;
}
return categories.value?.[0] || null;
return selectedCategoryId.value
? categories.value.find(
category => category.id === selectedCategoryId.value
)
: categories.value[0] || null;
}
return categories.value.find(
category => category.id === props.article?.category?.id
@@ -2,7 +2,6 @@
import { ref, computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
@@ -19,7 +18,6 @@ import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/A
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import BulkTranslateDialog from './BulkTranslateDialog.vue';
const props = defineProps({
@@ -64,7 +62,6 @@ const isFeatureEnabledonAccount = useMapGetter(
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
const isCategoryMenuOpen = ref(false);
const { isEnterprise } = useConfig();
@@ -171,9 +168,7 @@ const handlePageChange = page => emit('pageChange', page);
const navigateToNewArticlePage = () => {
const { categorySlug, locale } = route.params;
router.push({
name: props.isCategoryArticles
? 'portals_categories_articles_new'
: 'portals_articles_new',
name: 'portals_articles_new',
params: { categorySlug, locale },
});
};
@@ -224,34 +219,6 @@ const bulkUpdateStatus = async status => {
}
};
const categoryMenuItems = computed(() =>
props.categories.map(category => ({
label: category.name,
value: category.id,
action: 'move',
emoji: category.icon,
}))
);
const handleBulkUpdateCategory = async ({ value }) => {
isCategoryMenuOpen.value = false;
try {
await articlesAPI.bulkUpdateCategory({
portalSlug: route.params.portalSlug,
articleIds: [...selectedArticleIds.value],
categoryId: value,
});
onBulkActionSuccess(
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_SUCCESS')
);
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.CATEGORY_ERROR')
);
}
};
const confirmBulkDelete = () => {
deleteConfirmDialogRef.value?.open();
};
@@ -307,7 +274,6 @@ watch(
:categories="categories"
:allowed-locales="allowedLocales"
:has-selected-category="isCategoryArticles"
@new-article="navigateToNewArticlePage"
/>
</div>
</template>
@@ -371,30 +337,6 @@ watch(
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="bulkUpdateStatus('archived')"
/>
<div v-if="categoryMenuItems.length" class="relative group">
<OnClickOutside @trigger="isCategoryMenuOpen = false">
<Button
sm
faded
slate
icon="i-lucide-folder-input"
:label="
t(
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.MOVE_TO_CATEGORY'
)
"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
/>
<DropdownMenu
v-if="isCategoryMenuOpen"
:menu-items="categoryMenuItems"
show-search
class="right-0 w-48 mt-2 top-full max-h-60"
@action="handleBulkUpdateCategory"
/>
</OnClickOutside>
</div>
<Button
v-if="isTranslationAvailable"
sm
@@ -202,7 +202,7 @@ defineExpose({ state, isSubmitDisabled });
size="sm"
type="button"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[2.38rem] !w-[2.375rem] absolute top-[2rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
@@ -25,7 +25,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['localeChange', 'newArticle']);
const emit = defineEmits(['localeChange']);
const route = useRoute();
const router = useRouter();
@@ -179,7 +179,7 @@ const handleBreadcrumbClick = () => {
/>
</OnClickOutside>
</div>
<div v-else class="relative flex items-center gap-2">
<div v-else class="relative">
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.EDIT_CATEGORY')"
@@ -196,12 +196,6 @@ const handleBreadcrumbClick = () => {
@close="isEditCategoryDialogOpen = false"
/>
</OnClickOutside>
<Button
:label="t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.NEW_ARTICLE')"
icon="i-lucide-plus"
size="sm"
@click="emit('newArticle')"
/>
</div>
</div>
</template>
@@ -1,98 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
portal: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const activeLocale = ref('');
const name = ref('');
const pageTitle = ref('');
const headerText = ref('');
const localeTranslations = computed(
() => props.portal?.config?.locale_translations || {}
);
const openForLocale = localeCode => {
const existing = localeTranslations.value[localeCode] || {};
activeLocale.value = localeCode;
name.value = existing.name || '';
pageTitle.value = existing.page_title || '';
headerText.value = existing.header_text || '';
dialogRef.value?.open();
};
const onConfirm = async () => {
const translations = { ...localeTranslations.value };
const fields = {};
if (name.value.trim()) fields.name = name.value.trim();
if (pageTitle.value.trim()) fields.page_title = pageTitle.value.trim();
if (headerText.value.trim()) fields.header_text = headerText.value.trim();
if (Object.keys(fields).length) {
translations[activeLocale.value] = fields;
} else {
delete translations[activeLocale.value];
}
try {
await store.dispatch('portals/update', {
portalSlug: props.portal?.slug,
config: { locale_translations: translations },
});
dialogRef.value?.close();
useAlert(t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.ERROR_MESSAGE')
);
}
};
defineExpose({ openForLocale });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.TITLE')"
:description="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.DESCRIPTION')"
@confirm="onConfirm"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.NAME.LABEL') }}
</label>
<Input v-model="name" :placeholder="portal.name" />
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.PAGE_TITLE.LABEL') }}
</label>
<Input v-model="pageTitle" :placeholder="portal.page_title" />
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.HEADER_TEXT.LABEL') }}
</label>
<Input v-model="headerText" :placeholder="portal.header_text" />
</div>
</div>
</Dialog>
</template>
@@ -1,7 +1,5 @@
<script setup>
import { ref } from 'vue';
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -25,8 +23,6 @@ const { t } = useI18n();
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const contentDialogRef = ref(null);
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
};
@@ -152,8 +148,6 @@ const handleAction = ({ action }, localeCode) => {
moveLocaleToDraft({ localeCode: localeCode });
} else if (action === 'publish-locale') {
publishLocale({ localeCode: localeCode });
} else if (action === 'customize-content') {
contentDialogRef.value.openForLocale(localeCode);
} else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode });
}
@@ -173,6 +167,5 @@ const handleAction = ({ action }, localeCode) => {
:category-count="locale.categoriesCount || 0"
@action="handleAction($event, locale.code)"
/>
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
</ul>
</template>
@@ -1,290 +0,0 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import classicLayoutPreview from './classic-layout-preview.svg?raw';
import documentationLayoutPreview from './documentation-layout-preview.svg?raw';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
const props = defineProps({
activePortal: { type: Object, required: true },
isFetching: { type: Boolean, default: false },
});
const emit = defineEmits(['updatePortalConfiguration']);
const { t } = useI18n();
const PORTAL_LAYOUTS = {
CLASSIC: 'classic',
DOCUMENTATION: 'documentation',
};
// `prefix` is the link the help center auto-fills; the DB only stores the handle.
const SOCIAL_PLATFORMS = [
{
key: 'facebook',
label: 'Facebook',
icon: 'i-ri-facebook-circle-fill',
prefix: 'facebook.com/',
},
{ key: 'x', label: 'X', icon: 'i-ri-twitter-x-fill', prefix: 'x.com/' },
{
key: 'instagram',
label: 'Instagram',
icon: 'i-ri-instagram-fill',
prefix: 'instagram.com/',
},
{
key: 'linkedin',
label: 'LinkedIn',
icon: 'i-ri-linkedin-box-fill',
prefix: 'linkedin.com/',
},
{
key: 'youtube',
label: 'YouTube',
icon: 'i-ri-youtube-fill',
prefix: 'youtube.com/',
},
{
key: 'tiktok',
label: 'TikTok',
icon: 'i-ri-tiktok-fill',
prefix: 'tiktok.com/',
},
{
key: 'github',
label: 'GitHub',
icon: 'i-ri-github-fill',
prefix: 'github.com/',
},
{
key: 'whatsapp',
label: 'WhatsApp',
icon: 'i-ri-whatsapp-fill',
prefix: 'wa.me/',
},
];
const portalConfig = computed(() => props.activePortal?.config || {});
const state = reactive({
layout: PORTAL_LAYOUTS.CLASSIC,
socialProfiles: {},
});
const visiblePlatforms = ref([]);
const showAddMenu = ref(false);
let originalSnapshot = '';
const platformByKey = key => SOCIAL_PLATFORMS.find(p => p.key === key);
const trimmedHandle = key => (state.socialProfiles[key] || '').trim();
const buildSocialProfiles = () =>
visiblePlatforms.value.reduce((acc, key) => {
const handle = trimmedHandle(key);
if (handle) acc[key] = handle;
return acc;
}, {});
const snapshot = () =>
JSON.stringify({ layout: state.layout, social: buildSocialProfiles() });
const resetFromPortal = () => {
const savedProfiles = portalConfig.value.social_profiles || {};
state.layout = portalConfig.value.layout || PORTAL_LAYOUTS.CLASSIC;
state.socialProfiles = SOCIAL_PLATFORMS.reduce((acc, { key }) => {
acc[key] = savedProfiles[key] || '';
return acc;
}, {});
visiblePlatforms.value = SOCIAL_PLATFORMS.map(p => p.key).filter(key =>
(savedProfiles[key] || '').trim()
);
originalSnapshot = snapshot();
};
watch(() => props.activePortal, resetFromPortal, {
immediate: true,
deep: true,
});
const hasChanges = computed(() => snapshot() !== originalSnapshot);
const visiblePlatformDetails = computed(() =>
visiblePlatforms.value.map(platformByKey)
);
const addablePlatforms = computed(() =>
SOCIAL_PLATFORMS.filter(p => !visiblePlatforms.value.includes(p.key)).map(
p => ({ label: p.label, value: p.key, action: p.key, icon: p.icon })
)
);
const addPlatform = ({ value }) => {
if (!visiblePlatforms.value.includes(value)) {
visiblePlatforms.value.push(value);
}
showAddMenu.value = false;
};
const removePlatform = key => {
visiblePlatforms.value = visiblePlatforms.value.filter(k => k !== key);
state.socialProfiles[key] = '';
};
const handleSave = () => {
emit('updatePortalConfiguration', {
id: props.activePortal.id,
slug: props.activePortal.slug,
config: {
layout: state.layout,
social_profiles: buildSocialProfiles(),
},
});
};
</script>
<template>
<div class="flex flex-col w-full gap-6">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.HEADER') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.DESCRIPTION') }}
</span>
</div>
<section class="flex flex-col gap-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-n-slate-11">
<RadioCard
:id="PORTAL_LAYOUTS.CLASSIC"
:is-active="state.layout === PORTAL_LAYOUTS.CLASSIC"
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.CLASSIC.TITLE')
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.CLASSIC.DESCRIPTION'
)
"
@select="value => (state.layout = value)"
>
<div
class="w-full mt-2 rounded-md overflow-hidden border border-solid border-n-weak bg-n-slate-2 dark:bg-n-slate-1"
>
<span v-dompurify-html="classicLayoutPreview" />
</div>
</RadioCard>
<RadioCard
:id="PORTAL_LAYOUTS.DOCUMENTATION"
beta
:is-active="state.layout === PORTAL_LAYOUTS.DOCUMENTATION"
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.SIDEBAR.TITLE')
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.SIDEBAR.DESCRIPTION'
)
"
@select="value => (state.layout = value)"
>
<div
class="w-full mt-2 rounded-md overflow-hidden border border-solid border-n-weak bg-n-slate-2 dark:bg-n-slate-1"
>
<span v-dompurify-html="documentationLayoutPreview" />
</div>
</RadioCard>
</div>
</section>
<section
v-if="state.layout === PORTAL_LAYOUTS.DOCUMENTATION"
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1">
<h6 class="text-sm font-medium text-n-slate-12">
{{
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.HEADER')
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.DESCRIPTION'
)
}}
</span>
</div>
<div
v-for="platform in visiblePlatformDetails"
:key="platform.key"
class="flex items-center h-10 gap-1.5 px-3 rounded-lg outline outline-1 outline-n-weak focus-within:outline-n-brand"
>
<Icon :icon="platform.icon" class="size-4 shrink-0 text-n-slate-11" />
<span class="text-sm shrink-0 text-n-slate-10">{{
platform.prefix
}}</span>
<input
v-model="state.socialProfiles[platform.key]"
type="text"
class="flex-1 min-w-0 text-sm bg-transparent outline-none reset-base text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.PLACEHOLDER'
)
"
/>
<Button
icon="i-lucide-x"
color="slate"
variant="ghost"
size="xs"
:aria-label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.REMOVE')
"
@click="removePlatform(platform.key)"
/>
</div>
<div
v-if="addablePlatforms.length"
v-on-clickaway="() => (showAddMenu = false)"
class="relative"
>
<Button
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.ADD')
"
icon="i-lucide-plus"
color="slate"
variant="faded"
size="sm"
@click="showAddMenu = !showAddMenu"
/>
<DropdownMenu
v-if="showAddMenu"
:menu-items="addablePlatforms"
class="mt-1 w-52 top-full ltr:left-0 rtl:right-0"
@action="addPlatform"
/>
</div>
</section>
<div class="flex justify-end">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SAVE')"
:disabled="!hasChanges || isFetching"
@click="handleSave"
/>
</div>
</div>
</template>
@@ -7,7 +7,6 @@ import { useMapGetter } from 'dashboard/composables/store.js';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -103,12 +102,6 @@ const handleDeletePortal = () => {
@send-cname-instructions="handleSendCnameInstructions"
/>
<div class="w-full h-px bg-n-weak" />
<PortalLayoutContentSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
<div class="w-full h-px bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120" class="w-full h-auto block" aria-hidden="true">
<rect x="0" y="0" width="200" height="14" fill="currentColor" opacity="0.1"/>
<rect x="14" y="5" width="20" height="4" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="14" y="22" width="64" height="5" rx="1" fill="currentColor" opacity="0.32"/>
<rect x="14" y="32" width="128" height="9" rx="2" fill="currentColor" opacity="0.18"/>
<rect x="14" y="70" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="104" y="70" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="14" y="93" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="104" y="93" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 818 B

@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120" class="w-full h-auto block" aria-hidden="true">
<rect x="0" y="0" width="200" height="14" fill="currentColor" opacity="0.1"/>
<rect x="6" y="5" width="20" height="4" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="0" y="14" width="50" height="106" fill="currentColor" opacity="0.07"/>
<rect x="6" y="22" width="38" height="4" rx="1" fill="currentColor" opacity="0.25"/>
<rect x="6" y="32" width="30" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="40" width="35" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="48" width="28" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="56" width="32" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="60" y="25" width="80" height="6" rx="1" fill="currentColor" opacity="0.35"/>
<rect x="60" y="38" width="120" height="8" rx="2" fill="currentColor" opacity="0.18"/>
<rect x="60" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="101" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="142" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="60" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="101" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="142" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -19,7 +19,6 @@ const DEFAULT_ROUTE = 'portals_articles_index';
const CATEGORY_ROUTE = 'portals_categories_index';
const CATEGORY_SUB_ROUTES = [
'portals_categories_articles_index',
'portals_categories_articles_new',
'portals_categories_articles_edit',
];
@@ -182,10 +182,7 @@ const MIN_SEARCH_LENGTH = 2;
export const createContactSearcher = () => {
let controller = null;
return async (
query,
{ skipMinLength = false, reachableOnly = true } = {}
) => {
return async (query, { skipMinLength = false } = {}) => {
const trimmed = typeof query === 'string' ? query.trim() : '';
controller?.abort();
@@ -202,8 +199,6 @@ export const createContactSearcher = () => {
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
if (!reachableOnly) return camelCasedPayload || [];
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
@@ -32,11 +32,7 @@ const modelValue = defineModel({ type: Boolean, default: false });
<span class="text-heading-3 text-n-slate-12">
{{ header }}
</span>
<template v-if="hideToggle">
<slot name="hiddenToggle">
<div class="size-2" />
</slot>
</template>
<div v-if="hideToggle" class="size-2" />
<ToggleSwitch v-else v-model="modelValue" />
</div>
<span v-if="description" class="text-body-main text-n-slate-11">

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