Scope Webhooks to Collections

This commit is contained in:
Stanko K.R.
2025-09-15 16:05:36 +02:00
parent 25b2daad93
commit 02a421675f
20 changed files with 122 additions and 68 deletions
+13 -6
View File
@@ -2,21 +2,22 @@ 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 Webhook.all.ordered
set_page_and_extract_portion_from @collection.webhooks.ordered
end
def show
end
def new
@webhook = Webhook.new
@webhook = @collection.webhooks.new
end
def create
@webhook = Webhook.new(webhook_params)
@webhook = @collection.webhooks.new(webhook_params)
if @webhook.save
redirect_to @webhook
@@ -38,15 +39,21 @@ class WebhooksController < ApplicationController
def destroy
@webhook.destroy!
redirect_to webhooks_path, status: :see_other
redirect_to collection_webhooks_path, status: :see_other
end
private
def set_collection
@collection = Collection.find(params[:collection_id])
end
def set_webhook
@webhook = Webhook.find(params[:id])
@webhook = @collection.webhooks.find(params[:id])
end
def webhook_params
params.expect webhook: [ :name, :url, subscribed_actions: [] ]
params
.expect(webhook: [ :name, :url, subscribed_actions: [] ])
.merge(collection_id: @collection.id)
end
end
+1
View File
@@ -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)") }
+2
View File
@@ -26,6 +26,8 @@ class Webhook < ApplicationRecord
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) }
+1 -1
View File
@@ -2,7 +2,7 @@ module Webhook::Triggerable
extend ActiveSupport::Concern
included do
scope :triggered_by, ->(event) { triggered_by_action(event.action) }
scope :triggered_by, ->(event) { where(collection: event.collection).triggered_by_action(event.action) }
scope :triggered_by_action, ->(action) { where("subscribed_actions LIKE ?", "%\"#{action}\"%") }
end
-7
View File
@@ -4,13 +4,6 @@
<%= render "filters/menu", user_filtering: @user_filtering %>
<h1 class="header__title"><%= @page_title %></h1>
<div class="header__actions header__actions--end">
<% if Current.user.admin? %>
<%= link_to webhooks_path, class: "btn" do %>
<%= icon_tag "world" %>
<span class="for-screen-reader">Webhooks</span>
<% end %>
<% end %>
<%= link_to workflows_path, class: "btn" do %>
<%= icon_tag "bolt" %>
<span class="for-screen-reader">Workflows</span>
+4
View File
@@ -0,0 +1,4 @@
<%= link_to collection_webhooks_path(collection_id: collection), class: "btn tooltip" do %>
<%= icon_tag "world" %>
<span class="for-screen-reader">Webhooks for <%= collection.name %></span>
<% end %>
+1
View File
@@ -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>
+1 -1
View File
@@ -20,6 +20,6 @@
<span>Update Webhook</span>
<% end %>
<%= link_to "Go back", webhooks_path, data: { form_target: "cancel" }, hidden: true %>
<%= link_to "Go back", collection_webhooks_path, data: { form_target: "cancel" }, hidden: true %>
<% end %>
</article>
+9 -2
View File
@@ -2,10 +2,15 @@
<% content_for :header do %>
<%= render "filters/menu", user_filtering: @user_filtering %>
<h1 class="header__title" style="view-transistion-name: webhooks-title"><%= @page_title %></h1>
<%= link_to cards_path(collection_ids: [ @collection ]), class: "header__title btn borderless txt-large", style: "--btn-padding: 0.25ch 1ch 0.25ch 0.75ch; view-transistion-name: webhooks-title;", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="overflow-ellipsis">
&larr;
<strong class="font-black"><%= @collection.name %></strong>
</span>
<% end %>
<div class="header__actions header__actions--end">
<%= link_to new_webhook_path, class: "btn" do %>
<%= link_to new_collection_webhook_path, class: "btn" do %>
<%= icon_tag "add" %>
<span class="for-screen-reader">Create a new webhook</span>
<% end %>
@@ -19,6 +24,8 @@
navigable_list_focus_on_selection_value: true,
navigable_list_actionable_items_value: true
} do %>
<h1 class="header__title" style="view-transistion-name: webhooks-title"><%= @page_title %></h1>
<div>
<ul class="flex flex-column gap-half" data-filter-target="list">
<%= render partial: "webhooks/webhook", collection: @page.records %>
+2 -2
View File
@@ -6,7 +6,7 @@
<% end %>
<article class="panel center" style="--panel-size: 40ch;" style="view-transition-name: <%= dom_id(@webhook) %>">
<%= form_with model: @webhook, url: webhooks_path, data: { controller: "form" }, html: { class: "flex flex-column gap align-center justify-starts" } do |form| %>
<%= form_with model: @webhook, url: collection_webhooks_path, data: { controller: "form" }, html: { class: "flex flex-column gap align-center justify-starts" } do |form| %>
<h2 class="txt-large margin-none">
<%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %>
</h2>
@@ -22,6 +22,6 @@
<span>Create Webhook</span>
<% end %>
<%= link_to "Go back", webhooks_path, data: { form_target: "cancel" }, hidden: true %>
<%= link_to "Go back", collection_webhooks_path, data: { form_target: "cancel" }, hidden: true %>
<% end %>
</article>
+3 -3
View File
@@ -3,7 +3,7 @@
<% content_for :header do %>
<%= render "filters/menu", user_filtering: @user_filtering %>
<%= link_to webhooks_path, class: "header__title btn borderless txt-large", style: "--btn-padding: 0.25ch 1ch 0.25ch 0.75ch; view-transistion-name: webhooks-title;", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<%= link_to collection_webhooks_path, class: "header__title btn borderless txt-large", style: "--btn-padding: 0.25ch 1ch 0.25ch 0.75ch; view-transistion-name: webhooks-title;", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="overflow-ellipsis">
&larr;
<strong class="font-black">Webhooks</strong>
@@ -13,7 +13,7 @@
<div class="panel shadow center txt-align-center margin-block-end-double">
<div class="flex flex-column gap position-relative">
<%= link_to edit_webhook_path(@webhook), class: "user-edit-link btn" do %>
<%= link_to edit_collection_webhook_path(@webhook.collection_id, @webhook), class: "user-edit-link btn" do %>
<%= icon_tag "pencil" %>
<span class="for-screen-reader">Edit</span>
<% end %>
@@ -28,7 +28,7 @@
<% unless @webhook.active? %>
<div>
<%= button_to "Reactivate", webhook_activation_path(@webhook), method: :post %>
<%= button_to "Reactivate", collection_webhook_activation_path(@webhook), method: :post %>
</div>
<% end %>
+10 -6
View File
@@ -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
@@ -86,12 +92,6 @@ Rails.application.routes.draw do
resources :stages, module: :workflows
end
resources :webhooks do
scope module: "webhooks" do
resource :activation, only: %i[ create ]
end
end
resources :uploads, only: :create
get "/u/*slug" => "uploads#show", as: :upload
@@ -176,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,5 @@
class AddCollectionIdToWebhooks < ActiveRecord::Migration[8.1]
def change
add_reference :webhooks, :collection, null: false, foreign_key: true
end
end
Generated
+3
View File
@@ -479,12 +479,14 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do
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|
@@ -535,6 +537,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_09_15_170056) do
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.
+18 -1
View File
@@ -1409,6 +1409,7 @@ columns:
- *37
webhooks:
- *38
- *24
- *5
- *6
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -3165,7 +3166,23 @@ indexes:
nulls_not_distinct:
comment:
valid: true
webhooks: []
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
@@ -11,9 +11,9 @@ class Webhooks::ActivationsControllerTest < ActionDispatch::IntegrationTest
assert_not webhook.active?
assert_changes -> { webhook.reload.active? }, from: false, to: true do
post webhook_activation_path(webhook)
post collection_webhook_activation_path(webhook.collection, webhook)
end
assert_redirected_to webhook_path(webhook)
assert_redirected_to collection_webhook_path(webhook.collection, webhook)
end
end
+18 -14
View File
@@ -6,25 +6,27 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest
end
test "index" do
get webhooks_path
get collection_webhooks_path(collections(:writebook))
assert_response :success
end
test "show" do
webhook = webhooks(:active)
get webhook_path(webhook)
get collection_webhook_path(webhook.collection, webhook)
assert_response :success
end
test "new" do
get new_webhook_path
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 webhooks_path, params: {
post collection_webhooks_path(collection), params: {
webhook: {
name: "Test Webhook",
url: "https://example.com/webhook",
@@ -35,15 +37,17 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest
webhook = Webhook.order(id: :desc).first
assert_redirected_to webhook_path(webhook)
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_created", "card_closed" ], webhook.subscribed_actions
end
test "create with invalid params" do
collection = collections(:writebook)
assert_no_difference "Webhook.count" do
post webhooks_path, params: {
post collection_webhooks_path(collection), params: {
webhook: {
name: "",
url: "invalid-url"
@@ -57,14 +61,14 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest
test "edit" do
webhook = webhooks(:active)
get edit_webhook_path(webhook)
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 webhook_path(webhook), params: {
patch collection_webhook_path(webhook.collection, webhook), params: {
webhook: {
name: "Updated Webhook",
subscribed_actions: [ "card_created" ]
@@ -73,14 +77,14 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest
webhook.reload
assert_redirected_to webhook_path(webhook)
assert_redirected_to collection_webhook_path(webhook.collection, webhook)
assert_equal "Updated Webhook", webhook.name
assert_equal [ "card_created" ], webhook.subscribed_actions
end
test "update with invalid params" do
webhook = webhooks(:active)
patch webhook_path(webhook), params: {
patch collection_webhook_path(webhook.collection, webhook), params: {
webhook: {
name: ""
}
@@ -90,7 +94,7 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest
assert_select "form"
assert_no_changes -> { webhook.reload.url } do
patch webhook_path(webhook), params: {
patch collection_webhook_path(webhook.collection, webhook), params: {
webhook: {
name: "Updated Webhook",
url: "https://different.com/webhook"
@@ -98,16 +102,16 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest
}
end
assert_redirected_to webhook_path(webhook)
assert_redirected_to collection_webhook_path(webhook.collection, webhook)
end
test "destroy" do
webhook = webhooks(:active)
assert_difference "Webhook.count", -1 do
delete webhook_path(webhook)
delete collection_webhook_path(webhook.collection, webhook)
end
assert_redirected_to webhooks_path
assert_redirected_to collection_webhooks_path(webhook.collection)
end
end
+2
View File
@@ -4,6 +4,7 @@ active:
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
@@ -11,3 +12,4 @@ inactive:
url: https://test.example.com/webhooks
signing_secret: H8ms8ADcV92v2x17hnLEiL5m
subscribed_actions: '<%= %w[ card_published card_assigned card_closed ].to_json %>'
collection: private
+4
View File
@@ -119,6 +119,7 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase
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"
)
@@ -140,6 +141,7 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase
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"
)
@@ -160,6 +162,7 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase
test "deliver with slack webhook format" do
webhook = Webhook.create!(
collection: collections(:writebook),
name: "Slack",
url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx"
)
@@ -181,6 +184,7 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase
test "deliver with generic webhook format" do
webhook = Webhook.create!(
collection: collections(:writebook),
name: "Generic",
url: "https://example.com/webhook"
)
+23 -23
View File
@@ -2,7 +2,7 @@ require "test_helper"
class WebhookTest < ActiveSupport::TestCase
test "create" do
webhook = Webhook.create! name: "Test", url: "https://example.com/webhook"
webhook = Webhook.create! name: "Test", url: "https://example.com/webhook", collection: collections(:writebook)
assert webhook.persisted?
assert webhook.active?
assert webhook.signing_secret.present?
@@ -10,30 +10,30 @@ class WebhookTest < ActiveSupport::TestCase
end
test "validates the url" do
webhook = Webhook.new name: "Test"
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", 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", url: "example.com/webhook"
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", url: "//example.com/webhook"
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", url: "gopher://example.com/webhook"
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", url: "http://example.com/webhook"
webhook = Webhook.new name: "HTTP", collection: collections(:writebook), url: "http://example.com/webhook"
assert webhook.valid?
webhook = Webhook.new name: "HTTPS", url: "https://example.com/webhook"
webhook = Webhook.new name: "HTTPS", collection: collections(:writebook), url: "https://example.com/webhook"
assert webhook.valid?
end
@@ -54,53 +54,53 @@ class WebhookTest < ActiveSupport::TestCase
end
test "for_slack?" do
webhook = Webhook.new name: "Test", url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx"
webhook = Webhook.new url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx"
assert webhook.for_slack?
webhook = Webhook.new name: "Test", url: "https://hooks.slack.com/services/T12345678/B12345678"
webhook = Webhook.new url: "https://hooks.slack.com/services/T12345678/B12345678"
assert_not webhook.for_slack?
webhook = Webhook.new name: "Test", url: "https://hooks.slack.com/services/T12345678"
webhook = Webhook.new url: "https://hooks.slack.com/services/T12345678"
assert_not webhook.for_slack?
webhook = Webhook.new name: "Test", url: "https://hooks.slack.com/services/"
webhook = Webhook.new url: "https://hooks.slack.com/services/"
assert_not webhook.for_slack?
webhook = Webhook.new name: "Test", url: "https://example.com/webhook"
webhook = Webhook.new url: "https://example.com/webhook"
assert_not webhook.for_slack?
end
test "for_campfire?" do
webhook = Webhook.new name: "Test", url: "https://example.com/rooms/123/456-room-name/messages"
webhook = Webhook.new url: "https://example.com/rooms/123/456-room-name/messages"
assert webhook.for_campfire?
webhook = Webhook.new name: "Test", url: "https://campfire.example.com/rooms/999/123-test-room/messages"
webhook = Webhook.new url: "https://campfire.example.com/rooms/999/123-test-room/messages"
assert webhook.for_campfire?
webhook = Webhook.new name: "Test", url: "https://campfire.example.com/rooms/999/123/messages"
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 name: "Test", url: "https://example.com/webhook"
webhook = Webhook.new url: "https://example.com/webhook"
assert_not webhook.for_campfire?
webhook = Webhook.new name: "Test", url: "https://example.com/rooms/123/messages"
webhook = Webhook.new url: "https://example.com/rooms/123/messages"
assert_not webhook.for_campfire?
webhook = Webhook.new name: "Test", url: "https://example.com/rooms/123/456-room-name/"
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 name: "Test", url: "https://basecamp.com/999/integrations/some-token/buckets/111/chats/222/lines"
webhook = Webhook.new url: "https://basecamp.com/999/integrations/some-token/buckets/111/chats/222/lines"
assert webhook.for_basecamp?
webhook = Webhook.new name: "Test", url: "https://example.com/webhook"
webhook = Webhook.new url: "https://example.com/webhook"
assert_not webhook.for_basecamp?
webhook = Webhook.new name: "Test", url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/"
webhook = Webhook.new url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/"
assert_not webhook.for_basecamp?
webhook = Webhook.new name: "Test", url: "https://3.basecamp.com/integrations/webhook/buckets/456/chats/789/lines"
webhook = Webhook.new url: "https://3.basecamp.com/integrations/webhook/buckets/456/chats/789/lines"
assert_not webhook.for_basecamp?
end
end