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 @@ + 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 @@
+ <% if can_create_acitivity_filters? %> +
+ <%= t('global_activities.index.save_filter') %> +
+ <% end %>
<%= t('global_activities.index.clear_filters') %>
diff --git a/app/views/global_activities/index.html.erb b/app/views/global_activities/index.html.erb index 12dae178c..d4193a172 100644 --- a/app/views/global_activities/index.html.erb +++ b/app/views/global_activities/index.html.erb @@ -32,6 +32,8 @@
+<%= 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 @@ + 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') %> +
+ + + <%= 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