feat: Add event subscription option to webhooks (#4540)

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Pranav Raj S 2022-04-25 17:44:42 +05:30 committed by GitHub
parent fa51fd1d73
commit 899176a793
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 552 additions and 359 deletions

View file

@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
private
def webhook_params
params.require(:webhook).permit(:inbox_id, :url)
params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
end
def fetch_webhook

View file

@ -2,6 +2,10 @@
margin-right: var(--space-small);
}
.margin-bottom-small {
margin-bottom: var(--space-small);
}
.margin-right-smaller {
margin-right: var(--space-smaller);
}

View file

@ -0,0 +1,50 @@
<template>
<span>
{{ textToBeDisplayed }}
<button class="show-more--button" @click="toggleShowMore">
{{ buttonLabel }}
</button>
</span>
</template>
<script>
export default {
props: {
text: {
type: String,
default: '',
},
limit: {
type: Number,
default: 120,
},
},
data() {
return {
showMore: false,
};
},
computed: {
textToBeDisplayed() {
if (this.showMore) {
return this.text;
}
return this.text.slice(0, this.limit) + '...';
},
buttonLabel() {
const i18nKey = !this.showMore ? 'SHOW_MORE' : 'SHOW_LESS';
return this.$t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
},
},
methods: {
toggleShowMore() {
this.showMore = !this.showMore;
},
},
};
</script>
<style scoped>
.show-more--button {
color: var(--w-500);
}
</style>

View file

@ -2,6 +2,29 @@
"INTEGRATION_SETTINGS": {
"HEADER": "Integrations",
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events",
"FORM": {
"CANCEL": "Cancel",
"DESC": "Webhook events provide you the realtime information about what's happening in your Chatwoot account. Please enter a valid URL to configure a callback.",
"SUBSCRIPTIONS": {
"LABEL": "Events",
"EVENTS": {
"CONVERSATION_CREATED": "Conversation Created",
"CONVERSATION_STATUS_CHANGED": "Conversation Status Changed",
"CONVERSATION_UPDATED": "Conversation Updated",
"MESSAGE_CREATED": "Message created",
"MESSAGE_UPDATED": "Message updated",
"WEBWIDGET_TRIGGERED": "Live chat widget opened by the user"
}
},
"END_POINT": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "Example: https://example/api/webhook",
"ERROR": "Please enter a valid URL"
},
"EDIT_SUBMIT": "Update webhook",
"ADD_SUBMIT": "Create webhook"
},
"TITLE": "Webhook",
"CONFIGURE": "Configure",
"HEADER": "Webhook settings",
@ -17,35 +40,16 @@
"EDIT": {
"BUTTON_TEXT": "Edit",
"TITLE": "Edit webhook",
"CANCEL": "Cancel",
"DESC": "Webhook events provide you the realtime information about what's happening in your Chatwoot account. Please enter a valid URL to configure a callback.",
"FORM": {
"END_POINT": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "Example: https://example/api/webhook",
"ERROR": "Please enter a valid URL"
},
"SUBMIT": "Edit webhook"
},
"API": {
"SUCCESS_MESSAGE": "Webhook URL updated successfully",
"SUCCESS_MESSAGE": "Webhook configuration updated successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
}
},
"ADD": {
"CANCEL": "Cancel",
"TITLE": "Add new webhook",
"DESC": "Webhook events provide you the realtime information about what's happening in your Chatwoot account. Please enter a valid URL to configure a callback.",
"FORM": {
"END_POINT": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "Example: https://example/api/webhook",
"ERROR": "Please enter a valid URL"
},
"SUBMIT": "Create webhook"
},
"API": {
"SUCCESS_MESSAGE": "Webhook added successfully",
"SUCCESS_MESSAGE": "Webhook configuration added successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
}
},
@ -57,7 +61,7 @@
},
"CONFIRM": {
"TITLE": "Confirm Deletion",
"MESSAGE": "Are you sure to delete ",
"MESSAGE": "Are you sure to delete the webhook? (%{webhookURL})",
"YES": "Yes, Delete ",
"NO": "No, Keep it"
}

View file

