diff --git a/.rubocop.yml b/.rubocop.yml
index 4d61db2bb..3a08acf61 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -6,6 +6,7 @@ AllCops:
Exclude:
- "vendor/**/*"
- "db/schema.rb"
+ - "spec/**/*"
NewCops: enable
UseCache: false
TargetRubyVersion: 2.6
diff --git a/app/assets/javascripts/global_activities/index.js b/app/assets/javascripts/global_activities/index.js
index 4efc1e2e1..20c5fab2c 100644
--- a/app/assets/javascripts/global_activities/index.js
+++ b/app/assets/javascripts/global_activities/index.js
@@ -1,4 +1,4 @@
-/* global animateSpinner globalActivities */
+/* global animateSpinner globalActivities HelperModule */
'use strict';
@@ -59,6 +59,37 @@
});
}
+ function validateActivityFilterName() {
+ let filterName = $('#saveFilterModal .activity-filter-name-input').val();
+ $('#saveFilterModal .btn-confirm').prop('disabled', filterName.length === 0);
+ }
+
+ $('#saveFilterModal')
+ .on('keyup', '.activity-filter-name-input', function() {
+ validateActivityFilterName();
+ })
+ .on('click', '.btn-confirm', function() {
+ $.ajax({
+ url: this.dataset.saveFilterUrl,
+ type: 'POST',
+ global: false,
+ dataType: 'json',
+ data: {
+ name: $('#saveFilterModal .activity-filter-name-input').val(),
+ filter: globalActivities.getFilters()
+ },
+ success: function(data) {
+ HelperModule.flashAlertMsg(data.message, 'success');
+ $('#saveFilterModal .activity-filter-name-input').val('');
+ validateActivityFilterName();
+ $('#saveFilterModal').modal('hide');
+ },
+ error: function(response) {
+ HelperModule.flashAlertMsg(response.responseJSON.errors.join(','), 'danger');
+ }
+ });
+ });
+
initExpandCollapseAllButtons();
initShowMoreButton();
}());
diff --git a/app/assets/javascripts/users/settings/webhooks/index.js b/app/assets/javascripts/users/settings/webhooks/index.js
new file mode 100644
index 000000000..1e7a61aee
--- /dev/null
+++ b/app/assets/javascripts/users/settings/webhooks/index.js
@@ -0,0 +1,70 @@
+/* global dropdownSelector */
+
+(function() {
+ function initDeleteFilterModal() {
+ $('.activity-filters-list').on('click', '.delete-filter', function() {
+ $('#deleteFilterModal').find('.description b').text(this.dataset.name);
+ $('#deleteFilterModal').find('#filter_id').val(this.dataset.id);
+ $('#deleteFilterModal').modal('show');
+ });
+ }
+
+ function initFilterInfoDropdown() {
+ $('.info-container').on('show.bs.dropdown', function() {
+ var tagsList = $(this).find('.tags-list');
+ if (tagsList.is(':empty')) {
+ $.get(this.dataset.url, function(data) {
+ $.each(data.filter_elements, function(i, element) {
+ let tag = $('');
+ tag.text(element);
+ tagsList.append(tag);
+ });
+ });
+ }
+ });
+ }
+ $('.activity-filters-list').on('ajax:error', '.webhook-form', function(e, data) {
+ $(this).renderFormErrors('webhook', data.responseJSON.errors);
+ });
+
+ $('.activity-filters-list').on('click', '.create-webhook', function() {
+ let filterElement = $(this).closest('.filter-element');
+ filterElement.find('.webhooks-list').collapse('show');
+ filterElement.find('.create-webhook-container').removeClass('hidden');
+ });
+
+ $('.activity-filters-list').on('click', '.create-webhook-container .cancel-action', function(e) {
+ let webhookContainer = $(this).closest('.create-webhook-container');
+ e.preventDefault();
+ webhookContainer.addClass('hidden');
+ webhookContainer.find('.url-input').val('');
+ });
+
+ $('.activity-filters-list').on('click', '.edit-webhook', function(e) {
+ let webhookContainer = $(this).closest('.webhook');
+ e.preventDefault();
+ webhookContainer.find('.view-mode').addClass('hidden');
+ webhookContainer.find('.edit-webhook-container').removeClass('hidden');
+ });
+
+ $('.activity-filters-list').on('click', '.edit-webhook-container .cancel-action', function(e) {
+ let webhookContainer = $(this).closest('.webhook');
+ e.preventDefault();
+ webhookContainer.find('.view-mode').removeClass('hidden');
+ webhookContainer.find('.edit-webhook-container').addClass('hidden');
+ });
+
+
+ $('.webhook-method-container select').each(function() {
+ dropdownSelector.init($(this), {
+ singleSelect: true,
+ closeOnSelect: true,
+ noEmptyOption: true,
+ selectAppearance: 'simple',
+ disableSearch: true
+ });
+ });
+
+ initDeleteFilterModal();
+ initFilterInfoDropdown();
+}());
diff --git a/app/assets/stylesheets/settings/webhooks.scss b/app/assets/stylesheets/settings/webhooks.scss
new file mode 100644
index 000000000..a2a7b2d49
--- /dev/null
+++ b/app/assets/stylesheets/settings/webhooks.scss
@@ -0,0 +1,146 @@
+// scss-lint:disable SelectorDepth NestingDepth
+
+.webhooks-index {
+ .webhooks-description {
+ @include font-main;
+ margin: 1em 0;
+ }
+
+ .activity-filters-list {
+ padding: 0;
+ }
+
+ .filter-element {
+ border-left: 3px solid $color-concrete;
+ list-style: none;
+ margin: 1em 0;
+ }
+
+ .filter-block {
+ align-items: center;
+ display: flex;
+ padding-left: 1em;
+
+ .fa-caret-down {
+ cursor: pointer;
+ margin-right: 1em;
+
+ &.collapsed {
+ @include rotate(-90deg);
+ }
+ }
+
+ .create-webhook {
+ margin-left: auto;
+ }
+
+ .filter-name {
+ @include font-h3;
+ margin-right: .5em;
+ }
+
+ .info-container {
+ .dropdown-menu {
+ padding: .5em;
+ width: 400px;
+ }
+ }
+
+ .filter-info-title {
+ @include font-small;
+ padding-left: .25em;
+ }
+
+ .tags-list {
+ display: flex;
+ flex-wrap: wrap;
+
+ .filter-info-tag {
+ @include font-small;
+ background: $color-concrete;
+ flex-shrink: 0;
+ margin: .25em;
+ padding: .25em;
+ }
+ }
+ }
+
+ .webhooks-list {
+ list-style: none;
+ }
+
+ .webhook-form {
+ align-items: center;
+ display: flex;
+
+ .form-group {
+ margin: 0;
+ }
+
+ .form-text {
+ flex-shrink: 0;
+ }
+
+ .webhook-method-container {
+ margin: .5em;
+ }
+
+ .url-input-container {
+ margin: .5em;
+ }
+ }
+
+ .webhook {
+ .view-mode {
+ align-items: center;
+ display: flex;
+
+ .method {
+ margin: 0 .5em;
+ }
+
+ .webhook-text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .active-webhook,
+ .disabled-webhook {
+ flex-basis: 110px;
+ flex-shrink: 0;
+ margin-left: auto;
+ padding: 0 1em;
+ text-align: right;
+
+ .fas {
+ margin-right: .25em;
+ }
+ }
+
+ .active-webhook {
+ color: $brand_success;
+ }
+
+ .dropdown-menu {
+ @include font-button;
+
+ .fas {
+ margin-right: .25em;
+ }
+ }
+ }
+
+ &:not(.active) {
+ .view-mode {
+ color: $color-silver-chalice;
+ }
+ }
+ }
+
+ #deleteFilterModal {
+ .delete-filter-form {
+ display: inline-block;
+ }
+ }
+}
diff --git a/app/controllers/global_activities_controller.rb b/app/controllers/global_activities_controller.rb
index a0b79c68b..67c493147 100644
--- a/app/controllers/global_activities_controller.rb
+++ b/app/controllers/global_activities_controller.rb
@@ -3,6 +3,8 @@
class GlobalActivitiesController < ApplicationController
include InputSanitizeHelper
+ before_action :check_create_activity_filter_permissions, only: :save_activity_filter
+
def index
# Preload filter format
# {
@@ -106,8 +108,21 @@ class GlobalActivitiesController < ApplicationController
render json: get_objects(Report)
end
+ def save_activity_filter
+ activity_filter = ActivityFilter.new(activity_filter_params)
+ if activity_filter.save
+ render json: { message: t('global_activities.index.activity_filter_saved') }
+ else
+ render json: { errors: activity_filter.errors.full_messages }, status: :unprocessable_entity
+ end
+ end
+
private
+ def check_create_activity_filter_permissions
+ render_403 && return unless can_create_acitivity_filters?
+ end
+
def get_objects(subject)
query = subject_search_params[:query]
teams =
@@ -138,6 +153,10 @@ class GlobalActivitiesController < ApplicationController
matched.map { |pr| { value: pr[0], label: escape_input(pr[1]) } }
end
+ def activity_filter_params
+ params.permit(:name, filter: {})
+ end
+
def activity_filters
params.permit(
:page, :starting_timestamp, :from_date, :to_date, types: [], subjects: {}, users: [], teams: []
diff --git a/app/controllers/users/settings/webhooks_controller.rb b/app/controllers/users/settings/webhooks_controller.rb
new file mode 100644
index 000000000..ed1733ec2
--- /dev/null
+++ b/app/controllers/users/settings/webhooks_controller.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+module Users
+ module Settings
+ class WebhooksController < ApplicationController
+ layout 'fluid'
+
+ before_action :can_manage_filters
+ before_action :load_filter, except: :index
+ before_action :load_webhook, only: %i(update destroy)
+ before_action :set_sort, except: :filter_info
+
+ def index
+ @activity_filters = ActivityFilter.includes(:webhooks).order(name: (@current_sort == 'atoz' ? :asc : :desc))
+ end
+
+ def destroy_filter
+ @filter.destroy
+ redirect_to users_settings_webhooks_path(sort: @current_sort)
+ end
+
+ def filter_info
+ render json: { filter_elements: load_filter_elements(@filter) }
+ end
+
+ def create
+ @webhook = @filter.webhooks.create(webhook_params)
+ if @webhook.errors.any?
+ render json: { errors: @webhook.errors.messages }, status: :unprocessable_entity
+ else
+ flash[:success] = t('webhooks.index.webhook_created')
+ redirect_to users_settings_webhooks_path(sort: @current_sort)
+ end
+ end
+
+ def update
+ @webhook.update(webhook_params)
+ if @webhook.errors.any?
+ render json: { errors: @webhook.errors.messages }, status: :unprocessable_entity
+ else
+ flash[:success] = t('webhooks.index.webhook_updated')
+ redirect_to users_settings_webhooks_path(sort: @current_sort)
+ end
+ end
+
+ def destroy
+ @webhook.destroy
+ flash[:success] = t('webhooks.index.webhook_deleted')
+ redirect_to users_settings_webhooks_path(sort: @current_sort)
+ end
+
+ private
+
+ def can_manage_filters
+ render_403 && return unless can_create_acitivity_filters?
+ end
+
+ def set_sort
+ @current_sort = params[:sort] || 'atoz'
+ end
+
+ def load_filter
+ @filter = ActivityFilter.find_by(id: params[:filter_id])
+
+ render_404 && return unless @filter
+ end
+
+ def load_webhook
+ @webhook = Webhook.find_by(id: params[:id])
+
+ render_404 && return unless @webhook
+ end
+
+ def webhook_params
+ params.require(:webhook).permit(:http_method, :url, :active)
+ end
+
+ def load_filter_elements(filter)
+ result = []
+ filters = filter.filter
+ result += Team.where(id: filters['teams']).pluck(:name) if filters['teams']
+ result += User.where(id: filters['users']).pluck(:full_name) if filters['users']
+
+ if filters['types']
+ result += Activity.type_ofs.select { |_k, v| filters['types'].include?(v.to_s) }
+ .map { |k, _v| I18n.t("global_activities.activity_name.#{k}") }
+ end
+
+ if filters['to_date'] || filters['from_date']
+ result.push("#{t('global_activities.index.period_label')} #{filters['from_date']} - #{filters['to_date']}")
+ end
+
+ filters['subjects']&.each do |subject, ids|
+ result += subject.constantize.where(id: ids).pluck(:name)
+ end
+
+ result
+ end
+ end
+ end
+end
diff --git a/app/helpers/user_settings_helper.rb b/app/helpers/user_settings_helper.rb
index 3332564e0..2af268f64 100644
--- a/app/helpers/user_settings_helper.rb
+++ b/app/helpers/user_settings_helper.rb
@@ -23,6 +23,11 @@ module UserSettingsHelper
action_name.in?(%w(index new create show audits_index))
end
+ def on_settings_webhook_page?
+ controller_name.in?(%w(webhooks)) &&
+ action_name.in?(%w(index))
+ end
+
def on_settings_account_connected_accounts_page?
controller_name == 'connected_accounts'
end
diff --git a/app/jobs/activities/dispatch_webhooks_job.rb b/app/jobs/activities/dispatch_webhooks_job.rb
new file mode 100644
index 000000000..69cef0a63
--- /dev/null
+++ b/app/jobs/activities/dispatch_webhooks_job.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Activities
+ class DispatchWebhooksJob < ApplicationJob
+ queue_as :high_priority
+
+ def perform(activity)
+ webhooks =
+ Webhook.active.where(
+ activity_filter_id:
+ Activities::ActivityFilterMatchingService.new(activity).activity_filters.select(:id)
+ )
+
+ webhooks.each do |webhook|
+ Activities::SendWebhookJob.perform_later(webhook, activity)
+ end
+ end
+ end
+end
diff --git a/app/jobs/activities/send_webhook_job.rb b/app/jobs/activities/send_webhook_job.rb
new file mode 100644
index 000000000..3480a4a04
--- /dev/null
+++ b/app/jobs/activities/send_webhook_job.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Activities
+ class SendWebhookJob < ApplicationJob
+ queue_as :webhooks
+
+ retry_on StandardError, attempts: 3, wait: :exponentially_longer
+
+ def perform(webhook, activity)
+ Activities::ActivityWebhookService.new(webhook, activity).send_webhook
+ end
+ end
+end
diff --git a/app/models/activity.rb b/app/models/activity.rb
index 588928af7..b09d2a1d4 100644
--- a/app/models/activity.rb
+++ b/app/models/activity.rb
@@ -74,6 +74,8 @@ class Activity < ApplicationRecord
breadcrumbs: {}
)
+ after_create ->(activity) { Activities::DispatchWebhooksJob.perform_later(activity) }
+
def self.activity_types_list
activity_list = type_ofs.map do |key, value|
[
diff --git a/app/models/activity_filter.rb b/app/models/activity_filter.rb
new file mode 100644
index 000000000..6e08ea628
--- /dev/null
+++ b/app/models/activity_filter.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class ActivityFilter < ApplicationRecord
+ validates :name, presence: true
+ validates :filter, presence: true
+
+ has_many :webhooks, dependent: :destroy
+end
diff --git a/app/models/webhook.rb b/app/models/webhook.rb
new file mode 100644
index 000000000..2388f0ebc
--- /dev/null
+++ b/app/models/webhook.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class Webhook < ApplicationRecord
+ enum http_method: { get: 0, post: 1, patch: 2 }
+
+ belongs_to :activity_filter
+ validates :http_method, presence: true
+ validates :url, presence: true
+ validate :valid_url
+
+ scope :active, -> { where(active: true) }
+
+ private
+
+ def valid_url
+ unless /\A#{URI::DEFAULT_PARSER.make_regexp(%w(http https))}\z/.match?(url)
+ errors.add(:url, I18n.t('activerecord.errors.models.webhook.attributes.url.not_valid'))
+ end
+ end
+end
diff --git a/app/permissions/organization.rb b/app/permissions/organization.rb
index 47e7891b9..c15a2ced9 100644
--- a/app/permissions/organization.rb
+++ b/app/permissions/organization.rb
@@ -8,5 +8,9 @@ module Organization
can :create_teams do |_|
true
end
+
+ can :create_acitivity_filters do
+ true
+ end
end
end
diff --git a/app/services/activities/activity_filter_matching_service.rb b/app/services/activities/activity_filter_matching_service.rb
new file mode 100644
index 000000000..bbdd7943a
--- /dev/null
+++ b/app/services/activities/activity_filter_matching_service.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module Activities
+ class ActivityFilterMatchingService
+ def initialize(activity)
+ @activity = activity
+ @activity_filters = ActivityFilter.all
+ end
+
+ def activity_filters
+ filter_date!
+ filter_users!
+ filter_types!
+ filter_teams!
+ filter_subjects!
+
+ @activity_filters
+ end
+
+ private
+
+ def filter_date!
+ @activity_filters = @activity_filters.where(
+ "((filter ->> 'from_date') = '' AND (filter ->> 'to_date') = '') OR " \
+ "((?)::date BETWEEN (filter ->> 'from_date')::date AND (filter ->> 'to_date')::date)",
+ @activity.created_at.to_date
+ )
+ end
+
+ def filter_users!
+ @activity_filters = @activity_filters.where(
+ "NOT(filter ? 'users') OR filter -> 'users' @> '\"#{@activity.owner_id}\"'"
+ )
+ end
+
+ def filter_types!
+ @activity_filters = @activity_filters.where(
+ "NOT(filter ? 'types') OR filter -> 'types' @> '\"#{@activity.type_of_before_type_cast}\"'"
+ )
+ end
+
+ def filter_teams!
+ @activity_filters = @activity_filters.where(
+ "NOT(filter ? 'teams') OR filter -> 'teams' @> '\"#{@activity.team_id}\"'"
+ )
+ end
+
+ def filter_subjects!
+ @activity_filters = @activity_filters.where(
+ "NOT(filter ? 'subjects') OR "\
+ "filter -> 'subjects' -> '#{@activity.subject_type}' @> '\"#{@activity.subject_id}\"'"
+ )
+ end
+ end
+end
diff --git a/app/services/activities/activity_webhook_service.rb b/app/services/activities/activity_webhook_service.rb
new file mode 100644
index 000000000..7abd797f4
--- /dev/null
+++ b/app/services/activities/activity_webhook_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Activities
+ class ActivityWebhookService
+ def initialize(webhook, activity)
+ @webhook = webhook
+ @activity = activity
+ end
+
+ def send_webhook
+ WebhookService.new(@webhook, activity_payload).send_webhook
+ end
+
+ def activity_payload
+ @activity.values.merge(
+ type: @activity.type_of,
+ created_at: @activity.created_at
+ )
+ end
+ end
+end
diff --git a/app/services/activities_service.rb b/app/services/activities_service.rb
index 509c5c60c..8ca182fcd 100644
--- a/app/services/activities_service.rb
+++ b/app/services/activities_service.rb
@@ -76,4 +76,8 @@ class ActivitiesService
*subjects_with_children.to_h.flatten
)
end
+
+ def self.activity_matches_filter?(user, teams, activity, activity_filter)
+ load_activities(user, teams, activity_filter.filter).where(id: activity.id).any?
+ end
end
diff --git a/app/services/webhook_service.rb b/app/services/webhook_service.rb
new file mode 100644
index 000000000..7cb6c6624
--- /dev/null
+++ b/app/services/webhook_service.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+class WebhookService
+ class InactiveWebhookSendException < StandardError; end
+
+ class RequestFailureException < StandardError; end
+
+ def initialize(webhook, payload)
+ @webhook = webhook
+ @payload = payload
+ end
+
+ def send_webhook
+ unless @webhook.active?
+ raise(
+ InactiveWebhookSendException,
+ 'Refused to send inactive webhook.'
+ )
+ end
+
+ response = HTTParty.public_send(
+ @webhook.http_method,
+ @webhook.url,
+ {
+ headers: { 'Content-Type' => 'application/json' },
+ body: @payload
+ }
+ )
+
+ unless response.success?
+ error_description = "#{response.code}: #{response.message}"
+ log_error!(error_description)
+
+ raise(
+ RequestFailureException,
+ error_description
+ )
+ end
+
+ response
+ rescue Net::ReadTimeout, Net::OpenTimeout, SocketError => e
+ log_error!(e)
+ raise e
+ end
+
+ private
+
+ def log_error!(message)
+ @webhook.update!(
+ last_error: message
+ )
+ end
+end
diff --git a/app/views/global_activities/_save_filter_modal.html.erb b/app/views/global_activities/_save_filter_modal.html.erb
new file mode 100644
index 000000000..8014d5d63
--- /dev/null
+++ b/app/views/global_activities/_save_filter_modal.html.erb
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+ <%= t('global_activities.index.save_filter_modal.description') %>
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/global_activities/_top_pane.html.erb b/app/views/global_activities/_top_pane.html.erb
index b4586d2e1..e83abcd3c 100644
--- a/app/views/global_activities/_top_pane.html.erb
+++ b/app/views/global_activities/_top_pane.html.erb
@@ -17,6 +17,11 @@
+<%= render partial: 'save_filter_modal' %>
+
diff --git a/app/views/users/settings/_sidebar.html.erb b/app/views/users/settings/_sidebar.html.erb
index 3f081d8e9..963816620 100644
--- a/app/views/users/settings/_sidebar.html.erb
+++ b/app/views/users/settings/_sidebar.html.erb
@@ -32,5 +32,12 @@
class: "sidebar-link #{'selected' if on_settings_team_page?}"
%>
+
+
<% end %>
diff --git a/app/views/users/settings/webhooks/_delete_filter_modal.html.erb b/app/views/users/settings/webhooks/_delete_filter_modal.html.erb
new file mode 100644
index 000000000..a075e84e4
--- /dev/null
+++ b/app/views/users/settings/webhooks/_delete_filter_modal.html.erb
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+ <%= t('webhooks.index.delete_filter_modal.description_html') %>
+
+
+
+
+
+
diff --git a/app/views/users/settings/webhooks/_webhook_form.html.erb b/app/views/users/settings/webhooks/_webhook_form.html.erb
new file mode 100644
index 000000000..0c5febbdc
--- /dev/null
+++ b/app/views/users/settings/webhooks/_webhook_form.html.erb
@@ -0,0 +1,16 @@
+<%= t("webhooks.index.webhook_trigger") %>
+
+ <%= f.select :http_method, options_for_select(Webhook.http_methods.map{ |k,_v| [k.upcase, k] }, f.object.http_method) %>
+
+<%= t("webhooks.index.target") %>
+
+ <%= f.text_field :url, class: "sci-input-field url-input", placeholder: t("webhooks.index.url_placeholder") %>
+
+
+<%= f.button class: "btn btn-primary save-webhook" do %>
+
+ <%= t('general.save') %>
+<% end %>
diff --git a/app/views/users/settings/webhooks/index.html.erb b/app/views/users/settings/webhooks/index.html.erb
new file mode 100644
index 000000000..435e2e810
--- /dev/null
+++ b/app/views/users/settings/webhooks/index.html.erb
@@ -0,0 +1,131 @@
+<% provide(:head_title, t('webhooks.index.title')) %>
+<% provide(:container_class, "no-second-nav-container") %>
+
+<%= render partial: "users/settings/sidebar.html.erb" %>
+
+
+
+
+ <%= t('webhooks.index.description') %>
+
+
+ <% @activity_filters.each do |filter| %>
+ -
+
+
+
+ <%= filter.name %>
+
+
+
+
+ <%= t('webhooks.index.new_webhook') %>
+
+
+
+
+
+
+ -
+ <%= form_with model: Webhook.new, url: users_settings_webhooks_path(filter_id: filter.id, sort: @current_sort), class: 'webhook-form' do |f| %>
+ <%= render partial: 'webhook_form', locals: {f: f} %>
+ <% end %>
+
+ <% filter.webhooks.order(created_at: :desc).each do |webhook| %>
+ -
+
+
+ <%= t('webhooks.index.webhook_text_html', method: webhook.http_method.upcase, url: webhook.url) %>
+
+ <% if webhook.active? %>
+
+
+ <%= t("webhooks.index.active") %>
+
+ <% else %>
+
+
+ <%= t("webhooks.index.disabled") %>
+
+ <% end %>
+
+
+
+ <%= form_with model: webhook, url: users_settings_webhook_path(webhook, filter_id: filter.id, sort: @current_sort), class: 'webhook-form', method: :patch do |f| %>
+ <%= render partial: 'webhook_form', locals: {f: f} %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= render partial: 'delete_filter_modal' %>
+
+
+
+
+
+<%= javascript_include_tag "users/settings/webhooks/index" %>
+
diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb
index c2d256238..46c4afc6e 100644
--- a/config/initializers/assets.rb
+++ b/config/initializers/assets.rb
@@ -26,6 +26,7 @@ Rails.application.config.assets.precompile += %w(users/settings/teams/datatable.
Rails.application.config.assets.precompile += %w(users/settings/teams/add_user_modal.js)
Rails.application.config.assets.precompile += %w(users/settings/teams/show.js)
Rails.application.config.assets.precompile += %w(users/settings/teams/invite_users_modal.js)
+Rails.application.config.assets.precompile += %w(users/settings/webhooks/index.js)
Rails.application.config.assets.precompile += %w(my_modules/activities.js)
Rails.application.config.assets.precompile += %w(my_modules/protocols.js)
Rails.application.config.assets.precompile += %w(my_modules/repositories.js)
diff --git a/config/initializers/delayed_job_config.rb b/config/initializers/delayed_job_config.rb
index 4f51127e5..6c85da170 100644
--- a/config/initializers/delayed_job_config.rb
+++ b/config/initializers/delayed_job_config.rb
@@ -24,9 +24,7 @@ module DelayedWorkerConfig
# or left in the database with "failed_at" set dempends on the
# DESTROY_FAILED_JOBS value
def max_attempts
- value = ENV['DELAYED_WORKER_MAX_ATTEMPTS'].to_i
- return 6 if value.zero?
- value
+ 1 # We want ActiveJob to handle retries
end
# The default DELAYED_WORKER_MAX_RUN_TIME is 30.minutes.
@@ -65,5 +63,6 @@ Delayed::Worker.read_ahead = DelayedWorkerConfig.read_ahead
Delayed::Worker.default_queue_name = DelayedWorkerConfig.default_queue_name
Delayed::Worker.queue_attributes = {
high_priority: { priority: -10 },
+ webhooks: { priority: 5 },
low_priority: { priority: 10 }
}
diff --git a/config/locales/en.yml b/config/locales/en.yml
index cef639afc..4c8a355cd 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -146,6 +146,10 @@ en:
attributes:
base:
per_column_limit: "Too many items in the column"
+ webhook:
+ attributes:
+ url:
+ not_valid: 'Not valid URL'
helpers:
label:
@@ -1619,6 +1623,7 @@ en:
activities_counter_label: " activities"
expand_all: "Expand all"
collapse_all: "Collapse all"
+
modal:
modal_title: "Activities"
result_type:
@@ -1775,6 +1780,7 @@ en:
sidebar:
account: "Account"
teams: "Teams"
+ webhooks: "Webhooks"
account_nav:
profile: "Profile"
preferences: "Preferences"
@@ -2400,6 +2406,27 @@ en:
export_request_success: "Export request received!"
export_error: "Error when creating zip export."
+ webhooks:
+ index:
+ title: 'Webhooks'
+ description: 'Saved activity filters are listed below as collapsible sections. You can add more saved filters on the Activities page. Each saved filter can hold several webhooks.'
+ new_webhook: "New webhook"
+ applied_filters: "Applied activity filters"
+ delete_filter_modal:
+ title: "Delete filter"
+ description_html: "Are you sure you want to delete ?"
+ webhook_text_html: "Trigger %{method} target %{url}"
+ enable: "Enable"
+ disable: "Disable"
+ active: "Active"
+ disabled: "Disabled"
+ webhook_trigger: "Webhook trigger"
+ target: "target"
+ url_placeholder: "Enter url or other html target"
+ webhook_created: "Webhook successfully created"
+ webhook_updated: "Webhook successfully updated"
+ webhook_deleted: "Webhook successfully deleted"
+ delete_webhook_confimration: "Are you sure you want to delete the webhook?"
# This section contains general words that can be used in any parts of
# application.
tiny_mce:
diff --git a/config/locales/global_activities/en.yml b/config/locales/global_activities/en.yml
index 7efe6a9a2..6d81011f8 100644
--- a/config/locales/global_activities/en.yml
+++ b/config/locales/global_activities/en.yml
@@ -36,6 +36,13 @@ en:
select_reports: Select Reports
no_name: "(unnamed)"
content_generation_error: "Failed to render activity content with id: %{activity_id}"
+ activity_filter_saved: "Filter successfully saved to the Webhooks page!"
+ save_filter: "Save filter"
+ save_filter_modal:
+ title: "Save filter"
+ description: "Saved filter will be located in the Webhooks section of the Settings. It can be used to trigger webhooks."
+ filter_name_label: "Filter name"
+ filter_name_placeholder: "Name your filter"
content:
create_project_html: "%{user} created project %{project}."
rename_project_html: "%{user} renamed project %{project}."
diff --git a/config/routes.rb b/config/routes.rb
index acb47b963..3d7042734 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -104,6 +104,18 @@ Rails.application.routes.draw do
to: 'users/settings/user_teams#destroy',
as: 'destroy_user_team'
+ namespace :users do
+ namespace :settings do
+ resources :webhooks, only: %i(index create update destroy) do
+ collection do
+ post :destroy_filter
+ get :filter_info
+ end
+ end
+ end
+ end
+
+
# Invite users
devise_scope :user do
post '/invite',
@@ -729,6 +741,7 @@ Rails.application.routes.draw do
get :protocol_filter
get :team_filter
get :user_filter
+ post :save_activity_filter
end
end
diff --git a/db/migrate/20210531114633_create_activity_filters.rb b/db/migrate/20210531114633_create_activity_filters.rb
new file mode 100644
index 000000000..675a0c70e
--- /dev/null
+++ b/db/migrate/20210531114633_create_activity_filters.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class CreateActivityFilters < ActiveRecord::Migration[6.0]
+ def change
+ create_table :activity_filters do |t|
+ t.string :name, null: false
+ t.jsonb :filter, null: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20210603152345_create_webhooks_table.rb b/db/migrate/20210603152345_create_webhooks_table.rb
new file mode 100644
index 000000000..d5ea5474b
--- /dev/null
+++ b/db/migrate/20210603152345_create_webhooks_table.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class CreateWebhooksTable < ActiveRecord::Migration[6.0]
+ def change
+ create_table :webhooks do |t|
+ t.references :activity_filter, null: false, index: true, foreign_key: true
+ t.boolean :active, null: false, default: true
+ t.string :url, null: false
+ t.integer :method, null: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20210616071836_add_error_info_to_webhooks_and_rename_method_column.rb b/db/migrate/20210616071836_add_error_info_to_webhooks_and_rename_method_column.rb
new file mode 100644
index 000000000..2ca9f9324
--- /dev/null
+++ b/db/migrate/20210616071836_add_error_info_to_webhooks_and_rename_method_column.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class AddErrorInfoToWebhooksAndRenameMethodColumn < ActiveRecord::Migration[6.1]
+ def change
+ change_table :webhooks, bulk: true do |t|
+ t.text :last_error, :text
+ t.rename :method, :http_method
+ end
+ end
+end
diff --git a/db/structure.sql b/db/structure.sql
index 03015835d..e608f7d16 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -193,6 +193,38 @@ CREATE SEQUENCE public.activities_id_seq
ALTER SEQUENCE public.activities_id_seq OWNED BY public.activities.id;
+--
+-- Name: activity_filters; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.activity_filters (
+ id bigint NOT NULL,
+ name character varying NOT NULL,
+ filter jsonb NOT NULL,
+ created_at timestamp(6) without time zone NOT NULL,
+ updated_at timestamp(6) without time zone NOT NULL
+);
+
+
+--
+-- Name: activity_filters_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.activity_filters_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: activity_filters_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.activity_filters_id_seq OWNED BY public.activity_filters.id;
+
+
--
-- Name: ar_internal_metadata; Type: TABLE; Schema: public; Owner: -
--
@@ -2758,6 +2790,42 @@ CREATE SEQUENCE public.view_states_id_seq
ALTER SEQUENCE public.view_states_id_seq OWNED BY public.view_states.id;
+--
+-- Name: webhooks; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.webhooks (
+ id bigint NOT NULL,
+ activity_filter_id bigint NOT NULL,
+ active boolean DEFAULT true NOT NULL,
+ url character varying NOT NULL,
+ http_method integer NOT NULL,
+ created_at timestamp(6) without time zone NOT NULL,
+ updated_at timestamp(6) without time zone NOT NULL,
+ last_error text,
+ text text
+);
+
+
+--
+-- Name: webhooks_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.webhooks_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: webhooks_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.webhooks_id_seq OWNED BY public.webhooks.id;
+
+
--
-- Name: wopi_actions; Type: TABLE; Schema: public; Owner: -
--
@@ -2918,6 +2986,13 @@ ALTER TABLE ONLY public.active_storage_variant_records ALTER COLUMN id SET DEFAU
ALTER TABLE ONLY public.activities ALTER COLUMN id SET DEFAULT nextval('public.activities_id_seq'::regclass);
+--
+-- Name: activity_filters id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.activity_filters ALTER COLUMN id SET DEFAULT nextval('public.activity_filters_id_seq'::regclass);
+
+
--
-- Name: asset_text_data id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -3408,6 +3483,13 @@ ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_
ALTER TABLE ONLY public.view_states ALTER COLUMN id SET DEFAULT nextval('public.view_states_id_seq'::regclass);
+--
+-- Name: webhooks id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.webhooks ALTER COLUMN id SET DEFAULT nextval('public.webhooks_id_seq'::regclass);
+
+
--
-- Name: wopi_actions id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -3468,6 +3550,14 @@ ALTER TABLE ONLY public.activities
ADD CONSTRAINT activities_pkey PRIMARY KEY (id);
+--
+-- Name: activity_filters activity_filters_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.activity_filters
+ ADD CONSTRAINT activity_filters_pkey PRIMARY KEY (id);
+
+
--
-- Name: ar_internal_metadata ar_internal_metadata_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -4044,6 +4134,14 @@ ALTER TABLE ONLY public.view_states
ADD CONSTRAINT view_states_pkey PRIMARY KEY (id);
+--
+-- Name: webhooks webhooks_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.webhooks
+ ADD CONSTRAINT webhooks_pkey PRIMARY KEY (id);
+
+
--
-- Name: wopi_actions wopi_actions_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -5805,6 +5903,13 @@ CREATE INDEX index_view_states_on_user_id ON public.view_states USING btree (use
CREATE INDEX index_view_states_on_viewable ON public.view_states USING btree (viewable_type, viewable_id);
+--
+-- Name: index_webhooks_on_activity_filter_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_webhooks_on_activity_filter_id ON public.webhooks USING btree (activity_filter_id);
+
+
--
-- Name: index_wopi_actions_on_extension_and_action; Type: INDEX; Schema: public; Owner: -
--
@@ -6147,6 +6252,14 @@ ALTER TABLE ONLY public.tags
ADD CONSTRAINT fk_rails_5f245fd6a7 FOREIGN KEY (created_by_id) REFERENCES public.users(id);
+--
+-- Name: webhooks fk_rails_61458d031d; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.webhooks
+ ADD CONSTRAINT fk_rails_61458d031d FOREIGN KEY (activity_filter_id) REFERENCES public.activity_filters(id);
+
+
--
-- Name: user_my_modules fk_rails_62fa90cb51; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -7257,6 +7370,9 @@ INSERT INTO "schema_migrations" (version) VALUES
('20210407143303'),
('20210410100006'),
('20210506125657'),
+('20210531114633'),
+('20210603152345'),
+('20210616071836'),
('20210622101238'),
('20210715125349');
diff --git a/spec/factories/activity_filters.rb b/spec/factories/activity_filters.rb
new file mode 100644
index 000000000..518e6164c
--- /dev/null
+++ b/spec/factories/activity_filters.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :activity_filter do
+ name { 'type filter 1' }
+ filter { { 'types' => %w(0), 'from_date' => '', 'to_date' => '' } }
+ end
+end
diff --git a/spec/factories/webhooks.rb b/spec/factories/webhooks.rb
new file mode 100644
index 000000000..518747ecc
--- /dev/null
+++ b/spec/factories/webhooks.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :webhook do
+ activity_filter
+ http_method { 'post' }
+ url { 'https://www.example.com' }
+ end
+end
diff --git a/spec/jobs/activities/dispatch_webhooks_job_spec.rb b/spec/jobs/activities/dispatch_webhooks_job_spec.rb
new file mode 100644
index 000000000..8107cba1b
--- /dev/null
+++ b/spec/jobs/activities/dispatch_webhooks_job_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Activities::DispatchWebhooksJob do
+ let!(:activity_filter_1) { create :activity_filter }
+ let!(:activity_filter_2) { create :activity_filter }
+ let!(:non_matching_activity_filter) do
+ create(
+ :activity_filter,
+ filter: { 'types' => ['163'], 'from_date' => '', 'to_date' => '' }
+ )
+ end
+ let!(:webhook_1) { create :webhook, activity_filter: activity_filter_1 }
+ let!(:webhook_2) { create :webhook, activity_filter: activity_filter_2 }
+ let!(:webhook_3) { create :webhook, activity_filter: non_matching_activity_filter }
+ let!(:activity) { create :activity }
+
+ it 'enqueues webhook jobs' do
+ ActiveJob::Base.queue_adapter = :test
+
+ expect { Activities::DispatchWebhooksJob.new(activity).perform_now }
+ .to have_enqueued_job(Activities::SendWebhookJob).exactly(2).times
+ end
+end
diff --git a/spec/jobs/activities/send_webhook_job_spec.rb b/spec/jobs/activities/send_webhook_job_spec.rb
new file mode 100644
index 000000000..f67090969
--- /dev/null
+++ b/spec/jobs/activities/send_webhook_job_spec.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Activities::SendWebhookJob do
+ let!(:webhook) { create :webhook }
+ let!(:activity) { create :activity }
+
+ it 'sends the webhook' do
+ stub_request(:post, webhook.url).to_return(status: 200, body: "", headers: {})
+
+ expect(Activities::SendWebhookJob.new(webhook, activity).perform_now.response.code).to eq("200")
+ end
+end
diff --git a/spec/models/activity_spec.rb b/spec/models/activity_spec.rb
index 7dc06219a..fab782e92 100644
--- a/spec/models/activity_spec.rb
+++ b/spec/models/activity_spec.rb
@@ -4,6 +4,9 @@ require 'rails_helper'
describe Activity, type: :model do
let(:activity) { build :activity }
+ let(:user) { create :user }
+ let(:team) { create :team }
+
let(:old_activity) { build :activity, :old }
it 'should be of class Activity' do
@@ -61,6 +64,14 @@ describe Activity, type: :model do
end
end
+ describe '.create' do
+ it 'enqueues webhook dispatch job' do
+ ActiveJob::Base.queue_adapter = :test
+ expect { Activity.create(owner: user, team: team, type_of: "generate_pdf_report") }
+ .to have_enqueued_job(Activities::DispatchWebhooksJob)
+ end
+ end
+
describe '.save' do
it 'adds user to message items' do
activity.save
diff --git a/spec/services/activities/activity_filter_matching_service_spec.rb b/spec/services/activities/activity_filter_matching_service_spec.rb
new file mode 100644
index 000000000..f651dd928
--- /dev/null
+++ b/spec/services/activities/activity_filter_matching_service_spec.rb
@@ -0,0 +1,178 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Activities::ActivityFilterMatchingService do
+ let(:user) { create :user }
+ let(:user_2) { create :user }
+ let(:team) { create :team, :with_members }
+ let(:team_2) { create :team }
+ let(:project) do
+ create :project, team: team, user_projects: []
+ end
+ let(:project_2) do
+ create :project, team: team, user_projects: []
+ end
+ let(:activity) { create :activity }
+
+ it 'matches activity filters by activity date' do
+ matching_filter = ActivityFilter.create(
+ name: "date filter",
+ filter: {"to_date"=>"2021-1-2", "from_date"=>"2021-1-1"}
+ )
+
+ non_matching_filter = ActivityFilter.create(
+ name: "date filter",
+ filter: {"to_date"=>"2021-12-2", "from_date"=>"2021-12-1"}
+ )
+
+ activity.update_column(:created_at, Date.parse("2021-1-1").to_time)
+
+ matched_activity_filters = Activities::ActivityFilterMatchingService.new(activity).activity_filters
+
+ expect(matched_activity_filters).to include(matching_filter)
+ expect(matched_activity_filters).to_not include(non_matching_filter)
+ end
+
+ it 'matches activity filters by activity user' do
+ matching_filter = ActivityFilter.create(
+ name: "user filter 1",
+ filter: {"users" => [user.id.to_s], "from_date" => "", "to_date" => ""}
+ )
+
+ non_matching_filter = ActivityFilter.create(
+ name: "user filter 2",
+ filter: {"users" => [user_2.id.to_s], "from_date" => "", "to_date" => ""}
+ )
+
+ activity.update_column(:owner_id, user.id)
+
+ matched_activity_filters = Activities::ActivityFilterMatchingService.new(activity).activity_filters
+
+ expect(matched_activity_filters).to include(matching_filter)
+ expect(matched_activity_filters).to_not include(non_matching_filter)
+ end
+
+ it 'matches activity filters by activity type' do
+ matching_filter = ActivityFilter.create(
+ name: "type filter 1",
+ filter: {"types" => ["163"], "from_date" => "", "to_date" => ""}
+ )
+
+ non_matching_filter = ActivityFilter.create(
+ name: "type filter 2",
+ filter: {"types" => ["0"], "from_date" => "", "to_date" => ""}
+ )
+
+ activity.update_column(:type_of, 163)
+
+ matched_activity_filters = Activities::ActivityFilterMatchingService.new(activity).activity_filters
+
+ expect(matched_activity_filters).to include(matching_filter)
+ expect(matched_activity_filters).to_not include(non_matching_filter)
+ end
+
+ it 'matches activity filters by activity team' do
+ matching_filter = ActivityFilter.create(
+ name: "team filter 1",
+ filter: {"teams" => [team.id.to_s], "from_date" => "", "to_date" => ""}
+ )
+
+ non_matching_filter = ActivityFilter.create(
+ name: "team filter 2",
+ filter: {"teams" => [team_2.id.to_s], "from_date" => "", "to_date" => ""}
+ )
+
+ activity.update_column(:team_id, team.id)
+
+ matched_activity_filters = Activities::ActivityFilterMatchingService.new(activity).activity_filters
+
+ expect(matched_activity_filters).to include(matching_filter)
+ expect(matched_activity_filters).to_not include(non_matching_filter)
+ end
+
+ it 'matches activity filters by activity subject' do
+ matching_filter = ActivityFilter.create(
+ name: "subject filter 1",
+ filter: {"subjects" => { "Project" => [project.id.to_s] }, "from_date" => "", "to_date" => ""}
+ )
+
+ non_matching_filter = ActivityFilter.create(
+ name: "subject filter 2",
+ filter: {"subjects" => { "Project" => [project_2.id.to_s] }, "from_date" => "", "to_date" => ""}
+ )
+
+ activity.update_columns(subject_type: "Project", subject_id: project.id)
+
+ matched_activity_filters = Activities::ActivityFilterMatchingService.new(activity).activity_filters
+
+ expect(matched_activity_filters).to include(matching_filter)
+ expect(matched_activity_filters).to_not include(non_matching_filter)
+ end
+
+ it 'matches activity filters by a combination of filters' do
+ matching_filter = ActivityFilter.create(
+ name: "mixed filter 1",
+ filter: {
+ "subjects" => { "Project" => [project.id.to_s] },
+ "to_date"=>"2021-1-2",
+ "from_date"=>"2021-1-1",
+ "teams"=>[team.id.to_s],
+ "users"=>[user.id.to_s],
+ "types"=>["163"]
+ }
+ )
+
+ activity.update_columns(
+ created_at: Date.parse("2021-1-1").to_time,
+ owner_id: user.id,
+ type_of: 163,
+ subject_type: "Project",
+ subject_id: project.id,
+ team_id: team.id
+ )
+
+ non_matching_filter_1 = ActivityFilter.create(
+ name: "mixed filter 1",
+ filter: {
+ "subjects" => { "Project" => [project.id.to_s] },
+ "to_date"=>"2021-10-2",
+ "from_date"=>"2021-10-1",
+ "teams"=>[team.id.to_s],
+ "users"=>[user.id.to_s],
+ "types"=>["163"]
+ }
+ )
+
+ non_matching_filter_2 = ActivityFilter.create(
+ name: "mixed filter 2",
+ filter: {
+ "subjects" => { "Project" => [project.id.to_s] },
+ "to_date"=>"2021-1-2",
+ "from_date"=>"2021-1-1",
+ "teams"=>[team_2.id.to_s],
+ "users"=>[user.id.to_s],
+ "types"=>["163"]
+ }
+ )
+
+ non_matching_filter_3 = ActivityFilter.create(
+ name: "mixed filter 3",
+ filter: {
+ "subjects" => { "Project" => [project_2.id.to_s] },
+ "to_date"=>"2021-1-2",
+ "from_date"=>"2021-1-1",
+ "teams"=>[team.id.to_s],
+ "users"=>[user.id.to_s],
+ "types"=>["163"]
+ }
+ )
+
+ matched_activity_filters = Activities::ActivityFilterMatchingService.new(activity).activity_filters
+
+ expect(matched_activity_filters).to include(matching_filter)
+ expect(matched_activity_filters).to_not include(non_matching_filter_1)
+ expect(matched_activity_filters).to_not include(non_matching_filter_2)
+ expect(matched_activity_filters).to_not include(non_matching_filter_3)
+ end
+end
diff --git a/spec/services/webhook_service_spec.rb b/spec/services/webhook_service_spec.rb
new file mode 100644
index 000000000..695523cf8
--- /dev/null
+++ b/spec/services/webhook_service_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Activities::CreateActivityService do
+ let(:webhook) { create :webhook }
+
+ context 'when webhook is valid' do
+ it 'executes webhook' do
+ stub_request(:post, webhook.url).to_return(status: 200, body: "", headers: {})
+
+ expect(WebhookService.new(webhook, { payload: "payload" }).send_webhook.response.code).to eq("200")
+ end
+ end
+
+ context 'when webhook gets non-success HTTP response' do
+ it 'logs error' do
+ stub_request(:post, webhook.url).to_return(status: 500, body: "", headers: {})
+
+ expect { WebhookService.new(webhook, { payload: "payload" }).send_webhook }.to(
+ raise_error(WebhookService::RequestFailureException)
+ )
+
+ expect(webhook.last_error).to eq("500: ")
+ end
+ end
+
+ context 'when webhook times out' do
+ it 'logs error' do
+ stub_request(:post, webhook.url).to_timeout
+
+ expect { WebhookService.new(webhook, { payload: "payload" }).send_webhook }.to raise_error(Net::OpenTimeout)
+ expect(webhook.last_error).to eq("execution expired")
+ end
+ end
+
+ context 'when webhook url cannot be resolved' do
+ it 'logs error' do
+ stub_request(:post, webhook.url).to_raise(SocketError)
+
+ expect { WebhookService.new(webhook, { payload: "payload" }).send_webhook }.to raise_error(SocketError)
+ expect(webhook.last_error).to eq("Exception from WebMock")
+ end
+ end
+end