diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css
index dabbec2c9..3eb820bfb 100644
--- a/app/assets/stylesheets/utilities.css
+++ b/app/assets/stylesheets/utilities.css
@@ -193,6 +193,7 @@
/* Lists */
:where(.list-style-none) {
+ list-style: none;
margin: 0 auto;
padding: 0;
}
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
index 60f2b01e1..4e7e52372 100644
--- a/app/controllers/admin_controller.rb
+++ b/app/controllers/admin_controller.rb
@@ -1,3 +1,3 @@
class AdminController < ApplicationController
- include StaffOnly
+ before_action :ensure_is_staff_member
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 6abefad23..aa35df0b3 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -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" }
diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb
new file mode 100644
index 000000000..38873b098
--- /dev/null
+++ b/app/controllers/concerns/authorization.rb
@@ -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
diff --git a/app/controllers/concerns/staff_only.rb b/app/controllers/concerns/staff_only.rb
deleted file mode 100644
index e8c34ffda..000000000
--- a/app/controllers/concerns/staff_only.rb
+++ /dev/null
@@ -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
diff --git a/app/controllers/conversations/messages_controller.rb b/app/controllers/conversations/messages_controller.rb
index c69b1e684..d37ac040b 100644
--- a/app/controllers/conversations/messages_controller.rb
+++ b/app/controllers/conversations/messages_controller.rb
@@ -1,6 +1,5 @@
class Conversations::MessagesController < ApplicationController
- include StaffOnly
-
+ before_action :ensure_is_staff_member
before_action :set_conversation
def index
diff --git a/app/controllers/conversations_controller.rb b/app/controllers/conversations_controller.rb
index 69de90d03..2e100fecf 100644
--- a/app/controllers/conversations_controller.rb
+++ b/app/controllers/conversations_controller.rb
@@ -1,5 +1,5 @@
class ConversationsController < ApplicationController
- include StaffOnly
+ before_action :ensure_is_staff_member
def create
Current.user.start_or_continue_conversation
diff --git a/app/controllers/webhooks/activations_controller.rb b/app/controllers/webhooks/activations_controller.rb
new file mode 100644
index 000000000..ea30e1e87
--- /dev/null
+++ b/app/controllers/webhooks/activations_controller.rb
@@ -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
diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb
new file mode 100644
index 000000000..fceb37c5c
--- /dev/null
+++ b/app/controllers/webhooks_controller.rb
@@ -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
diff --git a/app/jobs/event/webhook_dispatch_job.rb b/app/jobs/event/webhook_dispatch_job.rb
new file mode 100644
index 000000000..1df418cc8
--- /dev/null
+++ b/app/jobs/event/webhook_dispatch_job.rb
@@ -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
diff --git a/app/jobs/webhook/cleanup_deliveries_job.rb b/app/jobs/webhook/cleanup_deliveries_job.rb
new file mode 100644
index 000000000..63f87ead5
--- /dev/null
+++ b/app/jobs/webhook/cleanup_deliveries_job.rb
@@ -0,0 +1,7 @@
+class Webhook::CleanupDeliveriesJob < ApplicationJob
+ def perform
+ ApplicationRecord.with_each_tenant do
+ Webhook::Delivery.cleanup
+ end
+ end
+end
diff --git a/app/jobs/webhook/delivery_job.rb b/app/jobs/webhook/delivery_job.rb
new file mode 100644
index 000000000..95102b9d6
--- /dev/null
+++ b/app/jobs/webhook/delivery_job.rb
@@ -0,0 +1,7 @@
+class Webhook::DeliveryJob < ApplicationJob
+ queue_as :webhooks
+
+ def perform(delivery)
+ delivery.deliver
+ end
+end
diff --git a/app/models/collection.rb b/app/models/collection.rb
index 4723ccd64..0d8fdeb37 100644
--- a/app/models/collection.rb
+++ b/app/models/collection.rb
@@ -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)") }
diff --git a/app/models/event.rb b/app/models/event.rb
index 811e207d3..c45702833 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -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
diff --git a/app/models/user/role.rb b/app/models/user/role.rb
index 4a90410b5..aaa4bd5ff 100644
--- a/app/models/user/role.rb
+++ b/app/models/user/role.rb
@@ -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
diff --git a/app/models/webhook.rb b/app/models/webhook.rb
new file mode 100644
index 000000000..10d2e383d
--- /dev/null
+++ b/app/models/webhook.rb
@@ -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
diff --git a/app/models/webhook/delinquency_tracker.rb b/app/models/webhook/delinquency_tracker.rb
new file mode 100644
index 000000000..6ae40eb4a
--- /dev/null
+++ b/app/models/webhook/delinquency_tracker.rb
@@ -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
diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb
new file mode 100644
index 000000000..0108a44a0
--- /dev/null
+++ b/app/models/webhook/delivery.rb
@@ -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
diff --git a/app/models/webhook/triggerable.rb b/app/models/webhook/triggerable.rb
new file mode 100644
index 000000000..a2f5ea186
--- /dev/null
+++ b/app/models/webhook/triggerable.rb
@@ -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
diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder
new file mode 100644
index 000000000..fdb242edd
--- /dev/null
+++ b/app/views/cards/_card.json.jbuilder
@@ -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
diff --git a/app/views/cards/_webhooks.html.erb b/app/views/cards/_webhooks.html.erb
new file mode 100644
index 000000000..2bdddaa3d
--- /dev/null
+++ b/app/views/cards/_webhooks.html.erb
@@ -0,0 +1,4 @@
+<%= link_to collection_webhooks_path(collection_id: collection), class: ["btn tooltip", {"btn--reversed": collection.webhooks.any?}] do %>
+ <%= icon_tag "world" %>
+ Webhooks
+<% end %>
diff --git a/app/views/cards/comments/_comment.json.jbuilder b/app/views/cards/comments/_comment.json.jbuilder
new file mode 100644
index 000000000..a66ae1ef9
--- /dev/null
+++ b/app/views/cards/comments/_comment.json.jbuilder
@@ -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
diff --git a/app/views/cards/index.html.erb b/app/views/cards/index.html.erb
index 59128e22e..24161c57a 100644
--- a/app/views/cards/index.html.erb
+++ b/app/views/cards/index.html.erb
@@ -20,6 +20,7 @@
diff --git a/app/views/collections/_collection.json.jbuilder b/app/views/collections/_collection.json.jbuilder
new file mode 100644
index 000000000..3c42b2fc9
--- /dev/null
+++ b/app/views/collections/_collection.json.jbuilder
@@ -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
diff --git a/app/views/users/_user.json.jbuilder b/app/views/users/_user.json.jbuilder
new file mode 100644
index 000000000..6b11459f3
--- /dev/null
+++ b/app/views/users/_user.json.jbuilder
@@ -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
diff --git a/app/views/webhooks/_delivery.html.erb b/app/views/webhooks/_delivery.html.erb
new file mode 100644
index 000000000..8dba2f474
--- /dev/null
+++ b/app/views/webhooks/_delivery.html.erb
@@ -0,0 +1,8 @@
+<%= tag.li id: dom_id(delivery), class: token_list(delivery.succeeded? && "delivery--succeeded") do %>
+
+ <%= delivery.state %>
+
+
+ <%= time_tag delivery.created_at, time_ago_in_words(delivery.created_at) %>
+
+<% end %>
diff --git a/app/views/webhooks/_webhook.html.erb b/app/views/webhooks/_webhook.html.erb
new file mode 100644
index 000000000..3a72a2473
--- /dev/null
+++ b/app/views/webhooks/_webhook.html.erb
@@ -0,0 +1,13 @@
+
+ <%= link_to webhook, class: "txt-ink flex gap-half align-center min-width txt-medium" do %>
+ <%= webhook.name %>
+ <% end %>
+
+
+
+ <%= 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" %>
+ Remove <%= webhook.name %>
+ <% end %>
+
diff --git a/app/views/webhooks/edit.html.erb b/app/views/webhooks/edit.html.erb
new file mode 100644
index 000000000..d7cc99aff
--- /dev/null
+++ b/app/views/webhooks/edit.html.erb
@@ -0,0 +1,35 @@
+<% @page_title = @webhook.name %>
+
+<% content_for :header do %>
+ <%= render "filters/menu", user_filtering: @user_filtering %>
+
+<% end %>
+
+
+ <%= form_with model: @webhook, url: @webhook, method: :put, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>
+
+ <%= form.text_field :name, required: true, autofocus: true, class: "input full-width", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %>
+
+
+
+ <%= form.label :actions do %>
+
Events
+
Trigger a call to the Payload URL when these things occur:
+ <% end %>
+ <%= render "webhooks/form/actions", form: form %>
+
+
+ <%= form.button type: :submit, class: "btn btn--link center txt-medium" do %>
+ Save Changes
+ <% end %>
+
+ <%= link_to "Go back", collection_webhooks_path, data: { form_target: "cancel" }, hidden: true %>
+ <% end %>
+
diff --git a/app/views/webhooks/event.html.erb b/app/views/webhooks/event.html.erb
new file mode 100644
index 000000000..038c5971d
--- /dev/null
+++ b/app/views/webhooks/event.html.erb
@@ -0,0 +1,4 @@
+<%= event_action_sentence(@event) %>
+<% if @event.eventable %>
+ <%= link_to "See more", @event.eventable %>
+<% end %>
diff --git a/app/views/webhooks/event.json.jbuilder b/app/views/webhooks/event.json.jbuilder
new file mode 100644
index 000000000..ff779c39a
--- /dev/null
+++ b/app/views/webhooks/event.json.jbuilder
@@ -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
diff --git a/app/views/webhooks/form/_actions.html.erb b/app/views/webhooks/form/_actions.html.erb
new file mode 100644
index 000000000..cd75e2dd6
--- /dev/null
+++ b/app/views/webhooks/form/_actions.html.erb
@@ -0,0 +1,16 @@
+
+ <%= form.collection_check_boxes \
+ :subscribed_actions,
+ Webhook::PERMITTED_ACTIONS,
+ :itself,
+ :humanize do |item| %>
+
+
+ <%= item.check_box(class: "switch__input") %>
+
+ <%= item.text %>
+
+ <%= item.text %>
+
+ <% end %>
+
diff --git a/app/views/webhooks/index.html.erb b/app/views/webhooks/index.html.erb
new file mode 100644
index 000000000..77a6c12b8
--- /dev/null
+++ b/app/views/webhooks/index.html.erb
@@ -0,0 +1,37 @@
+<% @page_title = "Webhooks" %>
+
+<% content_for :header do %>
+ <%= render "filters/menu", user_filtering: @user_filtering %>
+
+
+
+
+
+<% end %>
+
+<%= tag.section class: "panel shadow center webhooks" do %>
+ <% if @page.records.any? %>
+
+ <%= render partial: "webhooks/webhook", collection: @page.records %>
+
+ <% else %>
+ 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.
+ For example, you could create a webhook that posts to a Campfire chat in Basecamp when new cards are added to Fizzy.
+ <%= link_to new_collection_webhook_path, class: "btn btn--link" do %>
+ <%= icon_tag "add" %>
+ Set up a webhook
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/app/views/webhooks/new.html.erb b/app/views/webhooks/new.html.erb
new file mode 100644
index 000000000..27d741fd2
--- /dev/null
+++ b/app/views/webhooks/new.html.erb
@@ -0,0 +1,51 @@
+<% @page_title = "Set up a Webhook" %>
+
+<% content_for :header do %>
+ <%= render "filters/menu", user_filtering: @user_filtering %>
+
+
+
+<% end %>
+
+
+ <%= form_with model: @webhook, url: collection_webhooks_path, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>
+
+ <%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name this Webhook…", data: { action: "keydown.esc@document->form#cancel" } %>
+
+
+
+ <%= form.label :url do %>
+
Payload URL
+
This is the URL for the app that will receive payloads from Fizzy.
+ <% 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" } %>
+
+
+
+ <%= form.label :actions do %>
+
Events
+
Trigger a call to the Payload URL when these things occur:
+ <% end %>
+ <%= render "webhooks/form/actions", form: form %>
+
+
+ <%= form.button type: :submit, class: "btn btn--link center txt-medium" do %>
+ Create Webhook
+ <% end %>
+
+ <%= link_to "Go back", collection_webhooks_path, data: { form_target: "cancel" }, hidden: true %>
+ <% end %>
+
diff --git a/app/views/webhooks/show.html.erb b/app/views/webhooks/show.html.erb
new file mode 100644
index 000000000..f4edfa392
--- /dev/null
+++ b/app/views/webhooks/show.html.erb
@@ -0,0 +1,66 @@
+<% @page_title = @webhook.name %>
+
+<% content_for :header do %>
+ <%= render "filters/menu", user_filtering: @user_filtering %>
+
+
+