@ -127,6 +127,10 @@
"BUTTON_TEXT": "Copy",
"COPY_SUCCESSFUL": "Code copied to clipboard successfully"
},
"SHOW_MORE_BLOCK": {
"SHOW_MORE": "Show More",
"SHOW_LESS": "Show Less"
},
"FILE_BUBBLE": {
"DOWNLOAD": "Download",
"UPLOADING": "Uploading..."

View file

@ -1,108 +0,0 @@
<template>
<div class="column content-box">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.TITLE')"
/>
<form class="row" @submit.prevent="editWebhook">
<div class="medium-12 columns">
<label :class="{ error: $v.endPoint.$error }">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.FORM.END_POINT.LABEL') }}
<input
v-model.trim="endPoint"
type="text"
name="endPoint"
:placeholder="
$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.FORM.END_POINT.PLACEHOLDER')
"
@input="$v.endPoint.$touch"
/>
<span v-if="$v.endPoint.$error" class="message">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.FORM.END_POINT.ERROR') }}
</span>
</label>
</div>
<div class="modal-footer">
<div class="medium-12 columns">
<woot-button
:is-disabled="
$v.endPoint.$invalid || uiFlags.updatingItem || endPoint === url
"
:is-loading="uiFlags.updatingItem"
>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.FORM.SUBMIT') }}
</woot-button>
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.ADD.CANCEL') }}
</woot-button>
</div>
</div>
</form>
</div>
</template>
<script>
import { required, url, minLength } from 'vuelidate/lib/validators';
import alertMixin from 'shared/mixins/alertMixin';
import { mapGetters } from 'vuex';
export default {
mixins: [alertMixin],
props: {
id: {
type: Number,
required: true,
},
url: {
type: String,
required: true,
},
onClose: {
type: Function,
required: true,
},
},
data() {
return {
alertMessage: '',
endPoint: this.url,
webhookId: this.id,
};
},
validations: {
endPoint: {
required,
minLength: minLength(7),
url,
},
},
computed: {
...mapGetters({ uiFlags: 'webhooks/getUIFlags' }),
},
methods: {
resetForm() {
this.endPoint = '';
this.$v.endPoint.$reset();
},
async editWebhook() {
try {
await this.$store.dispatch('webhooks/update', {
webhook: { url: this.endPoint },
id: this.webhookId,
});
this.alertMessage = this.$t(
'INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.SUCCESS_MESSAGE'
);
this.resetForm();
this.onClose();
} catch (error) {
this.alertMessage =
error.response.data.message ||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
} finally {
this.showAlert(this.alertMessage);
}
},
},
};
</script>

View file

@ -1,121 +0,0 @@
<template>
<modal :show.sync="show" :on-close="onClose" :close-on-backdrop-click="false">
<div class="column content-box">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
:header-content="
useInstallationName(
$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.DESC'),
globalConfig.installationName
)
"
/>
<form class="row" @submit.prevent="addWebhook">
<div class="medium-12 columns">
<label :class="{ error: $v.endPoint.$error }">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.ADD.FORM.END_POINT.LABEL') }}
<input
v-model.trim="endPoint"
type="text"
name="endPoint"
:placeholder="
$t(
'INTEGRATION_SETTINGS.WEBHOOK.ADD.FORM.END_POINT.PLACEHOLDER'
)
"
@input="$v.endPoint.$touch"
/>
<span v-if="$v.endPoint.$error" class="message">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.ADD.FORM.END_POINT.ERROR') }}
</span>
</label>
</div>
<div class="modal-footer">
<div class="medium-12 columns">
<woot-button
:disabled="$v.endPoint.$invalid || addWebHook.showLoading"
:is-loading="addWebHook.showLoading"
>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.ADD.FORM.SUBMIT') }}
</woot-button>
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.ADD.CANCEL') }}
</woot-button>
</div>
</div>
</form>
</div>
</modal>
</template>
<script>
import { required, url, minLength } from 'vuelidate/lib/validators';
import alertMixin from 'shared/mixins/alertMixin';
import Modal from '../../../../components/Modal';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { mapGetters } from 'vuex';
export default {
components: {
Modal,
},
mixins: [alertMixin, globalConfigMixin],
props: {
onClose: {
type: Function,
required: true,
},
},
data() {
return {
endPoint: '',
addWebHook: {
showAlert: false,
showLoading: false,
},
show: true,
};
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
},
validations: {
endPoint: {
required,
minLength: minLength(7),
url,
},
},
methods: {
resetForm() {
this.endPoint = '';
this.$v.endPoint.$reset();
},
async addWebhook() {
this.addWebHook.showLoading = true;
try {
await this.$store.dispatch('webhooks/create', {
webhook: { url: this.endPoint },
});
this.addWebHook.showLoading = false;
this.addWebHook.message = this.$t(
'INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE'
);
this.resetForm();
this.onClose();
} catch (error) {
this.addWebHook.showLoading = false;
this.addWebHook.message =
error.response.data.message ||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
} finally {
this.addWebHook.showLoading = false;
this.showAlert(this.addWebHook.message);
}
},
},
};
</script>

View file

@ -0,0 +1,61 @@
<template>
<div class="column content-box">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.TITLE')"
/>
<webhook-form
:value="value"
:is-submitting="uiFlags.updatingItem"
:submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.EDIT_SUBMIT')"
@submit="onSubmit"
@cancel="onClose"
/>
</div>
</template>
<script>
import alertMixin from 'shared/mixins/alertMixin';
import { mapGetters } from 'vuex';
import WebhookForm from './WebhookForm.vue';
export default {
components: { WebhookForm },
mixins: [alertMixin],
props: {
value: {
type: Object,
required: true,
},
id: {
type: [Number, String],
required: true,
},
onClose: {
type: Function,
required: true,
},
},
computed: {
...mapGetters({ uiFlags: 'webhooks/getUIFlags' }),
},
methods: {
async onSubmit(webhook) {
try {
await this.$store.dispatch('webhooks/update', {
webhook,
id: this.id,
});
this.showAlert(
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.SUCCESS_MESSAGE')
);
this.onClose();
} catch (error) {
const alertMessage =
error.response.data.message ||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
this.showAlert(alertMessage);
}
},
},
};
</script>

View file

@ -4,7 +4,7 @@
color-scheme="success"
class-names="button--fixed-right-top"
icon="add-circle"
@click="openAddPopup()"
@click="openAddPopup"
>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.HEADER_BTN_TXT') }}
</woot-button>
@ -37,35 +37,14 @@
</th>
</thead>
<tbody>
<tr v-for="(webHookItem, index) in records" :key="webHookItem.id">
<td class="webhook-link">
{{ webHookItem.url }}
</td>
<td class="button-wrapper">
<woot-button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.BUTTON_TEXT')
"
variant="smooth"
size="tiny"
color-scheme="secondary"
icon="edit"
@click="openEditPopup(webHookItem)"
>
</woot-button>
<woot-button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.BUTTON_TEXT')
"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
@click="openDeletePopup(webHookItem, index)"
>
</woot-button>
</td>
</tr>
<webhook-row
v-for="(webHookItem, index) in records"
:key="webHookItem.id"
:index="index"
:webhook="webHookItem"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</tbody>
</table>
</div>
@ -83,24 +62,27 @@
</div>
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<new-webhook :on-close="hideAddPopup" />
<new-webhook v-if="showAddPopup" :on-close="hideAddPopup" />
</woot-modal>
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
<edit-webhook
v-if="showEditPopup"
:id="selectedWebHook.id"
:url="selectedWebHook.url"
:value="selectedWebHook"
:on-close="hideEditPopup"
/>
</woot-modal>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.TITLE')"
:message="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.MESSAGE')"
:message="
$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.MESSAGE', {
webhookURL: selectedWebHook.url,
})
"
:confirm-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.YES')"
:reject-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.NO')"
/>
@ -112,11 +94,13 @@ import NewWebhook from './NewWebHook';
import EditWebhook from './EditWebHook';
import alertMixin from 'shared/mixins/alertMixin';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import WebhookRow from './WebhookRow';
export default {
components: {
NewWebhook,
EditWebhook,
WebhookRow,
},
mixins: [alertMixin, globalConfigMixin],
data() {
@ -179,11 +163,3 @@ export default {
},
};
</script>
<style scoped lang="scss">
.webhook-link {
word-break: break-word;
}
.button-wrapper button:nth-child(2) {
margin-left: var(--space-normal);
}
</style>

View file

@ -0,0 +1,59 @@
<template>
<div class="column content-box">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
:header-content="
useInstallationName(
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'),
globalConfig.installationName
)
"
/>
<webhook-form
:is-submitting="uiFlags.creatingItem"
:submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
@submit="onSubmit"
@cancel="onClose"
/>
</div>
</template>
<script>
import alertMixin from 'shared/mixins/alertMixin';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { mapGetters } from 'vuex';
import WebhookForm from './WebhookForm.vue';
export default {
components: { WebhookForm },
mixins: [alertMixin, globalConfigMixin],
props: {
onClose: {
type: Function,
required: true,
},
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
uiFlags: 'webhooks/getUIFlags',
}),
},
methods: {
async onSubmit(webhook) {
try {
await this.$store.dispatch('webhooks/create', { webhook });
this.showAlert(
this.$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
);
this.onClose();
} catch (error) {
const message =
error.response.data.message ||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
this.showAlert(message);
}
},
},
};
</script>

