diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index c65f37532..ddd0f09da 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -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 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/webhook.rb b/app/models/webhook.rb index defb57186..9fbdada34 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -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) } diff --git a/app/models/webhook/triggerable.rb b/app/models/webhook/triggerable.rb index 4e8afeb43..a2f5ea186 100644 --- a/app/models/webhook/triggerable.rb +++ b/app/models/webhook/triggerable.rb @@ -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 diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 492ee6cb4..6d41d439d 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -4,13 +4,6 @@ <%= render "filters/menu", user_filtering: @user_filtering %>

<%= @page_title %>

- <% if Current.user.admin? %> - <%= link_to webhooks_path, class: "btn" do %> - <%= icon_tag "world" %> - Webhooks - <% end %> - <% end %> - <%= link_to workflows_path, class: "btn" do %> <%= icon_tag "bolt" %> Workflows diff --git a/app/views/cards/_webhooks.html.erb b/app/views/cards/_webhooks.html.erb new file mode 100644 index 000000000..bc18b0bc6 --- /dev/null +++ b/app/views/cards/_webhooks.html.erb @@ -0,0 +1,4 @@ +<%= link_to collection_webhooks_path(collection_id: collection), class: "btn tooltip" do %> + <%= icon_tag "world" %> + Webhooks for <%= collection.name %> +<% 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 @@
<% if collection = @filter.single_collection %> + <%= render "cards/webhooks", collection: collection if Current.user.admin? %> <%= render "cards/collection_settings", collection: collection %> <% end %>
diff --git a/app/views/webhooks/edit.html.erb b/app/views/webhooks/edit.html.erb index ceba7b793..c6358248d 100644 --- a/app/views/webhooks/edit.html.erb +++ b/app/views/webhooks/edit.html.erb @@ -20,6 +20,6 @@ Update Webhook <% 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 %> diff --git a/app/views/webhooks/index.html.erb b/app/views/webhooks/index.html.erb index 1b835634f..3f2c47e19 100644 --- a/app/views/webhooks/index.html.erb +++ b/app/views/webhooks/index.html.erb @@ -2,10 +2,15 @@ <% content_for :header do %> <%= render "filters/menu", user_filtering: @user_filtering %> -

<%= @page_title %>

+ <%= 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 %> + + ← + <%= @collection.name %> + + <% end %>
- <%= link_to new_webhook_path, class: "btn" do %> + <%= link_to new_collection_webhook_path, class: "btn" do %> <%= icon_tag "add" %> Create a new webhook <% end %> @@ -19,6 +24,8 @@ navigable_list_focus_on_selection_value: true, navigable_list_actionable_items_value: true } do %> +

<%= @page_title %>

+
    <%= render partial: "webhooks/webhook", collection: @page.records %> diff --git a/app/views/webhooks/new.html.erb b/app/views/webhooks/new.html.erb index 996e2e3ea..98c5642f7 100644 --- a/app/views/webhooks/new.html.erb +++ b/app/views/webhooks/new.html.erb @@ -6,7 +6,7 @@ <% end %>
    - <%= 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| %>

    <%= form.text_field :name, required: true, autofocus: true, class: "input", placeholder: "Name your Webhook…", data: { action: "keydown.esc@document->form#cancel" } %>

    @@ -22,6 +22,6 @@ Create Webhook <% 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 %>
    diff --git a/app/views/webhooks/show.html.erb b/app/views/webhooks/show.html.erb index 1926b2cd8..a14cefe85 100644 --- a/app/views/webhooks/show.html.erb +++ b/app/views/webhooks/show.html.erb @@ -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 %> Webhooks @@ -13,7 +13,7 @@
    - <%= 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" %> Edit <% end %> @@ -28,7 +28,7 @@ <% unless @webhook.active? %>
    - <%= button_to "Reactivate", webhook_activation_path(@webhook), method: :post %> + <%= button_to "Reactivate", collection_webhook_activation_path(@webhook), method: :post %>
    <% end %> diff --git a/config/routes.rb b/config/routes.rb index 15262d129..608b2aafd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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" diff --git a/db/migrate/20250913150256_add_collection_id_to_webhooks.rb b/db/migrate/20250913150256_add_collection_id_to_webhooks.rb new file mode 100644 index 000000000..871c44c83 --- /dev/null +++ b/db/migrate/20250913150256_add_collection_id_to_webhooks.rb @@ -0,0 +1,5 @@ +class AddCollectionIdToWebhooks < ActiveRecord::Migration[8.1] + def change + add_reference :webhooks, :collection, null: false, foreign_key: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 0a1759a5e..319f33939 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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. diff --git a/db/schema_cache.yml b/db/schema_cache.yml index e390ad7ea..3b8395663 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -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 diff --git a/test/controllers/webhooks/activations_controller_test.rb b/test/controllers/webhooks/activations_controller_test.rb index 228382a64..0144f0bd9 100644 --- a/test/controllers/webhooks/activations_controller_test.rb +++ b/test/controllers/webhooks/activations_controller_test.rb @@ -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 diff --git a/test/controllers/webhooks_controller_test.rb b/test/controllers/webhooks_controller_test.rb index dbfab6e79..aa8696fa5 100644 --- a/test/controllers/webhooks_controller_test.rb +++ b/test/controllers/webhooks_controller_test.rb @@ -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 diff --git a/test/fixtures/webhooks.yml b/test/fixtures/webhooks.yml index 4317606fe..4184eb577 100644 --- a/test/fixtures/webhooks.yml +++ b/test/fixtures/webhooks.yml @@ -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 diff --git a/test/models/webhook/delivery_test.rb b/test/models/webhook/delivery_test.rb index 3be700b3d..d79713717 100644 --- a/test/models/webhook/delivery_test.rb +++ b/test/models/webhook/delivery_test.rb @@ -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" ) diff --git a/test/models/webhook_test.rb b/test/models/webhook_test.rb index ac2ddc748..fe5a92cac 100644 --- a/test/models/webhook_test.rb +++ b/test/models/webhook_test.rb @@ -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