Compare commits

..
Author SHA1 Message Date
Shivam Mishra 11f74ad581 chore: backfill api_and_webhooks for existing accounts 2026-07-09 17:51:43 +05:30
Shivam Mishra d50bccf4e5 feat: reconcile api_and_webhooks entitlement from billing plan 2026-07-09 17:51:32 +05:30
Shivam Mishra d3b294fe9c feat: gate token API access and account webhooks behind api_and_webhooks flag 2026-07-09 17:51:05 +05:30
8d2ef4ec5e feat: assistant overview drilldown reports [CW-7408] (#14920)
<img width="2616" height="1716" alt="CleanShot 2026-07-09 at 14 22
42@2x"
src="https://github.com/user-attachments/assets/504e58df-0243-4cb9-a811-62504fdcb0ac"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-07-09 16:25:22 +05:30
bddb561546 chore: improve account suspended message (#14968)
Updates the suspended account screen copy to use clearer policy and
safety-oriented language, while giving users a direct support path if
they believe the suspension is a mistake.

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
2026-07-09 13:01:08 +04:00
Vishnu NarayananandGitHub 20227403ce fix: return public contact inbox payload after update (#14946)
# Pull Request Template

## Description
This keeps the public inbox contact update response on the public
contact inbox serializer shape after applying the contact identify
update.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- `bundle exec rspec
spec/controllers/public/api/v1/inbox/contacts_controller_spec.rb`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

Fixes
[https://linear.app/chatwoot/issue/CW-6937](https://linear.app/chatwoot/issue/CW-6937)
Fixes
[https://linear.app/chatwoot/issue/CW-7464](https://linear.app/chatwoot/issue/CW-7464)
Fixes
[https://linear.app/chatwoot/issue/CW-7457](https://linear.app/chatwoot/issue/CW-7457)
2026-07-09 13:50:01 +05:30
Vishnu NarayananandGitHub 0a13dbedfb fix: force account picker on microsoft email inbox OAuth (#14794)
## Description

The Microsoft authorize URL had no `prompt` parameter. With Microsoft's
default behavior, a browser carrying an active Microsoft session is
silently signed in to that account. If the same account is already
attached to an inbox in the same Chatwoot account, the callback treats
the OAuth response as a re-auth and routes the user to the existing
inbox settings page instead of letting them create the new inbox they
intended.

Adding `prompt=select_account` interrupts SSO and always presents the
Microsoft account picker, so the user explicitly chooses the mailbox
they want to connect.

This is distinct from `prompt=consent`, which was removed in
[#13962](https://github.com/chatwoot/chatwoot/pull/13962) to fix the
admin consent loop. `select_account` only interrupts SSO and does not
retrigger the consent dialog.

Closes INF-76

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

`bundle exec rspec
spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb`
(3 examples, 0 failures). The regression test introduced in
[#13962](https://github.com/chatwoot/chatwoot/pull/13962) is updated to
assert `prompt=select_account` instead of asserting absence of the
parameter.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-07-09 13:49:33 +05:30
Muhsin KelothandGitHub 8d554f2b74 chore(inbox): disable Instagram inbox creation (#14964)
Reverts chatwoot/chatwoot#14955
2026-07-09 10:02:02 +04:00
Sony MathewandGitHub 4854fb7b4b chore: upgrade websocket-driver (#14961)
# Pull Request Template

## Description

Updates `websocket-driver` from `0.7.7` to `0.8.2` to resolve the
bundle-audit findings for CVE-2026-54463, CVE-2026-54464,
CVE-2026-54465, and GHSA-2x63-gw47-w4mm.

This is a lockfile-only transitive dependency update through Rails
Action Cable. No application code changes are included.

Fixes # (issue)
N/A

## Type of change

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

## How Has This Been Tested?

- `bundle exec bundle audit update && bundle exec bundle audit check -v`
- `bundle check && bundle exec ruby -e 'require "action_cable"; require
"action_cable/connection/client_socket"; require "websocket/driver";
puts "actioncable=#{ActionCable::VERSION::STRING}"; puts
"websocket-driver=#{Gem.loaded_specs["websocket-driver"].version}"; puts
"driver_rack=#{WebSocket::Driver.respond_to?(:rack)}"'`
- `POSTGRES_DATABASE=chatwoot_test_3d6a RAILS_ENV=test bundle exec rails
db:prepare && POSTGRES_DATABASE=chatwoot_test_3d6a bundle exec rspec
spec/listeners/action_cable_listener_spec.rb`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas (not applicable; lockfile-only change)
- [ ] I have made corresponding changes to the documentation (not
applicable; dependency security update)
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works (not applicable; dependency security update)
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules (not applicable)
2026-07-08 15:52:40 -07:00
42 changed files with 614 additions and 131 deletions
-7
View File
@@ -1,21 +1,14 @@
.bundle
.codex
.env
.env.*
.git
.github
.agents
docker-compose.*
docker/Dockerfile
docker/dockerfiles
log
spec
storage
tests
public/system
tmp
.codeclimate.yml
coverage
public/packs
node_modules
vendor/bundle
+2 -8
View File
@@ -78,12 +78,6 @@ jobs:
platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
build-args: |
GIT_SHA=${{ github.sha }}
cache-from: |
type=gha,scope=${{ matrix.platform }}
type=gha,scope=${{ matrix.platform }}-ee
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}-ee
- name: Export digest
run: |
@@ -131,7 +125,7 @@ jobs:
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi
docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
@@ -145,5 +139,5 @@ jobs:
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
fi
docker buildx imagetools inspect $TAG
+2 -8
View File
@@ -83,12 +83,6 @@ jobs:
platforms: ${{ matrix.platform }}
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
build-args: |
GIT_SHA=${{ github.sha }}
cache-from: |
type=gha,scope=${{ matrix.platform }}
type=gha,scope=${{ matrix.platform }}-ce
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}-ce
- name: Export digest
run: |
@@ -136,7 +130,7 @@ jobs:
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools create -t $TAG \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
@@ -150,5 +144,5 @@ jobs:
else
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
fi
docker buildx imagetools inspect $TAG
+2 -6
View File
@@ -39,9 +39,5 @@ jobs:
platforms: ${{ matrix.platform }}
push: false
load: false
build-args: |
GIT_SHA=${{ github.sha }}
cache-from: |
type=gha,scope=${{ matrix.platform }}
type=gha,scope=${{ matrix.platform }}-ce
type=gha,scope=${{ matrix.platform }}-ee
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
+1 -1
View File
@@ -1030,7 +1030,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.7.7)
websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
+1 -2
View File
@@ -1,7 +1,6 @@
# Variables
APP_NAME := chatwoot
RAILS_ENV ?= development
GIT_SHA ?= $(shell git rev-parse HEAD 2>/dev/null || echo unknown)
# Targets
setup:
@@ -62,6 +61,6 @@ debug_worker:
overmind connect worker
docker:
docker build --build-arg GIT_SHA=$(GIT_SHA) -t $(APP_NAME) -f ./docker/Dockerfile .
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker
@@ -2,5 +2,12 @@ class Api::V1::Accounts::BaseController < Api::BaseController
include SwitchLocale
include EnsureCurrentAccountHelper
before_action :current_account
before_action :validate_token_api_access, if: :authenticate_by_access_token?
around_action :switch_locale_using_account_locale
private
def validate_token_api_access
render_unauthorized('Invalid Access Token') unless Current.account.feature_enabled?('api_and_webhooks')
end
end
@@ -6,7 +6,10 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state
state: state,
# Force the Microsoft account picker so an already-signed-in account does not
# silently authorize and re-bind to an existing inbox in the new-inbox flow.
prompt: 'select_account'
}
)
if redirect_url
@@ -1,4 +1,5 @@
class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
before_action :ensure_api_and_webhooks_enabled
before_action :check_authorization
before_action :fetch_webhook, only: [:update, :destroy]
@@ -22,6 +23,10 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
private
def ensure_api_and_webhooks_enabled
render_unauthorized('You are not authorized to do this action') unless Current.account.feature_enabled?('api_and_webhooks')
end
def webhook_params
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
end
@@ -18,7 +18,8 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
contact: @contact_inbox.contact,
params: permitted_params.to_h.deep_symbolize_keys.except(:identifier)
)
render json: contact_identify_action.perform
contact_identify_action.perform
@contact_inbox.reload
end
private
@@ -37,6 +37,20 @@ class CaptainAssistant extends ApiClient {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
getDrilldown({ assistantId, metric, range, page, signal }) {
const requestConfig = {
params: {
metric,
range,
timezone_offset: getTimezoneOffset(),
page,
},
};
if (signal) requestConfig.signal = signal;
return axios.get(`${this.url}/${assistantId}/drilldown`, requestConfig);
}
}
export default new CaptainAssistant();
@@ -0,0 +1,234 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import { useReportDrilldown } from 'dashboard/routes/dashboard/settings/reports/composables/useReportDrilldown';
import ReportDrilldownCard from 'dashboard/routes/dashboard/settings/reports/components/ReportDrilldownCard.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
const props = defineProps({
open: { type: Boolean, default: false },
assistantId: { type: [String, Number], default: null },
metric: { type: String, default: '' },
metricName: { type: String, default: '' },
metricValue: { type: String, default: '' },
range: { type: String, default: '' },
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const drawerRef = ref(null);
const {
records,
meta,
isFetching,
isFetchingMore,
hasError,
hasRecords,
hasMore,
open: openDrilldown,
close,
loadMore,
} = useReportDrilldown(params => CaptainAssistant.getDrilldown(params));
let previousActiveElement = null;
const isOpen = computed(() => props.open);
const title = computed(() => props.metricName || '');
const subtitle = computed(() => {
if (!meta.value.conversation_count) return '';
return t('CAPTAIN.OVERVIEW.DRILLDOWN.RESULT_COUNT_CONVERSATION', {
count: meta.value.conversation_count,
});
});
const recordKey = record => `${record.conversation?.id}-${record.occurred_at}`;
const restoreFocus = () => {
if (previousActiveElement?.isConnected) {
previousActiveElement.focus();
}
previousActiveElement = null;
};
const closeDrawer = () => {
close();
emit('close');
restoreFocus();
};
const rememberActiveElement = () => {
if (previousActiveElement) return;
previousActiveElement =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
};
const focusDrawer = () => {
nextTick(() => drawerRef.value?.focus());
};
const fetchDrilldown = () => {
if (!props.metric || !props.assistantId) return;
openDrilldown({
assistantId: props.assistantId,
metric: props.metric,
range: props.range,
});
};
const onKeydown = event => {
if (!isOpen.value) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeDrawer();
}
};
useEventListener(document, 'keydown', onKeydown);
watch(
() => props.open,
isDrawerOpen => {
if (!isDrawerOpen) {
close();
restoreFocus();
return;
}
rememberActiveElement();
fetchDrilldown();
focusDrawer();
},
{ immediate: true }
);
watch(
() => [props.metric, props.range],
() => {
if (props.open) fetchDrilldown();
}
);
// The drawer's headline value is a snapshot of the previous assistant's card,
// so switching assistants (e.g. browser Back/Forward) closes it instead of
// refetching records that would no longer match the header.
watch(
() => props.assistantId,
() => {
if (props.open) closeDrawer();
}
);
onBeforeUnmount(() => {
restoreFocus();
});
</script>
<template>
<TeleportWithDirection to="body">
<Transition name="report-drilldown-fade">
<div
v-if="isOpen"
class="fixed inset-0 z-50 bg-black/30"
role="presentation"
@click.self="closeDrawer"
>
<aside
ref="drawerRef"
class="fixed inset-y-0 end-0 flex w-full max-w-xl flex-col bg-n-solid-1 shadow-xl outline outline-1 outline-n-container"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
>
<header
class="flex items-start justify-between gap-4 border-b border-n-weak px-6 py-5"
>
<div class="min-w-0">
<h2 class="truncate text-base font-medium text-n-slate-12">
{{ title }}
</h2>
<p
v-if="metricValue"
class="mt-1 text-xl font-semibold text-n-slate-12"
>
{{ metricValue }}
</p>
<div
class="text-sm text-n-slate-11"
:class="{
'mt-2': metricValue,
'mt-1': !metricValue,
}"
>
{{ subtitle }}
</div>
</div>
<Button
ghost
slate
size="sm"
icon="i-ph-x"
:aria-label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.CLOSE')"
@click="closeDrawer"
/>
</header>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
<div
v-if="isFetching"
class="flex h-40 items-center justify-center"
>
<Spinner />
</div>
<div
v-else-if="hasError"
class="flex h-40 items-center justify-center text-sm text-n-ruby-11"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.ERROR') }}
</div>
<div
v-else-if="!hasRecords"
class="flex h-40 items-center justify-center text-sm text-n-slate-10"
>
{{ $t('CAPTAIN.OVERVIEW.DRILLDOWN.EMPTY') }}
</div>
<div v-else class="flex flex-col gap-2">
<ReportDrilldownCard
v-for="record in records"
:key="recordKey(record)"
:record="record"
/>
<Button
v-if="hasMore"
faded
slate
size="sm"
class="mx-auto mt-2"
:label="$t('CAPTAIN.OVERVIEW.DRILLDOWN.LOAD_MORE')"
:is-loading="isFetchingMore"
@click="loadMore"
/>
</div>
</div>
</aside>
</div>
</Transition>
</TeleportWithDirection>
</template>
@@ -8,16 +8,35 @@ const props = defineProps({
hint: { type: String, default: '' },
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
clickable: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
const trendClass = computed(() => {
if (props.trendGood === null) return 'text-n-slate-11';
return props.trendGood ? 'text-n-teal-11' : 'text-n-ruby-11';
});
const onActivate = () => {
if (props.clickable) emit('click');
};
</script>
<template>
<div class="flex flex-col gap-3 p-5 group bg-n-solid-1">
<div
class="flex flex-col gap-3 p-5 group bg-n-solid-1"
:class="
clickable
? 'cursor-pointer transition-colors hover:bg-n-slate-2/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-n-brand'
: ''
"
:role="clickable ? 'button' : undefined"
:tabindex="clickable ? 0 : undefined"
@click="onActivate"
@keydown.enter.self.prevent="onActivate"
@keydown.space.self.prevent="onActivate"
>
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-n-slate-11">{{ label }}</span>
<span
@@ -1,7 +1,7 @@
<script setup>
import { computed } from 'vue';
import ChannelSelector from '../ChannelSelector.vue';
import { IS_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
const props = defineProps({
channel: {
@@ -21,7 +21,10 @@ const hasFbConfigured = computed(() => {
});
const hasInstagramConfigured = computed(() => {
return window.chatwootConfig?.instagramAppId;
return (
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
window.chatwootConfig?.instagramAppId
);
});
const hasTiktokConfigured = computed(() => {
@@ -59,7 +62,7 @@ const isActive = computed(() => {
if (key === 'whatsapp_call') {
return (
!IS_WHATSAPP_INBOX_CREATION_DISABLED &&
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
props.enabledFeatures.channel_voice &&
!!window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
@@ -79,6 +79,7 @@ export default {
};
export const DEFAULT_REDIRECT_URL = '/app/';
// Temporarily disables WhatsApp embedded signup and WhatsApp Call inbox
// creation. Flip to false when the channel is brought back.
export const IS_WHATSAPP_INBOX_CREATION_DISABLED = true;
// Temporarily disables Instagram and WhatsApp inbox creation
// (WhatsApp embedded signup popup, Instagram OAuth, WhatsApp Call).
// Flip to false when the channels are brought back.
export const IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED = true;
@@ -439,6 +439,13 @@
"HINT": "Average replies the assistant sends per conversation."
}
},
"DRILLDOWN": {
"CLOSE": "Close details",
"EMPTY": "No records found for this metric.",
"ERROR": "Could not load records. Please try again.",
"LOAD_MORE": "Load more",
"RESULT_COUNT_CONVERSATION": "{count} conversation | {count} conversations"
},
"KNOWLEDGE": {
"TITLE": "Knowledge coverage",
"COVERAGE": "{pct}% approved",
@@ -446,17 +453,6 @@
"PENDING": "Pending FAQs",
"DOCUMENTS": "Documents"
},
"FLAGGED": {
"TITLE": "Response quality",
"TOTAL": "{count} flagged · {rate}"
},
"CREDITS": {
"TITLE": "Credit usage",
"UNIT": "credits",
"LEGEND": "Daily credits used",
"AXIS_START": "{count}d ago",
"AXIS_END": "Today"
},
"LINKS": {
"DOCS": {
"TITLE": "Captain docs",
@@ -470,13 +466,6 @@
"TITLE": "Billing",
"DESCRIPTION": "Manage credits and plan"
}
},
"FLAG_REASONS": {
"INCORRECT": "Incorrect info",
"INCOMPLETE": "Incomplete",
"OUTDATED": "Outdated",
"INAPPROPRIATE": "Inappropriate",
"OTHER": "Other"
}
},
"ASSISTANT_SWITCHER": {
@@ -263,7 +263,7 @@
"EMAIL_VERIFICATION_SENT": "Verification email has been sent. Please check your inbox.",
"ACCOUNT_SUSPENDED": {
"TITLE": "Account Suspended",
"MESSAGE": "Your account is suspended. Please reach out to the support team for more information."
"MESSAGE": "Your account has been suspended after we detected activity that may violate our policies or put other users at risk. If you believe this is a mistake, please contact our support team."
},
"NO_ACCOUNTS": {
"TITLE": "No account found",
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { usePolicy } from 'dashboard/composables/usePolicy';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
@@ -10,6 +11,7 @@ import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Pay
import RangeSelector from 'dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue';
import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue';
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
import AssistantDrilldownDrawer from 'dashboard/components-next/captain/pageComponents/overview/AssistantDrilldownDrawer.vue';
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
import QuickLinks from 'dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue';
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
@@ -17,6 +19,9 @@ import CoverageBanner from 'dashboard/components-next/captain/pageComponents/ove
const { t } = useI18n();
const route = useRoute();
// Drilldown is admin-only; the backend policy enforces the same restriction.
const { checkPermissions } = usePolicy();
const canDrilldown = computed(() => checkPermissions(['administrator']));
const selectedRange = ref('this_month');
@@ -69,34 +74,38 @@ const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
const metrics = computed(() => [
{
key: 'handled',
metric: 'conversations_handled',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.HINT'),
...metricFor('conversations_handled', v => v.toLocaleString(), 'up'),
},
{
key: 'autoResolution',
metric: 'auto_resolution_rate',
label: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.HINT'),
...metricFor('auto_resolution_rate', v => `${v}%`, 'up', 'point'),
},
{
key: 'handoff',
metric: 'handoff_rate',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.HINT'),
...metricFor('handoff_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'reopen',
metric: 'reopen_rate',
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'hoursSaved',
label: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.HINT'),
...metricFor('hours_saved', formatDuration, 'up'),
},
{
key: 'reopen',
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'depth',
label: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.LABEL'),
@@ -109,6 +118,22 @@ const metrics = computed(() => [
),
},
]);
const drilldown = ref({ metric: '', label: '', value: '' });
const isDrilldownOpen = ref(false);
const openDrilldown = metric => {
drilldown.value = {
metric: metric.metric,
label: metric.label,
value: metric.value,
};
isDrilldownOpen.value = true;
};
const closeDrilldown = () => {
isDrilldownOpen.value = false;
};
</script>
<template>
@@ -144,6 +169,8 @@ const metrics = computed(() => [
:trend="metric.trend"
:hint="metric.hint"
:trend-good="metric.trendGood"
:clickable="canDrilldown && Boolean(metric.metric)"
@click="openDrilldown(metric)"
/>
</div>
@@ -151,6 +178,17 @@ const metrics = computed(() => [
<QuickLinks />
</div>
<AssistantDrilldownDrawer
v-if="canDrilldown"
:open="isDrilldownOpen"
:assistant-id="assistantId"
:metric="drilldown.metric"
:metric-name="drilldown.label"
:metric-value="drilldown.value"
:range="selectedRange"
@close="closeDrilldown"
/>
</template>
</PageLayout>
</template>
@@ -1,5 +1,5 @@
import { useMapGetter } from 'dashboard/composables/store';
import { IS_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
// OAuth/SDK channels need installation-level app credentials to be usable. When
// the credential is missing the channel is "not configured" and is hidden from
@@ -14,12 +14,14 @@ export function useChannelConfig() {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
!IS_WHATSAPP_INBOX_CREATION_DISABLED &&
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
facebook: () => Boolean(installationConfig.fbAppId),
instagram: () => Boolean(installationConfig.instagramAppId),
instagram: () =>
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
Boolean(installationConfig.instagramAppId),
tiktok: () => Boolean(installationConfig.tiktokAppId),
gmail: () => Boolean(installationConfig.googleOAuthClientId),
outlook: () => Boolean(globalConfig.value.azureAppId),
@@ -6,11 +6,11 @@ import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
vi.mock('vue-router');
// Neutralize the temporary WhatsApp kill switch so these specs keep covering
// the credential-based gating it short-circuits.
// Neutralize the temporary Instagram/WhatsApp kill switch so these specs keep
// covering the credential-based gating it currently short-circuits.
vi.mock('dashboard/constants/globals', async importOriginal => ({
...(await importOriginal()),
IS_WHATSAPP_INBOX_CREATION_DISABLED: false,
IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED: false,
}));
// Mounts the composable against a real store and the real useAccount (only
@@ -7,7 +7,7 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import { IS_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
const route = useRoute();
const router = useRouter();
@@ -24,7 +24,7 @@ const PROVIDER_TYPES = {
const hasWhatsappAppId = computed(() => {
return (
!IS_WHATSAPP_INBOX_CREATION_DISABLED &&
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
@@ -1,7 +1,13 @@
import { computed, ref } from 'vue';
import ReportsAPI from 'dashboard/api/reports';
export function useReportDrilldown() {
// `fetcher` is any `({ ...request, page, signal }) => Promise` returning the
// shared drilldown envelope (`{ data: { meta, payload } }`), so the same paging
// and abort machinery backs both the reports and Captain assistant drilldowns.
// The default is wrapped so `ReportsAPI` stays the receiver when invoked.
export function useReportDrilldown(
fetcher = params => ReportsAPI.getDrilldown(params)
) {
const activeRequest = ref(null);
const records = ref([]);
const meta = ref({});
@@ -20,17 +26,7 @@ export function useReportDrilldown() {
const isCurrentRequest = token =>
token === requestToken && !!activeRequest.value;
const requestFingerprint = request =>
JSON.stringify({
metric: request.metric,
bucketTimestamp: request.bucketTimestamp,
from: request.from,
to: request.to,
type: request.type,
id: request.id,
groupBy: request.groupBy,
businessHours: request.businessHours,
});
const requestFingerprint = request => JSON.stringify(request);
const abortActiveRequest = () => {
if (!activeRequestController) return;
@@ -55,7 +51,7 @@ export function useReportDrilldown() {
hasError.value = false;
try {
const response = await ReportsAPI.getDrilldown({
const response = await fetcher({
...request,
page,
signal: controller.signal,
+2
View File
@@ -108,6 +108,8 @@ class WebhookListener < BaseListener
end
def deliver_account_webhooks(payload, account)
return unless account.feature_enabled?('api_and_webhooks')
account.webhooks.account_type.each do |webhook|
next unless webhook.subscriptions.include?(payload[:event])
+4
View File
@@ -249,3 +249,7 @@
display_name: Advanced Assignment
enabled: false
premium: true
- name: api_and_webhooks
display_name: API and Webhooks
enabled: true
column: feature_flags_ext_1
@@ -0,0 +1,12 @@
# Enable api_and_webhooks for existing accounts.
# Like 20260120121402_enable_captain_tasks_for_existing_accounts.rb, we don't need
# to update ACCOUNT_LEVEL_FEATURE_DEFAULTS or clear GlobalConfig cache because
# api_and_webhooks has `enabled: true` in features.yml - ConfigLoader handles the
# defaults on deploy automatically.
class EnableApiAndWebhooksForExistingAccounts < ActiveRecord::Migration[7.0]
def up
Account.find_in_batches(batch_size: 100) do |accounts|
accounts.each { |account| account.enable_features!('api_and_webhooks') }
end
end
end
+1 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_07_06_215758) do
ActiveRecord::Schema[7.1].define(version: 2026_07_09_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
+3 -3
View File
@@ -87,13 +87,13 @@ RUN if [ "$RAILS_ENV" = "production" ]; then \
fi
# Generate .git_sha file with current commit hash
ARG GIT_SHA="unknown"
RUN printf "%s\n" "$GIT_SHA" > /app/.git_sha
RUN git rev-parse HEAD > /app/.git_sha
# Remove unnecessary files
RUN rm -rf /gems/ruby/3.4.0/cache/*.gem \
&& find /gems/ruby/3.4.0/gems/ \( -name "*.c" -o -name "*.o" \) -delete \
&& rm -rf .git .gitignore
&& rm -rf .git \
&& rm .gitignore
# final build stage
FROM ruby:3.4.4-alpine3.21
@@ -1,6 +1,6 @@
# Lists the underlying records behind a single Captain assistant stat card, so a
# viewer can drill from an aggregate (e.g. "auto-resolution 42%") into the exact
# conversations or messages that produced it.
# conversations that produced it.
#
# The window is resolved by Captain::AssistantStatsWindow from the same `range`
# and `timezone_offset` the stat card used, so the drilldown covers precisely the
@@ -11,10 +11,8 @@ class Captain::AssistantDrilldownBuilder
RESOLVED_EVENT_NAMES = Captain::AssistantStatsBuilder::RESOLVED_EVENT_NAMES
HANDOFF_EVENT_NAMES = Captain::AssistantStatsBuilder::HANDOFF_EVENT_NAMES
# Metrics whose records are individual messages rather than conversations.
MESSAGE_METRICS = %w[hours_saved].freeze
SUPPORTED_METRICS = %w[
conversations_handled auto_resolution_rate handoff_rate hours_saved reopen_rate conversation_depth
conversations_handled auto_resolution_rate handoff_rate reopen_rate
].freeze
DEFAULT_PAGE = 1
@@ -43,21 +41,14 @@ class Captain::AssistantDrilldownBuilder
def meta
{
metric: metric,
record_type: record_type,
current_page: current_page,
per_page: per_page,
total_count: paginated_records.total_count,
conversation_count: conversation_count,
conversation_count: paginated_records.total_count,
range: { since: range.first.to_i, until: range.last.to_i }
}
end
def conversation_count
return paginated_records.total_count unless message_metric?
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
end
def paginated_records
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
end
@@ -67,9 +58,7 @@ class Captain::AssistantDrilldownBuilder
when 'conversations_handled' then handled_conversations
when 'auto_resolution_rate' then conversations_for(resolved_events.select(:conversation_id))
when 'handoff_rate' then event_conversations(HANDOFF_EVENT_NAMES)
when 'hours_saved' then public_reply_messages
when 'reopen_rate' then reopened_conversations
when 'conversation_depth' then depth_conversations
else
raise ArgumentError, "Unsupported assistant drilldown metric: #{metric}"
end
@@ -88,13 +77,6 @@ class Captain::AssistantDrilldownBuilder
conversations_for(handled_conversation_ids)
end
# Public agent-facing replies the assistant sent; the rows behind hours_saved.
def public_reply_messages
handled_messages.where(message_type: :outgoing, private: false)
.includes(:sender, conversation: [:assignee, :contact, :inbox])
.reorder(created_at: :desc)
end
# Conversations in the handled cohort that recorded one of the given reporting
# events in the window (resolved or handed-off).
def event_conversations(event_names)
@@ -129,11 +111,6 @@ class Captain::AssistantDrilldownBuilder
conversations_for(ids)
end
# Conversations the assistant sent at least one public reply in; the denominator behind conversation_depth.
def depth_conversations
conversations_for(handled_messages.where(message_type: :outgoing, private: false).select(:conversation_id))
end
def conversations_for(conversation_ids)
account.conversations
.where(id: conversation_ids)
@@ -147,10 +124,6 @@ class Captain::AssistantDrilldownBuilder
def metric = params[:metric].to_s
def message_metric? = MESSAGE_METRICS.include?(metric)
def record_type = message_metric? ? 'message' : 'conversation'
def current_page = [params[:page].to_i, DEFAULT_PAGE].max
def per_page
@@ -37,8 +37,10 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
def perform
account.disable_features(*PREMIUM_PLAN_FEATURES)
account.disable_features('captain_integration_v2') if default_plan?
account.disable_features('api_and_webhooks')
account.enable_features(*current_plan_features)
account.enable_features('captain_integration_v2') if captain_v2_default_eligible?
account.enable_features('api_and_webhooks') if api_and_webhooks_eligible?
account.enable_features(*manually_managed_features)
account.save!
end
@@ -56,6 +58,10 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
end
end
def api_and_webhooks_eligible?
!default_plan? && account.custom_attributes['subscription_status'] == 'active'
end
def default_plan?
default_plan_name = cloud_plans.first&.dig('name')
return false if default_plan_name.blank?
@@ -54,7 +54,7 @@ class Internal::Accounts::InternalAttributesService
def valid_feature_list
Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES +
Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES +
%w[inbound_emails]
%w[inbound_emails api_and_webhooks]
end
# Account notes functionality removed for now
+59
View File
@@ -0,0 +1,59 @@
# Grandfather api_and_webhooks for existing accounts (cloud only).
#
# Adds api_and_webhooks to each account's manually_managed_features internal
# attribute so per-account billing reconciles never strip the feature from
# accounts that existed before the flag was introduced. Idempotent.
#
# Usage Examples:
# # Grandfather a single account
# ACCOUNT_ID=1 bundle exec rake api_and_webhooks:grandfather
#
# # Grandfather all accounts in the installation
# bundle exec rake api_and_webhooks:grandfather
# rubocop:disable Metrics/BlockLength
namespace :api_and_webhooks do
desc 'Grandfather api_and_webhooks via manually_managed_features for existing accounts'
task grandfather: :environment do
abort 'Aborted. This task requires the enterprise edition.' unless ChatwootApp.enterprise?
account_id = ENV.fetch('ACCOUNT_ID', nil)
accounts = account_id.present? ? Account.where(id: account_id) : Account.all
if account_id.blank?
print 'No ACCOUNT_ID specified. This will grandfather ALL accounts. Continue? [y/N] '
abort 'Aborted.' unless $stdin.gets.chomp.casecmp('y').zero?
end
if account_id.present? && accounts.empty?
puts "Error: Account with ID #{account_id} not found"
exit(1)
end
total = accounts.count
puts "Grandfathering api_and_webhooks for #{total} account(s)..."
updated = 0
skipped = 0
errored = 0
accounts.find_each do |account|
service = Internal::Accounts::InternalAttributesService.new(account)
features = service.manually_managed_features
if features.include?('api_and_webhooks')
skipped += 1
next
end
service.manually_managed_features = features + ['api_and_webhooks']
account.enable_features!('api_and_webhooks')
updated += 1
rescue StandardError => e
errored += 1
puts " Account #{account.id} — error: #{e.message}"
end
puts "Done! Updated: #{updated}, Skipped: #{skipped}, Errored: #{errored}, Total: #{total}"
end
end
# rubocop:enable Metrics/BlockLength
+40 -1
View File
@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe 'API Base', type: :request do
let!(:account) { create(:account) }
let!(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } }
let!(:user) { create(:user, account: account) }
describe 'request with api_access_token for user' do
@@ -23,6 +23,32 @@ RSpec.describe 'API Base', type: :request do
end
end
context 'when the account does not have the api_and_webhooks feature' do
let!(:admin) { create(:user, :administrator, account: account) }
let!(:conversation) { create(:conversation, account: account) }
before do
account.disable_features!('api_and_webhooks')
end
it 'returns unauthorized for token authenticated requests' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
headers: { api_access_token: admin.access_token.token },
as: :json
expect(response).to have_http_status(:unauthorized)
expect(response.parsed_body['error']).to eq('Invalid Access Token')
end
it 'allows session authenticated requests' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
context 'when it is an invalid api_access_token' do
it 'returns unauthorized' do
get '/api/v1/profile',
@@ -94,6 +120,19 @@ RSpec.describe 'API Base', type: :request do
end
end
context 'when the account does not have the api_and_webhooks feature' do
it 'returns unauthorized for accessible bot endpoints' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
account.disable_features!('api_and_webhooks')
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
headers: { api_access_token: agent_bot.access_token.token },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when the account is suspended' do
it 'returns 401 unauthorized' do
account.update!(status: :suspended)
@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe 'Conversation Assignment API', type: :request do
let(:account) { create(:account) }
let(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } }
describe 'POST /api/v1/accounts/{account.id}/conversations/<id>/assignments' do
let(:conversation) { create(:conversation, account: account) }
@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe 'Conversation Messages API', type: :request do
let!(:account) { create(:account) }
let!(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } }
describe 'POST /api/v1/accounts/{account.id}/conversations/<id>/messages' do
let!(:inbox) { create(:inbox, account: account) }
@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe 'Conversations API', type: :request do
let(:account) { create(:account) }
let(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } }
describe 'GET /api/v1/accounts/{account.id}/conversations' do
context 'when it is an unauthenticated user' do
@@ -43,7 +43,7 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
]
expect(params['scope']).to eq(expected_scope)
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
expect(url).not_to match(/(?:\?|&)prompt=/)
expect(params['prompt']).to eq(['select_account'])
# Validate state parameter exists and can be decoded back to the account
expect(params['state']).to be_present
@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe 'Webhooks API', type: :request do
let(:account) { create(:account) }
let(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } }
let(:inbox) { create(:inbox, account: account) }
let(:webhook) { create(:webhook, account: account, inbox: inbox, url: 'https://hello.com', name: 'My Webhook') }
let(:administrator) { create(:user, account: account, role: :administrator) }
@@ -26,6 +26,16 @@ RSpec.describe 'Webhooks API', type: :request do
expect(response.parsed_body['payload']['webhooks'].count).to eql account.webhooks.count
end
end
context 'when api_and_webhooks feature is disabled' do
it 'returns unauthorized even for an admin' do
account.disable_features!('api_and_webhooks')
get "/api/v1/accounts/#{account.id}/webhooks",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'POST /api/v1/accounts/<account_id>/webhooks' do
@@ -47,5 +47,17 @@ RSpec.describe 'Public Inbox Contacts API', type: :request do
data = response.parsed_body
expect(data['name']).to eq 'John Smith'
end
it 'does not expose internal contact columns' do
contact.update!(identifier: 'contact-identifier', custom_attributes: { tier: 'vip' }, additional_attributes: { company_name: 'Acme' })
patch "/public/api/v1/inboxes/#{api_channel.identifier}/contacts/#{contact_inbox.source_id}",
params: { name: 'John Smith' }
expect(response).to have_http_status(:success)
data = response.parsed_body
expect(data.keys).to include('email', 'id', 'name', 'phone_number', 'pubsub_token', 'source_id')
expect(data.keys).not_to include('account_id', 'identifier', 'custom_attributes', 'additional_attributes', 'company_id')
end
end
end
@@ -0,0 +1,54 @@
require 'rails_helper'
describe Enterprise::Billing::ReconcilePlanFeaturesService do
let(:account) { create(:account) }
before do
create(:installation_config, {
name: 'CHATWOOT_CLOUD_PLANS',
value: [
{ 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] }
]
})
end
describe '#perform' do
context 'with api_and_webhooks feature' do
it 'enables the feature for a paid plan with an active subscription' do
account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'active' })
described_class.new(account: account).perform
expect(account.reload).to be_feature_enabled('api_and_webhooks')
end
it 'disables the feature for a paid plan on trial' do
account.enable_features!('api_and_webhooks')
account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'trialing' })
described_class.new(account: account).perform
expect(account.reload).not_to be_feature_enabled('api_and_webhooks')
end
it 'disables the feature on the default plan' do
account.enable_features!('api_and_webhooks')
account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'active' })
described_class.new(account: account).perform
expect(account.reload).not_to be_feature_enabled('api_and_webhooks')
end
it 'keeps the feature enabled when manually managed' do
account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'trialing' })
Internal::Accounts::InternalAttributesService.new(account).manually_managed_features = ['api_and_webhooks']
described_class.new(account: account).perform
expect(account.reload).to be_feature_enabled('api_and_webhooks')
end
end
end
end
+25 -1
View File
@@ -1,7 +1,7 @@
require 'rails_helper'
describe WebhookListener do
let(:listener) { described_class.instance }
let!(:account) { create(:account) }
let!(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } }
let(:report_identity) { Reports::UpdateAccountIdentity.new(account, Time.zone.now) }
let!(:user) { create(:user, account: account) }
let!(:inbox) { create(:inbox, account: account) }
@@ -44,6 +44,30 @@ describe WebhookListener do
end
end
context 'when api_and_webhooks feature is disabled' do
before do
account.disable_features!('api_and_webhooks')
end
it 'does not trigger account webhooks' do
create(:webhook, inbox: inbox, account: account)
expect(WebhookJob).not_to receive(:perform_later)
listener.message_created(message_created_event)
end
it 'still triggers API inbox webhooks' do
channel_api = create(:channel_api, account: account)
api_conversation = create(:conversation, account: account, inbox: channel_api.inbox, assignee: user)
api_message = create(:message, message_type: 'outgoing', account: account, inbox: channel_api.inbox, conversation: api_conversation)
api_event = Events::Base.new(event_name, Time.zone.now, message: api_message)
expect(WebhookJob).to receive(:perform_later).with(
channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'),
:api_inbox_webhook, secret: channel_api.secret, delivery_id: instance_of(String)
).once
listener.message_created(api_event)
end
end
context 'when inbox is an API Channel' do
it 'triggers webhook if webhook_url is present' do
channel_api = create(:channel_api, account: account)
+1 -1
View File
@@ -108,7 +108,7 @@ RSpec.describe Account do
it 'configures the account feature flag extension column' do
expect(described_class.flag_columns).to include('feature_flags', 'feature_flags_ext_1')
expect(described_class.flag_mapping['feature_flags_ext_1']).to eq({})
expect(described_class.flag_mapping['feature_flags_ext_1']).to eq(feature_api_and_webhooks: 1)
end
it 'keeps existing feature flags on the original column' do
@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::BaseController', type: :request do
let(:account) { create(:account) }
let(:account) { create(:account).tap { |account| account.enable_features!('api_and_webhooks') } }
let(:inbox) { create(:inbox, account: account) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:agent) { create(:user, account: account, role: :agent) }