View file

@ -0,0 +1,108 @@
<template>
<form class="row" @submit.prevent="onSubmit">
<div class="medium-12 columns">
<label :class="{ error: $v.url.$error }">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.LABEL') }}
<input
v-model.trim="url"
type="text"
name="url"
:placeholder="
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER')
"
@input="$v.url.$touch"
/>
<span v-if="$v.url.$error" class="message">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.ERROR') }}
</span>
</label>
<label :class="{ error: $v.url.$error }" class="margin-bottom-small">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }}
</label>
<div v-for="event in supportedWebhookEvents" :key="event">
<input
:id="event"
v-model="subscriptions"
type="checkbox"
:value="event"
name="subscriptions"
class="margin-right-small"
/>
<span class="fs-small">
{{ `${getEventLabel(event)} (${event})` }}
</span>
</div>
</div>
<div class="modal-footer">
<div class="medium-12 columns">
<woot-button
:disabled="$v.$invalid || isSubmitting"
:is-loading="isSubmitting"
>
{{ submitLabel }}
</woot-button>
<woot-button class="button clear" @click.prevent="$emit('cancel')">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.CANCEL') }}
</woot-button>
</div>
</div>
</form>
</template>
<script>
import { required, url, minLength } from 'vuelidate/lib/validators';
import webhookMixin from './webhookMixin';
const SUPPORTED_WEBHOOK_EVENTS = [
'conversation_created',
'conversation_status_changed',
'conversation_updated',
'message_created',
'message_updated',
'webwidget_triggered',
];
export default {
mixins: [webhookMixin],
props: {
value: {
type: Object,
default: () => ({}),
},
isSubmitting: {
type: Boolean,
default: false,
},
submitLabel: {
type: String,
required: true,
},
},
validations: {
url: {
required,
minLength: minLength(7),
url,
},
subscriptions: {
required,
},
},
data() {
return {
url: this.value.url || '',
subscriptions: this.value.subscriptions || [],
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
};
},
methods: {
onSubmit() {
this.$emit('submit', {
url: this.url,
subscriptions: this.subscriptions,
});
},
},
};
</script>

View file

@ -0,0 +1,83 @@
<template>
<tr>
<td>
<div class="webhook--link">{{ webhook.url }}</div>
<span class="webhook--subscribed-events">
<span class="webhook--subscribed-label">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SUBSCRIBED_EVENTS') }}:
</span>
<show-more :text="subscribedEvents" :limit="60" />
</span>
</td>
<td class="button-wrapper">
<woot-button
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.BUTTON_TEXT')"
variant="smooth"
size="tiny"
color-scheme="secondary"
icon="edit"
@click="$emit('edit', webhook)"
>
</woot-button>
<woot-button
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
@click="$emit('delete', webhook, index)"
>
</woot-button>
</td>
</tr>
</template>
<script>
import webhookMixin from './webhookMixin';
import ShowMore from 'dashboard/components/widgets/ShowMore';
export default {
components: { ShowMore },
mixins: [webhookMixin],
props: {
webhook: {
type: Object,
required: true,
},
index: {
type: Number,
required: true,
},
},
computed: {
subscribedEvents() {
const { subscriptions } = this.webhook;
return subscriptions.map(event => this.getEventLabel(event)).join(', ');
},
},
};
</script>
<style scoped lang="scss">
.webhook--link {
color: var(--s-700);
font-weight: var(--font-weight-medium);
word-break: break-word;
}
.webhook--subscribed-events {
color: var(--s-500);
font-size: var(--font-size-mini);
}
.webhook--subscribed-label {
font-weight: var(--font-weight-medium);
}
.button-wrapper {
max-width: var(--space-mega);
min-width: auto;
button:nth-child(2) {
margin-left: var(--space-normal);
}
}
</style>

View file

@ -0,0 +1,26 @@
import { createWrapper } from '@vue/test-utils';
import webhookMixin from '../webhookMixin';
import Vue from 'vue';
describe('webhookMixin', () => {
describe('#getEventLabel', () => {
it('returns correct i18n translation:', () => {
const Component = {
render() {},
title: 'WebhookComponent',
mixins: [webhookMixin],
methods: {
$t(text) {
return text;
},
},
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
expect(wrapper.vm.getEventLabel('message_created')).toEqual(
`INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.MESSAGE_CREATED`
);
});
});
});

View file

@ -0,0 +1,10 @@
export default {
methods: {
getEventLabel(event) {
const eventName = event.toUpperCase();
return this.$t(
`INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.${eventName}`
);
},
},
};

View file

@ -1,6 +1,6 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import Webhook from './Webhook';
import Webhook from './Webhooks/Index';
import ShowIntegration from './ShowIntegration';
import { frontendURL } from '../../../../helper/URLHelper';

View file

@ -14,7 +14,7 @@ const state = {
export const getters = {
getWebhooks(_state) {
return _state.records;
return _state.records.sort((w1, w2) => w1.id - w2.id);
},
getUIFlags(_state) {
return _state.uiFlags;

View file

@ -1,22 +1,4 @@
class WebhookListener < BaseListener
# FIXME: deprecate the opened and resolved events in future in favor of status changed event.
def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0]
changed_attributes = extract_changed_attributes(event)
inbox = conversation.inbox
payload = conversation.webhook_data.merge(event: __method__.to_s, changed_attributes: changed_attributes)
deliver_webhook_payloads(payload, inbox)
end
# FIXME: deprecate the opened and resolved events in future in favor of status changed event.
def conversation_opened(event)
conversation = extract_conversation_and_account(event)[0]
changed_attributes = extract_changed_attributes(event)
inbox = conversation.inbox
payload = conversation.webhook_data.merge(event: __method__.to_s, changed_attributes: changed_attributes)
deliver_webhook_payloads(payload, inbox)
end
def conversation_status_changed(event)
conversation = extract_conversation_and_account(event)[0]
changed_attributes = extract_changed_attributes(event)
@ -71,15 +53,23 @@ class WebhookListener < BaseListener
private
def deliver_webhook_payloads(payload, inbox)
# Account webhooks
def deliver_account_webhooks(payload, inbox)
inbox.account.webhooks.account.each do |webhook|
next unless webhook.subscriptions.include?(payload[:event])
WebhookJob.perform_later(webhook.url, payload)
end
end
def deliver_api_inbox_webhooks(payload, inbox)
return unless inbox.channel_type == 'Channel::Api'
return if inbox.channel.webhook_url.blank?
WebhookJob.perform_later(inbox.channel.webhook_url, payload)
end
def deliver_webhook_payloads(payload, inbox)
deliver_account_webhooks(payload, inbox)
deliver_api_inbox_webhooks(payload, inbox)
end
end

View file

@ -2,13 +2,14 @@
#
# Table name: webhooks
#
# id :bigint not null, primary key
# url :string
# webhook_type :integer default("account")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer
# inbox_id :integer
# id :bigint not null, primary key
# subscriptions :jsonb
# url :string
# webhook_type :integer default("account")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer
# inbox_id :integer
#
# Indexes
#
@ -21,6 +22,18 @@ class Webhook < ApplicationRecord
validates :account_id, presence: true
validates :url, uniqueness: { scope: [:account_id] }, format: URI::DEFAULT_PARSER.make_regexp(%w[http https])
validate :validate_webhook_subscriptions
enum webhook_type: { account: 0, inbox: 1 }
ALLOWED_WEBHOOK_EVENTS = %w[conversation_status_changed conversation_updated conversation_created message_created message_updated
webwidget_triggered].freeze
private
def validate_webhook_subscriptions
invalid_subscriptions = !subscriptions.instance_of?(Array) ||
subscriptions.blank? ||
(subscriptions.uniq - ALLOWED_WEBHOOK_EVENTS).length.positive?
errors.add(:subscriptions, I18n.t('errors.webhook.invalid')) if invalid_subscriptions
end
end

View file

@ -1,6 +1,7 @@
json.id webhook.id
json.url webhook.url
json.account_id webhook.account_id
json.subscriptions webhook.subscriptions
if webhook.inbox
json.inbox do
json.id webhook.inbox.id

View file

@ -36,6 +36,8 @@ en:
reset_password_failure: Uh ho! We could not find any user with the specified email.
errors:
webhook:
invalid: Invalid events
signup:
disposable_email: We do not allow disposable emails
invalid_email: You have entered an invalid email

View file

@ -0,0 +1,12 @@
class AddSubscriptionsToWebhooks < ActiveRecord::Migration[6.1]
def change
add_column :webhooks, :subscriptions, :jsonb, default: %w[
conversation_status_changed
conversation_updated
conversation_created
message_created
message_updated
webwidget_triggered
]
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_04_18_094715) do
ActiveRecord::Schema.define(version: 2022_04_24_081117) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
@ -763,6 +763,7 @@ ActiveRecord::Schema.define(version: 2022_04_18_094715) do
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.integer "webhook_type", default: 0
t.jsonb "subscriptions", default: ["conversation_status_changed", "conversation_updated", "conversation_created", "message_created", "message_updated", "webwidget_triggered"]
t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true
end

View file

@ -57,6 +57,36 @@ RSpec.describe 'Webhooks API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)['message']).to eql 'Url is invalid'
end
it 'throws error if subscription events are invalid' do
post "/api/v1/accounts/#{account.id}/webhooks",
params: { url: 'https://hello.com', subscriptions: ['conversation_random_event'] },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)['message']).to eql 'Subscriptions Invalid events'
end
it 'throws error if subscription events are empty' do
post "/api/v1/accounts/#{account.id}/webhooks",
params: { url: 'https://hello.com', subscriptions: [] },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)['message']).to eql 'Subscriptions Invalid events'
end
it 'use default if subscription events are nil' do
post "/api/v1/accounts/#{account.id}/webhooks",
params: { url: 'https://hello.com', subscriptions: nil },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(
JSON.parse(response.body)['payload']['webhook']['subscriptions']
).to eql %w[conversation_status_changed conversation_updated conversation_created message_created message_updated
webwidget_triggered]
end
end
end

