Compare commits

...
Author SHA1 Message Date
Vishnu NarayananandVishnu Narayanan 2228a6c0ed chore: fix rubocop 2026-06-02 13:11:58 +05:30
Vishnu NarayananandVishnu Narayanan a7ea02a029 chore: track session limit events 2026-06-02 13:11:58 +05:30
Vishnu NarayananandVishnu Narayanan 2534875cf3 chore: add specs for session limit feature 2026-06-02 13:11:58 +05:30
Vishnu NarayananandVishnu Narayanan fcf660b7d9 fix: evict untracked legacy tokens before tracked sessions
During the rollout window, tracked sessions are newer than any
pre-existing legacy tokens. The previous logic walked user_sessions
first and would kill the only tracked session while leaving older
legacy tokens alive. Drop untracked tokens first when there is a
gap between user_sessions count and user.tokens size.
2026-06-02 13:11:58 +05:30
Vishnu NarayananandVishnu Narayanan 1af8334ea8 fix: silent-evict legacy tokens when picker would show partial sessions 2026-06-02 13:11:58 +05:30
Vishnu NarayananandVishnu Narayanan 13ad505bbb feat: limit concurrent sessions per user with active session management (CW-7169) 2026-06-02 13:11:58 +05:30
3eed8905cc fix(facebook): render shared links as fallback attachments (#14554)
Fixes Facebook fallback and shared-post attachments so they render as
clickable links in conversations.

Closes:
- https://github.com/chatwoot/chatwoot/issues/4767
- https://github.com/chatwoot/chatwoot/issues/5327

Why:
Facebook can send shared links as `fallback` attachments with a
top-level `url`, and shared posts as `share` attachments with the URL
under `payload.url`. The current flow either misses the nested URL or
treats `share` as downloadable media, so these messages do not render
correctly.

What changed:
- Store Facebook fallback URLs from either `attachment.url` or
`attachment.payload.url`.
- Treat Facebook `share` attachments as fallback link attachments
instead of downloading them as files.
- Render fallback attachments in the next message bubble UI as clickable
links.

How to test:
1. Connect a Facebook inbox.
2. Send a shared link to the page.
3. Send/share a Facebook post to the page.
4. Open the conversation in Chatwoot.
5. Confirm both messages appear as clickable link bubbles.

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-01 17:08:00 +05:30
Sojan JoseandGitHub f27bbef73b feat: show processing status for one-off campaigns (#14592)
## Summary

One-off SMS and WhatsApp campaigns now show a `Processing` state while
the audience send is in progress. The campaign moves to `Completed`
after processing finishes, and already-processing campaigns are skipped
by the scheduler to avoid duplicate sends.

## Closes

- [CW-6037: feat: Introduce an in-progress status for
campaigns](https://linear.app/chatwoot/issue/CW-6037/feat-introduce-an-in-progress-status-for-campaigns)

## Screenshot

SMS campaign card showing the new `Processing` status.

<img width="3840" height="2160" alt="framed-campaign-processing-status"
src="https://github.com/user-attachments/assets/de7913b5-65fb-4121-9034-24a568eb0382"
/>

## What changed

- Added `processing` as a campaign status.
- Mark one-off campaigns as `processing` under a row lock before the
send service runs.
- Complete SMS, Twilio SMS, and WhatsApp one-off campaigns after
audience processing finishes.
- Keep campaigns in `processing` if an unexpected service error escapes,
so the scheduler does not automatically resend the audience.
- Added the `Processing` label for SMS and WhatsApp campaign cards.

## Known operational behavior

If a worker is interrupted or an unexpected service error escapes after
a campaign is marked `processing`, the campaign can remain in
`processing`. This is intentional for now to avoid automatic
full-audience resends. Installation admins can decide whether to mark
the campaign completed or restart it manually from the Rails console
after checking what was sent.

## How to test

- Create a one-off SMS or WhatsApp campaign scheduled for now.
- Run the scheduled job or trigger the campaign job.
- Confirm the campaign card shows `Processing` while the audience is
being processed. For small audiences, refresh during processing or use a
larger audience so the state is observable.
- Confirm the campaign moves to `Completed` after audience processing
finishes.
- Confirm an already-processing campaign is not enqueued again by the
scheduled job.
2026-06-01 16:47:17 +05:30
ed3059c1aa docs: Document API inbox webhook URL (#14593)
Documents the existing API inbox webhook_url request field and expands
inbox create/update request docs with channel-specific schemas and
examples.

Closes #14591

## Why
API inboxes already accept channel.webhook_url through Channel::Api
editable attributes, but the OpenAPI request schemas did not document
the field. The previous mixed channel object also made it hard to see
which fields belong to each supported channel type.

## What changed
- Added named channel payload schemas for supported inbox create channel
types.
- Added channel-specific create request samples in this order: Website
inbox, API channel, Email channel, LINE channel, Telegram channel,
WhatsApp channel, SMS channel.
- Added common inbox fields such as greeting_enabled, greeting_message,
enable_auto_assignment, working_hours_enabled, and timezone to request
samples.
- Kept website-only flags such as allow_messages_after_resolved and
enable_email_collect only in website inbox samples.
- Annotated inbox request field descriptions with channel availability
where Swagger UI cannot dynamically filter fields.
- Added channel-specific update settings schemas and request samples for
editable channel attributes.
- Documented channel.webhook_url for API inbox create/update payloads.
- Rebuilt generated Swagger JSON and tag-group specs.

## Screenshots

**Website inbox request sample**

Shows the request sample selector set to Website inbox, including
website-only fields such as enable_email_collect and
allow_messages_after_resolved.

<img width="3840" height="2160" alt="Website inbox request sample"
src="https://github.com/user-attachments/assets/ad130f04-b336-4cc3-aba1-e934b646d447"
/>

**API channel request sample**

Shows the selector switched to API channel, with API-specific fields
such as webhook_url, hmac_mandatory, and additional_attributes.

<img width="3840" height="2160" alt="API channel request sample"
src="https://github.com/user-attachments/assets/09484816-1d47-490f-9ab4-195835ed5cd3"
/>

**Expanded channel schema**

Shows the request-body schema with channel expanded, including the type
selector and channel-specific fields under it.

<img width="3840" height="2160" alt="Expanded channel schema"
src="https://github.com/user-attachments/assets/f6c4878e-ac34-40fe-8be3-5913f27cbdd8"
/>

## Validation
- bundle exec rake swagger:build
- bundle exec rspec spec/swagger/openapi_spec.rb
- Rendered the local Swagger page and verified the request sample
dropdown order and API channel payload.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-01 12:37:07 +04:00
Sojan JoseandGitHub 9d591b8f46 feat: enable companies for Business cloud plans (#14598)
Enable the Companies feature automatically for Chatwoot Cloud accounts
on the Business plan and higher.

## Closes
None.

## How to test
Upgrade or reconcile a Cloud account on Business or Enterprise and
verify Companies is available. Reconcile a Startups account and verify
Companies remains disabled.

## What changed
- Added companies to the Business plan feature set, which Enterprise
inherits through the existing hierarchy.
- Added billing specs that assert Companies is disabled for Startups and
enabled for Business and Enterprise.
2026-06-01 14:02:03 +05:30
Sojan JoseandGitHub 1afcd36dee fix(contacts): align contact export permissions (#14601)
Allows contact managers to export and import contacts from the Contacts
page while keeping plain agents blocked. The contacts action menu now
mirrors backend permissions for both export and import.

## Closes

- https://linear.app/chatwoot/issue/CW-4438/contact-export-is-broken

## What changed

- Allows Enterprise custom roles with `contact_manage` to pass
`ContactPolicy#export?` and `ContactPolicy#import?`.
- Shows Export and Import to admins and contact managers only.
- Adds Enterprise policy coverage for contact export and import.

## Screenshots

Admin: Export and Import are available.

<img width="3840" height="2160" alt="Admin contact actions with Export
and Import visible"
src="https://github.com/user-attachments/assets/2b2cdaf2-ca8f-470d-be34-31cba68b9dce"
/>

Contact manager: Export and Import are available.

<img width="3840" height="2160" alt="Contact manager contact actions
with Export and Import visible"
src="https://github.com/user-attachments/assets/48fc038b-2e78-4d0c-ba17-a5965641bd88"
/>

Regular agent: Export and Import are hidden.

<img width="3840" height="2160" alt="Regular agent contact actions with
Export and Import hidden"
src="https://github.com/user-attachments/assets/a63b5731-743a-4223-8dab-ce58383067fe"
/>

## How to test

- Sign in as an administrator and open Contacts; the action menu shows
Export and Import.
- Sign in as a custom-role user with `contact_manage`; the action menu
shows Export and Import.
- Sign in as a plain agent; Export and Import are not available and both
APIs remain unauthorized.
2026-06-01 13:58:57 +05:30
Shivam MishraandGitHub a3ffb48a47 refactor(onboarding): use separate onboarding controller (#14507)
Depends on: https://github.com/chatwoot/chatwoot/pull/14370

This PR creates a new onboarding controller, this allows more control
that the default account update API. Allowing us to spin tasks and
update details required specifically during the onboarding flow
2026-06-01 13:34:11 +05:30
81 changed files with 7897 additions and 558 deletions
@@ -92,10 +92,18 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'],
external_url: attachment['url']
external_url: attachment['url'] || attachment.dig('payload', '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,
@@ -0,0 +1,36 @@
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
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
end
@@ -58,7 +58,6 @@ 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
@@ -0,0 +1,31 @@
class Api::V1::Profile::SessionsController < Api::BaseController
before_action :set_session, only: [:destroy]
def index
@sessions = current_user.user_sessions.order(last_activity_at: :desc)
@current_client_id = request.headers['client']
end
def destroy
if @session.current?(request.headers['client'])
render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity
return
end
revoke_token!(@session.client_id)
@session.destroy!
head :ok
end
private
def set_session
@session = current_user.user_sessions.find(params[:id])
end
def revoke_token!(client_id)
tokens = current_user.tokens
tokens.delete(client_id)
current_user.update!(tokens: tokens)
end
end
@@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base
include RequestExceptionHandler
include Pundit::Authorization
include SwitchLocale
include TrackSessionActivity
skip_before_action :verify_authenticity_token
@@ -0,0 +1,22 @@
module TrackSessionActivity
extend ActiveSupport::Concern
included do
after_action :update_session_activity
end
private
def update_session_activity
return unless current_user
return if request.headers['client'].blank?
UserSessionTrackingService.new(
user: current_user,
request: request,
client_id: request.headers['client']
).update_activity!
rescue StandardError => e
Rails.logger.warn "Session activity update failed: #{e.message}"
end
end
@@ -1,4 +1,6 @@
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
MAX_SESSIONS = 5
# Prevent session parameter from being passed
# Unpermitted parameter: session
wrap_parameters format: []
@@ -14,12 +16,14 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = find_user_for_authentication
return handle_mfa_required(user) if user&.mfa_enabled?
return if user && enforce_session_limit_for_password_login(user)
# Only proceed with standard authentication if no MFA is required
super
end
def render_create_success
track_user_session
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
end
@@ -53,6 +57,8 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def handle_sso_authentication
return if enforce_session_limit_for_password_login(@resource)
authenticate_resource_with_sso_token
yield @resource if block_given?
render_create_success
@@ -103,6 +109,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def sign_in_mfa_user(user)
evict_oldest_session(user) if sessions_limit_reached?(user)
@resource = user
@token = @resource.create_token
@resource.save!
@@ -114,6 +121,115 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
def render_mfa_error(message_key, status = :bad_request)
render json: { error: I18n.t(message_key) }, status: status
end
def sessions_limit_reached?(user)
(user.tokens || {}).keys.size >= MAX_SESSIONS
end
# Returns true when the response has been rendered (e.g., 409 picker shown). Browsers see
# the picker; non-browser clients (mobile, API) auto-evict so they don't get stuck on a UI
# they can't render. If the user is revoking, perform the revoke and let login proceed.
def enforce_session_limit_for_password_login(user)
if revoking_sessions?
revoke_sessions_for_login(user)
return false
end
return false unless sessions_limit_reached?(user)
# Picker only when every token has a tracked session; partial tracking would
# show a misleading count, so fall through to silent eviction instead.
if browser_request? && user.user_sessions.count >= user.tokens.size
handle_sessions_limit_for_login(user)
true
else
evict_oldest_session(user)
false
end
end
def browser_request?
request.user_agent.to_s.include?('Mozilla')
end
def revoking_sessions?
params[:revoke_session_id].present? || params[:revoke_all_sessions].present?
end
def revoke_sessions_for_login(user)
if params[:revoke_all_sessions].present?
user.tokens = {}
user.save!
user.user_sessions.destroy_all
elsif params[:revoke_session_id].present?
session = user.user_sessions.find_by(id: params[:revoke_session_id])
return unless session
user.tokens.delete(session.client_id)
user.save!
session.destroy!
end
end
def evict_oldest_session(user)
# Untracked tokens are pre-rollout leftovers and almost always older than any
# tracked session; drop those first so freshly tracked logins aren't evicted.
return evict_oldest_token(user) if user.user_sessions.count < user.tokens.size
oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first
return evict_oldest_token(user) unless oldest_session
user.tokens.delete(oldest_session.client_id)
user.save!
oldest_session.destroy!
end
# Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped).
def evict_oldest_token(user)
return if user.tokens.blank?
oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first
return unless oldest_client_id
user.tokens.delete(oldest_client_id)
user.save!
end
def handle_sessions_limit_for_login(user)
sessions = user.user_sessions.order(last_activity_at: :desc).map do |session|
{
id: session.id,
browser_name: session.browser_name,
browser_version: session.browser_version,
device_name: session.device_name,
platform_name: session.platform_name,
platform_version: session.platform_version,
ip_address: session.ip_address,
city: session.city,
country: session.country,
last_activity_at: session.last_activity_at,
created_at: session.created_at
}
end
render json: {
sessions_limit_reached: true,
sessions: sessions
}, status: :conflict
end
def track_user_session
client_id = @token&.try(:client) || response.headers['client']
return unless client_id.present? && @resource.present?
UserSessionTrackingService.new(
user: @resource,
request: request,
client_id: client_id
).create_or_update!
rescue StandardError => e
Rails.logger.warn "Session tracking failed: #{e.message}"
end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
+6
View File
@@ -106,4 +106,10 @@ export default {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
getSessions() {
return axios.get('/api/v1/profile/sessions');
},
revokeSession(id) {
return axios.delete(`/api/v1/profile/sessions/${id}`);
},
};
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from './ApiClient';
class OnboardingAPI extends ApiClient {
constructor() {
super('onboarding', { accountScoped: true });
}
update(data) {
return axios.patch(this.url, data);
}
}
export default new OnboardingAPI();
@@ -49,6 +49,7 @@ const emit = defineEmits(['edit', 'delete']);
const { t } = useI18n();
const STATUS_COMPLETED = 'completed';
const STATUS_PROCESSING = 'processing';
const { formatMessage } = useMessageFormatter();
@@ -68,9 +69,15 @@ const campaignStatus = computed(() => {
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
}
return props.status === STATUS_COMPLETED
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
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');
});
const inboxName = computed(() => props.inbox?.name || '');
@@ -1,34 +1,48 @@
<script setup>
import { ref } from 'vue';
import { computed, 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 = [
const contactMenuItems = computed(() => [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
action: 'add',
value: 'add',
icon: 'i-lucide-plus',
},
{
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',
},
];
...(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',
},
]
: []),
]);
const showActionsDropdown = ref(false);
const handleContactAction = ({ action }) => {
@@ -31,6 +31,7 @@ import FileBubble from './bubbles/File.vue';
import AudioBubble from './bubbles/Audio.vue';
import VideoBubble from './bubbles/Video.vue';
import EmbedBubble from './bubbles/Embed.vue';
import FallbackBubble from './bubbles/Fallback.vue';
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
import EmailBubble from './bubbles/Email/Index.vue';
import UnsupportedBubble from './bubbles/Unsupported.vue';
@@ -328,6 +329,8 @@ const componentToRender = computed(() => {
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
const fileType = props.attachments[0].fileType;
if (fileType === ATTACHMENT_TYPES.FALLBACK) return FallbackBubble;
if (!props.content) {
if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble;
if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble;
@@ -0,0 +1,37 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import FormattedContent from './Text/FormattedContent.vue';
import { useMessageContext } from '../provider.js';
const { attachments, content } = useMessageContext();
const attachment = computed(() => attachments.value?.[0] || {});
const url = computed(
() => attachment.value.dataUrl || attachment.value.data_url
);
const title = computed(
() =>
attachment.value.fallbackTitle ||
attachment.value.fallback_title ||
url.value
);
</script>
<template>
<BaseBubble class="p-3" data-bubble-name="fallback">
<FormattedContent v-if="content" :content="content" class="mb-2" />
<a
v-if="url"
:href="url"
target="_blank"
rel="noopener noreferrer"
class="block max-w-[320px] truncate text-sm text-n-brand underline"
>
{{ title }}
</a>
<span v-else class="text-sm text-n-slate-11">
{{ title }}
</span>
</BaseBubble>
</template>
@@ -0,0 +1,133 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { format, parseISO } from 'date-fns';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
sessions: {
type: Array,
required: true,
},
});
const emit = defineEmits(['revoke', 'revokeAll', 'cancel']);
const { t } = useI18n();
const revokingId = ref(null);
const revokingAll = ref(false);
const sortedSessions = computed(() =>
[...props.sessions].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
)
);
const formatDate = dateStr => {
if (!dateStr) return '';
return format(parseISO(dateStr), 'MMMM d, yyyy');
};
const formatTime = dateStr => {
if (!dateStr) return '';
return format(parseISO(dateStr), 'hh:mma');
};
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
const sessionLabel = session => {
const parts = [];
if (!isUnknown(session.browser_name)) parts.push(session.browser_name);
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
return parts.join(' on ') || t('SESSION_LIMIT.UNKNOWN_DEVICE');
};
const handleRevoke = session => {
revokingId.value = session.id;
emit('revoke', session.id);
};
const handleRevokeAll = () => {
revokingAll.value = true;
emit('revokeAll');
};
</script>
<template>
<div class="w-full max-w-lg mx-auto">
<div
class="bg-white shadow dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
>
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<h2 class="text-2xl font-semibold text-n-slate-12">
{{ $t('SESSION_LIMIT.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11 mt-2">
{{ $t('SESSION_LIMIT.DESCRIPTION') }}
</p>
</div>
<NextButton
type="button"
faded
sm
class="flex-shrink-0 whitespace-nowrap"
:label="$t('SESSION_LIMIT.END_ALL')"
:is-loading="revokingAll"
:disabled="revokingId !== null"
@click="handleRevokeAll"
/>
</div>
<!-- Session List -->
<div class="flex flex-col gap-3 max-h-80 overflow-y-auto">
<div
v-for="session in sortedSessions"
:key="session.id"
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background px-4 py-3"
>
<div class="flex items-center gap-3 min-w-0">
<Icon
icon="i-lucide-monitor"
class="size-4 text-n-slate-10 flex-shrink-0"
/>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-sm font-medium text-n-slate-12">
{{ sessionLabel(session) }}
</span>
<span class="text-xs text-n-slate-10">
{{
`${$t('SESSION_LIMIT.STARTED')} ${formatDate(session.created_at)}, ${formatTime(session.created_at)}`
}}
</span>
</div>
</div>
<button
type="button"
class="text-sm font-medium text-n-slate-11 hover:text-n-slate-12 flex-shrink-0 disabled:opacity-50"
:disabled="revokingId !== null || revokingAll"
@click="handleRevoke(session)"
>
{{ $t('SESSION_LIMIT.END') }}
</button>
</div>
</div>
<!-- Cancel -->
<div class="text-center pt-4">
<NextButton
sm
slate
link
type="button"
class="w-full hover:!no-underline"
:label="$t('SESSION_LIMIT.CANCEL')"
@click="() => emit('cancel')"
/>
</div>
</div>
</div>
</template>
@@ -52,6 +52,10 @@ export function useAccount() {
});
};
const finishOnboarding = async data => {
await store.dispatch('accounts/finishOnboarding', data);
};
return {
accountId,
route,
@@ -61,5 +65,6 @@ export function useAccount() {
isCloudFeatureEnabled,
isOnChatwootCloud,
updateAccount,
finishOnboarding,
};
}
@@ -154,6 +154,11 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
SHARE_CLICKED: 'Year in Review: Share clicked',
});
export const SESSION_EVENTS = Object.freeze({
LIMIT_HIT: 'Session limit reached at login',
REVOKED_FROM_PROFILE: 'Revoked an active session',
});
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
@@ -88,6 +88,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
@@ -146,6 +147,7 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
@@ -40,6 +40,7 @@ import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import onboarding from './onboarding.json';
import sessionLimit from './sessionLimit.json';
import yearInReview from './yearInReview.json';
export default {
@@ -85,5 +86,6 @@ export default {
...contentTemplates,
...mfa,
...onboarding,
...sessionLimit,
...yearInReview,
};
@@ -0,0 +1,12 @@
{
"SESSION_LIMIT": {
"TITLE": "Active session limit reached",
"DESCRIPTION": "You have reached your limit of active sessions. Please end a session before logging in.",
"END": "End",
"END_ALL": "End all sessions",
"LOG_IN": "Log in",
"CANCEL": "Back to login",
"UNKNOWN_DEVICE": "Unknown device",
"STARTED": "Started"
}
}
@@ -86,6 +86,16 @@
"NOTE": "Manage additional security features for your account.",
"MFA_BUTTON": "Manage Two-Factor Authentication"
},
"SESSIONS_SECTION": {
"TITLE": "Active Sessions",
"NOTE": "These are the devices currently logged in to your account.",
"CURRENT": "Current session",
"REVOKE": "Revoke",
"REVOKE_SUCCESS": "Session revoked successfully",
"REVOKE_ERROR": "Unable to revoke session. Please try again.",
"FETCH_ERROR": "Unable to fetch sessions. Please try again.",
"LAST_ACTIVE": "Last active"
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
@@ -28,7 +28,7 @@ import {
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { accountId, currentAccount, updateAccount } = useAccount();
const { accountId, currentAccount, finishOnboarding } = useAccount();
const { enabledLanguages } = useConfig();
const currentUser = useMapGetter('getCurrentUser');
@@ -195,6 +195,12 @@ const handleWebsiteEnter = () => {
websiteInput.value?.blur();
};
const normalizeWebsiteUrl = raw => {
const trimmed = (raw || '').trim();
if (!trimmed) return '';
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
};
const handleSubmit = async () => {
// Block submit while enrichment is still running so users can't bypass
// the form with empty values — the controller would otherwise clear
@@ -211,9 +217,27 @@ const handleSubmit = async () => {
return;
}
// Detect which enrichable fields the user actually edited *before*
// normalizing — otherwise an untouched auto-filled domain
// (acme.com -> https://acme.com) compares unequal against the raw snapshot
// and gets falsely reported as changed, skewing onboarding telemetry.
const init = initialValues.value;
const enrichableFields = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
const fieldsChanged = Object.entries(enrichableFields)
.filter(([key, val]) => val !== init[key])
.map(([key]) => key);
// Persist with a scheme so downstream consumers (Firecrawl, portal
// homepage_link) get a fully-qualified URL regardless of what the user typed.
website.value = normalizeWebsiteUrl(website.value);
isSubmitting.value = true;
try {
await updateAccount({
await finishOnboarding({
name: accountName.value,
locale: locale.value,
website: website.value,
@@ -224,20 +248,11 @@ const handleSubmit = async () => {
user_role: userRole.value,
});
const init = initialValues.value;
const enrichableFields = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
has_enriched_data: Boolean(
currentAccount.value?.custom_attributes?.brand_info
),
fields_changed: Object.entries(enrichableFields)
.filter(([key, val]) => val !== init[key])
.map(([key]) => key),
fields_changed: fieldsChanged,
user_role: userRole.value,
company_size: companySize.value,
industry: industry.value,
@@ -0,0 +1,134 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatDistanceToNow, parseISO } from 'date-fns';
import { useAlert } from 'dashboard/composables';
import authAPI from 'dashboard/api/auth';
import AnalyticsHelper from 'dashboard/helper/AnalyticsHelper';
import { SESSION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const sessions = ref([]);
const loading = ref(false);
const relativeTime = dateStr => {
if (!dateStr) return '';
return formatDistanceToNow(parseISO(dateStr), { addSuffix: true });
};
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
const deviceIcon = session => {
const name = (session.device_name || '').toLowerCase();
if (
name.includes('iphone') ||
name.includes('android') ||
name.includes('mobile')
) {
return 'i-lucide-smartphone';
}
if (name.includes('ipad') || name.includes('tablet')) {
return 'i-lucide-tablet';
}
return 'i-lucide-monitor';
};
const sessionLabel = session => {
const parts = [];
if (!isUnknown(session.browser_name)) {
parts.push(
session.browser_version
? `${session.browser_name} ${session.browser_version}`
: session.browser_name
);
}
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
return parts.join(' on ') || t('SESSION_LIMIT.UNKNOWN_DEVICE');
};
const locationLabel = session => {
const parts = [];
if (session.city) parts.push(session.city);
if (session.country) parts.push(session.country);
return parts.join(', ');
};
const fetchSessions = async () => {
loading.value = true;
try {
const { data } = await authAPI.getSessions();
sessions.value = data;
} catch {
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.FETCH_ERROR'));
} finally {
loading.value = false;
}
};
const revokeSession = async session => {
try {
await authAPI.revokeSession(session.id);
sessions.value = sessions.value.filter(s => s.id !== session.id);
AnalyticsHelper.track(SESSION_EVENTS.REVOKED_FROM_PROFILE);
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_SUCCESS'));
} catch {
useAlert(t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE_ERROR'));
}
};
onMounted(fetchSessions);
</script>
<template>
<div class="flex flex-col gap-3">
<div
v-for="session in sessions"
:key="session.id"
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background p-4"
>
<div class="flex items-start gap-3">
<Icon
:icon="deviceIcon(session)"
class="size-5 mt-0.5 text-n-slate-10 flex-shrink-0"
/>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-heading-3 text-n-slate-12">
{{ sessionLabel(session) }}
</span>
<span
v-if="session.current"
class="rounded-full bg-n-teal-3 px-2 py-0.5 text-caption text-n-teal-11"
>
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.CURRENT') }}
</span>
</div>
<span
v-if="locationLabel(session)"
class="text-body-b3 text-n-slate-11"
>
{{ locationLabel(session) }}
</span>
<span
v-if="session.last_activity_at"
class="text-body-b3 text-n-slate-10"
>
{{ $t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.LAST_ACTIVE') }}
{{ relativeTime(session.last_activity_at) }}
</span>
</div>
</div>
<Button
v-if="!session.current"
type="button"
faded
xs
:label="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.REVOKE')"
color-scheme="alert"
@click="revokeSession(session)"
/>
</div>
</div>
</template>
@@ -20,6 +20,7 @@ import SectionLayout from '../account/components/SectionLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AccessToken from './AccessToken.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
import ActiveSessions from './ActiveSessions.vue';
import Policy from 'dashboard/components/policy.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import {
@@ -42,6 +43,7 @@ export default {
AudioNotifications,
AccessToken,
MfaSettingsCard,
ActiveSessions,
BaseSettingsHeader,
},
setup() {
@@ -307,6 +309,13 @@ export default {
>
<MfaSettingsCard />
</SectionLayout>
<SectionLayout
with-border
:title="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.SESSIONS_SECTION.NOTE')"
>
<ActiveSessions />
</SectionLayout>
<Policy :permissions="audioNotificationPermissions">
<SectionLayout
with-border
@@ -1,6 +1,7 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import AccountAPI from '../../api/account';
import OnboardingAPI from '../../api/onboarding';
import { differenceInDays } from 'date-fns';
import EnterpriseAccountAPI from '../../api/enterprise/account';
import { throwErrorMessage } from '../utils/api';
@@ -83,6 +84,15 @@ export const actions = {
throw new Error(error);
}
},
finishOnboarding: async ({ commit }, payload) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
try {
const response = await OnboardingAPI.update(payload);
commit(types.default.EDIT_ACCOUNT, response.data);
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
}
},
delete: async ({ commit }, { id }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
try {
+9
View File
@@ -43,6 +43,15 @@ export const login = async ({
mfaToken: error.response.data.mfa_token,
};
}
if (
error.response?.status === 409 &&
error.response?.data?.sessions_limit_reached
) {
return {
sessionsLimitReached: true,
sessions: error.response.data.sessions,
};
}
const loginError = new Error(parseAPIErrorResponse(error));
loginError.errorCode = error.response?.data?.error_code;
throw loginError;
+71 -1
View File
@@ -8,6 +8,8 @@ import { useVuelidate } from '@vuelidate/core';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
import SessionStorage from 'shared/helpers/sessionStorage';
import { useBranding } from 'shared/composables/useBranding';
import AnalyticsHelper from 'dashboard/helper/AnalyticsHelper';
import { SESSION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
// components
import SimpleDivider from '../../components/Divider/SimpleDivider.vue';
@@ -17,6 +19,7 @@ import Spinner from 'shared/components/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
import SessionLimitOverlay from 'dashboard/components/auth/SessionLimitOverlay.vue';
const ERROR_MESSAGES = {
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
@@ -36,6 +39,7 @@ export default {
NextButton,
SimpleDivider,
MfaVerification,
SessionLimitOverlay,
Icon,
},
props: {
@@ -68,6 +72,8 @@ export default {
error: '',
mfaRequired: false,
mfaToken: null,
sessionsLimitReached: false,
limitedSessions: [],
};
},
validations() {
@@ -182,6 +188,15 @@ export default {
return;
}
// Check if sessions limit reached
if (result?.sessionsLimitReached) {
this.loginApi.showLoading = false;
this.sessionsLimitReached = true;
this.limitedSessions = result.sessions;
AnalyticsHelper.track(SESSION_EVENTS.LIMIT_HIT);
return;
}
this.handleImpersonation();
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
})
@@ -224,6 +239,51 @@ export default {
this.mfaToken = null;
this.credentials.password = '';
},
retryLoginWithParams(extraParams) {
const credentials = {
email: this.email
? decodeURIComponent(this.email)
: this.credentials.email,
password: this.credentials.password,
sso_auth_token: this.ssoAuthToken,
ssoAccountId: this.ssoAccountId,
ssoConversationId: this.ssoConversationId,
...extraParams,
};
this.sessionsLimitReached = false;
this.limitedSessions = [];
this.loginApi.showLoading = true;
login(credentials)
.then(result => {
if (result?.sessionsLimitReached) {
this.loginApi.showLoading = false;
this.sessionsLimitReached = true;
this.limitedSessions = result.sessions;
AnalyticsHelper.track(SESSION_EVENTS.LIMIT_HIT);
return;
}
this.handleImpersonation();
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
})
.catch(response => {
this.loginApi.hasErrored = true;
this.showAlertMessage(
response?.message || this.$t('LOGIN.API.UNAUTH')
);
});
},
handleSessionRevoke(sessionId) {
this.retryLoginWithParams({ revoke_session_id: sessionId });
},
handleSessionRevokeAll() {
this.retryLoginWithParams({ revoke_all_sessions: true });
},
handleSessionLimitCancel() {
this.sessionsLimitReached = false;
this.limitedSessions = [];
this.credentials.password = '';
},
},
};
</script>
@@ -255,8 +315,18 @@ export default {
</p>
</section>
<!-- Session Limit Section -->
<section v-if="sessionsLimitReached" class="mt-11">
<SessionLimitOverlay
:sessions="limitedSessions"
@revoke="handleSessionRevoke"
@revoke-all="handleSessionRevokeAll"
@cancel="handleSessionLimitCancel"
/>
</section>
<!-- MFA Verification Section -->
<section v-if="mfaRequired" class="mt-11">
<section v-else-if="mfaRequired" class="mt-11">
<MfaVerification
:mfa-token="mfaToken"
@verified="handleMfaVerified"
+17 -3
View File
@@ -47,7 +47,7 @@ class Campaign < ApplicationRecord
enum campaign_type: { ongoing: 0, one_off: 1 }
# TODO : enabled attribute is unneccessary . lets move that to the campaign status with additional statuses like draft, disabled etc.
enum campaign_status: { active: 0, completed: 1 }
enum campaign_status: { active: 0, completed: 1, processing: 2 }
has_many :conversations, dependent: :nullify, autosave: true
@@ -56,13 +56,27 @@ class Campaign < ApplicationRecord
def trigger!
return unless one_off?
return if completed?
return unless feature_enabled?
return unless mark_processing!
execute_campaign
end
private
def feature_enabled?
inbox.inbox_type != 'Whatsapp' || account.feature_enabled?(:whatsapp_campaign)
end
def mark_processing!
# Multiple scheduler jobs can pick the same active campaign; lock before flipping status to avoid duplicate sends.
with_lock do
next if completed? || processing?
processing!
end
end
def execute_campaign
case inbox.inbox_type
when 'Twilio SMS'
@@ -70,7 +84,7 @@ class Campaign < ApplicationRecord
when 'Sms'
Sms::OneoffSmsCampaignService.new(campaign: self).perform
when 'Whatsapp'
Whatsapp::OneoffCampaignService.new(campaign: self).perform if account.feature_enabled?(:whatsapp_campaign)
Whatsapp::OneoffCampaignService.new(campaign: self).perform
end
end
+7
View File
@@ -101,6 +101,7 @@ class User < ApplicationRecord
has_many :messages, as: :sender, dependent: :nullify
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify
has_many :user_sessions, dependent: :destroy
has_many :custom_filters, dependent: :destroy_async
has_many :dashboard_apps, dependent: :nullify
has_many :mentions, dependent: :destroy_async
@@ -118,6 +119,7 @@ class User < ApplicationRecord
before_validation :set_password_and_uid, on: :create
after_destroy :remove_macros
after_save :sync_user_sessions, if: :saved_change_to_tokens?
scope :order_by_full_name, -> { order('lower(name) ASC') }
@@ -214,6 +216,11 @@ class User < ApplicationRecord
private
def sync_user_sessions
active_client_ids = (tokens || {}).keys
user_sessions.where.not(client_id: active_client_ids).destroy_all
end
def remove_macros
macros.personal.destroy_all
end
+46
View File
@@ -0,0 +1,46 @@
# == Schema Information
#
# Table name: user_sessions
#
# id :bigint not null, primary key
# browser_name :string
# browser_version :string
# city :string
# country :string
# country_code :string
# device_name :string
# ip_address :string
# last_activity_at :datetime
# platform_name :string
# platform_version :string
# user_agent :string
# created_at :datetime not null
# updated_at :datetime not null
# client_id :string not null
# user_id :bigint not null
#
# Indexes
#
# index_user_sessions_on_user_id (user_id)
# index_user_sessions_on_user_id_and_client_id (user_id,client_id) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class UserSession < ApplicationRecord
ACTIVITY_THROTTLE = 5.minutes
belongs_to :user
validates :client_id, presence: true, uniqueness: { scope: :user_id }
def current?(active_client_id)
client_id == active_client_id
end
def should_update_activity?
last_activity_at.nil? || last_activity_at < ACTIVITY_THROTTLE.ago
end
end
+2
View File
@@ -51,3 +51,5 @@ class ContactPolicy < ApplicationPolicy
@account_user.administrator?
end
end
ContactPolicy.prepend_mod_with('ContactPolicy')
@@ -5,12 +5,10 @@ class Sms::OneoffSmsCampaignService
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Sms' || !campaign.one_off?
raise 'Completed Campaign' if campaign.completed?
# marks campaign completed so that other jobs won't pick it up
campaign.completed!
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
process_audience(audience_labels)
campaign.completed!
end
private
@@ -5,12 +5,10 @@ class Twilio::OneoffSmsCampaignService
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Twilio SMS' || !campaign.one_off?
raise 'Completed Campaign' if campaign.completed?
# marks campaign completed so that other jobs won't pick it up
campaign.completed!
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
process_audience(audience_labels)
campaign.completed!
end
private
@@ -0,0 +1,42 @@
class UserSessionTrackingService
def initialize(user:, request:, client_id:)
@user = user
@request = request
@client_id = client_id
end
def create_or_update!
session = @user.user_sessions.find_or_initialize_by(client_id: @client_id)
session.assign_attributes(session_attributes)
session.last_activity_at = Time.current
session.save!
session
end
def update_activity!
session = @user.user_sessions.find_by(client_id: @client_id)
return unless session&.should_update_activity?
session.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations
end
private
def session_attributes
browser = Browser.new(@request.user_agent)
location = IpLookupService.new.perform(@request.remote_ip)
{
ip_address: @request.remote_ip,
user_agent: @request.user_agent,
browser_name: browser.name,
browser_version: browser.full_version,
device_name: browser.device.name,
platform_name: browser.platform.name,
platform_version: browser.platform.version,
city: location&.city,
country: location&.country,
country_code: location&.country_code
}
end
end
@@ -3,9 +3,8 @@ class Whatsapp::OneoffCampaignService
def perform
validate_campaign!
# marks campaign completed so that other jobs won't pick it up
campaign.completed!
process_audience(extract_audience_labels)
campaign.completed!
end
private
@@ -0,0 +1,15 @@
json.array! @sessions do |session|
json.id session.id
json.browser_name session.browser_name
json.browser_version session.browser_version
json.device_name session.device_name
json.platform_name session.platform_name
json.platform_version session.platform_version
json.ip_address session.ip_address
json.city session.city
json.country session.country
json.country_code session.country_code
json.last_activity_at session.last_activity_at
json.created_at session.created_at
json.current session.current?(@current_client_id)
end
+1 -1
View File
@@ -15,7 +15,7 @@ DeviseTokenAuth.setup do |config|
# Sets the max number of concurrent devices per user, which is 10 by default.
# After this limit is reached, the oldest tokens will be removed.
config.max_number_of_devices = 25
config.max_number_of_devices = 10
# Sometimes it's necessary to make several requests to the API at the same
# time. In this case, each request in the batch will need to share the same
+4
View File
@@ -47,6 +47,10 @@ en:
saml_not_available: SAML authentication is not available in this installation.
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
profile_settings:
sessions:
cannot_revoke_current: You cannot revoke the current session.
errors:
account:
reporting_timezone:
+2
View File
@@ -54,6 +54,7 @@ Rails.application.routes.draw do
resource :contact_merge, only: [:create]
end
resource :bulk_actions, only: [:create]
resource :onboarding, only: [:update]
resources :agents, only: [:index, :create, :update, :destroy] do
post :bulk_create, on: :collection
end
@@ -432,6 +433,7 @@ Rails.application.routes.draw do
post :verify
post :backup_codes
end
resources :sessions, only: [:index, :destroy]
end
end
@@ -0,0 +1,23 @@
class CreateUserSessions < ActiveRecord::Migration[7.0]
def change
create_table :user_sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :client_id, null: false
t.string :ip_address
t.string :user_agent
t.string :browser_name
t.string :browser_version
t.string :device_name
t.string :platform_name
t.string :platform_version
t.string :city
t.string :country
t.string :country_code
t.datetime :last_activity_at
t.timestamps
end
add_index :user_sessions, [:user_id, :client_id], unique: true
end
end
+21
View File
@@ -1252,6 +1252,26 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
t.index ["name", "account_id"], name: "index_teams_on_name_and_account_id", unique: true
end
create_table "user_sessions", force: :cascade do |t|
t.bigint "user_id", null: false
t.string "client_id", null: false
t.string "ip_address"
t.string "user_agent"
t.string "browser_name"
t.string "browser_version"
t.string "device_name"
t.string "platform_name"
t.string "platform_version"
t.string "city"
t.string "country"
t.string "country_code"
t.datetime "last_activity_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id", "client_id"], name: "index_user_sessions_on_user_id_and_client_id", unique: true
t.index ["user_id"], name: "index_user_sessions_on_user_id"
end
create_table "users", id: :serial, force: :cascade do |t|
t.string "provider", default: "email", null: false
t.string "uid", default: "", null: false
@@ -1324,6 +1344,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "inboxes", "portals"
add_foreign_key "user_sessions", "users"
create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1).
on("accounts").
after(:insert).
@@ -0,0 +1,9 @@
module Enterprise::ContactPolicy
def export?
@account_user.custom_role&.permissions&.include?('contact_manage') || super
end
def import?
@account_user.custom_role&.permissions&.include?('contact_manage') || super
end
end
@@ -19,7 +19,15 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
channel_voice
].freeze
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment custom_tools].freeze
BUSINESS_PLAN_FEATURES = %w[
sla
custom_roles
csat_review_notes
conversation_required_attributes
advanced_assignment
custom_tools
companies
].freeze
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
PREMIUM_PLAN_FEATURES = (STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES).freeze
@@ -140,6 +140,45 @@ describe Messages::Facebook::MessageBuilder do
end
end
[
{
source_id: 'm_fallback_test',
attachment: { type: 'fallback', title: 'Shared link', url: 'https://www.example.com/shared-link' },
title: 'Shared link',
url: 'https://www.example.com/shared-link'
},
{
source_id: 'm_share_test',
attachment: { type: 'share', title: 'Shared Facebook post', payload: { url: 'https://www.facebook.com/example/posts/123' } },
title: 'Shared Facebook post',
url: 'https://www.facebook.com/example/posts/123'
}
].each do |message_data|
it "stores #{message_data[:attachment][:type]} attachments as fallback links" do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{ first_name: 'Jane', last_name: 'Dae', profile_pic: 'https://chatwoot-assets.local/sample.png' }.with_indifferent_access
)
expect(Down).not_to receive(:download)
message_object = {
messaging: {
sender: { id: '3383290475046708' },
recipient: { id: facebook_channel.page_id },
message: { mid: message_data[:source_id], attachments: [message_data[:attachment]] }
}
}.to_json
message = Integrations::Facebook::MessageParser.new(message_object)
described_class.new(message, facebook_channel.inbox).perform
attachment = facebook_channel.inbox.messages.find_by(source_id: message_data[:source_id]).attachments.first
expect(attachment.file_type).to eq('fallback')
expect(attachment.fallback_title).to eq(message_data[:title])
expect(attachment.external_url).to eq(message_data[:url])
end
end
context 'when lock to single conversation' do
subject(:mocked_message_builder) do
described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform
@@ -0,0 +1,114 @@
require 'rails_helper'
RSpec.describe 'Onboarding API', type: :request do
let(:account) { create(:account, domain: 'example.com') }
let(:admin) { create(:user, account: account, role: :administrator) }
describe 'PATCH /api/v1/accounts/{account.id}/onboarding' do
context 'when unauthenticated' do
it 'returns unauthorized' do
patch "/api/v1/accounts/#{account.id}/onboarding", params: { website: 'acme.com' }, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as an agent (non-admin)' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized and does not change the account' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { name: 'Hijacked', website: 'attacker.com' },
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
expect(account.reload.name).not_to eq('Hijacked')
end
it 'does not create a help center portal' do
account.update!(custom_attributes: { 'onboarding_step' => 'account_details' })
expect do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'attacker.com' },
headers: agent.create_new_auth_token, as: :json
end.not_to change(account.portals, :count)
end
end
context 'when finalizing account_details' do
before { account.update!(custom_attributes: { 'onboarding_step' => 'account_details' }) }
it 'saves name and locale' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { name: 'Acme Inc', locale: 'fr' },
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(account.reload.name).to eq('Acme Inc')
expect(account.locale).to eq('fr')
end
it 'merges custom_attributes' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com', industry: 'tech', company_size: '10-50' },
headers: admin.create_new_auth_token, as: :json
attrs = account.reload.custom_attributes
expect(attrs['website']).to eq('acme.com')
expect(attrs['industry']).to eq('tech')
expect(attrs['company_size']).to eq('10-50')
end
it 'clears onboarding_step' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
end
it 'invokes HelpCenterCreationService when website is present', skip: 'help center generation wiring disabled until UI is ready' do
service = instance_double(Onboarding::HelpCenterCreationService, perform: nil)
allow(Onboarding::HelpCenterCreationService).to receive(:new).and_return(service)
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
expect(Onboarding::HelpCenterCreationService).to have_received(:new) do |arg_account, arg_user|
expect(arg_account.id).to eq(account.id)
expect(arg_user.id).to eq(admin.id)
end
expect(service).to have_received(:perform)
end
it 'does not create a help center portal when website is blank' do
expect do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { name: 'Acme Inc' },
headers: admin.create_new_auth_token, as: :json
end.not_to change(account.portals, :count)
end
end
context 'when onboarding_step is not account_details' do
before { account.update!(custom_attributes: { 'onboarding_step' => 'invite_team' }) }
it 'does not clear onboarding_step' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
end
it 'does not create a help center portal' do
expect do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
end.not_to change(account.portals, :count)
end
end
end
end
@@ -302,16 +302,6 @@ RSpec.describe 'Accounts API', type: :request do
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
end
it 'clears onboarding step when current value is account_details' do
account.update(custom_attributes: { onboarding_step: 'account_details' })
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
end
it 'will not update onboarding step if onboarding step is not present in account custom attributes' do
patch "/api/v1/accounts/#{account.id}",
params: params,
@@ -163,4 +163,156 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do
expect(response).to redirect_to('/frontend/app/login?error=access-denied')
end
end
describe 'session limit enforcement' do
let(:user) { create(:user, password: 'Test@123456') }
let(:browser_ua) { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15' }
let(:mobile_ua) { 'okhttp/4.9.3' }
def seed_token(client_id, expiry_offset_days: 30, with_session: true)
user.tokens = user.tokens.merge(
client_id => { 'token' => 'x', 'expiry' => (Time.current + expiry_offset_days.days).to_i }
)
user.save!
user.user_sessions.create!(client_id: client_id, last_activity_at: Time.current) if with_session
end
def login_params
{ email: user.email, password: 'Test@123456' }
end
context 'when under the limit' do
it 'allows login without intervention' do
request.env['HTTP_USER_AGENT'] = browser_ua
3.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
post :create, params: login_params
expect(response).to have_http_status(:success)
end
end
context 'when at the limit from a browser with full tracking' do
before do
request.env['HTTP_USER_AGENT'] = browser_ua
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
end
it 'returns 409 with the session list (picker)' do
post :create, params: login_params
expect(response).to have_http_status(:conflict)
body = response.parsed_body
expect(body['sessions_limit_reached']).to be true
expect(body['sessions'].size).to eq(5)
end
it 'does not create a new session row' do
expect { post :create, params: login_params }.not_to change(user.user_sessions, :count)
end
end
context 'when at the limit from a non-browser client' do
before do
request.env['HTTP_USER_AGENT'] = mobile_ua
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30 + i, with_session: false) }
end
it 'silently evicts the oldest token and lets login proceed' do
post :create, params: login_params
expect(response).to have_http_status(:success)
expect(user.reload.tokens.keys).not_to include('c0')
end
end
context 'when at the limit but tracking is partial (legacy tokens present)' do
before do
request.env['HTTP_USER_AGENT'] = browser_ua
# one tracked, four legacy (no user_session rows)
seed_token('tracked', expiry_offset_days: 60, with_session: true)
4.times { |i| seed_token("legacy#{i}", expiry_offset_days: 10 + i, with_session: false) }
end
it 'silent-evicts instead of showing a partial picker' do
post :create, params: login_params
expect(response).to have_http_status(:success)
end
it 'drops an untracked token first, keeping the tracked session alive' do
post :create, params: login_params
tokens = user.reload.tokens.keys
expect(tokens).to include('tracked')
# legacy0 expires soonest -> evict_oldest_token picks it
expect(tokens).not_to include('legacy0')
end
end
context 'when at the limit with full tracking (no legacy gap)' do
before do
request.env['HTTP_USER_AGENT'] = mobile_ua
# Five tracked sessions, varying activity timestamps
5.times do |i|
seed_token("tracked#{i}", expiry_offset_days: 30)
user.user_sessions.find_by(client_id: "tracked#{i}").update!(last_activity_at: (5 - i).days.ago)
end
end
it 'evicts the oldest tracked session by last_activity_at' do
post :create, params: login_params
expect(response).to have_http_status(:success)
# tracked0 had the oldest last_activity_at (5 days ago)
expect(user.reload.tokens.keys).not_to include('tracked0')
expect(user.user_sessions.exists?(client_id: 'tracked0')).to be false
end
end
context 'with revoke_session_id during login' do
before do
request.env['HTTP_USER_AGENT'] = browser_ua
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
end
it 'revokes the chosen session and proceeds with login' do
target = user.user_sessions.find_by(client_id: 'c2')
post :create, params: login_params.merge(revoke_session_id: target.id)
expect(response).to have_http_status(:success)
expect(user.reload.tokens.keys).not_to include('c2')
expect(user.user_sessions.exists?(id: target.id)).to be false
end
end
context 'with revoke_all_sessions during login' do
before do
request.env['HTTP_USER_AGENT'] = browser_ua
5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) }
end
it 'wipes all sessions and tokens, then proceeds with login' do
post :create, params: login_params.merge(revoke_all_sessions: true)
expect(response).to have_http_status(:success)
expect(user.reload.tokens.keys).not_to include('c0', 'c1', 'c2', 'c3', 'c4')
# the new login adds one fresh token
expect(user.tokens.keys.size).to eq(1)
end
end
context 'with a successful login' do
before { request.env['HTTP_USER_AGENT'] = browser_ua }
it 'creates a UserSession row for the new client_id' do
expect { post :create, params: login_params }.to change(user.user_sessions, :count).by(1)
session = user.user_sessions.last
expect(session.browser_name).to eq('Safari')
expect(session.platform_name).to eq('macOS')
end
end
end
end
@@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise::ContactPolicy', type: :policy do
subject(:contact_policy) { ContactPolicy }
let(:account) { create(:account) }
let(:contact) { create(:contact, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: ['contact_manage']) }
let(:agent) { create(:user) }
let(:account_user) { create(:account_user, user: agent, account: account, role: :agent, custom_role: custom_role) }
let(:agent_context) { { user: agent, account: account, account_user: account_user } }
permissions :export? do
context 'when agent has contact_manage permission' do
it { expect(contact_policy).to permit(agent_context, contact) }
end
end
permissions :import? do
context 'when agent has contact_manage permission' do
it { expect(contact_policy).to permit(agent_context, contact) }
end
end
end
@@ -40,5 +40,13 @@ RSpec.describe TriggerScheduledItemsJob do
expect(Campaigns::TriggerOneoffCampaignJob).to receive(:perform_later).with(campaign).once
described_class.perform_now
end
it 'does not trigger campaigns that are already processing' do
create(:campaign, inbox: twilio_inbox, account: account, campaign_status: :processing)
expect(Campaigns::TriggerOneoffCampaignJob).not_to receive(:perform_later)
described_class.perform_now
end
end
end
+48
View File
@@ -83,6 +83,38 @@ RSpec.describe Campaign do
campaign.save!
campaign.trigger!
end
it 'marks the campaign as processing before triggering the service' do
campaign.save!
sms_service = double
expect(Twilio::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service)
expect(sms_service).to receive(:perform) do
expect(campaign.reload.processing?).to be true
end
campaign.trigger!
end
it 'does not trigger a processing campaign again' do
campaign.save!
campaign.processing!
expect(Twilio::OneoffSmsCampaignService).not_to receive(:new)
campaign.trigger!
end
it 'keeps the campaign processing when triggering fails' do
campaign.save!
sms_service = double
expect(Twilio::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service)
expect(sms_service).to receive(:perform).and_raise(StandardError, 'provider error')
expect { campaign.trigger! }.to raise_error(StandardError, 'provider error')
expect(campaign.reload.processing?).to be true
end
end
context 'when SMS campaign' do
@@ -107,6 +139,22 @@ RSpec.describe Campaign do
end
end
context 'when WhatsApp campaign feature is disabled' do
let(:account) { create(:account) }
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false)
end
let(:campaign) { create(:campaign, account: account, inbox: whatsapp_channel.inbox) }
it 'does not mark the campaign as processing' do
expect(Whatsapp::OneoffCampaignService).not_to receive(:new)
campaign.trigger!
expect(campaign.reload.active?).to be true
end
end
context 'when Website campaign' do
let(:campaign) { build(:campaign) }
+61
View File
@@ -0,0 +1,61 @@
require 'rails_helper'
RSpec.describe UserSession do
let(:user) { create(:user) }
describe 'associations' do
it { is_expected.to belong_to(:user) }
end
describe 'validations' do
subject { described_class.new(user: user, client_id: 'abc') }
it { is_expected.to validate_presence_of(:client_id) }
it 'validates uniqueness of client_id scoped to user_id' do
described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current)
duplicate = described_class.new(user: user, client_id: 'abc')
expect(duplicate).not_to be_valid
expect(duplicate.errors[:client_id]).to be_present
end
it 'allows the same client_id for different users' do
other = create(:user)
described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current)
expect(described_class.new(user: other, client_id: 'abc', last_activity_at: Time.current)).to be_valid
end
end
describe '#current?' do
let(:session) { described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current) }
it 'returns true when client_id matches' do
expect(session.current?('abc')).to be true
end
it 'returns false when client_id differs' do
expect(session.current?('xyz')).to be false
end
end
describe '#should_update_activity?' do
let(:session) { described_class.new(user: user, client_id: 'abc') }
it 'returns true when last_activity_at is nil' do
session.last_activity_at = nil
expect(session.should_update_activity?).to be true
end
it 'returns true when last_activity_at is older than the throttle window' do
session.last_activity_at = 10.minutes.ago
expect(session.should_update_activity?).to be true
end
it 'returns false when last_activity_at is within the throttle window' do
session.last_activity_at = 1.minute.ago
expect(session.should_update_activity?).to be false
end
end
end
+33
View File
@@ -254,4 +254,37 @@ RSpec.describe User do
end
end
end
describe 'sync_user_sessions callback' do
let(:user_with_tokens) do
u = create(:user)
u.tokens = {
'client-a' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i },
'client-b' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i }
}
u.save!
u.user_sessions.create!(client_id: 'client-a', last_activity_at: Time.current)
u.user_sessions.create!(client_id: 'client-b', last_activity_at: Time.current)
u
end
it 'destroys user_sessions whose client_id is no longer in tokens' do
user_with_tokens.tokens = user_with_tokens.tokens.except('client-a')
expect { user_with_tokens.save! }.to change(user_with_tokens.user_sessions, :count).by(-1)
expect(user_with_tokens.user_sessions.pluck(:client_id)).to eq(['client-b'])
end
it 'leaves user_sessions alone when tokens did not change' do
user_with_tokens.update!(name: 'New Name')
expect(user_with_tokens.user_sessions.count).to eq(2)
end
it 'destroys all user_sessions when tokens is cleared' do
user_with_tokens.tokens = {}
expect { user_with_tokens.save! }.to change(user_with_tokens.user_sessions, :count).by(-2)
end
end
end
@@ -0,0 +1,87 @@
require 'rails_helper'
RSpec.describe 'Profile Sessions API', type: :request do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:auth_headers) { user.create_new_auth_token }
let(:current_client_id) { auth_headers['client'] }
describe 'GET /api/v1/profile/sessions' do
it 'returns 401 without auth' do
get '/api/v1/profile/sessions', as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'returns the current user sessions ordered by last_activity_at desc' do
older = user.user_sessions.create!(client_id: current_client_id, browser_name: 'Chrome', last_activity_at: 2.days.ago)
newer = user.user_sessions.create!(client_id: 'other-client', browser_name: 'Firefox', last_activity_at: 1.hour.ago)
get '/api/v1/profile/sessions', headers: auth_headers, as: :json
expect(response).to have_http_status(:success)
sessions = response.parsed_body
expect(sessions.map { |s| s['id'] }).to eq([newer.id, older.id])
expect(sessions.find { |s| s['id'] == older.id }['current']).to be true
expect(sessions.find { |s| s['id'] == newer.id }['current']).to be false
end
it 'returns an empty array when no sessions exist' do
get '/api/v1/profile/sessions', headers: auth_headers, as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body).to eq([])
end
end
describe 'DELETE /api/v1/profile/sessions/:id' do
let!(:other_session) { user.user_sessions.create!(client_id: 'other-client', last_activity_at: 1.hour.ago) }
before do
# Seed tokens hash so revoke can clean it up
user.tokens = user.tokens.merge('other-client' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i })
user.save!
end
it 'destroys the session and removes its token entry' do
expect do
delete "/api/v1/profile/sessions/#{other_session.id}", headers: auth_headers, as: :json
end.to change(user.user_sessions, :count).by(-1)
expect(response).to have_http_status(:ok)
expect(user.reload.tokens.keys).not_to include('other-client')
end
it 'returns 422 when trying to revoke the current session' do
current = user.user_sessions.create!(client_id: current_client_id, last_activity_at: Time.current)
delete "/api/v1/profile/sessions/#{current.id}", headers: auth_headers, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to be_present
expect(user.user_sessions.exists?(id: current.id)).to be true
end
it 'returns 404 for a nonexistent session id' do
delete '/api/v1/profile/sessions/9999999', headers: auth_headers, as: :json
expect(response).to have_http_status(:not_found)
end
it 'does not allow revoking another user' do
other_user = create(:user, account: account)
foreign = other_user.user_sessions.create!(client_id: 'foreign', last_activity_at: 1.hour.ago)
delete "/api/v1/profile/sessions/#{foreign.id}", headers: auth_headers, as: :json
expect(response).to have_http_status(:not_found)
expect(other_user.user_sessions.exists?(id: foreign.id)).to be true
end
it 'returns 401 without auth' do
delete "/api/v1/profile/sessions/#{other_session.id}", as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
@@ -45,6 +45,19 @@ describe Sms::OneoffSmsCampaignService do
expect(campaign.reload.completed?).to be true
end
it 'marks the campaign completed after processing the audience' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
expect(sms_channel).to receive(:send_text_message) do
expect(campaign.reload.completed?).to be false
end
sms_campaign_service.perform
expect(campaign.reload.completed?).to be true
end
it 'uses liquid template service to process campaign message' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
@@ -61,6 +61,24 @@ describe Twilio::OneoffSmsCampaignService do
expect(campaign.reload.completed?).to be true
end
it 'marks the campaign completed after processing the audience' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
expect(twilio_messages).to receive(:create).with(
body: campaign.message,
messaging_service_sid: twilio_sms.messaging_service_sid,
to: contact.phone_number,
status_callback: 'http://localhost:3000/twilio/delivery_status'
) do
expect(campaign.reload.completed?).to be false
end
sms_campaign_service.perform
expect(campaign.reload.completed?).to be true
end
it 'uses liquid template service to process campaign message' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
@@ -0,0 +1,90 @@
require 'rails_helper'
RSpec.describe UserSessionTrackingService do
let(:user) { create(:user) }
let(:client_id) { 'client-abc' }
let(:request) do
instance_double(
ActionDispatch::Request,
user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15',
remote_ip: '8.8.8.8'
)
end
let(:service) { described_class.new(user: user, request: request, client_id: client_id) }
let(:geo_result) { OpenStruct.new(city: 'Mountain View', country: 'United States', country_code: 'US') }
let(:ip_lookup) { instance_double(IpLookupService, perform: geo_result) }
before { allow(IpLookupService).to receive(:new).and_return(ip_lookup) }
describe '#create_or_update!' do
it 'creates a new UserSession with the right client_id and timestamps' do
expect { service.create_or_update! }.to change(user.user_sessions, :count).by(1)
session = user.user_sessions.last
expect(session.client_id).to eq(client_id)
expect(session.last_activity_at).to be_within(1.second).of(Time.current)
end
it 'populates request, browser, and geo metadata on the new session', :aggregate_failures do
service.create_or_update!
session = user.user_sessions.last
expect(session.ip_address).to eq('8.8.8.8')
expect(session.browser_name).to eq('Safari')
expect(session.platform_name).to eq('macOS')
expect(session.city).to eq('Mountain View')
expect(session.country).to eq('United States')
expect(session.country_code).to eq('US')
end
it 'updates an existing session when client_id matches' do
existing = user.user_sessions.create!(client_id: client_id, ip_address: '1.1.1.1', last_activity_at: 1.day.ago)
expect { service.create_or_update! }.not_to change(user.user_sessions, :count)
expect(existing.reload.ip_address).to eq('8.8.8.8')
expect(existing.last_activity_at).to be_within(1.second).of(Time.current)
end
it 'handles missing geo data gracefully' do
allow(ip_lookup).to receive(:perform).and_return(nil)
service.create_or_update!
session = user.user_sessions.last
expect(session.city).to be_nil
expect(session.country).to be_nil
expect(session.country_code).to be_nil
end
end
describe '#update_activity!' do
it 'does nothing when no session exists for the client_id' do
expect { service.update_activity! }.not_to change(user.user_sessions, :count)
end
it 'does nothing when the session was recently active' do
session = user.user_sessions.create!(client_id: client_id, last_activity_at: 1.minute.ago)
before_ts = session.last_activity_at
service.update_activity!
expect(session.reload.last_activity_at).to be_within(1.second).of(before_ts)
end
it 'bumps last_activity_at when the session is stale' do
session = user.user_sessions.create!(client_id: client_id, last_activity_at: 10.minutes.ago)
service.update_activity!
expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current)
end
it 'bumps last_activity_at when last_activity_at is nil' do
session = user.user_sessions.create!(client_id: client_id, last_activity_at: nil)
service.update_activity!
expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current)
end
end
end
@@ -82,6 +82,19 @@ describe Whatsapp::OneoffCampaignService do
expect(campaign.reload.completed?).to be true
end
it 'marks the campaign completed after processing the audience' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
expect(whatsapp_channel).to receive(:send_template) do
expect(campaign.reload.completed?).to be false
end
described_class.new(campaign: campaign).perform
expect(campaign.reload.completed?).to be true
end
it 'processes contacts with matching labels' do
contact_with_label1, contact_with_label2, contact_with_both_labels =
create_list(:contact, 3, :with_phone_number, account: account)
+28
View File
@@ -145,6 +145,34 @@ inbox_create_payload:
$ref: ./request/inbox/create_payload.yml
inbox_update_payload:
$ref: ./request/inbox/update_payload.yml
inbox_create_web_widget_channel_payload:
$ref: ./request/inbox/channels/create_web_widget_channel_payload.yml
inbox_create_api_channel_payload:
$ref: ./request/inbox/channels/create_api_channel_payload.yml
inbox_create_email_channel_payload:
$ref: ./request/inbox/channels/create_email_channel_payload.yml
inbox_create_line_channel_payload:
$ref: ./request/inbox/channels/create_line_channel_payload.yml
inbox_create_telegram_channel_payload:
$ref: ./request/inbox/channels/create_telegram_channel_payload.yml
inbox_create_whatsapp_channel_payload:
$ref: ./request/inbox/channels/create_whatsapp_channel_payload.yml
inbox_create_sms_channel_payload:
$ref: ./request/inbox/channels/create_sms_channel_payload.yml
inbox_update_web_widget_channel_payload:
$ref: ./request/inbox/channels/update_web_widget_channel_payload.yml
inbox_update_api_channel_payload:
$ref: ./request/inbox/channels/update_api_channel_payload.yml
inbox_update_email_channel_payload:
$ref: ./request/inbox/channels/update_email_channel_payload.yml
inbox_update_line_channel_payload:
$ref: ./request/inbox/channels/update_line_channel_payload.yml
inbox_update_telegram_channel_payload:
$ref: ./request/inbox/channels/update_telegram_channel_payload.yml
inbox_update_whatsapp_channel_payload:
$ref: ./request/inbox/channels/update_whatsapp_channel_payload.yml
inbox_update_sms_channel_payload:
$ref: ./request/inbox/channels/update_sms_channel_payload.yml
# Team
team_create_update_payload:
@@ -0,0 +1,22 @@
type: object
title: API channel
required:
- type
properties:
type:
type: string
enum: ['api']
example: api
webhook_url:
type: string
description: Webhook URL for API channel inbox callbacks
example: 'https://example.com/webhook'
hmac_mandatory:
type: boolean
description: Require HMAC verification for incoming API channel messages
example: false
additional_attributes:
type: object
description: Additional attributes stored on contacts created through the API channel
example:
source: mobile_app
@@ -0,0 +1,90 @@
type: object
title: Email channel
required:
- type
- email
properties:
type:
type: string
enum: ['email']
example: email
email:
type: string
description: Email address for the inbox
example: support@example.com
imap_enabled:
type: boolean
description: Enable IMAP for inbound emails
example: true
imap_login:
type: string
description: IMAP login username
example: support@example.com
imap_password:
type: string
description: IMAP login password
example: your-imap-password
imap_address:
type: string
description: IMAP server address
example: imap.example.com
imap_port:
type: integer
description: IMAP server port
example: 993
imap_enable_ssl:
type: boolean
description: Enable SSL for IMAP
example: true
imap_authentication:
type: string
description: IMAP authentication method
example: plain
smtp_enabled:
type: boolean
description: Enable SMTP for outbound emails
example: true
smtp_login:
type: string
description: SMTP login username
example: support@example.com
smtp_password:
type: string
description: SMTP login password
example: your-smtp-password
smtp_address:
type: string
description: SMTP server address
example: smtp.example.com
smtp_port:
type: integer
description: SMTP server port
example: 587
smtp_domain:
type: string
description: SMTP HELO domain
example: example.com
smtp_enable_starttls_auto:
type: boolean
description: Automatically enable STARTTLS for SMTP
example: true
smtp_enable_ssl_tls:
type: boolean
description: Enable SSL/TLS for SMTP
example: false
smtp_openssl_verify_mode:
type: string
description: OpenSSL certificate verification mode for SMTP
example: none
smtp_authentication:
type: string
description: SMTP authentication method
example: login
provider:
type: string
description: Email provider
example: google
verified_for_sending:
type: boolean
description: Whether the inbox is verified for sending emails
example: false
@@ -0,0 +1,24 @@
type: object
title: LINE channel
required:
- type
- line_channel_id
- line_channel_secret
- line_channel_token
properties:
type:
type: string
enum: ['line']
example: line
line_channel_id:
type: string
description: LINE channel ID
example: '1234567890'
line_channel_secret:
type: string
description: LINE channel secret
example: line-channel-secret
line_channel_token:
type: string
description: LINE channel access token
example: line-channel-token
@@ -0,0 +1,20 @@
type: object
title: SMS channel
required:
- type
- phone_number
properties:
type:
type: string
enum: ['sms']
example: sms
phone_number:
type: string
description: SMS phone number
example: '+15551234567'
provider_config:
type: object
description: Provider-specific SMS configuration
example:
account_id: your-account-id
application_id: your-application-id
@@ -0,0 +1,14 @@
type: object
title: Telegram channel
required:
- type
- bot_token
properties:
type:
type: string
enum: ['telegram']
example: telegram
bot_token:
type: string
description: Telegram bot token
example: 123456789:telegram-bot-token
@@ -0,0 +1,66 @@
type: object
title: Website channel
required:
- type
- website_url
properties:
type:
type: string
enum: ['web_widget']
example: web_widget
website_url:
type: string
description: URL at which the widget will be loaded
example: 'https://example.com'
welcome_title:
type: string
description: Welcome title to be displayed on the widget
example: 'Welcome to our support'
welcome_tagline:
type: string
description: Welcome tagline to be displayed on the widget
example: 'We are here to help you'
widget_color:
type: string
description: A Hex-color string used to customize the widget
example: '#FF5733'
reply_time:
type: string
description: Expected reply time shown on the widget
enum: ['in_a_few_minutes', 'in_a_few_hours', 'in_a_day']
example: in_a_few_minutes
pre_chat_form_enabled:
type: boolean
description: Enable the pre-chat form before starting a conversation
example: false
pre_chat_form_options:
type: object
description: Pre-chat form configuration
example:
pre_chat_message: Share your queries or comments here.
pre_chat_fields:
- field_type: standard
label: Email Id
name: emailAddress
type: email
required: true
enabled: true
continuity_via_email:
type: boolean
description: Continue conversations over email when the contact leaves the website
example: true
hmac_mandatory:
type: boolean
description: Require HMAC verification for contacts using the widget
example: false
allowed_domains:
type: string
description: Comma-separated list of domains where the widget is allowed to load
example: example.com
selected_feature_flags:
type: array
description: Enabled widget feature flags
items:
type: string
enum: ['attachments', 'emoji_picker', 'end_conversation', 'use_inbox_avatar_for_bot', 'allow_mobile_webview']
example: ['attachments', 'emoji_picker', 'end_conversation']
@@ -0,0 +1,80 @@
oneOf:
- type: object
title: WhatsApp Cloud channel
required:
- type
- phone_number
- provider
- provider_config
properties:
type:
type: string
enum: ['whatsapp']
example: whatsapp
phone_number:
type: string
description: WhatsApp phone number
example: '+15551234567'
provider:
type: string
description: WhatsApp provider
enum: ['whatsapp_cloud']
example: whatsapp_cloud
provider_config:
type: object
description: WhatsApp Cloud provider configuration
required:
- api_key
- phone_number_id
- business_account_id
properties:
api_key:
type: string
description: WhatsApp Cloud API key
example: your-api-key
phone_number_id:
type: string
description: Phone number ID for WhatsApp Cloud
example: your-phone-number-id
business_account_id:
type: string
description: Business account ID for WhatsApp Cloud
example: your-business-account-id
example:
api_key: your-api-key
phone_number_id: your-phone-number-id
business_account_id: your-business-account-id
- type: object
title: Legacy 360dialog WhatsApp channel
deprecated: true
required:
- type
- phone_number
- provider_config
properties:
type:
type: string
enum: ['whatsapp']
example: whatsapp
phone_number:
type: string
description: WhatsApp phone number
example: '+15551234567'
provider:
type: string
description: Legacy 360dialog provider. Omit this field or use `default` only for existing deprecated 360dialog setups.
enum: ['default']
deprecated: true
example: default
provider_config:
type: object
description: Legacy 360dialog provider configuration
required:
- api_key
properties:
api_key:
type: string
description: 360dialog API key
example: your-api-key
example:
api_key: your-api-key
@@ -0,0 +1,16 @@
type: object
title: API channel settings
properties:
webhook_url:
type: string
description: Webhook URL for API channel inbox callbacks
example: 'https://example.com/webhook'
hmac_mandatory:
type: boolean
description: Require HMAC verification for incoming API channel messages
example: false
additional_attributes:
type: object
description: Additional attributes stored on contacts created through the API channel
example:
source: mobile_app
@@ -0,0 +1,83 @@
type: object
title: Email channel settings
properties:
email:
type: string
description: Email address for the inbox
example: support@example.com
imap_enabled:
type: boolean
description: Enable IMAP for inbound emails
example: true
imap_login:
type: string
description: IMAP login username
example: support@example.com
imap_password:
type: string
description: IMAP login password
example: your-imap-password
imap_address:
type: string
description: IMAP server address
example: imap.example.com
imap_port:
type: integer
description: IMAP server port
example: 993
imap_enable_ssl:
type: boolean
description: Enable SSL for IMAP
example: true
imap_authentication:
type: string
description: IMAP authentication method
example: plain
smtp_enabled:
type: boolean
description: Enable SMTP for outbound emails
example: true
smtp_login:
type: string
description: SMTP login username
example: support@example.com
smtp_password:
type: string
description: SMTP login password
example: your-smtp-password
smtp_address:
type: string
description: SMTP server address
example: smtp.example.com
smtp_port:
type: integer
description: SMTP server port
example: 587
smtp_domain:
type: string
description: SMTP HELO domain
example: example.com
smtp_enable_starttls_auto:
type: boolean
description: Automatically enable STARTTLS for SMTP
example: true
smtp_enable_ssl_tls:
type: boolean
description: Enable SSL/TLS for SMTP
example: false
smtp_openssl_verify_mode:
type: string
description: OpenSSL certificate verification mode for SMTP
example: none
smtp_authentication:
type: string
description: SMTP authentication method
example: login
provider:
type: string
description: Email provider
example: google
verified_for_sending:
type: boolean
description: Whether the inbox is verified for sending emails
example: false
@@ -0,0 +1,15 @@
type: object
title: LINE channel settings
properties:
line_channel_id:
type: string
description: LINE channel ID
example: '1234567890'
line_channel_secret:
type: string
description: LINE channel secret
example: line-channel-secret
line_channel_token:
type: string
description: LINE channel access token
example: line-channel-token
@@ -0,0 +1,15 @@
type: object
title: SMS channel settings
properties:
phone_number:
type: string
description: SMS phone number
example: '+15551234567'
provider_config:
type: object
description: Provider-specific SMS configuration
example:
api_key: your-api-key
api_secret: your-api-secret
application_id: your-application-id
account_id: your-account-id
@@ -0,0 +1,7 @@
type: object
title: Telegram channel settings
properties:
bot_token:
type: string
description: Telegram bot token
example: 123456789:telegram-bot-token
@@ -0,0 +1,59 @@
type: object
title: Website channel settings
properties:
website_url:
type: string
description: URL at which the widget will be loaded
example: 'https://example.com'
welcome_title:
type: string
description: Welcome title to be displayed on the widget
example: 'Welcome to our support'
welcome_tagline:
type: string
description: Welcome tagline to be displayed on the widget
example: 'We are here to help you'
widget_color:
type: string
description: A Hex-color string used to customize the widget
example: '#FF5733'
reply_time:
type: string
description: Expected reply time shown on the widget
enum: ['in_a_few_minutes', 'in_a_few_hours', 'in_a_day']
example: in_a_few_minutes
pre_chat_form_enabled:
type: boolean
description: Enable the pre-chat form before starting a conversation
example: false
pre_chat_form_options:
type: object
description: Pre-chat form configuration
example:
pre_chat_message: Share your queries or comments here.
pre_chat_fields:
- field_type: standard
label: Email Id
name: emailAddress
type: email
required: true
enabled: true
continuity_via_email:
type: boolean
description: Continue conversations over email when the contact leaves the website
example: true
hmac_mandatory:
type: boolean
description: Require HMAC verification for contacts using the widget
example: false
allowed_domains:
type: string
description: Comma-separated list of domains where the widget is allowed to load
example: example.com
selected_feature_flags:
type: array
description: Enabled widget feature flags
items:
type: string
enum: ['attachments', 'emoji_picker', 'end_conversation', 'use_inbox_avatar_for_bot', 'allow_mobile_webview']
example: ['attachments', 'emoji_picker', 'end_conversation']
@@ -0,0 +1,32 @@
type: object
title: WhatsApp channel settings
properties:
phone_number:
type: string
description: WhatsApp phone number
example: '+15551234567'
provider:
type: string
description: WhatsApp provider. `default` is supported only for existing deprecated 360dialog setups.
enum: ['whatsapp_cloud', 'default']
example: whatsapp_cloud
provider_config:
type: object
description: WhatsApp provider configuration. Cloud channels use `api_key`, `phone_number_id`, and `business_account_id`; legacy 360dialog channels use `api_key`.
properties:
api_key:
type: string
description: Provider API key
example: your-api-key
phone_number_id:
type: string
description: Phone number ID for WhatsApp Cloud
example: your-phone-number-id
business_account_id:
type: string
description: Business account ID for WhatsApp Cloud
example: your-business-account-id
example:
api_key: your-api-key
phone_number_id: your-phone-number-id
business_account_id: your-business-account-id
@@ -2,87 +2,129 @@ type: object
properties:
name:
type: string
description: The name of the inbox
description: The name of the inbox.
example: 'Support'
avatar:
type: string
format: binary
description: Image file for avatar
description: Image file for avatar.
greeting_enabled:
type: boolean
description: Enable greeting message
description: Enable greeting message.
example: true
greeting_message:
type: string
description: Greeting message to be displayed on the widget
description: Greeting message to send when greeting messages are enabled.
example: Hello, how can I help you?
enable_email_collect:
type: boolean
description: Enable email collection
description: |
Enable email collection.
Available for: `Website`
example: true
csat_survey_enabled:
type: boolean
description: Enable CSAT survey
description: Enable CSAT survey.
example: true
csat_config:
type: object
description: CSAT survey configuration.
properties:
display_type:
type: string
description: Display style for the CSAT survey.
enum: ['emoji', 'star']
example: emoji
message:
type: string
description: Message shown with the CSAT survey.
example: Please rate your conversation
button_text:
type: string
description: Text shown on the CSAT survey button.
example: Please rate us
language:
type: string
description: Language code for the CSAT survey.
example: en
survey_rules:
type: object
description: Rules that decide when to show the CSAT survey.
properties:
operator:
type: string
example: contains
values:
type: array
items:
type: string
example: ['billing']
enable_auto_assignment:
type: boolean
description: Enable Auto Assignment
description: Enable Auto Assignment.
example: true
working_hours_enabled:
type: boolean
description: Enable working hours
description: Enable working hours.
example: true
out_of_office_message:
type: string
description: Out of office message to be displayed on the widget
description: Out of office message to send outside working hours.
example: We are currently out of office. Please leave a message and we will get back to you.
timezone:
type: string
description: Timezone of the inbox
description: Timezone of the inbox.
example: 'America/New_York'
allow_messages_after_resolved:
type: boolean
description: Allow messages after conversation is resolved
description: |
Allow messages after conversation is resolved.
Available for: `Website`
example: true
lock_to_single_conversation:
type: boolean
description: Lock to single conversation
description: |
Lock contact messages to a single active conversation.
Available for: `API` `LINE` `Telegram` `WhatsApp` `SMS`
example: true
portal_id:
type: integer
description: Id of the help center portal to attach to the inbox
description: Id of the help center portal to attach to the inbox.
example: 1
sender_name_type:
type: string
description: Sender name type for the inbox
description: |
Sender name type for outbound email replies.
Available for: `Website` `Email`
enum: ['friendly', 'professional']
example: 'friendly'
business_name:
type: string
description: Business name for the inbox
description: |
Business name for outbound email replies.
Available for: `Website` `Email`
example: 'My Business'
channel:
type: object
properties:
type:
type: string
description: Type of the channel
enum:
['web_widget', 'api', 'email', 'line', 'telegram', 'whatsapp', 'sms']
example: web_widget
website_url:
type: string
description: URL at which the widget will be loaded
example: 'https://example.com'
welcome_title:
type: string
description: Welcome title to be displayed on the widget
example: 'Welcome to our support'
welcome_tagline:
type: string
description: Welcome tagline to be displayed on the widget
example: 'We are here to help you'
widget_color:
type: string
description: A Hex-color string used to customize the widget
example: '#FF5733'
oneOf:
- $ref: '#/components/schemas/inbox_create_web_widget_channel_payload'
- $ref: '#/components/schemas/inbox_create_api_channel_payload'
- $ref: '#/components/schemas/inbox_create_email_channel_payload'
- $ref: '#/components/schemas/inbox_create_line_channel_payload'
- $ref: '#/components/schemas/inbox_create_telegram_channel_payload'
- $ref: '#/components/schemas/inbox_create_whatsapp_channel_payload'
- $ref: '#/components/schemas/inbox_create_sms_channel_payload'
discriminator:
propertyName: type
mapping:
web_widget: '#/components/schemas/inbox_create_web_widget_channel_payload'
api: '#/components/schemas/inbox_create_api_channel_payload'
email: '#/components/schemas/inbox_create_email_channel_payload'
line: '#/components/schemas/inbox_create_line_channel_payload'
telegram: '#/components/schemas/inbox_create_telegram_channel_payload'
whatsapp: '#/components/schemas/inbox_create_whatsapp_channel_payload'
sms: '#/components/schemas/inbox_create_sms_channel_payload'
@@ -2,81 +2,119 @@ type: object
properties:
name:
type: string
description: The name of the inbox
description: The name of the inbox.
example: 'Support'
avatar:
type: string
format: binary
description: Image file for avatar
description: Image file for avatar.
greeting_enabled:
type: boolean
description: Enable greeting message
description: Enable greeting message.
example: true
greeting_message:
type: string
description: Greeting message to be displayed on the widget
description: Greeting message to send when greeting messages are enabled.
example: Hello, how can I help you?
enable_email_collect:
type: boolean
description: Enable email collection
description: |
Enable email collection.
Available for: `Website`
example: true
csat_survey_enabled:
type: boolean
description: Enable CSAT survey
description: Enable CSAT survey.
example: true
csat_config:
type: object
description: CSAT survey configuration.
properties:
display_type:
type: string
description: Display style for the CSAT survey.
enum: ['emoji', 'star']
example: emoji
message:
type: string
description: Message shown with the CSAT survey.
example: Please rate your conversation
button_text:
type: string
description: Text shown on the CSAT survey button.
example: Please rate us
language:
type: string
description: Language code for the CSAT survey.
example: en
survey_rules:
type: object
description: Rules that decide when to show the CSAT survey.
properties:
operator:
type: string
example: contains
values:
type: array
items:
type: string
example: ['billing']
enable_auto_assignment:
type: boolean
description: Enable Auto Assignment
description: Enable Auto Assignment.
example: true
working_hours_enabled:
type: boolean
description: Enable working hours
description: Enable working hours.
example: true
out_of_office_message:
type: string
description: Out of office message to be displayed on the widget
description: Out of office message to send outside working hours.
example: We are currently out of office. Please leave a message and we will get back to you.
timezone:
type: string
description: Timezone of the inbox
description: Timezone of the inbox.
example: 'America/New_York'
allow_messages_after_resolved:
type: boolean
description: Allow messages after conversation is resolved
description: |
Allow messages after conversation is resolved.
Available for: `Website`
example: true
lock_to_single_conversation:
type: boolean
description: Lock to single conversation
description: |
Lock contact messages to a single active conversation.
Available for: `API` `LINE` `Telegram` `WhatsApp` `SMS`
example: true
portal_id:
type: integer
description: Id of the help center portal to attach to the inbox
description: Id of the help center portal to attach to the inbox.
example: 1
sender_name_type:
type: string
description: Sender name type for the inbox
description: |
Sender name type for outbound email replies.
Available for: `Website` `Email`
enum: ['friendly', 'professional']
example: 'friendly'
business_name:
type: string
description: Business name for the inbox
description: |
Business name for outbound email replies.
Available for: `Website` `Email`
example: 'My Business'
channel:
type: object
properties:
website_url:
type: string
description: URL at which the widget will be loaded
example: 'https://example.com'
welcome_title:
type: string
description: Welcome title to be displayed on the widget
example: 'Welcome to our support'
welcome_tagline:
type: string
description: Welcome tagline to be displayed on the widget
example: 'We are here to help you'
widget_color:
type: string
description: A Hex-color string used to customize the widget
example: '#FF5733'
anyOf:
- $ref: '#/components/schemas/inbox_update_web_widget_channel_payload'
- $ref: '#/components/schemas/inbox_update_api_channel_payload'
- $ref: '#/components/schemas/inbox_update_email_channel_payload'
- $ref: '#/components/schemas/inbox_update_line_channel_payload'
- $ref: '#/components/schemas/inbox_update_telegram_channel_payload'
- $ref: '#/components/schemas/inbox_update_whatsapp_channel_payload'
- $ref: '#/components/schemas/inbox_update_sms_channel_payload'
@@ -1,35 +0,0 @@
post:
tags:
- Inboxes
operationId: inboxCreation
summary: Create an inbox
description: You can create more than one website inbox in each account
security:
- userApiKey: []
parameters:
- $ref: '#/components/parameters/account_id'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/inbox_create_payload'
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/inbox'
'404':
description: Inbox not found
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'
'403':
description: Access denied
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'
+115
View File
@@ -49,6 +49,121 @@ post:
application/json:
schema:
$ref: '#/components/schemas/inbox_create_payload'
examples:
web_widget:
summary: Website inbox
value:
name: Support
greeting_enabled: true
greeting_message: Hello, how can I help you?
enable_email_collect: true
enable_auto_assignment: true
working_hours_enabled: true
timezone: America/New_York
allow_messages_after_resolved: true
channel:
type: web_widget
website_url: https://example.com
welcome_title: Welcome to our support
welcome_tagline: We are here to help you
widget_color: '#FF5733'
reply_time: in_a_few_minutes
pre_chat_form_enabled: false
continuity_via_email: true
hmac_mandatory: false
selected_feature_flags:
- attachments
- emoji_picker
- end_conversation
api:
summary: API channel
value:
name: API Inbox
greeting_enabled: true
greeting_message: Hello, how can I help you?
enable_auto_assignment: true
working_hours_enabled: true
timezone: America/New_York
channel:
type: api
webhook_url: https://example.com/webhook
hmac_mandatory: false
additional_attributes:
source: mobile_app
email:
summary: Email channel
value:
name: Email Inbox
greeting_enabled: true
greeting_message: Hello, how can I help you?
enable_auto_assignment: true
working_hours_enabled: true
timezone: America/New_York
channel:
type: email
email: support@example.com
imap_enabled: false
smtp_enabled: false
line:
summary: LINE channel
value:
name: LINE Inbox
greeting_enabled: true
greeting_message: Hello, how can I help you?
enable_auto_assignment: true
working_hours_enabled: true
timezone: America/New_York
channel:
type: line
line_channel_id: '1234567890'
line_channel_secret: line-channel-secret
line_channel_token: line-channel-token
telegram:
summary: Telegram channel
value:
name: Telegram Inbox
greeting_enabled: true
greeting_message: Hello, how can I help you?
enable_auto_assignment: true
working_hours_enabled: true
timezone: America/New_York
channel:
type: telegram
bot_token: 123456789:telegram-bot-token
whatsapp:
summary: WhatsApp channel
value:
name: WhatsApp Inbox
greeting_enabled: true
greeting_message: Hello, how can I help you?
enable_auto_assignment: true
working_hours_enabled: true
timezone: America/New_York
channel:
type: whatsapp
phone_number: '+15551234567'
provider: whatsapp_cloud
provider_config:
api_key: your-api-key
phone_number_id: your-phone-number-id
business_account_id: your-business-account-id
sms:
summary: SMS channel
value:
name: SMS Inbox
greeting_enabled: true
greeting_message: Hello, how can I help you?
enable_auto_assignment: true
working_hours_enabled: true
timezone: America/New_York
channel:
type: sms
phone_number: '+15551234567'
provider_config:
api_key: your-api-key
api_secret: your-api-secret
application_id: your-application-id
account_id: your-account-id
responses:
'200':
description: Success
@@ -55,6 +55,114 @@ patch:
application/json:
schema:
$ref: '#/components/schemas/inbox_update_payload'
examples:
web_widget:
summary: Website inbox settings
value:
name: Support
greeting_enabled: true
greeting_message: Hello, how can I help you?
enable_email_collect: true
enable_auto_assignment: true
working_hours_enabled: true
timezone: America/New_York
allow_messages_after_resolved: true
channel:
website_url: https://example.com
welcome_title: Welcome to our support
welcome_tagline: We are here to help you
widget_color: '#FF5733'
reply_time: in_a_few_minutes
pre_chat_form_enabled: false
continuity_via_email: true
hmac_mandatory: false
selected_feature_flags:
- attachments
- emoji_picker
- end_conversation
api:
summary: API channel settings
value:
name: API Inbox
greeting_enabled: true
greeting_message: Hello, how can I help you?
enable_auto_assignment: true
working_hours_enabled: true
timezone: America/New_York
channel:
webhook_url: https://example.com/webhook
hmac_mandatory: false
additional_attributes:
source: mobile_app
email:
summary: Email channel settings
value:
name: Email Inbox
greeting_enabled: true
greeting_message: Hello, how can I help you?
enable_auto_assignment: true
working_hours_enabled: true
timezone: America/New_York
channel:
email: support@example.com
imap_enabled: false
smtp_enabled: false
line:
summary: LINE channel settings
value:
name: LINE Inbox
greeting_enabled: true
greeting_message: Hello, how can I help you?
enable_auto_assignment: true
working_hours_enabled: true
timezone: America/New_York
channel:
line_channel_id: '1234567890'
line_channel_secret: line-channel-secret
line_channel_token: line-channel-token
telegram:
summary: Telegram channel settings
value:
name: Telegram Inbox
greeting_enabled: true
greeting_message: Hello, how can I help you?
enable_auto_assignment: true
working_hours_enabled: true
timezone: America/New_York
channel:
bot_token: 123456789:telegram-bot-token
whatsapp:
summary: WhatsApp channel settings
value:
name: WhatsApp Inbox
greeting_enabled: true
greeting_message: Hello, how can I help you?
enable_auto_assignment: true
working_hours_enabled: true
timezone: America/New_York
channel:
phone_number: '+15551234567'
provider: whatsapp_cloud
provider_config:
api_key: your-api-key
phone_number_id: your-phone-number-id
business_account_id: your-business-account-id
sms:
summary: SMS channel settings
value:
name: SMS Inbox
greeting_enabled: true
greeting_message: Hello, how can I help you?
enable_auto_assignment: true
working_hours_enabled: true
timezone: America/New_York
channel:
phone_number: '+15551234567'
provider_config:
api_key: your-api-key
api_secret: your-api-secret
application_id: your-application-id
account_id: your-account-id
responses:
'200':
description: Success
+1226 -79
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff