@@ -193,6 +193,7 @@
|
||||
|
||||
/* Lists */
|
||||
:where(.list-style-none) {
|
||||
list-style: none;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
class AdminController < ApplicationController
|
||||
include StaffOnly
|
||||
before_action :ensure_is_staff_member
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Authentication, CurrentRequest, CurrentTimezone, SetPlatform, TurboFlash, WriterAffinity
|
||||
include Authentication, Authorization, CurrentRequest, CurrentTimezone, SetPlatform, TurboFlash, WriterAffinity
|
||||
|
||||
stale_when_importmap_changes
|
||||
allow_browser versions: :modern, block: -> { render "errors/not_acceptable", layout: "error" }
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
module Authorization
|
||||
private
|
||||
def ensure_can_administer
|
||||
head :forbidden unless Current.user.admin?
|
||||
end
|
||||
|
||||
def ensure_is_staff_member
|
||||
head :forbidden unless Current.user.staff?
|
||||
end
|
||||
end
|
||||
@@ -1,12 +0,0 @@
|
||||
module StaffOnly
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :ensure_staff
|
||||
end
|
||||
|
||||
private
|
||||
def ensure_staff
|
||||
head :forbidden unless Current.user.staff?
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,5 @@
|
||||
class Conversations::MessagesController < ApplicationController
|
||||
include StaffOnly
|
||||
|
||||
before_action :ensure_is_staff_member
|
||||
before_action :set_conversation
|
||||
|
||||
def index
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class ConversationsController < ApplicationController
|
||||
include StaffOnly
|
||||
before_action :ensure_is_staff_member
|
||||
|
||||
def create
|
||||
Current.user.start_or_continue_conversation
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
class Webhooks::ActivationsController < ApplicationController
|
||||
before_action :ensure_can_administer
|
||||
before_action :set_webhook
|
||||
|
||||
def create
|
||||
@webhook.activate unless @webhook.active?
|
||||
redirect_to @webhook, status: :see_other
|
||||
end
|
||||
|
||||
private
|
||||
def set_webhook
|
||||
@webhook = Webhook.find(params[:webhook_id])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
class WebhooksController < ApplicationController
|
||||
include FilterScoped
|
||||
|
||||
before_action :ensure_can_administer
|
||||
before_action :set_collection
|
||||
before_action :set_webhook, except: %i[ index new create ]
|
||||
|
||||
def index
|
||||
set_page_and_extract_portion_from @collection.webhooks.ordered
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def new
|
||||
@webhook = @collection.webhooks.new
|
||||
end
|
||||
|
||||
def create
|
||||
@webhook = @collection.webhooks.create!(webhook_params)
|
||||
redirect_to @webhook
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@webhook.update!(webhook_params.except(:url))
|
||||
redirect_to @webhook
|
||||
end
|
||||
|
||||
def destroy
|
||||
@webhook.destroy!
|
||||
redirect_to collection_webhooks_path, status: :see_other
|
||||
end
|
||||
|
||||
private
|
||||
def set_collection
|
||||
@collection = Collection.find(params[:collection_id])
|
||||
end
|
||||
|
||||
def set_webhook
|
||||
@webhook = @collection.webhooks.find(params[:id])
|
||||
end
|
||||
|
||||
def webhook_params
|
||||
params
|
||||
.expect(webhook: [ :name, :url, subscribed_actions: [] ])
|
||||
.merge(collection_id: @collection.id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
require "active_job/continuable"
|
||||
|
||||
class Event::WebhookDispatchJob < ApplicationJob
|
||||
include ActiveJob::Continuable
|
||||
|
||||
queue_as :webhooks
|
||||
|
||||
def perform(event)
|
||||
step :dispatch do |step|
|
||||
Webhook.active.triggered_by(event).find_each(start: step.cursor) do |webhook|
|
||||
webhook.trigger(event)
|
||||
step.advance! from: webhook.id
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class Webhook::CleanupDeliveriesJob < ApplicationJob
|
||||
def perform
|
||||
ApplicationRecord.with_each_tenant do
|
||||
Webhook::Delivery.cleanup
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class Webhook::DeliveryJob < ApplicationJob
|
||||
queue_as :webhooks
|
||||
|
||||
def perform(delivery)
|
||||
delivery.deliver
|
||||
end
|
||||
end
|
||||
@@ -8,6 +8,7 @@ class Collection < ApplicationRecord
|
||||
has_many :cards, dependent: :destroy
|
||||
has_many :tags, -> { distinct }, through: :cards
|
||||
has_many :events
|
||||
has_many :webhooks, dependent: :destroy
|
||||
has_one :entropy_configuration, class_name: "Entropy::Configuration", as: :container, dependent: :destroy
|
||||
|
||||
scope :alphabetically, -> { order("lower(name)") }
|
||||
|
||||
@@ -5,9 +5,12 @@ class Event < ApplicationRecord
|
||||
belongs_to :creator, class_name: "User"
|
||||
belongs_to :eventable, polymorphic: true
|
||||
|
||||
has_many :webhook_deliveries, class_name: "Webhook::Delivery", dependent: :delete_all
|
||||
|
||||
scope :chronologically, -> { order created_at: :asc, id: :desc }
|
||||
|
||||
after_create -> { eventable.event_was_created(self) }
|
||||
after_create_commit :dispatch_webhooks
|
||||
|
||||
delegate :card, to: :eventable
|
||||
|
||||
@@ -18,4 +21,9 @@ class Event < ApplicationRecord
|
||||
def notifiable_target
|
||||
eventable
|
||||
end
|
||||
|
||||
private
|
||||
def dispatch_webhooks
|
||||
Event::WebhookDispatchJob.perform_later(self)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,7 +18,7 @@ module User::Role
|
||||
admin? || other == self
|
||||
end
|
||||
|
||||
def can_administer?(other)
|
||||
def can_administer?(other = nil)
|
||||
admin? && other != self
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
class Webhook < ApplicationRecord
|
||||
include Triggerable
|
||||
|
||||
SLACK_WEBHOOK_URL_REGEX = %r{//hooks\.slack\.com/services/T[^\/]+/B[^\/]+/[^\/]+\Z}i
|
||||
CAMPFIRE_WEBHOOK_URL_REGEX = %r{/rooms/\d+/\d+-[^\/]+/messages\Z}i
|
||||
BASECAMP_CAMPFIRE_WEBHOOK_URL_REGEX = %r{/\d+/integrations/[^\/]+/buckets/\d+/chats/\d+/lines\Z}i
|
||||
|
||||
PERMITTED_SCHEMES = %w[ http https ].freeze
|
||||
PERMITTED_ACTIONS = %w[
|
||||
card_assigned
|
||||
card_closed
|
||||
card_collection_changed
|
||||
card_due_date_added
|
||||
card_due_date_changed
|
||||
card_due_date_removed
|
||||
card_published
|
||||
card_reopened
|
||||
card_staged
|
||||
card_title_changed
|
||||
card_unassigned
|
||||
card_unstaged
|
||||
comment_created
|
||||
].freeze
|
||||
|
||||
has_secure_token :signing_secret
|
||||
|
||||
has_many :deliveries, dependent: :delete_all
|
||||
has_one :delinquency_tracker, dependent: :delete
|
||||
|
||||
belongs_to :collection
|
||||
|
||||
serialize :subscribed_actions, type: Array, coder: JSON
|
||||
|
||||
scope :ordered, -> { order(name: :asc, id: :desc) }
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
after_create :create_delinquency_tracker!
|
||||
|
||||
normalizes :subscribed_actions, with: ->(value) { Array.wrap(value).map(&:to_s).uniq & PERMITTED_ACTIONS }
|
||||
|
||||
validates :name, presence: true
|
||||
validate :validate_url
|
||||
|
||||
def activate
|
||||
update_columns active: true
|
||||
end
|
||||
|
||||
def deactivate
|
||||
update_columns active: false
|
||||
end
|
||||
|
||||
def renderer
|
||||
@renderer ||= ApplicationController.renderer.new
|
||||
end
|
||||
|
||||
def for_basecamp?
|
||||
url.match? BASECAMP_CAMPFIRE_WEBHOOK_URL_REGEX
|
||||
end
|
||||
|
||||
def for_campfire?
|
||||
url.match? CAMPFIRE_WEBHOOK_URL_REGEX
|
||||
end
|
||||
|
||||
def for_slack?
|
||||
url.match? SLACK_WEBHOOK_URL_REGEX
|
||||
end
|
||||
|
||||
private
|
||||
def validate_url
|
||||
uri = URI.parse(url.presence)
|
||||
|
||||
if PERMITTED_SCHEMES.exclude?(uri.scheme)
|
||||
errors.add :url, "must use #{PERMITTED_SCHEMES.to_choice_sentence}"
|
||||
end
|
||||
rescue URI::InvalidURIError
|
||||
errors.add :url, "not a URL"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
class Webhook::DelinquencyTracker < ApplicationRecord
|
||||
DELINQUENCY_THRESHOLD = 10
|
||||
DELINQUENCY_DURATION = 1.hour
|
||||
|
||||
belongs_to :webhook
|
||||
|
||||
def record_delivery_of(delivery)
|
||||
if delivery.succeeded?
|
||||
reset
|
||||
else
|
||||
mark_first_failure_time if consecutive_failures_count.zero?
|
||||
increment!(:consecutive_failures_count)
|
||||
|
||||
webhook.deactivate if delinquent?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def reset
|
||||
update_columns consecutive_failures_count: 0, first_failure_at: nil
|
||||
end
|
||||
|
||||
def mark_first_failure_time
|
||||
update_columns first_failure_at: Time.current
|
||||
end
|
||||
|
||||
def delinquent?
|
||||
failing_for_too_long? && too_many_consecutive_failures?
|
||||
end
|
||||
|
||||
def failing_for_too_long?
|
||||
if first_failure_at
|
||||
first_failure_at.before?(DELINQUENCY_DURATION.ago)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def too_many_consecutive_failures?
|
||||
consecutive_failures_count >= DELINQUENCY_THRESHOLD
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,150 @@
|
||||
class Webhook::Delivery < ApplicationRecord
|
||||
STALE_TRESHOLD = 7.days
|
||||
USER_AGENT = "fizzy/1.0.0 Webhook"
|
||||
ENDPOINT_TIMEOUT = 7.seconds
|
||||
DNS_RESOLUTION_TIMEOUT = 2
|
||||
DISALLOWED_IP_RANGES = [
|
||||
# IPv4 mapped to IPv6
|
||||
IPAddr.new("::ffff:0:0/96"),
|
||||
# Broadcasts
|
||||
IPAddr.new("0.0.0.0/8")
|
||||
].freeze
|
||||
|
||||
belongs_to :webhook
|
||||
belongs_to :event
|
||||
|
||||
store :request, coder: JSON
|
||||
store :response, coder: JSON
|
||||
|
||||
enum :state, %w[ pending in_progress completed errored ].index_by(&:itself), default: :pending
|
||||
|
||||
scope :ordered, -> { order created_at: :desc, id: :desc }
|
||||
scope :stale, -> { where(created_at: ...STALE_TRESHOLD.ago) }
|
||||
|
||||
after_create_commit :deliver_later
|
||||
|
||||
def self.cleanup
|
||||
stale.delete_all
|
||||
end
|
||||
|
||||
def deliver_later
|
||||
Webhook::DeliveryJob.perform_later(self)
|
||||
end
|
||||
|
||||
def deliver
|
||||
in_progress!
|
||||
|
||||
self.request[:headers] = headers
|
||||
self.response = perform_request
|
||||
self.state = :completed
|
||||
save!
|
||||
|
||||
webhook.delinquency_tracker.record_delivery_of(self)
|
||||
rescue
|
||||
errored!
|
||||
raise
|
||||
end
|
||||
|
||||
def failed?
|
||||
(errored? || completed?) && !succeeded?
|
||||
end
|
||||
|
||||
def succeeded?
|
||||
completed? && response[:error].blank? && response[:code].between?(200, 299)
|
||||
end
|
||||
|
||||
private
|
||||
def perform_request
|
||||
if private_uri?
|
||||
{ error: :private_uri }
|
||||
else
|
||||
response = http.request(
|
||||
Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload }
|
||||
)
|
||||
|
||||
{ code: response.code.to_i }
|
||||
end
|
||||
rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError
|
||||
{ error: :dns_lookup_failed }
|
||||
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT
|
||||
{ error: :connection_timeout }
|
||||
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET
|
||||
{ error: :destination_unreachable }
|
||||
rescue OpenSSL::SSL::SSLError
|
||||
{ error: :failed_tls }
|
||||
end
|
||||
|
||||
def private_uri?
|
||||
ip_addresses = []
|
||||
|
||||
Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
|
||||
dns.each_address(uri.host) do |ip_address|
|
||||
ip_addresses << IPAddr.new(ip_address)
|
||||
end
|
||||
end
|
||||
|
||||
ip_addresses.any? do |ip|
|
||||
ip.private? || ip.loopback? || DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) }
|
||||
end
|
||||
end
|
||||
|
||||
def uri
|
||||
@uri ||= URI(webhook.url)
|
||||
end
|
||||
|
||||
def http
|
||||
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
||||
http.use_ssl = (uri.scheme == "https")
|
||||
http.open_timeout = ENDPOINT_TIMEOUT
|
||||
http.read_timeout = ENDPOINT_TIMEOUT
|
||||
end
|
||||
end
|
||||
|
||||
def headers
|
||||
{
|
||||
"User-Agent" => USER_AGENT,
|
||||
"Content-Type" => "application/json",
|
||||
"X-Webhook-Signature" => signature,
|
||||
"X-Webhook-Timestamp" => event.created_at.utc.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
def signature
|
||||
OpenSSL::HMAC.hexdigest("SHA256", webhook.signing_secret, payload)
|
||||
end
|
||||
|
||||
def payload
|
||||
@payload ||= if webhook.for_basecamp?
|
||||
{ line: { content: render_payload(formats: :html) } }.to_json
|
||||
elsif webhook.for_campfire?
|
||||
render_payload(formats: :html)
|
||||
elsif webhook.for_slack?
|
||||
html = render_payload(formats: :html)
|
||||
{ text: convert_html_to_mrkdwn(html) }.to_json
|
||||
else
|
||||
render_payload(formats: :json)
|
||||
end
|
||||
end
|
||||
|
||||
def render_payload(**options)
|
||||
webhook.renderer.render(layout: false, template: "webhooks/event", assigns: { event: event }, **options).strip
|
||||
end
|
||||
|
||||
def convert_html_to_mrkdwn(html)
|
||||
document = Nokogiri::HTML5(html)
|
||||
|
||||
document.css("a").each do |a|
|
||||
a.replace("<#{a["href"].strip}|#{a.text}>") if a["href"].present?
|
||||
end
|
||||
|
||||
document.css("b").each do |b|
|
||||
b.replace("*#{b.text}*")
|
||||
end
|
||||
|
||||
document.css("i").each do |i|
|
||||
i.replace("_#{i.text}_")
|
||||
end
|
||||
|
||||
document.text
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
module Webhook::Triggerable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
scope :triggered_by, ->(event) { where(collection: event.collection).triggered_by_action(event.action) }
|
||||
scope :triggered_by_action, ->(action) { where("subscribed_actions LIKE ?", "%\"#{action}\"%") }
|
||||
end
|
||||
|
||||
def trigger(event)
|
||||
deliveries.create!(event: event)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
json.cache! card do
|
||||
json.(card, :id, :title, :status)
|
||||
json.image_url card.image.presence && url_for(card.image)
|
||||
|
||||
json.golden card.golden?
|
||||
json.last_active_at card.last_active_at.utc
|
||||
json.created_at card.created_at.utc
|
||||
|
||||
json.url card_url(card)
|
||||
|
||||
json.collection do
|
||||
json.partial! "collections/collection", locals: { collection: card.collection }
|
||||
end
|
||||
|
||||
json.stage do
|
||||
if card.stage
|
||||
json.partial! "workflows/stages/stage", stage: card.stage
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
json.creator do
|
||||
json.partial! "users/user", user: card.creator
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,4 @@
|
||||
<%= link_to collection_webhooks_path(collection_id: collection), class: ["btn tooltip", {"btn--reversed": collection.webhooks.any?}] do %>
|
||||
<%= icon_tag "world" %>
|
||||
<span class="for-screen-reader">Webhooks</span>
|
||||
<% end %>
|
||||
@@ -0,0 +1,18 @@
|
||||
json.cache! comment do
|
||||
json.(comment, :id)
|
||||
|
||||
json.created_at comment.created_at.utc
|
||||
json.updated_at comment.updated_at.utc
|
||||
|
||||
json.body do
|
||||
json.plain_text comment.body.to_plain_text
|
||||
json.html comment.body.to_s
|
||||
end
|
||||
|
||||
json.creator do
|
||||
json.partial! "users/user", user: comment.creator
|
||||
end
|
||||
|
||||
json.reactions_url card_comment_reactions_url(comment.card_id, comment.id)
|
||||
json.url card_comment_url(comment.card_id, comment.id)
|
||||
end
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<div class="header__actions header__actions--end">
|
||||
<% if collection = @filter.single_collection %>
|
||||
<%= render "cards/webhooks", collection: collection if Current.user.admin? %>
|
||||
<%= render "cards/collection_settings", collection: collection %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
json.cache! collection do
|
||||
json.(collection, :id, :name, :all_access)
|
||||
json.created_at collection.created_at.utc
|
||||
|
||||
json.creator do
|
||||
json.partial! "users/user", user: collection.creator
|
||||
end
|
||||
|
||||
json.workflow do
|
||||
if collection.workflow
|
||||
json.partial! "workflows/workflow", workflow: collection.workflow
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
json.cache! user do
|
||||
json.(user, :id, :name, :email_address, :role, :active)
|
||||
|
||||
json.created_at user.created_at.utc
|
||||
|
||||
json.url user_url(user)
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
<%= tag.li id: dom_id(delivery), class: token_list(delivery.succeeded? && "delivery--succeeded") do %>
|
||||
<strong>
|
||||
<%= delivery.state %>
|
||||
</strong>
|
||||
<div class="txt-subtle">
|
||||
<%= time_tag delivery.created_at, time_ago_in_words(delivery.created_at) %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,13 @@
|
||||
<li class="list-style-none margin-none flex align-center gap full-width">
|
||||
<%= link_to webhook, class: "txt-ink flex gap-half align-center min-width txt-medium" do %>
|
||||
<strong class="overflow-ellipsis"><%= webhook.name %></strong>
|
||||
<% end %>
|
||||
|
||||
<hr class="separator--horizontal flex-item-grow" style="--border-color: var(--color-ink-medium); --border-style: dashed" aria-hidden="true">
|
||||
|
||||
<%= button_to webhook, method: :delete, class: "btn btn--circle btn--negative txt-xx-small flex-item-no-shrink",
|
||||
data: { turbo_confirm: "Are you sure you want to permanently remove this webhook?" } do %>
|
||||
<%= icon_tag "minus" %>
|
||||
<span class="for-screen-reader">Remove <%= webhook.name %></span>
|
||||
<% end %>
|
||||
</li>
|
||||
@@ -0,0 +1,35 @@
|
||||
<% @page_title = @webhook.name %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<%= render "filters/menu", user_filtering: @user_filtering %>
|
||||
<div class="header__actions header__actions--start">
|
||||
<%= link_to @webhook, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
|
||||
<span class="overflow-ellipsis">
|
||||
←
|
||||
<strong><%= @page_title %></strong>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<article class="panel center txt-align-start" style="view-transition-name: <%= dom_id(@webhook) %>">
|
||||
<%= form_with model: @webhook, url: @webhook, method: :put, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>
|
||||
<h2 class="txt-large margin-none">
|
||||
<%= form.text_field :name, required: true, autofocus: true, class: "input full-width", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %>
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-column gap-half">
|
||||
<%= form.label :actions do %>
|
||||
<strong>Events</strong><br>
|
||||
<p class="margin-none txt-x-small txt-subtle">Trigger a call to the Payload URL when these things occur:</p>
|
||||
<% end %>
|
||||
<%= render "webhooks/form/actions", form: form %>
|
||||
</div>
|
||||
|
||||
<%= form.button type: :submit, class: "btn btn--link center txt-medium" do %>
|
||||
<span>Save Changes</span>
|
||||
<% end %>
|
||||
|
||||
<%= link_to "Go back", collection_webhooks_path, data: { form_target: "cancel" }, hidden: true %>
|
||||
<% end %>
|
||||
</article>
|
||||
@@ -0,0 +1,4 @@
|
||||
<%= event_action_sentence(@event) %>
|
||||
<% if @event.eventable %>
|
||||
<%= link_to "See more", @event.eventable %>
|
||||
<% end %>
|
||||
@@ -0,0 +1,19 @@
|
||||
json.cache! @event do
|
||||
json.(@event, :id, :action)
|
||||
json.created_at @event.created_at.utc
|
||||
|
||||
json.eventable do
|
||||
case @event.eventable
|
||||
when Card then json.partial! "cards/card", card: @event.eventable
|
||||
when Comment then json.partial! "cards/comments/comment", comment: @event.eventable
|
||||
end
|
||||
end
|
||||
|
||||
json.collection do
|
||||
json.partial! "collections/collection", locals: { collection: @event.collection }
|
||||
end
|
||||
|
||||
json.creator do
|
||||
json.partial! "users/user", user: @event.creator
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
<ul class="flex flex-column align-start gap-half margin-none unpad">
|
||||
<%= form.collection_check_boxes \
|
||||
:subscribed_actions,
|
||||
Webhook::PERMITTED_ACTIONS,
|
||||
:itself,
|
||||
:humanize do |item| %>
|
||||
<li class="list-style-none margin-none flex align-center gap">
|
||||
<label class="switch toggler__visible-when-off flex-item-no-shrink txt-x-small">
|
||||
<%= item.check_box(class: "switch__input") %>
|
||||
<span class="switch__btn round"></span>
|
||||
<span class="for-screen-reader"><%= item.text %></span>
|
||||
</label>
|
||||
<span><%= item.text %></span>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
@@ -0,0 +1,37 @@
|
||||
<% @page_title = "Webhooks" %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<%= render "filters/menu", user_filtering: @user_filtering %>
|
||||
<div class="header__actions header__actions--start">
|
||||
<%= link_to cards_path(collection_ids: [ @collection ]), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
|
||||
<span class="overflow-ellipsis">
|
||||
←
|
||||
<strong><%= @collection.name %></strong>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<h1 class="header__title"><%= @page_title %></h1>
|
||||
|
||||
<div class="header__actions header__actions--end">
|
||||
<%= link_to new_collection_webhook_path, class: "btn" do %>
|
||||
<%= icon_tag "add" %>
|
||||
<span class="for-screen-reader">Create a new webhook</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= tag.section class: "panel shadow center webhooks" do %>
|
||||
<% if @page.records.any? %>
|
||||
<ul class="flex flex-column align-start gap margin-none unpad webhooks-list">
|
||||
<%= render partial: "webhooks/webhook", collection: @page.records %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<p>Webhooks can notify another application when something happens in this Fizzy collection. You'll choose which events to subscribe to and provide a URL to receive the data.</p>
|
||||
<p>For example, you could create a webhook that posts to a Campfire chat in Basecamp when new cards are added to Fizzy.</p>
|
||||
<%= link_to new_collection_webhook_path, class: "btn btn--link" do %>
|
||||
<%= icon_tag "add" %>
|
||||
<span>Set up a webhook</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -0,0 +1,51 @@
|
||||
<% @page_title = "Set up a Webhook" %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<%= render "filters/menu", user_filtering: @user_filtering %>
|
||||
<div class="header__actions header__actions--start">
|
||||
<%= link_to collection_webhooks_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
|
||||
<span class="overflow-ellipsis">
|
||||
←
|
||||
<strong>Webhooks</strong>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<h1 class="header__title"><%= @page_title %></h1>
|
||||
<% end %>
|
||||
|
||||
<article class="panel center txt-align-start" style="view-transition-name: <%= dom_id(@webhook) %>">
|
||||
<%= form_with model: @webhook, url: collection_webhooks_path, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>
|
||||
<h2 class="txt-large margin-none">
|
||||
<%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name this Webhook…", data: { action: "keydown.esc@document->form#cancel" } %>
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-column gap-half">
|
||||
<%= form.label :url do %>
|
||||
<strong>Payload URL</strong><br>
|
||||
<p class="margin-none txt-x-small txt-subtle">This is the URL for the app that will receive payloads from Fizzy.</p>
|
||||
<% end %>
|
||||
<%= form.url_field :url,
|
||||
required: true,
|
||||
pattern: "https?://.*",
|
||||
title: "Must be an http:// or https:// URL",
|
||||
class: "input",
|
||||
placeholder: "https://example.com",
|
||||
data: { action: "keydown.esc@document->form#cancel" } %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-column gap-half">
|
||||
<%= form.label :actions do %>
|
||||
<strong>Events</strong><br>
|
||||
<p class="margin-none txt-x-small txt-subtle">Trigger a call to the Payload URL when these things occur:</p>
|
||||
<% end %>
|
||||
<%= render "webhooks/form/actions", form: form %>
|
||||
</div>
|
||||
|
||||
<%= form.button type: :submit, class: "btn btn--link center txt-medium" do %>
|
||||
<span>Create Webhook</span>
|
||||
<% end %>
|
||||
|
||||
<%= link_to "Go back", collection_webhooks_path, data: { form_target: "cancel" }, hidden: true %>
|
||||
<% end %>
|
||||
</article>
|
||||
@@ -0,0 +1,66 @@
|
||||
<% @page_title = @webhook.name %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<%= render "filters/menu", user_filtering: @user_filtering %>
|
||||
<div class="header__actions header__actions--start">
|
||||
<%= link_to collection_webhooks_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
|
||||
<span class="overflow-ellipsis">
|
||||
←
|
||||
<strong>Webhooks</strong>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="header__actions header__actions--end">
|
||||
<%= link_to edit_collection_webhook_path(@webhook.collection_id, @webhook), class: "btn" do %>
|
||||
<%= icon_tag "pencil" %>
|
||||
<span class="for-screen-reader">Edit</span>
|
||||
<% end %>
|
||||
<div>
|
||||
<% end %>
|
||||
|
||||
<div class="panel shadow center flex flex-column gap txt-align-start">
|
||||
<header>
|
||||
<h1 class="txt-x-large margin-none"><%= @webhook.name %></h1>
|
||||
<span class="txt-medium"><%= @webhook.url %></span>
|
||||
</header>
|
||||
<% unless @webhook.active? %>
|
||||
<div>
|
||||
<%= button_to "Reactivate", collection_webhook_activation_path(@webhook), method: :post %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<h2 class="margin-none txt-uppercase txt-medium">Secret</h2>
|
||||
<strong><code><%= @webhook.signing_secret %></code></strong>
|
||||
<p class="txt-small txt-subtle margin-none">
|
||||
We'll send a <code>X-Webhook-Signature</code> header with each request.
|
||||
You can generate a <code>HMAC</code> using <code>SHA256</code> of the request body with this secret
|
||||
to verify that the request came from us.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-column">
|
||||
<h2 class="margin-none txt-uppercase txt-medium">Subscribed to</h2>
|
||||
<% if @webhook.subscribed_actions.empty? %>
|
||||
<p class="margin-none txt-subtle">This Webhook isn't subscribed to any events. It will never trigger.<p/>
|
||||
<% else %>
|
||||
<ul class="margin-none unpad-block">
|
||||
<% @webhook.subscribed_actions.each do |action| %>
|
||||
<li><%= action.humanize %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-column">
|
||||
<h2 class="margin-none txt-uppercase txt-medium">Deliveries</h2>
|
||||
<% if @webhook.deliveries.empty? %>
|
||||
<p class="margin-none txt-subtle">This Webhook hasn't been triggered yet<p/>
|
||||
<% else %>
|
||||
<ul class="margin-none unpad-block">
|
||||
<%= render partial: "webhooks/delivery", collection: @webhook.deliveries.ordered.limit(20), as: :delivery %>
|
||||
</ul>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
json.cache! workflow do
|
||||
json.(workflow, :id, :name)
|
||||
|
||||
json.created_at workflow.created_at.utc
|
||||
json.updated_at workflow.updated_at.utc
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
json.cache! stage do
|
||||
json.(stage, :id, :name, :color)
|
||||
|
||||
json.created_at stage.created_at.utc
|
||||
|
||||
json.workflow do
|
||||
json.partial! "workflows/workflow", workflow: stage.workflow
|
||||
end
|
||||
end
|
||||
@@ -1 +1 @@
|
||||
hTAo532D++IfAePtvgvwjKd6Cx3RRCdoO+77b4yFvgZSGtEXB6mfNGKlEICRL4o0qh0lqimE2/3Sbmnx97lzhfdYXE3b09KewXIub14s1jzTRWzZSRPNcxPeYou10kiN0b/4R/IFON8+/P7Ratok1MHIrAt4M3EUYwtEpzsi3aOYDGlRoLW5rAxFAHRl7zhgkMH3Kzkmjh2tr+QOc5fxqmYT8ETmLJrOtvJtULMW4mfie722zmwm9RFJxtHpz4gnMAwSr2vaZ0edk0sHeErnDJo7Zl1Ul7uCJyXPP4PCqmuNVOQbxjLMNVTmo8ntUWZa7pvocpa5uYy8DIy3iCAYYIxdh/xhJPnqPC0XQnS6Aj4zaOD/fOXSnEuSQjR8d5XyIUAWuywh6Li7qWyj3W8btfmc1HWOo4cwx8LkwRuUG1r/SvMQdIR1E2xtgROaoXZmPOXmYlFJm3mzy+oGOk7Pa/u/1iiBiNEkDx4nhHe+0XuPZcBe4c9PKn3zw/3SOm32SpBqe6zPZI/68VoiXx5wVaogY5gnKQOHNkhi9RhN6SSrM58oS+EKYkCJ4+iRqj80IQNtj4in4pyQ/ONZgc0tjMh1+C342NkhbaJYmGGUPpPVhZPCUf3clbBXqex15+6UkmymWtz3WH6GixPVwrvp1gGUqOmqCiWVNJM/kQ84IOTzlxfeyKjGK5xKhEC3ajYqXDDUDek/tUIhqtOYxBS9T44cKDPDTL1BKFMdIDR7gdtRyQouW3RvD4juqyjh710I9aq3JB1Jvj8UZ7uSHYEJ9NpmllfAx0RrrxMFHcwr3LqjDlDf7mtACfDISVtD6TQLuGySDIhs8gWTiSuZ75jRTswx8y3paI17Yy0en23lDIQ9hlPh6xvvJWQQtCT6B8GCz56GrLOVDfKM/0qeqJtyed2p2/MIJfy+QmqTSMSeQz+wiLi14I2Vgy229A1CW4izMSoR+RqoZzIzCbZg29croJv9DRhp/nVtkjj/enHnHksZKqoxzf0c7nHSWtGSYtCiewDydsBCioKdo9tn/FM9UKpnRAy1XHiq1DvBQ+wuq74KLnC/4nBZ7P90xlI8jQuQfldpDRqKaIYjIvz8UbLdKOjETT1dJjZ9Tk629zdLEVybLSGhoE0bs460oFvlrjJDCXtgi/3tWbTIuwsxY6Iy3eMiS5Kxl42dnlOdVrxwwY/om/MzL4+yvsx38wXS2QG8lDKz2JuGBS4GYq5EEg==--P2KPkoocz8wNhqSN--1u5WuEBtEaqJckCflHkb6g==
|
||||
SlqumuYcQYbvuwMZNdQf54cXZ4zBavRLDyl6eiWzEYe/nGlGOlzAYpsPs3ZUA4bJC9tVm8c29E0FzmmG5X6b18ShCB8y6qMiSMEU8CeyMCIlvi0+CeWQPMbujA8ro5P6eE8deZ4+nN1pEqCpK8XCx+HFypx5sLpZ9G2uAPOBNPtOqX9KGXvWfe6RyHvR6+mk+a0jpPs50y2sQvA+/l77h6VnT/uoaa7P6q0YkZWmCWwsPT5ikgP5w+Ur+yn/VfWWS70CIqVSZhZJrzq96OV6GxKLLFdE/2im83Gg2vKODbu10FiQAdjwyubietv4PHLagtXux/mGRtBAzvwt2mKJpOxZFbUYr3u4luplTAxwMMvGMoEvxXs8ujTHjcWL8BCd3TjjOvuB82+/hYI/ds28GwMxDmurKlP0TfQXEUmrx27+mF5Bi/BNyaGwD3EMpP5f71HWxTgdTI2SVbM1pVHS3wnnnhvI4r9TcApt4oasZ2HcUJ1tqYvxDGShFnlsjB0XfJYQg5IM7/6PBr2FMi+7czz7gSaCiUJ/ew5HuTzXzS1NdNKaP407SIMjB8yKGmOTPlIf2Pe/i5kj6CVVV/ALPantfTddqpEXDjfSCYv+UwY5dKGKZXFzcbtQa9/LB+q1tPTi8Xo94CnxrSeJIzUJ6P3VXM9ihQn72GWrMz5Mt58VTf6yQve3gTq0gOIpLpMhOwwsWJ0tvOu5/4ma95qG4WTb7XKJIwmT6p0T7dBm8CJgigZckkme+Qob5nxUzg9pc1YQ9t5a3r0E1wCEdEeqXzBHgLfjjrF2ohYJwGWyw0zUqRxVYgrThpsIA9x4DqzFOAMRP76IV7WTCNTCoGslteV0hSFctBp1dAPUnN9bmvoWs4VS6n/tOxruYwEKLKGXzfo897LSMK6lEa1YF5r7BJIwSC1LxQ/60zuW8JYFV/4gyR3e7J2u0apExCZyohCDHgA/5Ol2mANuXIScFo6ikBJBKDWEN1GBjEA6aw0G0cY34JXJ73oN3JH8PRDmDAmd5RA3S97lyadPZxmvssN4pvncA6wj6ow3u27+TS+yDVYhtiri5Ce661Ps5sU9ih1EvxDvaoPTQnsWsvZ4/A3ybO0aNGDwlDcziNWV7+DZ6dDO6MHC93M6REYtDIjvtfSmnGC+8GNwoaa5rDCtrtbs4dSCYe3CIn9oLS03GgoTFdTpPKPNucwjN3yDIWGQ/HNJt2eCZvdVuljDWOiHR+1/rZZR/JNVvfhQRJdr+jT0gom0b9o/K3xh6N7VCPZMxalQHgELCkNa/+9WZ28L+6gpi7lEKy8dXdIoNlNE5Mpre/PiOHJViQi1ZquIM6h8fT09hv5bnT3s6zumdL7piUljRr00buCqWGF6UYNf3/3e6WeYPCKgxcDfVvi49AL4zrPKeEeW2KUwIAC0Vz0gbdh1bAAmdtjuQd4bXMMohcend69KV1nLByuLksmnyfHMXNnYmj0YHQI=--d+g/vj0Xg+A57CDr--GutJH+N6jhC1+wTQ52DuCA==
|
||||
@@ -1 +1 @@
|
||||
qGtDqUHZuqVpajiHSIgxtaKdNgBjWFZaKMWitxGBAtV/q/P0uVAzry+MRonSMglb8SZEW28/LAKF28kUeAMGZVwnbMHAmcmOdOHNwfPBDu/3+c67J0qedEH2k8998o7wBqDi7iFTaWlDQROOi8LiiE3oVnpsRZGXtjXgClYI5ED062ITCd/tMLDsXiZjq/PhodWjI/YgEycwy0r1OldJZjOCzS1VD924AfOIH2/qtszc7oeArEpv8XYm3DJDCixYy5LLnhNIek7o8CHaZIeQxfm9pEh5ZEXaMNfSB4Gq+laAyLoXqsoPWuonjw0273Rjw1U3PU+6GlU85ZUrm9dBnekZiatLIHt3yX1CKynS8yqen0ON+MrP5W98pAa9Njq2GtB2SUrGY1xQtyoIfYPxFbNZoOph4PubcXqCT94bkAbZOiYKSiNLONuhhvKR6r9amh+LlDELp7QLR8EIfRpgoN5D8qhbhEi1/c2Sxogv/7+YtkpaYq47Ram2THll+URQ3L4iLKyPIaimxt9vsqdO5COn69fTqtqkYR/fKy93/3JIJxiNQ8I6z5xhGRLFk9ADWB3ZVz1VzXzW0Q1B0UQmMg0KEEM9ZQTxignWeJasbG2L/T656Lg/wPdKdAw0jcZAOh+BGtePgOOjZ5Le8rweSHKou0kTn1EjR81csRuSnHEEGTdlNp34LC0NirqwEwmPySRrQxmNwslS82R8uQvZQ+XtalJBBvUVwaB+o2rm9AfZFzzjGmZhVOKCQLZx4ugG+CUfc+soLGuQtcyPQe+0V2hJ97zzqdWxKvdUQ/aCerBwUQczl7r/PPxKucl4LtF+3mNYNMWjIdNIHJRzhvCnmZ8Zj1TuO4SGWMKNx7kwNP54dd4pqRD6Z0vm/x8zZXeccfu8Y39IOlawyEDR7idd0CSZ--41mPjeBDVAaVR2Q8--Qfq5LzxnQOYGDaGie2K6FQ==
|
||||
lbv40Nq+bDCBc6DGVp3Q+CBuKLAt26v1vkKhOuUBJ/hPr07N98H13tejmM+C1GHF8DUJsTTsmSLYdYgelng0GDo9qhSpC/k+thZ1+Ne5pmZh00sH58PwItUNPBtLluqlpolRfNrJGMK3kjprWyTCbznAfGV6BZoT7Hbv12ZIri2bKsuTvsPZiIYLLAe2a2CWrNWjs4RezZbGWzbJpIyj+RIj3GKhGNCWNFhq79HyeRChaZNxioMaAgWBfAYKvXvkR2MuKEaspJM3lXnpnyxqVKWEWz4orhTCvlqbZchESDLF7hAuIkzXCXEC2EHpeEbzD0F52K/mLzT+kmi8dI+eLVimPJn0PZslccsHNAnXXHwvLvLOPFwzPz9N0jjGihu6yZKE1vl5r1dzo9mVWVTnsI5Z/g5SAmOtY+DB+pzssQdXSLFQZ/l2JcWvgYA9wCQyQbyGLEeirGe/QeCQciTkE+Z/bj7b+20rBgsRQIyHxNdzWDsmi4onJ75lIz6PA+1X4AIkwnAqUVzuBDwB+VxdwbzRZZeURMLBVzs76Ta/qSemIpuZ782cyt6MRmeYuAT6RMccnG/zwdvPHtpJ6C8fqKyOu+XkU2a49Yx/D6l/uvVtwiOayHTCwn2QmGYDlnniCBDJCzp0y0Y1M4d3XxAdV61XEL6rISMuM0mjdRZL3PYTpR8mrrflnYA4kFrLUnA+hEmcOq8stHSL1RYe83PI4dj6l1/hzQ6up2cGNBk+to7FXpQOhkuYV2+9xu+4PnJnl261G1D/v8zoWV+0OIn4ySC1o/t8BkvoYvFzDJkDIGYwNV6tTdhgitlZ3ljohwTj2HIclr0pZVwptQDJpe/ZYbpxq13uN0Xi6QdAWHJcUWzt3jZnX7N07nY34NW7DhM/D5SSDMkZbjPIPBS4t+n/lShh--MofA9sfR6Z6UuGQ2--jtVAHhYAxdvdXOJ/vb06Ug==
|
||||
@@ -1 +1 @@
|
||||
gJofgOA5kUBwjcFwc6IDRjLbYQfG7qGpm8BcAEnS0FODuMbEE48FKN4jdUfhGAw0yCHu9DT1cJiP2wKR8s7nyf6dUGl6fPljR0sKTvO0eznmWigWCc+Ki6zVef0mRqQ2lZguiokjapG9c4qBcy2MYhVOhw3JAYvH6X4piiyO52nLL/kKLEQ64/uqC9VDKgR8TRhyVRqQN2jvB62VnIGxWX/qSPcapF/jw8ECgPsXgOSY/s4oNvQ8gCuZMweDlSYqFLAXpn2GP+wAaT9u6UmprPx7niDb1EIIm+LFWU1Ar4lY2n5Mo9ckN0ddsoMnwlsYTjlAWnIn9m0o/GbaxWmtSScnxHXbzqr630rEffvZrzLTG60dxkI0it8cVXwSHI6pMQHZw/lUkHd13IHMzR9eNGUq4pV+fdQjhvcfS2sTwVeCZ6VV1DbnY1vOeyMnVfvFLlJ7eqChkpFBAmOKS4hV/Mrqks6OP7K6ubyXt/REwuNhRMeJ1SJAfgiDSKbmNFtSnODyXKP1nFTCvvdndiP1H6t8qkJo8COSnSWWzcyyVKMwEKwbOxxtX2C+V4aySTllw0gFODLjQPxPBwqnkwcEcDEHxtsgImOYnOc0Ro/4faI1C5WdCuF6+VOBGpq1nI4HCQqepx4b/HZt/EyJSaQU08emhHht2vlCqJUN2yPUxnbCKBAbQhz0khRANBWIf8q0wfwSLaAqQHJ9FsZp4ztOC5U4lULzaMJh7dBUbv/9+mH6Yb3lFbg57178GYCgbzFwhvEOlEKBjfwDRFNmYfiVH4YYbZTIgQvu3tSywNNQJ4flu6TSjMC6zdOBi41BAIXH7j04zwpQV7Zndlu3xE1LTMbcLfBdqzOBGuRVD5KvQH5XFj0RfKTg2DN7Mo2XwSb7IZnl3Ga/Tuvb7DwKpRtql2RCYIYXwg5EnCYF3rw8J9m4tLzr9AFh3KALq+Lnfa0d1MysB1NQJ5XqtJ3jeYW+sxv4ivv9gpzQ9SZF26Z3rZoQHnb2S7usIgzEquiDyhAQr67AYOMJe5DKRw/QShJwYK+k+n/IrpQxUBnTrZvf2I47KF6UkhgWpZgmQaDB7WR1PePHVqJdkdQgqQzUU2t0SQIUY0FHH6MRLyHDcqOFiPl76HNFOmElZzrfXzqtwQdXOau7hV645LqaUy2xinA97zkEdpAGr/P/eloveh6wIgSIZNTsm8RbgRkl8SS1O9Q9goxMir4S3JvjWTJmAhxKaKlGeIVcMRVIv5C634Ugpp5jzh8PHX9nHAN2w9HHI+8AMaBUeFyuixbfk88EIvby72wY8MWxYzv1H/THWSL8WTiQJGxWE4PjSBQtwuc1zzHqG8Ve45Z1nIkt4TmC1VXftUHj5SghncAHCA27v+Hdk+cjSw/ymaVwIRreaZYMjOs2xX8XGOYXpbChFJaMk7ywR3FyBzOVvFXGnGCuA55f+j/7E2KuBI4lnr/mmCTeYvLOBrBDd2c1sEjKTURM4cSlDBw9NKc8oloxYZQKn9vA--z6fcV0Ojv6v39SCA--yOvnjjoQdp6k7P89GMEgYw==
|
||||
z0tKJ9f/hCNXOi7b6lZlfzmFm8HAGNafiluMTcRlKmFxTm5ygs6fk0N0JNnJyrxsv6huye3/mptfrqkMOHRQcWzmZQyWDmayLxtmghRkpXmX92QZ8DiSs6jgWFX9zKuMPEW9eFpuLjstF1khPskg70SYVXBc3cTRJbyx5JGhnv1c3qOBfN4PKGYvTWsxacoQ356LYfu3haezmiS0yPzREo1rhFdwNyveV8uZ1QX1ZNZFp+WN5RXeiM632kL4R73KhwkbGBSHj5z9xxl0v4BzLrGM+Jf3VsJFmhYXS10mN+XOsNELCHUaM1wQOqMNHegBfupTzwur6Q3R154XP7rrBLquFT3wkAlkrI8ox+NRJgnGQ/yCc3PM8zhxgdrLzmr9s35eByKRCMsuIkF0WSfcBeWpCfNQaeucw10fuKirlI+G5JsX6HaRfAea3dUvVsuKCeutKn2/YPmw7Qodat2G9sZgUhBOYRzDcmzWaUyudciAMXRzNfoTZqngwFXDvfcAlmq4FrnucnfAsMB/TtqrZzV/td4nPfQLpxZCCl7v3hIggQUJl7gmdR335J6wJ4r8i0eTQjTw+j/Xm8wF1AEHpwxz2DOg8BjAI+/F8Lgqc+YDQCEYINYn+bwy5eMUv7H3aLDGrF3ZKnsEwWj62Q5LtHF/Fi7ZIOWgQyk4d5lLc4+T6hx4qe9iqu29LlduIxwJRG1dWPgNxuikPM2hcl6dQP8ebP9dNpdeeMCGylzR/kZjro7mAawxgIKEdOvWE6NJs8xGkjV2JjqWu7ufiIpk+jurUBlO5mIJuZhVvRumpMgcxVwqXWjJRt5BTVT/qcuLdaPcYY9hUt3TQrqjSGIt5bSFjcCFEfY15DxXsIVe84Oi4KYQNmuM4h2ub1zfYQYYH4UaGg7bLbb2OeQIIkkTwXXAR6o3BKJWtskOD3GTdamk3gKhudhm+BOgbOavoiE4hpvv/4ajjl8OGseGOAjC4bj8hpNnhezjQFRe2pK1515hi8iyYQ2tEScGy4wmsAbSaoA54+tHki0yeLUF8oJ9GTr9UCwnz8nUhQmfrS4VJO6Q/J4MXRCon+mAt3cyHaC6z3RISREw0a4aO3T2PGvUBVDEP0kfhDJmZXM5Of0RdFX27pwaQUPo3hqzQ+BmyenGqUonC1nPd/npojmRWXan9v5iMRKRx8qEBfhm+WQPEQuJqi5szm8pla6gUtNKaL6P4zhJOnIZiKxpYSlnezlMWNof/xcOb5BqYeEYQJ9FTvIxGrmN7OxQFlfk21z+n1OJI82TKIaKB/Oo+bsTtta8BuXONJUtsRSP9HcqMlMnwq2We4jn8JLFf/9O0s+iQtB6um7EAcHkE48jBHkMVzJVWB4/9Z8xe4DIwrg9BZH6UNL/pc/K5/dOeW8QoOPceBWmHP9BGoWFVqmNmEwPeNmnQZ5H/zxGPD94oG5YfiUMfMrOtIiAwS3Qqbmr+nAa5eNe4Ij7/i5nlV5gJ8y3NSNQvjico4iqBU1WAEHL6bHV--JcYezF+/1oGWkoz5--lepA3Pi+iuzJTgNRR4ax8Q==
|
||||
@@ -1 +1 @@
|
||||
DHJSW4c/xFkMWWYW3b1uqnoiwuqVvRabPVvSKZWaoM+EPzUses5EaJ7GBXjpVS/4TYQ+0XsuLBTniuhUtFx56PHo1vWWFRy3n8I1YGPOs2X9mvG2wK5MlwSGO55xatQ1MabRyfjVJfW/inx4oWaqh4IsZhQ5uL/PUWwWy+7tKx3yWbPDa9K2vsgJuJJqZav0gJ7Bn5j8sdpj8xRofA0ZmiA3QxVIHOLiBYRXH9gmuzJ9BcDiAfZ1EA2FnM7T/2v5u6K4k5Lkp4Khn7t5kSF5hrUIuU7dWFaz4WFS6sfcCkRTHgPKu1ZfunzQl9vDXxkwVUjnfGnIk6XUQz8qfI1sDwhxjaiKAtn/6V8/zSo0v9lj91kfVWg/e8byzDxtZhRN71lnDgwaRIQxSDumfFIX2Yd5aXoQw7b+VymZaA+8J7vh18GPkBSsWTZ9b1fA1iVHncSDSEYH/FVXK3Ul/mpcN1bJrlBXMkZ0HmDA1EXhMJqPGosE6/OT5f1Zsn5SSpbqM6VZjQ6LOHwNCZs24F8BhGnCQtdl/VlIHKyGq8/zcyWUuROAVSxV9fiMDddWrprjDsjXPUigFqgtyco1CCZ9p6ScMihOzpOhT0dcvvjKZY56e3GR6n7u5JfSL9CpykQOXATHBXJlAc3HYxPxfESkEa31VmwZGOZxUsTpIZDLC/7VCHXGgtvOYLfoPBJBZ7BP8Eun+q/hz4iAr40xlfSeg3G18JpQZYHeF2/3WucrTW8NinwkPW/Yr/jtd9duyuIZNY2/zGyLiyoKiuHnk/iVDsyLIth1P7ZoQNyyiITzB/OQ2KqAvqwjnNYqLfBAQfe6grBdAGrNInsBHc7HNMcuHP9bvvS2cjlepP4zZyKo5oiAkmaWUPuBkUqmY6ZcOmwLTwl//fBXlUzCuUhR+0k7af6IlCsDhmp0HL575JmYN+HKGZ2LtQo+1v4MFJR5c2gh0Q4zTAkJsaOz+39aI5e2VPXkpXNyz+VVFJO36lg9tpaomUgtI2BjrO3Y3qQ8Y5jilnSX2HslL4lFMLDvyphDYJx7ODPimUjNkJ616YXSgFJSBUVa/ui1CnxNqOfO+AtuxJz26cuQw8ORKZNihd0QT276pHGhnXb+B0JT+XCuniZnSF+oCn6Kmbt1bsElCN/sMrII7UdsRGD5sQWicjpOM0g50GPYF9z6v8tCYlEmTQSjp7/KOwKLo4l4re3KeuD4FuK/1n0x2CNm1MLbvXcOGkmCa3P79YsKl8L1x8+hbNoHpEpswc0dPb6FLthCxCqrGQiREO4pbTrLasVL6CJA0eRd39B/8AcXnsJwNj4B3a2PbafnkdhJMPAuXvieFFN6leMXBF0YCsg+1Br8h9YmZfzmkECo9JVkNCfYdNbkfo8AA+hHu/youi6vcJD+x+eKTVdi88jy9qwyDdzIf8GdiIHy00PnfXj4lgTx7SQr+WUpsUYnQZh8xqkAuh5Lgi7ExqZMjjo4FORFxoJHToNU6c44+aVb36P9Fg6v3YedINYZkRWMTUq/f9O1zoOx3wIE10Qk4SkF+MLPSuLJWbEmfCyNZZwFUH7xde9lSDfB1s+1XhtEz0Li3Kq8jCeftRcIZvQ3LVjFuirf6nfZmZoTjaVDyUiWHtXyrQ==--NSFlvZtAfCQmzAp3--o8XatBC+Yh/p55fY485A7g==
|
||||
tTZW5futjROLhOOU4sULsj8f7nCQ73Z7Pmh/3HT2Y1hxhz7MxKOREq2ZjwEFPhL2/c/zim/C9PzgNql6MDjtUzYmd26Q+2HzFSS2JpFBqU7DnEgL6mWC7x5s9feTG61ilOhmlLIwZRgNvugwRAUTlCv2KZ8R5iaUviPcwf6fl4dOhM7pW2Fx8MhuJQBSbjkoqisyniIzdCvgsE2s4qIkxBz+qrbjVNhmyTvl/jdttjTcIYlubCOlELZdbT4zNsitcs/8xBTBlxsR4Vt9rBcwr+EPrhQfpvGVKnSleeiatc6EOi0kAniqbjwJFkcpJ95PrU3yhV8TP7RAzHpOIhYHSwDcrQS2ltP9z7fVP2CRicpWEIh7iFR79JtIsp1cYH6pbx08ZAnKKeu240SKkKA/wZH5BzyOrVaTHwQ5q18U8hzWon6loMiQMV9+/jRZtb8rOTHU4IpUi/oM7shUZoHxccBq68ZxpclLEDZs7CLNytXbRq9oaCqyg5tW5qW+Lws+4iRlmR43hTq2SHJ+eMjq4N8WxAdecDGbgPdNFaoY6Ymw9YEwsYiWzHx7fRC0EUYckbUPEccT6b8IuYTJg/QeQQ8HVenboM9U/9Cd6SODR4Vdyn6fGkFmc7mfPke01naCUEconH7RL0EB38yz/MMqW355PhsaZqsv7PlCE7KJIxkejp3gwiBwP8petq4OazxZY2MJahYLwvMQwFt35qu9RJVAMiAMN9DLvRQu7w+oNA0g3OSGOwnIzHtjKMRks3W4YNEgwMGrq3YzI11EmORB/1F3t+UDDN8GOxxGgBmIdVrFHwSazrw5igqv/kTFsrzxSHW1ja3/U27JVYOSGDBMZ5TlAH7CSFD6SgbDHreauN/O0zuesTH9QnkUARhOHw75dfTEvacjy6uIcO9GmrRConJGWzJdZj5joBROhCtoflcn9jfce3W6YJfzHgveUxPnRU1bhaNULGTW/lA+cclDmm1jL2AhpDYzk1hq6/I5we0fY9/VNMgFnm+c/qrkjrXChFNCT4w+EU+n7TaXJ5ocWdCMTyM2/RSnm1dBX8wGoygryOYEnHM+6GDeX64vfGSnWZ2yfpsKrAzn7zVFWAY6Ah2YEXm+pQdUPB4CYwvg6A1liaCvF71P2p9SZgX79YEwGvsODMzrBavxVMIlVa92jtOZ23yoOjfR8gWleqeyoAOCN2JaM4rJfPrzZrs5LKDfYtgpR4Jg36LjD1UKWp3Kab+kHOpxzkPOgq6v8px3Rk3xaGADwCPnE+4qSTFByYaHCdx6FNeQQit3jSEq4h6WNeVkrKnZ6ybCnd7LwBKiu9uQURB6jar7t4IqM6kYThN9J7q/oxp3HS5+TkNT+1Org13CuNL9iF3M6n+R/U/vJCCRUMbM9+O8cYHEt5MJ875qBD1NRxj4UbVCcv5rDXhAFq+GuJqJTLN2+NPWVJ2QhZk1bhmwxIsZJ5vlWTMUXoOZOxUJrLqPK+v8h2x6G8J8Cc0WpzsHAl1B8peN7TxXJgcOyaVkHbPr2CYao/z7mG7D+5SIHcqcbNUB09Nh4iMVbQa1SEpTnWPqum1EwXkmH8QjUcj+IMsXcNWTVkaAfAsme50fMS8XGLiOU9AGfV5QcryaJQ6w250ewQ==--IKMr2eXgh2p/VO+z--2sCRrfBLg9TEjHH93qFmcw==
|
||||
@@ -1 +1 @@
|
||||
UFzTQdDbGY5BJ/SHVPWzNlBZoO2+BF0eHHwGICuSB5pi8ESFOHrGhmT7a9qpKLl8cbBMTPPayqLEuuuRbbtXEBS3EiDgAP7XaDViQHHAzBwgaATFT7U+1svnAwppTrAToXJlk51BdPLVRzftY9VZJ9RoDTfzKo0WriuYZ8KIRHmJjaiNDJK4xnqKaqPpF6SU0OVW2Qpo6xAKJVYKlV6SEO2Nt6C2lYra02G49f7poBypJnqouDnn+zlrLd2VGfDXOqFdi+R8T+ZS4HIzHUPi2IpEyg9jFt7qCNpSX7gy2NPZEx2mQmLC0jf5E+ew5ytKqxX1HnoJ7u8IMFvM9gQuNyA1+gaWT42uAqhUB9JKMoyE25KOSnV548cOfxCLnRNb0TgIbp5hpl66yjG32bQkyrvkCRmLBtkii/aGiwpA63UP2DTy0C/Nn86PFXJslHnVxbm8iSCRbhlu91YbmG08pzyCZQe9nRhIX2QBPufIELaJiMcFKYGfQ4Hs30TVCQ3ATdnC1UfWa7BdUsMD92ckneSrexaNofrH4WaRxyIf4VMtfRXqVgv9ovv5I4sr5BwYrPN5WvS3ksvAaoOJ/EXeq+io5OMB2ItzPJucfw==--q/sQOAAr2yruQ50H--dPendfhTyJuxPPImcH2Vqg==
|
||||
OCWdPtq1nxi6erX3KVmolRjjazqRbSQ102v9t/MUwHy2Af7CztQt+S0N2fGBNnGb0QTNGbzAlETRVUo7trc7+pjO8w7lyuHS/eKO84wuuPh7qRkrZfwxF2R0D2jmcaCa2S2Hfs8Ws7xFN2j9xCdjWJKd4khP4OygyLFK9wah9fWW+QvMl1evgnSA8Vtko1Eh+/bBQC4Lb3/tlsjeqfv/N229oPywD6uM6XfRsSS0/tuCp3w1MPhs2lb1ZfTwt4y5Dsk+r0YoUUr0CDPUAPSohmDJn5++8NiipIt7CQoGe72M4dp7WZ4eQAiprID8QIWAQ2I8RdvwbJdjFRKjcjgeB7vYiLUTRgmav8q/7QDk+JxWCU5oQxDmVex63CJ+sZ8NI94EnyppqHqihyLDzViwbBHkOmWj3WsgPOQHNjwhX+LwMeJ8UkWjAJlA7q59EqEDvPPN7MAgpunPuRXL6YXsS6UBpUmpof6fwMyhHbnFpRgTx0QyUFW3Ta7cs2JWCST2PXI5lGQKJBKIhcLB60bO1NtJYtX3xlheywnbNUzvsBTQO3j+ezPxcy07REvW1NfXQInpAgOudsZ4ScW0mblwiO5v/rLGJN5cMUNWAg==--8NVEpFCH347bJZoD--6fw4f/Fad/N8XQH6oTwpQg==
|
||||
+1
-1
@@ -3,7 +3,7 @@ default: &default
|
||||
- polling_interval: 1
|
||||
batch_size: 500
|
||||
workers:
|
||||
- queues: [ "default", "solid_queue_recurring", "backend" ]
|
||||
- queues: [ "default", "solid_queue_recurring", "backend", "webhooks" ]
|
||||
threads: 3
|
||||
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
|
||||
polling_interval: 0.1
|
||||
|
||||
@@ -26,6 +26,9 @@ production: &production
|
||||
generate_weekly_highlights:
|
||||
command: "User.generate_all_weekly_highlights_later"
|
||||
schedule: every sunday at noon
|
||||
cleanup_webhook_deliveries:
|
||||
command: "Webhook::CleanupDeliveriesJob"
|
||||
schedule: every 4 hours
|
||||
|
||||
beta: *production
|
||||
staging: *production
|
||||
|
||||
@@ -20,6 +20,12 @@ Rails.application.routes.draw do
|
||||
end
|
||||
|
||||
resources :cards, only: %i[ create ]
|
||||
|
||||
resources :webhooks do
|
||||
scope module: "webhooks" do
|
||||
resource :activation, only: %i[ create ]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
namespace :cards do
|
||||
@@ -170,6 +176,10 @@ Rails.application.routes.draw do
|
||||
polymorphic_url(event.eventable, options)
|
||||
end
|
||||
|
||||
resolve "Webhook" do |webhook, options|
|
||||
route_for :collection_webhook, webhook.collection, webhook, options
|
||||
end
|
||||
|
||||
get "up", to: "rails/health#show", as: :rails_health_check
|
||||
get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
|
||||
get "service-worker" => "pwa#service_worker"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class CreateWebhooks < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :webhooks do |t|
|
||||
t.boolean :active, default: true, null: false
|
||||
t.string :name
|
||||
t.text :url, null: false
|
||||
t.text :subscribed_actions, index: true
|
||||
t.string :signing_secret, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
class CreateWebhookDeliveries < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :webhook_deliveries do |t|
|
||||
t.belongs_to :webhook, null: false, foreign_key: true
|
||||
t.belongs_to :event, null: false, foreign_key: true
|
||||
t.string :state, null: false
|
||||
t.text :request
|
||||
t.text :response
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
class CreateWebhookDelinquencyTrackers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :webhook_delinquency_trackers do |t|
|
||||
t.belongs_to :webhook, null: false, foreign_key: true
|
||||
t.datetime :last_reset_at
|
||||
t.integer :total_count, default: 0, null: false
|
||||
t.integer :failed_count, default: 0, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddCollectionIdToWebhooks < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_reference :webhooks, :collection, null: false, foreign_key: true
|
||||
end
|
||||
end
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
class AddConsecutiveFailuresCountToWebhookDelinquencyTrackers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :webhook_delinquency_trackers, :consecutive_failures_count, :integer
|
||||
add_column :webhook_delinquency_trackers, :first_failure_at, :datetime
|
||||
|
||||
remove_column :webhook_delinquency_trackers, :total_count, :integer
|
||||
remove_column :webhook_delinquency_trackers, :failed_count, :integer
|
||||
remove_column :webhook_delinquency_trackers, :last_reset_at, :datetime
|
||||
end
|
||||
end
|
||||
Generated
+37
-1
@@ -419,7 +419,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do
|
||||
t.datetime "created_at", null: false
|
||||
t.string "title"
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["title"], name: "index_tags_on_account_id_and_title", unique: true
|
||||
end
|
||||
|
||||
create_table "user_settings", force: :cascade do |t|
|
||||
@@ -456,6 +455,39 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do
|
||||
t.index ["user_id"], name: "index_watches_on_user_id"
|
||||
end
|
||||
|
||||
create_table "webhook_delinquency_trackers", force: :cascade do |t|
|
||||
t.integer "consecutive_failures_count"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "first_failure_at"
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "webhook_id", null: false
|
||||
t.index ["webhook_id"], name: "index_webhook_delinquency_trackers_on_webhook_id"
|
||||
end
|
||||
|
||||
create_table "webhook_deliveries", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.integer "event_id", null: false
|
||||
t.text "request"
|
||||
t.text "response"
|
||||
t.string "state", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "webhook_id", null: false
|
||||
t.index ["event_id"], name: "index_webhook_deliveries_on_event_id"
|
||||
t.index ["webhook_id"], name: "index_webhook_deliveries_on_webhook_id"
|
||||
end
|
||||
|
||||
create_table "webhooks", force: :cascade do |t|
|
||||
t.boolean "active", default: true, null: false
|
||||
t.integer "collection_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.string "name"
|
||||
t.string "signing_secret", null: false
|
||||
t.text "subscribed_actions"
|
||||
t.datetime "updated_at", null: false
|
||||
t.text "url", null: false
|
||||
t.index ["collection_id"], name: "index_webhooks_on_collection_id"
|
||||
end
|
||||
|
||||
create_table "workflow_stages", force: :cascade do |t|
|
||||
t.string "color"
|
||||
t.datetime "created_at", null: false
|
||||
@@ -501,6 +533,10 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do
|
||||
add_foreign_key "user_settings", "users"
|
||||
add_foreign_key "watches", "cards"
|
||||
add_foreign_key "watches", "users"
|
||||
add_foreign_key "webhook_delinquency_trackers", "webhooks"
|
||||
add_foreign_key "webhook_deliveries", "events"
|
||||
add_foreign_key "webhook_deliveries", "webhooks"
|
||||
add_foreign_key "webhooks", "collections"
|
||||
add_foreign_key "workflow_stages", "workflows"
|
||||
|
||||
# Virtual tables defined in this database.
|
||||
|
||||
+200
-18
@@ -1248,7 +1248,7 @@ columns:
|
||||
- *9
|
||||
- *18
|
||||
users:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
- &38 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: active
|
||||
cast_type: *32
|
||||
@@ -1318,6 +1318,131 @@ columns:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
webhook_delinquency_trackers:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: consecutive_failures_count
|
||||
cast_type: *3
|
||||
sql_type_metadata: *4
|
||||
'null': true
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- *5
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: first_failure_at
|
||||
cast_type: *1
|
||||
sql_type_metadata: *2
|
||||
'null': true
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- *6
|
||||
- *9
|
||||
- &37 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: webhook_id
|
||||
cast_type: *3
|
||||
sql_type_metadata: *4
|
||||
'null': false
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
webhook_deliveries:
|
||||
- *5
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: event_id
|
||||
cast_type: *3
|
||||
sql_type_metadata: *4
|
||||
'null': false
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- *6
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: request
|
||||
cast_type: *15
|
||||
sql_type_metadata: *16
|
||||
'null': true
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: response
|
||||
cast_type: *15
|
||||
sql_type_metadata: *16
|
||||
'null': true
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: state
|
||||
cast_type: *7
|
||||
sql_type_metadata: *8
|
||||
'null': false
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- *9
|
||||
- *37
|
||||
webhooks:
|
||||
- *38
|
||||
- *24
|
||||
- *5
|
||||
- *6
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: name
|
||||
cast_type: *7
|
||||
sql_type_metadata: *8
|
||||
'null': true
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: signing_secret
|
||||
cast_type: *7
|
||||
sql_type_metadata: *8
|
||||
'null': false
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: subscribed_actions
|
||||
cast_type: *15
|
||||
sql_type_metadata: *16
|
||||
'null': true
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
- *9
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
name: url
|
||||
cast_type: *15
|
||||
sql_type_metadata: *16
|
||||
'null': false
|
||||
default:
|
||||
default_function:
|
||||
collation:
|
||||
comment:
|
||||
workflow_stages:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
|
||||
auto_increment:
|
||||
@@ -1397,6 +1522,9 @@ primary_keys:
|
||||
user_settings: id
|
||||
users: id
|
||||
watches: id
|
||||
webhook_delinquency_trackers: id
|
||||
webhook_deliveries: id
|
||||
webhooks: id
|
||||
workflow_stages: id
|
||||
workflows: id
|
||||
data_sources:
|
||||
@@ -1448,6 +1576,9 @@ data_sources:
|
||||
user_settings: true
|
||||
users: true
|
||||
watches: true
|
||||
webhook_delinquency_trackers: true
|
||||
webhook_deliveries: true
|
||||
webhooks: true
|
||||
workflow_stages: true
|
||||
workflows: true
|
||||
indexes:
|
||||
@@ -2858,23 +2989,7 @@ indexes:
|
||||
nulls_not_distinct:
|
||||
comment:
|
||||
valid: true
|
||||
tags:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
|
||||
table: tags
|
||||
name: index_tags_on_account_id_and_title
|
||||
unique: true
|
||||
columns:
|
||||
- title
|
||||
lengths: {}
|
||||
orders: {}
|
||||
opclasses: {}
|
||||
where:
|
||||
type:
|
||||
using:
|
||||
include:
|
||||
nulls_not_distinct:
|
||||
comment:
|
||||
valid: true
|
||||
tags: []
|
||||
user_settings:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
|
||||
table: user_settings
|
||||
@@ -2991,6 +3106,73 @@ indexes:
|
||||
nulls_not_distinct:
|
||||
comment:
|
||||
valid: true
|
||||
webhook_delinquency_trackers:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
|
||||
table: webhook_delinquency_trackers
|
||||
name: index_webhook_delinquency_trackers_on_webhook_id
|
||||
unique: false
|
||||
columns:
|
||||
- webhook_id
|
||||
lengths: {}
|
||||
orders: {}
|
||||
opclasses: {}
|
||||
where:
|
||||
type:
|
||||
using:
|
||||
include:
|
||||
nulls_not_distinct:
|
||||
comment:
|
||||
valid: true
|
||||
webhook_deliveries:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
|
||||
table: webhook_deliveries
|
||||
name: index_webhook_deliveries_on_event_id
|
||||
unique: false
|
||||
columns:
|
||||
- event_id
|
||||
lengths: {}
|
||||
orders: {}
|
||||
opclasses: {}
|
||||
where:
|
||||
type:
|
||||
using:
|
||||
include:
|
||||
nulls_not_distinct:
|
||||
comment:
|
||||
valid: true
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
|
||||
table: webhook_deliveries
|
||||
name: index_webhook_deliveries_on_webhook_id
|
||||
unique: false
|
||||
columns:
|
||||
- webhook_id
|
||||
lengths: {}
|
||||
orders: {}
|
||||
opclasses: {}
|
||||
where:
|
||||
type:
|
||||
using:
|
||||
include:
|
||||
nulls_not_distinct:
|
||||
comment:
|
||||
valid: true
|
||||
webhooks:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
|
||||
table: webhooks
|
||||
name: index_webhooks_on_collection_id
|
||||
unique: false
|
||||
columns:
|
||||
- collection_id
|
||||
lengths: {}
|
||||
orders: {}
|
||||
opclasses: {}
|
||||
where:
|
||||
type:
|
||||
using:
|
||||
include:
|
||||
nulls_not_distinct:
|
||||
comment:
|
||||
valid: true
|
||||
workflow_stages:
|
||||
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
|
||||
table: workflow_stages
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
require "test_helper"
|
||||
|
||||
class Webhooks::ActivationsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in_as :kevin
|
||||
end
|
||||
|
||||
test "create" do
|
||||
webhook = webhooks(:inactive)
|
||||
|
||||
assert_not webhook.active?
|
||||
|
||||
assert_changes -> { webhook.reload.active? }, from: false, to: true do
|
||||
post collection_webhook_activation_path(webhook.collection, webhook)
|
||||
end
|
||||
|
||||
assert_redirected_to collection_webhook_path(webhook.collection, webhook)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,115 @@
|
||||
require "test_helper"
|
||||
|
||||
class WebhooksControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in_as :kevin
|
||||
end
|
||||
|
||||
test "index" do
|
||||
get collection_webhooks_path(collections(:writebook))
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "show" do
|
||||
webhook = webhooks(:active)
|
||||
get collection_webhook_path(webhook.collection, webhook)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "new" do
|
||||
get new_collection_webhook_path(collections(:writebook))
|
||||
assert_response :success
|
||||
assert_select "form"
|
||||
end
|
||||
|
||||
test "create with valid params" do
|
||||
collection = collections(:writebook)
|
||||
|
||||
assert_difference "Webhook.count", 1 do
|
||||
post collection_webhooks_path(collection), params: {
|
||||
webhook: {
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com/webhook",
|
||||
subscribed_actions: [ "", "card_published", "card_closed" ]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
webhook = Webhook.order(id: :desc).first
|
||||
|
||||
assert_redirected_to collection_webhook_path(webhook.collection, webhook)
|
||||
assert_equal collection, webhook.collection
|
||||
assert_equal "Test Webhook", webhook.name
|
||||
assert_equal "https://example.com/webhook", webhook.url
|
||||
assert_equal [ "card_published", "card_closed" ], webhook.subscribed_actions
|
||||
end
|
||||
|
||||
test "create with invalid params" do
|
||||
collection = collections(:writebook)
|
||||
assert_no_difference "Webhook.count" do
|
||||
post collection_webhooks_path(collection), params: {
|
||||
webhook: {
|
||||
name: "",
|
||||
url: "invalid-url"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "edit" do
|
||||
webhook = webhooks(:active)
|
||||
get edit_collection_webhook_path(webhook.collection, webhook)
|
||||
assert_response :success
|
||||
assert_select "form"
|
||||
end
|
||||
|
||||
test "update with valid params" do
|
||||
webhook = webhooks(:active)
|
||||
patch collection_webhook_path(webhook.collection, webhook), params: {
|
||||
webhook: {
|
||||
name: "Updated Webhook",
|
||||
subscribed_actions: [ "card_published" ]
|
||||
}
|
||||
}
|
||||
|
||||
webhook.reload
|
||||
|
||||
assert_redirected_to collection_webhook_path(webhook.collection, webhook)
|
||||
assert_equal "Updated Webhook", webhook.name
|
||||
assert_equal [ "card_published" ], webhook.subscribed_actions
|
||||
end
|
||||
|
||||
test "update with invalid params" do
|
||||
webhook = webhooks(:active)
|
||||
patch collection_webhook_path(webhook.collection, webhook), params: {
|
||||
webhook: {
|
||||
name: ""
|
||||
}
|
||||
}
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
|
||||
assert_no_changes -> { webhook.reload.url } do
|
||||
patch collection_webhook_path(webhook.collection, webhook), params: {
|
||||
webhook: {
|
||||
name: "Updated Webhook",
|
||||
url: "https://different.com/webhook"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to collection_webhook_path(webhook.collection, webhook)
|
||||
end
|
||||
|
||||
test "destroy" do
|
||||
webhook = webhooks(:active)
|
||||
|
||||
assert_difference "Webhook.count", -1 do
|
||||
delete collection_webhook_path(webhook.collection, webhook)
|
||||
end
|
||||
|
||||
assert_redirected_to collection_webhooks_path(webhook.collection)
|
||||
end
|
||||
end
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
active_webhook_tracker:
|
||||
webhook: active
|
||||
consecutive_failures_count: 1
|
||||
first_failure_at: <%= 1.hour.ago %>
|
||||
|
||||
inactive_webhook_tracker:
|
||||
webhook: inactive
|
||||
consecutive_failures_count: 1
|
||||
first_failure_at: <%= 1.hour.ago %>
|
||||
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
successfully_completed:
|
||||
webhook: active
|
||||
event: logo_published
|
||||
state: completed
|
||||
request: '<%= { headers: {} }.to_json %>'
|
||||
response: '<%= { code: 200, headers: {} }.to_json %>'
|
||||
created_at: <%= 1.week.ago %>
|
||||
|
||||
unsuccessfully_completed:
|
||||
webhook: active
|
||||
event: logo_assignment_jz
|
||||
state: completed
|
||||
request: '<%= { headers: {} }.to_json %>'
|
||||
response: '<%= { code: 422, headers: {} }.to_json %>'
|
||||
created_at: <%= 1.week.ago + 1.hour %>
|
||||
|
||||
errored:
|
||||
webhook: active
|
||||
event: layout_published
|
||||
state: errored
|
||||
request: '<%= { headers: {} }.to_json %>'
|
||||
response: '<%= { error: "destination_unreachable" }.to_json %>'
|
||||
created_at: <%= 1.week.ago %>
|
||||
|
||||
pending:
|
||||
webhook: active
|
||||
event: shipping_closed
|
||||
state: pending
|
||||
request: null
|
||||
response: null
|
||||
created_at: <%= 2.day.ago %>
|
||||
|
||||
in_progress:
|
||||
webhook: active
|
||||
event: logo_assignment_km
|
||||
state: in_progress
|
||||
request: null
|
||||
response: null
|
||||
created_at: <%= 1.day.ago %>
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
active:
|
||||
active: true
|
||||
name: Production API
|
||||
url: https://api.example.com/webhooks
|
||||
signing_secret: p94Bx2HjempCdYB4DTyZkY1b
|
||||
subscribed_actions: '<%= %w[ card_published card_assigned card_closed ].to_json %>'
|
||||
collection: writebook
|
||||
|
||||
inactive:
|
||||
active: false
|
||||
name: Test Webhook
|
||||
url: https://test.example.com/webhooks
|
||||
signing_secret: H8ms8ADcV92v2x17hnLEiL5m
|
||||
subscribed_actions: '<%= %w[ card_published card_assigned card_closed ].to_json %>'
|
||||
collection: private
|
||||
@@ -0,0 +1,39 @@
|
||||
require "test_helper"
|
||||
|
||||
class Webhook::DelinquencyTrackerTest < ActiveSupport::TestCase
|
||||
test "record_delivery_of" do
|
||||
tracker = webhook_delinquency_trackers(:active_webhook_tracker)
|
||||
webhook = tracker.webhook
|
||||
successful_delivery = webhook_deliveries(:successfully_completed)
|
||||
failed_delivery = webhook_deliveries(:errored)
|
||||
|
||||
tracker.update!(consecutive_failures_count: 5)
|
||||
tracker.record_delivery_of(successful_delivery)
|
||||
tracker.reload
|
||||
|
||||
assert_equal 0, tracker.consecutive_failures_count
|
||||
assert_nil tracker.first_failure_at
|
||||
|
||||
assert_difference -> { tracker.reload.consecutive_failures_count }, +1 do
|
||||
tracker.record_delivery_of(failed_delivery)
|
||||
end
|
||||
|
||||
tracker.reload
|
||||
assert_not_nil tracker.first_failure_at
|
||||
|
||||
assert_difference -> { tracker.reload.consecutive_failures_count }, +1 do
|
||||
assert_no_difference -> { tracker.reload.first_failure_at } do
|
||||
tracker.record_delivery_of(failed_delivery)
|
||||
end
|
||||
end
|
||||
|
||||
travel_to 2.hours.from_now do
|
||||
tracker.update!(consecutive_failures_count: 9)
|
||||
webhook.activate
|
||||
|
||||
assert_changes -> { webhook.reload.active? }, from: true, to: false do
|
||||
tracker.record_delivery_of(failed_delivery)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,217 @@
|
||||
require "test_helper"
|
||||
|
||||
class Webhook::DeliveryTest < ActiveSupport::TestCase
|
||||
test "create" do
|
||||
webhook = webhooks(:active)
|
||||
event = events(:layout_commented)
|
||||
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
||||
|
||||
assert_equal "pending", delivery.state
|
||||
end
|
||||
|
||||
test "succeeded" do
|
||||
webhook = webhooks(:active)
|
||||
event = events(:layout_commented)
|
||||
delivery = Webhook::Delivery.new(
|
||||
webhook: webhook,
|
||||
event: event,
|
||||
response: { code: 200 },
|
||||
state: :completed
|
||||
)
|
||||
assert delivery.succeeded?
|
||||
|
||||
delivery.response[:code] = 422
|
||||
assert_not delivery.succeeded?, "resonse must have a 2XX status"
|
||||
|
||||
delivery.response[:code] = 200
|
||||
delivery.state = :pending
|
||||
assert_not delivery.succeeded?, "state must be completed"
|
||||
|
||||
delivery.state = :in_progress
|
||||
assert_not delivery.succeeded?, "state must be completed"
|
||||
|
||||
delivery.state = :errored
|
||||
assert_not delivery.succeeded?, "state must be completed"
|
||||
|
||||
delivery.state = :completed
|
||||
delivery.response[:error] = :destination_unreachable
|
||||
|
||||
assert_not delivery.succeeded?, "the response can't have an error"
|
||||
end
|
||||
|
||||
test "deliver_later" do
|
||||
delivery = webhook_deliveries(:pending)
|
||||
|
||||
assert_enqueued_with job: Webhook::DeliveryJob, args: [ delivery ] do
|
||||
delivery.deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
test "deliver" do
|
||||
delivery = webhook_deliveries(:pending)
|
||||
|
||||
stub_request(:post, delivery.webhook.url)
|
||||
.to_return(status: 200, headers: { "content-type" => "application/json" })
|
||||
|
||||
assert_equal "pending", delivery.state
|
||||
|
||||
tracker = delivery.webhook.delinquency_tracker
|
||||
tracker.update!(consecutive_failures_count: 0)
|
||||
|
||||
assert_no_difference -> { tracker.reload.consecutive_failures_count } do
|
||||
delivery.deliver
|
||||
end
|
||||
|
||||
assert delivery.persisted?
|
||||
assert_equal "completed", delivery.state
|
||||
assert delivery.request[:headers].present?
|
||||
assert_equal 200, delivery.response[:code]
|
||||
assert delivery.response[:error].blank?
|
||||
assert delivery.succeeded?
|
||||
end
|
||||
|
||||
test "deliver when the network timeouts" do
|
||||
delivery = webhook_deliveries(:pending)
|
||||
stub_request(:post, delivery.webhook.url).to_timeout
|
||||
|
||||
tracker = delivery.webhook.delinquency_tracker
|
||||
assert_difference -> { tracker.reload.consecutive_failures_count }, 1 do
|
||||
delivery.deliver
|
||||
end
|
||||
|
||||
assert_equal "completed", delivery.state
|
||||
assert_equal "connection_timeout", delivery.response[:error]
|
||||
assert_not delivery.succeeded?
|
||||
end
|
||||
|
||||
test "deliver when the connection is refused" do
|
||||
delivery = webhook_deliveries(:pending)
|
||||
stub_request(:post, delivery.webhook.url).to_raise(Errno::ECONNREFUSED)
|
||||
|
||||
delivery.deliver
|
||||
|
||||
assert_equal "completed", delivery.state
|
||||
assert_equal "destination_unreachable", delivery.response[:error]
|
||||
end
|
||||
|
||||
test "deliver when an SSL error occurs" do
|
||||
delivery = webhook_deliveries(:pending)
|
||||
stub_request(:post, delivery.webhook.url).to_raise(OpenSSL::SSL::SSLError)
|
||||
|
||||
delivery.deliver
|
||||
|
||||
assert_equal "completed", delivery.state
|
||||
assert_equal "failed_tls", delivery.response[:error]
|
||||
end
|
||||
|
||||
test "deliver when an unexpected error occurs" do
|
||||
delivery = webhook_deliveries(:pending)
|
||||
stub_request(:post, delivery.webhook.url).to_raise(StandardError, "Unexpected error")
|
||||
|
||||
assert_raises(StandardError) do
|
||||
delivery.deliver
|
||||
end
|
||||
|
||||
assert_equal "errored", delivery.state
|
||||
end
|
||||
|
||||
test "deliver with basecamp webhook format" do
|
||||
webhook = Webhook.create!(
|
||||
collection: collections(:writebook),
|
||||
name: "Basecamp",
|
||||
url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines"
|
||||
)
|
||||
event = events(:layout_commented)
|
||||
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
||||
|
||||
request_stub = stub_request(:post, webhook.url)
|
||||
.with do |request|
|
||||
body = JSON.parse(request.body)
|
||||
body.key?("line") && body["line"].key?("content") && body["line"]["content"].present?
|
||||
end
|
||||
.to_return(status: 200)
|
||||
|
||||
delivery.deliver
|
||||
|
||||
assert_requested request_stub
|
||||
assert delivery.succeeded?
|
||||
end
|
||||
|
||||
test "deliver with campfire webhook format" do
|
||||
webhook = Webhook.create!(
|
||||
collection: collections(:writebook),
|
||||
name: "Campfire",
|
||||
url: "https://example.com/rooms/123/456-room-name/messages"
|
||||
)
|
||||
event = events(:layout_commented)
|
||||
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
||||
|
||||
request_stub = stub_request(:post, webhook.url)
|
||||
.with do |request|
|
||||
request.body.is_a?(String) && !request.body.start_with?("{") && request.body.present?
|
||||
end
|
||||
.to_return(status: 200)
|
||||
|
||||
delivery.deliver
|
||||
|
||||
assert_requested request_stub
|
||||
assert delivery.succeeded?
|
||||
end
|
||||
|
||||
test "deliver with slack webhook format" do
|
||||
webhook = Webhook.create!(
|
||||
collection: collections(:writebook),
|
||||
name: "Slack",
|
||||
url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx"
|
||||
)
|
||||
event = events(:layout_commented)
|
||||
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
||||
|
||||
request_stub = stub_request(:post, webhook.url)
|
||||
.with do |request|
|
||||
body = JSON.parse(request.body)
|
||||
body.key?("text") && body["text"].present?
|
||||
end
|
||||
.to_return(status: 200)
|
||||
|
||||
delivery.deliver
|
||||
|
||||
assert_requested request_stub
|
||||
assert delivery.succeeded?
|
||||
end
|
||||
|
||||
test "deliver with generic webhook format" do
|
||||
webhook = Webhook.create!(
|
||||
collection: collections(:writebook),
|
||||
name: "Generic",
|
||||
url: "https://example.com/webhook"
|
||||
)
|
||||
event = events(:layout_commented)
|
||||
delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
||||
|
||||
request_stub = stub_request(:post, webhook.url)
|
||||
.with do |request|
|
||||
body = JSON.parse(request.body)
|
||||
body.present? && !body.key?("line") && !body.key?("text")
|
||||
end
|
||||
.to_return(status: 200)
|
||||
|
||||
delivery.deliver
|
||||
|
||||
assert_requested request_stub
|
||||
assert delivery.succeeded?
|
||||
end
|
||||
|
||||
test "cleanup" do
|
||||
webhook = webhooks(:active)
|
||||
event = events(:layout_commented)
|
||||
|
||||
fresh_delivery = Webhook::Delivery.create!(webhook: webhook, event: event)
|
||||
stale_delivery = Webhook::Delivery.create!(webhook: webhook, event: event, created_at: 8.days.ago)
|
||||
|
||||
Webhook::Delivery.cleanup
|
||||
|
||||
assert Webhook::Delivery.exists?(fresh_delivery.id)
|
||||
assert_not Webhook::Delivery.exists?(stale_delivery.id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
require "test_helper"
|
||||
|
||||
class WebhookTest < ActiveSupport::TestCase
|
||||
test "create" do
|
||||
webhook = Webhook.create! name: "Test", url: "https://example.com/webhook", collection: collections(:writebook)
|
||||
assert webhook.persisted?
|
||||
assert webhook.active?
|
||||
assert webhook.signing_secret.present?
|
||||
assert webhook.delinquency_tracker.present?
|
||||
end
|
||||
|
||||
test "validates the url" do
|
||||
webhook = Webhook.new name: "Test", collection: collections(:writebook)
|
||||
assert_not webhook.valid?
|
||||
assert_includes webhook.errors[:url], "not a URL"
|
||||
|
||||
webhook = Webhook.new name: "Test", collection: collections(:writebook), url: "not a url"
|
||||
assert_not webhook.valid?
|
||||
assert_includes webhook.errors[:url], "not a URL"
|
||||
|
||||
webhook = Webhook.new name: "NOTHING", collection: collections(:writebook), url: "example.com/webhook"
|
||||
assert_not webhook.valid?
|
||||
assert_includes webhook.errors[:url], "must use http or https"
|
||||
|
||||
webhook = Webhook.new name: "BLANK", collection: collections(:writebook), url: "//example.com/webhook"
|
||||
assert_not webhook.valid?
|
||||
assert_includes webhook.errors[:url], "must use http or https"
|
||||
|
||||
webhook = Webhook.new name: "GOPHER", collection: collections(:writebook), url: "gopher://example.com/webhook"
|
||||
assert_not webhook.valid?
|
||||
assert_includes webhook.errors[:url], "must use http or https"
|
||||
|
||||
webhook = Webhook.new name: "HTTP", collection: collections(:writebook), url: "http://example.com/webhook"
|
||||
assert webhook.valid?
|
||||
|
||||
webhook = Webhook.new name: "HTTPS", collection: collections(:writebook), url: "https://example.com/webhook"
|
||||
assert webhook.valid?
|
||||
end
|
||||
|
||||
test "deactivate" do
|
||||
webhook = webhooks(:active)
|
||||
|
||||
assert_changes -> { webhook.active? }, from: true, to: false do
|
||||
webhook.deactivate
|
||||
end
|
||||
end
|
||||
|
||||
test "activate" do
|
||||
webhook = webhooks(:inactive)
|
||||
|
||||
assert_changes -> { webhook.active? }, from: false, to: true do
|
||||
webhook.activate
|
||||
end
|
||||
end
|
||||
|
||||
test "for_slack?" do
|
||||
webhook = Webhook.new url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx"
|
||||
assert webhook.for_slack?
|
||||
|
||||
webhook = Webhook.new url: "https://hooks.slack.com/services/T12345678/B12345678"
|
||||
assert_not webhook.for_slack?
|
||||
|
||||
webhook = Webhook.new url: "https://hooks.slack.com/services/T12345678"
|
||||
assert_not webhook.for_slack?
|
||||
|
||||
webhook = Webhook.new url: "https://hooks.slack.com/services/"
|
||||
assert_not webhook.for_slack?
|
||||
|
||||
webhook = Webhook.new url: "https://example.com/webhook"
|
||||
assert_not webhook.for_slack?
|
||||
end
|
||||
|
||||
test "for_campfire?" do
|
||||
webhook = Webhook.new url: "https://example.com/rooms/123/456-room-name/messages"
|
||||
assert webhook.for_campfire?
|
||||
|
||||
webhook = Webhook.new url: "https://campfire.example.com/rooms/999/123-test-room/messages"
|
||||
assert webhook.for_campfire?
|
||||
|
||||
webhook = Webhook.new url: "https://campfire.example.com/rooms/999/123/messages"
|
||||
assert_not webhook.for_campfire?, "The bot key is missing a token"
|
||||
|
||||
webhook = Webhook.new url: "https://example.com/webhook"
|
||||
assert_not webhook.for_campfire?
|
||||
|
||||
webhook = Webhook.new url: "https://example.com/rooms/123/messages"
|
||||
assert_not webhook.for_campfire?
|
||||
|
||||
webhook = Webhook.new url: "https://example.com/rooms/123/456-room-name/"
|
||||
assert_not webhook.for_campfire?
|
||||
end
|
||||
|
||||
test "for_basecamp?" do
|
||||
webhook = Webhook.new url: "https://basecamp.com/999/integrations/some-token/buckets/111/chats/222/lines"
|
||||
assert webhook.for_basecamp?
|
||||
|
||||
webhook = Webhook.new url: "https://example.com/webhook"
|
||||
assert_not webhook.for_basecamp?
|
||||
|
||||
webhook = Webhook.new url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/"
|
||||
assert_not webhook.for_basecamp?
|
||||
|
||||
webhook = Webhook.new url: "https://3.basecamp.com/integrations/webhook/buckets/456/chats/789/lines"
|
||||
assert_not webhook.for_basecamp?
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user