View file

@ -3,5 +3,15 @@ FactoryBot.define do
account_id { 1 }
inbox_id { 1 }
url { 'https://api.chatwoot.com' }
subscriptions do
%w[
conversation_status_changed
conversation_updated
conversation_created
message_created
message_updated
webwidget_triggered
]
end
end
end

View file

@ -23,14 +23,22 @@ describe WebhookListener do
end
end
context 'when webhook is configured' do
it 'triggers webhook' do
context 'when webhook is configured and event is subscribed' do
it 'triggers the webhook event' do
webhook = create(:webhook, inbox: inbox, account: account)
expect(WebhookJob).to receive(:perform_later).with(webhook.url, message.webhook_data.merge(event: 'message_created')).once
listener.message_created(message_created_event)
end
end
context 'when webhook is configured and event is not subscribed' do
it 'does not trigger the webhook event' do
create(:webhook, subscriptions: ['conversation_created'], inbox: inbox, account: account)
expect(WebhookJob).not_to receive(:perform_later)
listener.message_created(message_created_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)
@ -106,36 +114,6 @@ describe WebhookListener do
end
end
describe '#conversation_resolved' do
let!(:conversation_resolved_event) do
Events::Base.new(event_name, Time.zone.now, conversation: conversation.reload, changed_attributes: { status: [:open, :resolved] })
end
let(:event_name) { :'conversation.resolved' }
context 'when webhook is not configured' do
it 'does not trigger webhook' do
expect(WebhookJob).to receive(:perform_later).exactly(0).times
listener.conversation_resolved(conversation_resolved_event)
end
end
context 'when webhook is configured' do
it 'triggers webhook' do
webhook = create(:webhook, inbox: inbox, account: account)
conversation.update(status: :resolved)
expect(WebhookJob).to receive(:perform_later).with(webhook.url,
conversation.webhook_data.merge(event: 'conversation_resolved',
changed_attributes: [{ status: {
current_value: :resolved, previous_value: :open
} }])).once
listener.conversation_resolved(conversation_resolved_event)
end
end
end
describe '#conversation_updated' do
let(:custom_attributes) { { test: nil } }
let!(:conversation_updated_event) do