Merge pull request #3463 from biosistemika/features/webhooks

Features/webhooks
This commit is contained in:
artoscinote 2021-08-03 13:32:12 +02:00 committed by GitHub
commit f24207588c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1275 additions and 4 deletions

View file

@ -6,6 +6,7 @@ AllCops:
Exclude:
- "vendor/**/*"
- "db/schema.rb"
- "spec/**/*"
NewCops: enable
UseCache: false
TargetRubyVersion: 2.6

View file

@ -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();
}());

View file

@ -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 = $('<span class="filter-info-tag"></span>');
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();
}());

View file

@ -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;
}
}
}

View file

@ -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: []

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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|
[

View file

@ -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

20
app/models/webhook.rb Normal file
View file

@ -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

View file

@ -8,5 +8,9 @@ module Organization
can :create_teams do |_|
true
end
can :create_acitivity_filters do
true
end
end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,25 @@
<div class="modal" id="saveFilterModal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="<%= t('general.close') %>"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="regenerate-report-modal-label">
<%= t('global_activities.index.save_filter_modal.title') %>
</h4>
</div>
<div class="modal-body">
<p>
<%= t('global_activities.index.save_filter_modal.description') %>
</p>
<div class="sci-input-container">
<label><%= t('global_activities.index.save_filter_modal.filter_name_label') %></label>
<input type="text" class="sci-input-field activity-filter-name-input" placeholder="<%= t('global_activities.index.save_filter_modal.filter_name_placeholder') %>"></input>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal"><%= t('general.cancel') %></button>
<button type="button" class="btn btn-primary btn-confirm" data-save-filter-url="<%= save_activity_filter_global_activities_path %>" disabled><%= t('general.save') %></button>
</div>
</div>
</div>
</div>

View file

@ -17,6 +17,11 @@
</div>
<div class="ga-tags-container">
<div class="ga-tags perfect-scrollbar"></div>
<% if can_create_acitivity_filters? %>
<div class="btn btn-light save-filter" data-toggle="modal" data-target="#saveFilterModal">
<i class="fas fa-save"></i><%= t('global_activities.index.save_filter') %>
</div>
<% end %>
<div class="btn btn-light clear-container">
<i class="fas fa-times-circle"></i><%= t('global_activities.index.clear_filters') %>
</div>

View file

@ -32,6 +32,8 @@
</div>
</div>
<%= render partial: 'save_filter_modal' %>
<script>
var gaUrlQueryParams= <%= raw @filters.to_json %>
</script>

View file

@ -32,5 +32,12 @@
class: "sidebar-link #{'selected' if on_settings_team_page?}"
%>
</li>
<li class="sidebar-leaf" >
<%= link_to t("users.settings.sidebar.webhooks"),
users_settings_webhooks_path,
class: "sidebar-link #{'selected' if on_settings_webhook_page?}"
%>
</li>
</ul>
<% end %>

View file

@ -0,0 +1,24 @@
<div class="modal" id="deleteFilterModal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="<%= t('general.close') %>"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title">
<%= t('webhooks.index.delete_filter_modal.title') %>
</h4>
</div>
<div class="modal-body">
<p class="description">
<%= t('webhooks.index.delete_filter_modal.description_html') %>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal"><%= t('general.cancel') %></button>
<%= form_with url: destroy_filter_users_settings_webhooks_path(sort: @current_sort), class: "delete-filter-form" do |f| %>
<%= hidden_field_tag :filter_id %>
<%= f.button t('general.delete'), class: "btn btn-danger" %>
<% end %>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,16 @@
<span class="form-text"><%= t("webhooks.index.webhook_trigger") %></span>
<div class="webhook-method-container">
<%= f.select :http_method, options_for_select(Webhook.http_methods.map{ |k,_v| [k.upcase, k] }, f.object.http_method) %>
</div>
<span class="form-text"><%= t("webhooks.index.target") %></span>
<div class="sci-input-container url-input-container form-group">
<%= f.text_field :url, class: "sci-input-field url-input", placeholder: t("webhooks.index.url_placeholder") %>
</div>
<button class="btn btn-light cancel-action">
<i class="fas fa-times"></i>
<%= t('general.cancel') %>
</button>
<%= f.button class: "btn btn-primary save-webhook" do %>
<i class="fas fa-save"></i>
<%= t('general.save') %>
<% end %>

View file

@ -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" %>
<div class="content-pane flexible webhooks-index">
<div class="content-header sticky-header">
<div class="title-row">
<h1>
<%= t('webhooks.index.title') %>
</h1>
<div class="header-actions">
<div class="dropdown sort-menu">
<button class="btn btn-light dropdown-toggle" type="button" id="sortMenu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
<span><i class="fas fa-sort-amount-down"></i></span>
</button>
<ul id="sortMenuDropdown" class="dropdown-menu dropdown-menu-right" aria-labelledby="sortMenu">
<% %w(atoz ztoa).each do |sort| %>
<li>
<%= link_to t("general.sort.#{sort}_html"), users_settings_webhooks_path(sort: sort), class: (@current_sort == sort ? 'selected' : '') %>
</li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<div class="webhooks-description">
<%= t('webhooks.index.description') %>
</div>
<ul class="activity-filters-list">
<% @activity_filters.each do |filter| %>
<li class="filter-element">
<div class="filter-block">
<i class="fas fa-caret-down collapsed" data-toggle="collapse" href="#activityFilter<%= filter.id %>" aria-expanded="false"></i>
<span class="filter-name" title="<%= filter.name %>">
<%= filter.name %>
</span>
<div class="info-container dropdown" data-url="<%= filter_info_users_settings_webhooks_path(filter_id: filter.id) %>" >
<div id="filter-info-<%= filter.id %>-button" class="btn btn-light show-filter icon-btn" data-toggle="dropdown">
<i class="fas fa-info-circle"></i>
</div>
<div class="dropdown-menu" aria-labelledby="filter-info-<%= filter.id %>-button" role="menu">
<p class="filter-info-title"><%= t('webhooks.index.applied_filters') %></p>
<div class="tags-list"></div>
</div>
</div>
<div class="btn btn-light create-webhook">
<i class="fas fa-plus"></i>
<%= t('webhooks.index.new_webhook') %>
</div>
<div class="btn btn-light delete-filter icon-btn" data-id="<%= filter.id %>" data-name="<%= filter.name %>">
<i class="fas fa-trash"></i>
</div>
</div>
<ul class="webhooks-list collapse" id="activityFilter<%= filter.id %>">
<li class="create-webhook-container hidden">
<%= 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 %>
</li>
<% filter.webhooks.order(created_at: :desc).each do |webhook| %>
<li class="webhook <%= 'active' if webhook.active? %>">
<div class="view-mode">
<span class="webhook-text">
<%= t('webhooks.index.webhook_text_html', method: webhook.http_method.upcase, url: webhook.url) %>
</span>
<% if webhook.active? %>
<span class="active-webhook">
<i class="fas fa-check-circle"></i>
<%= t("webhooks.index.active") %>
</span>
<% else %>
<span class="disabled-webhook">
<i class="fas fa-unlink"></i>
<%= t("webhooks.index.disabled") %>
</span>
<% end %>
<div class="dropdown webhook-menu">
<button class="btn btn-light icon-btn dropdown-toggle" type="button" id="webhookMenuButton<%= webhook.id %>" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
<span><i class="fas fa-ellipsis-h"></i></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" aria-labelledby="webhookMenuButton<%= webhook.id %>">
<li>
<a href="#" class="edit-webhook">
<i class="fas fa-pen"></i>
<%= t('general.edit') %>
</a>
</li>
<li>
<% if webhook.active? %>
<%= link_to users_settings_webhook_path(webhook, filter_id: filter.id, 'webhook[active]' => false, sort: @current_sort), method: :patch do %>
<i class="fas fa-unlink"></i>
<%= t("webhooks.index.disable") %>
<% end %>
<% else %>
<%= link_to users_settings_webhook_path(webhook, filter_id: filter.id, 'webhook[active]' => true, sort: @current_sort), method: :patch do %>
<i class="fas fa-check-circle"></i>
<%= t("webhooks.index.enable") %>
<% end %>
<% end %>
</li>
<li>
<%= link_to users_settings_webhook_path(webhook, filter_id: filter.id, sort: @current_sort), method: :delete, data: { confirm: t('webhooks.index.delete_webhook_confimration') } do %>
<i class="fas fa-trash"></i>
<%= t('general.delete') %>
<% end %>
</li>
</ul>
</div>
</div>
<div class="edit-webhook-container hidden">
<%= 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 %>
</div>
</li>
<% end %>
</ul>
</li>
<% end %>
</ul>
<%= render partial: 'delete_filter_modal' %>
</div>
<%= javascript_include_tag "users/settings/webhooks/index" %>

View file

@ -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)

View file

@ -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 }
}

View file

@ -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 <b></b>?"
webhook_text_html: "Trigger <b class=\"method\">%{method}</b> target <b>%{url}</b>"
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:

View file

@ -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}."

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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');

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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