From 988a5361a6db4ecb5bffb5047d42aab3f7aa2636 Mon Sep 17 00:00:00 2001 From: Alex Turchyn Date: Fri, 4 Jul 2025 21:11:08 +0300 Subject: [PATCH] add webhook events --- Gemfile | 1 + Gemfile.lock | 2 + app/controllers/api/submissions_controller.rb | 12 +-- .../api/submitter_form_views_controller.rb | 5 +- .../api/templates_clone_controller.rb | 5 +- app/controllers/api/templates_controller.rb | 5 +- app/controllers/start_form_controller.rb | 9 +- app/controllers/submissions_controller.rb | 16 +-- .../submit_form_decline_controller.rb | 5 +- app/controllers/templates_controller.rb | 18 +--- .../templates_uploads_controller.rb | 9 +- app/controllers/webhook_events_controller.rb | 40 ++++++++ .../webhook_settings_controller.rb | 27 +++++- app/jobs/process_submission_expired_job.rb | 5 +- app/jobs/process_submitter_completion_job.rb | 16 ++- ...send_form_completed_webhook_request_job.rb | 3 + .../send_form_declined_webhook_request_job.rb | 6 +- .../send_form_started_webhook_request_job.rb | 6 +- .../send_form_viewed_webhook_request_job.rb | 6 +- ...submission_archived_webhook_request_job.rb | 6 +- ...ubmission_completed_webhook_request_job.rb | 3 + ..._submission_created_webhook_request_job.rb | 6 +- ..._submission_expired_webhook_request_job.rb | 6 +- ...nd_template_created_webhook_request_job.rb | 6 +- ...nd_template_updated_webhook_request_job.rb | 6 +- app/jobs/send_test_webhook_request_job.rb | 26 +++++ app/models/account.rb | 1 + app/models/webhook_attempt.rb | 25 +++++ app/models/webhook_event.rb | 32 ++++++ app/models/webhook_url.rb | 1 + app/views/shared/_turbo_drawer.html.erb | 2 +- app/views/webhook_events/show.html.erb | 65 +++++++++++++ app/views/webhook_settings/show.html.erb | 73 +++++++++++++- config/brakeman.ignore | 4 + config/initializers/rouge.rb | 21 ++++ config/locales/i18n.yml | 54 +++++++++++ config/routes.rb | 4 + ...0628_create_webhook_events_and_attempts.rb | 30 ++++++ db/schema.rb | 27 +++++- lib/highlight_code.rb | 15 +++ lib/send_webhook_request.rb | 70 ++++++++++++- lib/submitters/submit_values.rb | 5 +- lib/webhook_urls.rb | 41 ++++++++ ...form_completed_webhook_request_job_spec.rb | 18 +++- ..._form_declined_webhook_request_job_spec.rb | 18 +++- ...d_form_started_webhook_request_job_spec.rb | 18 +++- ...nd_form_viewed_webhook_request_job_spec.rb | 18 +++- ...ssion_archived_webhook_request_job_spec.rb | 17 +++- ...sion_completed_webhook_request_job_spec.rb | 17 +++- ...ission_created_webhook_request_job_spec.rb | 17 +++- ...ission_expired_webhook_request_job_spec.rb | 97 +++++++++++++++++++ ...mplate_created_webhook_request_job_spec.rb | 18 +++- ...mplate_updated_webhook_request_job_spec.rb | 18 +++- spec/system/webhook_settings_spec.rb | 4 +- 54 files changed, 825 insertions(+), 160 deletions(-) create mode 100644 app/controllers/webhook_events_controller.rb create mode 100644 app/jobs/send_test_webhook_request_job.rb create mode 100644 app/models/webhook_attempt.rb create mode 100644 app/models/webhook_event.rb create mode 100644 app/views/webhook_events/show.html.erb create mode 100644 config/initializers/rouge.rb create mode 100644 db/migrate/20250627130628_create_webhook_events_and_attempts.rb create mode 100644 lib/highlight_code.rb create mode 100644 spec/jobs/send_submission_expired_webhook_request_job_spec.rb diff --git a/Gemfile b/Gemfile index 7820977c..d0e64233 100644 --- a/Gemfile +++ b/Gemfile @@ -34,6 +34,7 @@ gem 'rails' gem 'rails_autolink' gem 'rails-i18n' gem 'rotp' +gem 'rouge', require: false gem 'rqrcode' gem 'ruby-vips' gem 'rubyXL' diff --git a/Gemfile.lock b/Gemfile.lock index 943a7d74..b55adaa1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -453,6 +453,7 @@ GEM retriable (3.1.2) rexml (3.4.0) rotp (6.3.0) + rouge (4.5.2) rqrcode (2.2.0) chunky_png (~> 1.0) rqrcode_core (~> 1.0) @@ -632,6 +633,7 @@ DEPENDENCIES rails-i18n rails_autolink rotp + rouge rqrcode rspec-rails rubocop diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb index 142b86bf..0cf7d2ba 100644 --- a/app/controllers/api/submissions_controller.rb +++ b/app/controllers/api/submissions_controller.rb @@ -63,12 +63,7 @@ module Api submissions = create_submissions(@template, params) - WebhookUrls.for_account_id(@template.account_id, 'submission.created').each do |webhook_url| - submissions.each do |submission| - SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submission.id, - 'webhook_url_id' => webhook_url.id) - end - end + WebhookUrls.enqueue_events(submissions, 'submission.created') Submissions.send_signature_requests(submissions) @@ -96,10 +91,7 @@ module Api else @submission.update!(archived_at: Time.current) - WebhookUrls.for_account_id(@submission.account_id, 'submission.archived').each do |webhook_url| - SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id, - 'webhook_url_id' => webhook_url.id) - end + WebhookUrls.enqueue_events(@submission, 'submission.archived') end render json: @submission.as_json(only: %i[id archived_at]) diff --git a/app/controllers/api/submitter_form_views_controller.rb b/app/controllers/api/submitter_form_views_controller.rb index e8b52095..d9ac441e 100644 --- a/app/controllers/api/submitter_form_views_controller.rb +++ b/app/controllers/api/submitter_form_views_controller.rb @@ -13,10 +13,7 @@ module Api SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request) - WebhookUrls.for_account_id(@submitter.account_id, 'form.viewed').each do |webhook_url| - SendFormViewedWebhookRequestJob.perform_async('submitter_id' => @submitter.id, - 'webhook_url_id' => webhook_url.id) - end + WebhookUrls.enqueue_events(@submitter, 'form.viewed') render json: {} end diff --git a/app/controllers/api/templates_clone_controller.rb b/app/controllers/api/templates_clone_controller.rb index 2619a243..9835850a 100644 --- a/app/controllers/api/templates_clone_controller.rb +++ b/app/controllers/api/templates_clone_controller.rb @@ -28,10 +28,7 @@ module Api cloned_template.save! - WebhookUrls.for_account_id(cloned_template.account_id, 'template.created').each do |webhook_url| - SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id, - 'webhook_url_id' => webhook_url.id) - end + WebhookUrls.enqueue_events(cloned_template, 'template.created') SearchEntries.enqueue_reindex(cloned_template) diff --git a/app/controllers/api/templates_controller.rb b/app/controllers/api/templates_controller.rb index d14be35e..a45f5c6c 100644 --- a/app/controllers/api/templates_controller.rb +++ b/app/controllers/api/templates_controller.rb @@ -67,10 +67,7 @@ module Api SearchEntries.enqueue_reindex(@template) - WebhookUrls.for_account_id(@template.account_id, 'template.updated').each do |webhook_url| - SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id, - 'webhook_url_id' => webhook_url.id) - end + WebhookUrls.enqueue_events(@template, 'template.updated') render json: @template.as_json(only: %i[id updated_at]) end diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index 5fd663b0..1cecfba6 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -51,7 +51,7 @@ class StartFormController < ApplicationController if @submitter.errors.blank? && @submitter.save if is_new_record - enqueue_submission_create_webhooks(@submitter) + WebhookUrls.enqueue_events(@submitter.submission, 'submission.created') SearchEntries.enqueue_reindex(@submitter) @@ -107,13 +107,6 @@ class StartFormController < ApplicationController redirect_to start_form_path(@template.slug) end - def enqueue_submission_create_webhooks(submitter) - WebhookUrls.for_account_id(submitter.account_id, 'submission.created').each do |webhook_url| - SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id, - 'webhook_url_id' => webhook_url.id) - end - end - def find_or_initialize_submitter(template, submitter_params) required_fields = template.preferences.fetch('link_form_fields', ['email']) diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index c411b5df..9d4de051 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -54,7 +54,7 @@ class SubmissionsController < ApplicationController params: params.merge('send_completed_email' => true)) end - enqueue_submission_created_webhooks(@template, submissions) + WebhookUrls.enqueue_events(submissions, 'submission.created') Submissions.send_signature_requests(submissions) @@ -77,10 +77,7 @@ class SubmissionsController < ApplicationController else @submission.update!(archived_at: Time.current) - WebhookUrls.for_account_id(@submission.account_id, 'submission.archived').each do |webhook_url| - SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id, - 'webhook_url_id' => webhook_url.id) - end + WebhookUrls.enqueue_events(@submission, 'submission.archived') I18n.t('submission_has_been_archived') end @@ -97,15 +94,6 @@ class SubmissionsController < ApplicationController template.save! end - def enqueue_submission_created_webhooks(template, submissions) - WebhookUrls.for_account_id(template.account_id, 'submission.created').each do |webhook_url| - submissions.each do |submission| - SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submission.id, - 'webhook_url_id' => webhook_url.id) - end - end - end - def submissions_params params.permit(submission: { submitters: [%i[uuid email phone name]] }) end diff --git a/app/controllers/submit_form_decline_controller.rb b/app/controllers/submit_form_decline_controller.rb index 15572da1..918903fe 100644 --- a/app/controllers/submit_form_decline_controller.rb +++ b/app/controllers/submit_form_decline_controller.rb @@ -25,10 +25,7 @@ class SubmitFormDeclineController < ApplicationController SubmitterMailer.declined_email(submitter, user).deliver_later! end - WebhookUrls.for_account_id(submitter.account_id, 'form.declined').each do |webhook_url| - SendFormDeclinedWebhookRequestJob.perform_async('submitter_id' => submitter.id, - 'webhook_url_id' => webhook_url.id) - end + WebhookUrls.enqueue_events(submitter, 'form.declined') redirect_to submit_form_path(submitter.slug) end diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index 40c8dbd1..fba51b90 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -74,7 +74,7 @@ class TemplatesController < ApplicationController SearchEntries.enqueue_reindex(@template) - enqueue_template_created_webhooks(@template) + WebhookUrls.enqueue_events(@template, 'template.created') maybe_redirect_to_template(@template) else @@ -91,7 +91,7 @@ class TemplatesController < ApplicationController SearchEntries.enqueue_reindex(@template) if is_name_changed - enqueue_template_updated_webhooks(@template) + WebhookUrls.enqueue_events(@template, 'template.updated') head :ok end @@ -142,20 +142,6 @@ class TemplatesController < ApplicationController end end - def enqueue_template_created_webhooks(template) - WebhookUrls.for_account_id(template.account_id, 'template.created').each do |webhook_url| - SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id, - 'webhook_url_id' => webhook_url.id) - end - end - - def enqueue_template_updated_webhooks(template) - WebhookUrls.for_account_id(template.account_id, 'template.updated').each do |webhook_url| - SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => template.id, - 'webhook_url_id' => webhook_url.id) - end - end - def load_base_template return if params[:base_template_id].blank? diff --git a/app/controllers/templates_uploads_controller.rb b/app/controllers/templates_uploads_controller.rb index 45403a7a..e6fb1069 100644 --- a/app/controllers/templates_uploads_controller.rb +++ b/app/controllers/templates_uploads_controller.rb @@ -23,7 +23,7 @@ class TemplatesUploadsController < ApplicationController @template.update!(schema:) - enqueue_template_created_webhooks(@template) + WebhookUrls.enqueue_events(@template, 'template.created') SearchEntries.enqueue_reindex(@template) @@ -68,11 +68,4 @@ class TemplatesUploadsController < ApplicationController { files: [file] } end - - def enqueue_template_created_webhooks(template) - WebhookUrls.for_account_id(template.account_id, 'template.created').each do |webhook_url| - SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id, - 'webhook_url_id' => webhook_url.id) - end - end end diff --git a/app/controllers/webhook_events_controller.rb b/app/controllers/webhook_events_controller.rb new file mode 100644 index 00000000..d8844ce1 --- /dev/null +++ b/app/controllers/webhook_events_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class WebhookEventsController < ApplicationController + load_and_authorize_resource :webhook_url, parent: false, only: %i[show resend], id_param: :webhook_id + + def show + @webhook_event = @webhook_url.webhook_events.find_by!(uuid: params[:id]) + @webhook_attempts = @webhook_event.webhook_attempts.order(created_at: :desc) + + return unless current_ability.can?(:read, @webhook_event.record) + + @data = + case @webhook_event.event_type + when 'form.started', 'form.completed', 'form.declined', 'form.viewed' + Submitters::SerializeForWebhook.call(@webhook_event.record) + when 'submission.created', 'submission.completed', 'submission.expired' + Submissions::SerializeForApi.call(@webhook_event.record) + when 'template.created', 'template.updated' + Templates::SerializeForApi.call(@webhook_event.record) + when 'submission.archived' + @webhook_event.record.as_json(only: %i[id archived_at]) + end + end + + def resend + @webhook_event = @webhook_url.webhook_events.find_by!(uuid: params[:id]) + + id_key = WebhookUrls::EVENT_TYPE_ID_KEYS.fetch(@webhook_event.event_type.split('.').first) + + WebhookUrls::EVENT_TYPE_TO_JOB_CLASS[@webhook_event.event_type].perform_async( + id_key => @webhook_event.record_id, + 'webhook_url_id' => @webhook_event.webhook_url_id, + 'event_uuid' => @webhook_event.uuid, + 'attempt' => SendWebhookRequest::MANUAL_ATTEMPT, + 'last_status' => 0 + ) + + head :ok + end +end diff --git a/app/controllers/webhook_settings_controller.rb b/app/controllers/webhook_settings_controller.rb index 5f023ad1..852b5314 100644 --- a/app/controllers/webhook_settings_controller.rb +++ b/app/controllers/webhook_settings_controller.rb @@ -8,10 +8,26 @@ class WebhookSettingsController < ApplicationController @webhook_urls = @webhook_urls.order(id: :desc) @webhook_url = @webhook_urls.first_or_initialize - render @webhook_urls.size > 1 ? 'index' : 'show' + if @webhook_urls.size > 1 + render :index + else + @webhook_events = @webhook_url.webhook_events + + @webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status]) + + @pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc)) + + render :show + end end - def show; end + def show + @webhook_events = @webhook_url.webhook_events + + @webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status]) + + @pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc)) + end def new; end @@ -42,8 +58,11 @@ class WebhookSettingsController < ApplicationController alert: I18n.t('unable_to_resend_webhook_request')) end - SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id, - 'webhook_url_id' => @webhook_url.id) + SendTestWebhookRequestJob.perform_async( + 'submitter_id' => submitter.id, + 'event_uuid' => SecureRandom.uuid, + 'webhook_url_id' => @webhook_url.id + ) redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_request_has_been_sent')) end diff --git a/app/jobs/process_submission_expired_job.rb b/app/jobs/process_submission_expired_job.rb index 8e9d6fb2..fe0f92d1 100644 --- a/app/jobs/process_submission_expired_job.rb +++ b/app/jobs/process_submission_expired_job.rb @@ -11,9 +11,6 @@ class ProcessSubmissionExpiredJob return if submission.submitters.where.not(declined_at: nil).exists? return unless submission.submitters.exists?(completed_at: nil) - WebhookUrls.for_account_id(submission.account_id, %w[submission.expired]).each do |webhook| - SendSubmissionExpiredWebhookRequestJob.perform_async('submission_id' => submission.id, - 'webhook_url_id' => webhook.id) - end + WebhookUrls.enqueue_events(submission, 'submission.expired') end end diff --git a/app/jobs/process_submitter_completion_job.rb b/app/jobs/process_submitter_completion_job.rb index c53baaf5..e8380a44 100644 --- a/app/jobs/process_submitter_completion_job.rb +++ b/app/jobs/process_submitter_completion_job.rb @@ -63,16 +63,24 @@ class ProcessSubmitterCompletionJob end def enqueue_completed_webhooks(submitter, is_all_completed: false) + event_uuids = {} + WebhookUrls.for_account_id(submitter.account_id, %w[form.completed submission.completed]).each do |webhook| if webhook.events.include?('form.completed') + event_uuids['form.completed'] ||= SecureRandom.uuid + SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id, + 'event_uuid' => event_uuids['form.completed'], 'webhook_url_id' => webhook.id) end - if webhook.events.include?('submission.completed') && is_all_completed - SendSubmissionCompletedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id, - 'webhook_url_id' => webhook.id) - end + next unless webhook.events.include?('submission.completed') && is_all_completed + + event_uuids['submission.completed'] ||= SecureRandom.uuid + + SendSubmissionCompletedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id, + 'event_uuid' => event_uuids['submission.completed'], + 'webhook_url_id' => webhook.id) end end diff --git a/app/jobs/send_form_completed_webhook_request_job.rb b/app/jobs/send_form_completed_webhook_request_job.rb index 41799f59..e4d23795 100644 --- a/app/jobs/send_form_completed_webhook_request_job.rb +++ b/app/jobs/send_form_completed_webhook_request_job.rb @@ -20,6 +20,9 @@ class SendFormCompletedWebhookRequestJob ActiveStorage::Current.url_options = Docuseal.default_url_options resp = SendWebhookRequest.call(webhook_url, event_type: 'form.completed', + event_uuid: params['event_uuid'], + record: submitter, + attempt:, data: Submitters::SerializeForWebhook.call(submitter)) if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && diff --git a/app/jobs/send_form_declined_webhook_request_job.rb b/app/jobs/send_form_declined_webhook_request_job.rb index 1c7a1e32..fa0f537e 100644 --- a/app/jobs/send_form_declined_webhook_request_job.rb +++ b/app/jobs/send_form_declined_webhook_request_job.rb @@ -18,13 +18,15 @@ class SendFormDeclinedWebhookRequestJob ActiveStorage::Current.url_options = Docuseal.default_url_options resp = SendWebhookRequest.call(webhook_url, event_type: 'form.declined', + event_uuid: params['event_uuid'], + record: submitter, + attempt:, data: Submitters::SerializeForWebhook.call(submitter)) if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, { - 'submitter_id' => submitter.id, - 'webhook_url_id' => webhook_url.id, + **params, 'attempt' => attempt + 1, 'last_status' => resp&.status.to_i }) diff --git a/app/jobs/send_form_started_webhook_request_job.rb b/app/jobs/send_form_started_webhook_request_job.rb index 44510f46..c233f682 100644 --- a/app/jobs/send_form_started_webhook_request_job.rb +++ b/app/jobs/send_form_started_webhook_request_job.rb @@ -18,13 +18,15 @@ class SendFormStartedWebhookRequestJob ActiveStorage::Current.url_options = Docuseal.default_url_options resp = SendWebhookRequest.call(webhook_url, event_type: 'form.started', + event_uuid: params['event_uuid'], + record: submitter, + attempt:, data: Submitters::SerializeForWebhook.call(submitter)) if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, { - 'submitter_id' => submitter.id, - 'webhook_url_id' => webhook_url.id, + **params, 'attempt' => attempt + 1, 'last_status' => resp&.status.to_i }) diff --git a/app/jobs/send_form_viewed_webhook_request_job.rb b/app/jobs/send_form_viewed_webhook_request_job.rb index 162743e4..11c891a3 100644 --- a/app/jobs/send_form_viewed_webhook_request_job.rb +++ b/app/jobs/send_form_viewed_webhook_request_job.rb @@ -18,13 +18,15 @@ class SendFormViewedWebhookRequestJob ActiveStorage::Current.url_options = Docuseal.default_url_options resp = SendWebhookRequest.call(webhook_url, event_type: 'form.viewed', + event_uuid: params['event_uuid'], + record: submitter, + attempt:, data: Submitters::SerializeForWebhook.call(submitter)) if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, { - 'submitter_id' => submitter.id, - 'webhook_url_id' => webhook_url.id, + **params, 'attempt' => attempt + 1, 'last_status' => resp&.status.to_i }) diff --git a/app/jobs/send_submission_archived_webhook_request_job.rb b/app/jobs/send_submission_archived_webhook_request_job.rb index 82fc271f..f8ee50fc 100644 --- a/app/jobs/send_submission_archived_webhook_request_job.rb +++ b/app/jobs/send_submission_archived_webhook_request_job.rb @@ -16,13 +16,15 @@ class SendSubmissionArchivedWebhookRequestJob return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.archived') resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.archived', + event_uuid: params['event_uuid'], + record: submission, + attempt:, data: submission.as_json(only: %i[id archived_at])) if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, { - 'submission_id' => submission.id, - 'webhook_url_id' => webhook_url.id, + **params, 'attempt' => attempt + 1, 'last_status' => resp&.status.to_i }) diff --git a/app/jobs/send_submission_completed_webhook_request_job.rb b/app/jobs/send_submission_completed_webhook_request_job.rb index 375bfa75..41ec1d64 100644 --- a/app/jobs/send_submission_completed_webhook_request_job.rb +++ b/app/jobs/send_submission_completed_webhook_request_job.rb @@ -16,6 +16,9 @@ class SendSubmissionCompletedWebhookRequestJob return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.completed') resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.completed', + event_uuid: params['event_uuid'], + record: submission, + attempt:, data: Submissions::SerializeForApi.call(submission)) if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && diff --git a/app/jobs/send_submission_created_webhook_request_job.rb b/app/jobs/send_submission_created_webhook_request_job.rb index d798e76a..5c09311a 100644 --- a/app/jobs/send_submission_created_webhook_request_job.rb +++ b/app/jobs/send_submission_created_webhook_request_job.rb @@ -16,13 +16,15 @@ class SendSubmissionCreatedWebhookRequestJob return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.created') resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.created', + event_uuid: params['event_uuid'], + record: submission, + attempt:, data: Submissions::SerializeForApi.call(submission)) if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, { - 'submission_id' => submission.id, - 'webhook_url_id' => webhook_url.id, + **params, 'attempt' => attempt + 1, 'last_status' => resp&.status.to_i }) diff --git a/app/jobs/send_submission_expired_webhook_request_job.rb b/app/jobs/send_submission_expired_webhook_request_job.rb index c2a5691b..288f3ac5 100644 --- a/app/jobs/send_submission_expired_webhook_request_job.rb +++ b/app/jobs/send_submission_expired_webhook_request_job.rb @@ -16,13 +16,15 @@ class SendSubmissionExpiredWebhookRequestJob return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.expired') resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.expired', + event_uuid: params['event_uuid'], + record: submission, + attempt:, data: Submissions::SerializeForApi.call(submission)) if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) SendSubmissionExpiredWebhookRequestJob.perform_in((2**attempt).minutes, { - 'submission_id' => submission.id, - 'webhook_url_id' => webhook_url.id, + **params, 'attempt' => attempt + 1, 'last_status' => resp&.status.to_i }) diff --git a/app/jobs/send_template_created_webhook_request_job.rb b/app/jobs/send_template_created_webhook_request_job.rb index 353ecb6d..cb2d6e5d 100644 --- a/app/jobs/send_template_created_webhook_request_job.rb +++ b/app/jobs/send_template_created_webhook_request_job.rb @@ -16,13 +16,15 @@ class SendTemplateCreatedWebhookRequestJob return if webhook_url.url.blank? || webhook_url.events.exclude?('template.created') resp = SendWebhookRequest.call(webhook_url, event_type: 'template.created', + event_uuid: params['event_uuid'], + record: template, + attempt:, data: Templates::SerializeForApi.call(template)) if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, { - 'template_id' => template.id, - 'webhook_url_id' => webhook_url.id, + **params, 'attempt' => attempt + 1, 'last_status' => resp&.status.to_i }) diff --git a/app/jobs/send_template_updated_webhook_request_job.rb b/app/jobs/send_template_updated_webhook_request_job.rb index 30623e15..7ef3c22b 100644 --- a/app/jobs/send_template_updated_webhook_request_job.rb +++ b/app/jobs/send_template_updated_webhook_request_job.rb @@ -16,13 +16,15 @@ class SendTemplateUpdatedWebhookRequestJob return if webhook_url.url.blank? || webhook_url.events.exclude?('template.updated') resp = SendWebhookRequest.call(webhook_url, event_type: 'template.updated', + event_uuid: params['event_uuid'], + record: template, + attempt:, data: Templates::SerializeForApi.call(template)) if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, { - 'template_id' => template.id, - 'webhook_url_id' => webhook_url.id, + **params, 'attempt' => attempt + 1, 'last_status' => resp&.status.to_i }) diff --git a/app/jobs/send_test_webhook_request_job.rb b/app/jobs/send_test_webhook_request_job.rb new file mode 100644 index 00000000..3caf5607 --- /dev/null +++ b/app/jobs/send_test_webhook_request_job.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class SendTestWebhookRequestJob + include Sidekiq::Job + + sidekiq_options retry: 0 + + USER_AGENT = 'DocuSeal.com Webhook' + + def perform(params = {}) + submitter = Submitter.find(params['submitter_id']) + webhook_url = WebhookUrl.find(params['webhook_url_id']) + + return unless webhook_url && submitter + + Faraday.post(webhook_url.url, + { + event_type: 'form.completed', + timestamp: Time.current.iso8601, + data: Submitters::SerializeForWebhook.call(submitter) + }.to_json, + 'Content-Type' => 'application/json', + 'User-Agent' => USER_AGENT, + **webhook_url.secret.to_h) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 6265734d..cbf157e5 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -33,6 +33,7 @@ class Account < ApplicationRecord has_many :account_linked_accounts, dependent: :destroy has_many :email_events, dependent: :destroy has_many :webhook_urls, dependent: :destroy + has_many :webhook_events, dependent: nil has_many :account_accesses, dependent: :destroy has_many :account_testing_accounts, -> { testing }, dependent: :destroy, class_name: 'AccountLinkedAccount', diff --git a/app/models/webhook_attempt.rb b/app/models/webhook_attempt.rb new file mode 100644 index 00000000..845c2dfa --- /dev/null +++ b/app/models/webhook_attempt.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: webhook_attempts +# +# id :bigint not null, primary key +# attempt :integer not null +# response_body :text +# response_status_code :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# webhook_event_id :bigint not null +# +# Indexes +# +# index_webhook_attempts_on_webhook_event_id (webhook_event_id) +# +class WebhookAttempt < ApplicationRecord + belongs_to :webhook_event + + def success? + response_status_code.to_i / 100 == 2 + end +end diff --git a/app/models/webhook_event.rb b/app/models/webhook_event.rb new file mode 100644 index 00000000..885a16b1 --- /dev/null +++ b/app/models/webhook_event.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: webhook_events +# +# id :bigint not null, primary key +# event_type :string not null +# record_type :string not null +# status :string not null +# uuid :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# record_id :bigint not null +# webhook_url_id :bigint not null +# +# Indexes +# +# index_webhook_events_error (webhook_url_id,id) WHERE ((status)::text = 'error'::text) +# index_webhook_events_on_uuid_and_webhook_url_id (uuid,webhook_url_id) UNIQUE +# index_webhook_events_on_webhook_url_id_and_id (webhook_url_id,id) +# +class WebhookEvent < ApplicationRecord + attribute :uuid, :string, default: -> { SecureRandom.uuid } + + belongs_to :webhook_url, optional: true + belongs_to :account, optional: true + belongs_to :record, polymorphic: true, optional: true + + has_many :webhook_attempts, dependent: nil +end diff --git a/app/models/webhook_url.rb b/app/models/webhook_url.rb index 1ee046cb..b0de0f58 100644 --- a/app/models/webhook_url.rb +++ b/app/models/webhook_url.rb @@ -37,6 +37,7 @@ class WebhookUrl < ApplicationRecord ].freeze belongs_to :account + has_many :webhook_events, dependent: nil attribute :events, :string, default: -> { %w[form.viewed form.started form.completed form.declined] } attribute :secret, :string, default: -> { {} } diff --git a/app/views/shared/_turbo_drawer.html.erb b/app/views/shared/_turbo_drawer.html.erb index 08accc38..12dd4ba7 100644 --- a/app/views/shared/_turbo_drawer.html.erb +++ b/app/views/shared/_turbo_drawer.html.erb @@ -14,7 +14,7 @@ × <% end %> -
+
<%= yield %>
diff --git a/app/views/webhook_events/show.html.erb b/app/views/webhook_events/show.html.erb new file mode 100644 index 00000000..d59c9745 --- /dev/null +++ b/app/views/webhook_events/show.html.erb @@ -0,0 +1,65 @@ +<%= render 'shared/turbo_drawer', title: @webhook_event.event_type, close_after_submit: false do %> +
+
    + <% if @webhook_event.status == 'error' %> + <% last_attempt = @webhook_attempts.select { |e| SendWebhookRequest::AUTOMATED_RETRY_RANGE.cover?(e.attempt) }.max_by(&:attempt) %> + <% if SendWebhookRequest::AUTOMATED_RETRY_RANGE.cover?(last_attempt&.attempt) %> +
  1. + + <%= svg_icon('clock', class: 'w-4 h-4 shrink-0') %> + +

    + <%= t('next_attempt_in_time_in_words', time_in_words: distance_of_time_in_words(Time.current, last_attempt.created_at + (2**last_attempt.attempt).minutes)) %> +

    +
  2. + <% end %> + <% end %> + <% if @webhook_attempts.present? %> + <% @webhook_attempts.each do |webhook_attempt| %> +
  3. + + <%= svg_icon(webhook_attempt.success? ? 'check' : 'x', class: 'w-4 h-4 shrink-0') %> + +

    + <%= l(webhook_attempt.created_at.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %> +

    +
    +

    + <%= Rack::Utils::HTTP_STATUS_CODES[webhook_attempt.response_status_code] %> + <% if webhook_attempt.response_status_code.positive? %> + (<%= webhook_attempt.response_status_code %>) + <% end %> +

    + <% unless webhook_attempt.success? %> +

    + <%= webhook_attempt.response_body.presence || Rack::Utils::HTTP_STATUS_CODES[webhook_attempt.response_status_code] %> +

    + <% end %> +
    +
  4. + <% end %> + <% else %> +
  5. + + <%= svg_icon('clock', class: 'w-4 h-4 shrink-0') %> + +

    + <%= l(@webhook_event.created_at.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %> +

    +
  6. + <% end %> +
+ <% unless @webhook_event.status == 'pending' %> + <%= button_to button_title(title: t('resend'), disabled_with: 'sending', icon: svg_icon('rotate', class: 'w-4 h-4'), icon_disabled: svg_icon('loader', class: 'w-4 h-4 animate-spin')), resend_settings_webhook_event_path(@webhook_url.id, @webhook_event.uuid), class: 'absolute right-4 top-3 btn btn-neutral btn-sm text-white', method: :post %> + <% end %> + <% if @data %> +
+ <% response = JSON.pretty_generate({ event_type: @webhook_event.event_type, timestamp: @webhook_event.created_at.as_json, data: @data }) %> + + <%= render 'shared/clipboard_copy', icon: 'copy', text: response, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %> + +
<%== HighlightCode.call(response, 'JSON', theme: 'base16.dark') %>
+
+ <% end %> +
+<% end %> diff --git a/app/views/webhook_settings/show.html.erb b/app/views/webhook_settings/show.html.erb index 5937bff3..0e9fbf3f 100644 --- a/app/views/webhook_settings/show.html.erb +++ b/app/views/webhook_settings/show.html.erb @@ -79,8 +79,75 @@ <% end %> - <% submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last %> - <% if submitter %> + <% if @webhook_events.present? || params[:status].present? %> +
+

<%= t('events_log') %>

+
+ <%= link_to t('all'), url_for(params.to_unsafe_h.except(:status)), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status].blank? ? 'tab-active tab-bordered' : 'pb-[3px]'}" %> + <%= link_to t('successed'), url_for(params.to_unsafe_h.merge(status: 'success')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'success' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %> + <%= link_to t('failed'), url_for(params.to_unsafe_h.merge(status: 'error')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'error' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %> +
+ <% if @webhook_events.present? %> +
+ <% @webhook_events.each do |event| %> +
+ +
+
+ <% if event.status == 'success' %> +
+ <%= svg_icon('check', class: 'w-4 h-4 shrink-0 stroke-2') %> +
+ <% elsif event.status == 'pending' %> +
+ <%= svg_icon('clock', class: 'w-4 h-4 shrink-0 stroke-2') %> +
+ <% elsif event.status == 'error' %> +
+ <%= svg_icon('x', class: 'w-4 h-4 shrink-0') %> +
+ <% end %> +
<%= event.event_type %>
+
+
+ <%= button_to button_title(title: t('resend'), disabled_with: t('sending'), icon: svg_icon('rotate', class: 'w-4 h-4'), icon_disabled: svg_icon('loader', class: 'w-4 h-4 animate-spin')), resend_settings_webhook_event_path(@webhook_url.id, event.uuid), class: 'btn btn-neutral btn-xs h-2 text-white relative z-[1] hidden md:group-hover:inline-block', data: { turbo_frame: :drawer }, method: :post %> + <%= l(event.created_at, locale: current_account.locale, format: :short) %> +
+
+
+ <% end %> +
+ <% else %> +
+ <%= t('there_are_no_events') %> +
+ <% end %> + <% if @pagy.pages > 1 %> +
+ +
+
+ <% if @pagy.prev %> + <%= link_to '«', url_for(page: @pagy.prev, anchor: 'log'), class: 'join-item btn min-h-full h-10' %> + <% else %> + « + <% end %> + + <%= "Page #{@pagy.page}" %> + + <% if @pagy.next %> + <%= link_to '»', url_for(page: @pagy.next, anchor: 'log'), class: 'join-item btn min-h-full h-10' %> + <% else %> + » + <% end %> +
+
+
+ <% end %> +
+ <% elsif submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last %>
@@ -98,7 +165,7 @@ <%= render 'shared/clipboard_copy', icon: 'copy', text: code = JSON.pretty_generate({ event_type: 'form.completed', timestamp: Time.current.iso8601, data: Submitters::SerializeForWebhook.call(submitter) }).gsub(/^/, ' ').sub(/^\s+/, ''), class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %> -
<%= code %>
+
<%== HighlightCode.call(code, 'JSON', theme: 'base16.dark') %>
diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 0c19ca5c..18d7601a 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -19,6 +19,10 @@ { "fingerprint": "5f52190d03ee922bba9792012d8fcbeb7d4736006bb899b3be9cc10d679e0af1", "note": "Safe Param" + }, + { + "fingerprint": "dbbfb4a4ace7f43d8247cbb44afa8b628e005e6194ca5552e029b200f725a2d5", + "message": "Unescaped find_by!(uuid: params[:id]) is not risky" } ] } diff --git a/config/initializers/rouge.rb b/config/initializers/rouge.rb new file mode 100644 index 00000000..bf00d89a --- /dev/null +++ b/config/initializers/rouge.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Rouge + autoload :InheritableHash, 'rouge/util' + autoload :Token, 'rouge/token' + autoload :Lexer, 'rouge/lexer' + autoload :RegexLexer, 'rouge/regex_lexer' + + module Lexers + autoload :JSON, 'rouge/lexers/json' + end + + autoload :Formatter, 'rouge/formatter' + + module Formatters + autoload :HTML, 'rouge/formatters/html' + autoload :HTMLInline, 'rouge/formatters/html_inline' + end + + autoload :Theme, 'rouge/theme' +end diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index d1999566..49f0ba0c 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -763,6 +763,12 @@ en: &en link_form_fields: Link form fields at_least_one_field_must_be_displayed_in_the_form: At least one field must be displayed in the form. this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: This template has multiple parties, which prevents the use of a shared link as it's unclear which party is responsible for specific fields. To resolve this, define the default party details. + events_log: Events Log + successed: Successed + failed: Failed + there_are_no_events: There are no events + resend: Resend + next_attempt_in_time_in_words: Next attempt in %{time_in_words} submission_sources: api: API bulk: Bulk Send @@ -863,6 +869,9 @@ en: &en items: range_with_total: "%{from}-%{to} of %{count} items" range_without_total: "%{from}-%{to} items" + events: + range_with_total: "%{from}-%{to} of %{count} events" + range_without_total: "%{from}-%{to} events" es: &es default_parties: Partes predeterminadas @@ -1609,6 +1618,12 @@ es: &es link_form_fields: Vincular campos del formulario at_least_one_field_must_be_displayed_in_the_form: Al menos un campo debe mostrarse en el formulario. this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: Esta plantilla tiene varias partes, lo que impide el uso de un enlace compartido porque no está claro qué parte es responsable de campos específicos. Para resolverlo, define los detalles predeterminados de la parte. + events_log: Registro de eventos + successed: Exitoso + failed: Fallido + there_are_no_events: No hay eventos + resend: Reenviar + next_attempt_in_time_in_words: Próximo intento en %{time_in_words} submission_sources: api: API bulk: Envío masivo @@ -1709,6 +1724,9 @@ es: &es items: range_with_total: "%{from}-%{to} de %{count} elementos" range_without_total: "%{from}-%{to} elementos" + events: + range_with_total: "%{from}-%{to} de %{count} eventos" + range_without_total: "%{from}-%{to} eventos" it: &it default_parties: Parti predefiniti @@ -2453,6 +2471,12 @@ it: &it link_form_fields: Collega i campi del modulo at_least_one_field_must_be_displayed_in_the_form: Almeno un campo deve essere visualizzato nel modulo. this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: Questo modello ha più parti, il che impedisce l’uso di un link di condivisione perché non è chiaro quale parte sia responsabile di campi specifici. Per risolvere, definisci i dettagli predefiniti della parte. + events_log: Registro eventi + successed: Riuscito + failed: Fallito + there_are_no_events: Nessun evento + resend: Invia di nuovo + next_attempt_in_time_in_words: Prossimo tentativo tra %{time_in_words} submission_sources: api: API bulk: Invio massivo @@ -2553,6 +2577,9 @@ it: &it items: range_with_total: "%{from}-%{to} di %{count} elementi" range_without_total: "%{from}-%{to} elementi" + events: + range_with_total: "%{from}-%{to} di %{count} eventi" + range_without_total: "%{from}-%{to} eventi" fr: &fr default_parties: Parties par défaut @@ -3300,6 +3327,12 @@ fr: &fr link_form_fields: Lier les champs du formulaire at_least_one_field_must_be_displayed_in_the_form: Au moins un champ doit être affiché dans le formulaire. this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: Ce modèle contient plusieurs parties, ce qui empêche l’utilisation d’un lien de partage car il n’est pas clair quelle partie est responsable de certains champs. Pour résoudre cela, définissez les détails de la partie par défaut. + events_log: Journal des événements + successed: Réussi + failed: Échoué + there_are_no_events: Aucun événement + resend: Renvoyer + next_attempt_in_time_in_words: Nouvelle tentative dans %{time_in_words} submission_sources: api: API bulk: Envoi en masse @@ -3400,6 +3433,9 @@ fr: &fr items: range_with_total: "%{from} à %{to} sur %{count} éléments" range_without_total: "%{from} à %{to} éléments" + events: + range_with_total: "%{from} à %{to} sur %{count} événements" + range_without_total: "%{from} à %{to} événements" pt: &pt default_parties: Partes padrão @@ -4146,6 +4182,12 @@ pt: &pt link_form_fields: Vincular campos do formulário at_least_one_field_must_be_displayed_in_the_form: Pelo menos um campo deve ser exibido no formulário. this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: Este modelo tem várias partes, o que impede o uso de um link de compartilhamento, pois não está claro qual parte é responsável por campos específicos. Para resolver isso, defina os detalhes padrão da parte. + events_log: Registro de eventos + successed: Sucesso + failed: Falhou + there_are_no_events: Nenhum evento + resend: Reenviar + next_attempt_in_time_in_words: Próxima tentativa em %{time_in_words} submission_sources: api: API bulk: Envio em massa @@ -4247,6 +4289,9 @@ pt: &pt items: range_with_total: "%{from}-%{to} de %{count} itens" range_without_total: "%{from}-%{to} itens" + events: + range_with_total: "%{from}-%{to} de %{count} eventos" + range_without_total: "%{from}-%{to} eventos" de: &de default_parties: Standardparteien @@ -4993,6 +5038,12 @@ de: &de link_form_fields: Formularfelder verknüpfen at_least_one_field_must_be_displayed_in_the_form: Mindestens ein Feld muss im Formular angezeigt werden. this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: Diese Vorlage enthält mehrere Parteien, was die Verwendung eines Freigabelinks verhindert, da unklar ist, welche Partei für bestimmte Felder verantwortlich ist. Um dies zu beheben, definieren Sie die Standardparteidetails. + events_log: Ereignisprotokoll + successed: Erfolgreich + failed: Fehlgeschlagen + there_are_no_events: Keine Ereignisse vorhanden + resend: Erneut senden + next_attempt_in_time_in_words: Nächster Versuch in %{time_in_words} submission_sources: api: API bulk: Massenversand @@ -5093,6 +5144,9 @@ de: &de items: range_with_total: "%{from}-%{to} von %{count} Elementen" range_without_total: "%{from}-%{to} Elemente" + events: + range_with_total: "%{from}-%{to} von %{count} Ereignissen" + range_without_total: "%{from}-%{to} Ereignisse" pl: require_phone_2fa_to_open: Wymagaj uwierzytelniania telefonicznego 2FA do otwarcia diff --git a/config/routes.rb b/config/routes.rb index 39cebbea..ad4ebb8a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -179,6 +179,10 @@ Rails.application.routes.draw do resources :api, only: %i[index create], controller: 'api_settings' resources :webhooks, only: %i[index show new create update destroy], controller: 'webhook_settings' do post :resend + + resources :events, only: %i[show], controller: 'webhook_events' do + post :resend, on: :member + end end resource :account, only: %i[show update destroy] resources :profile, only: %i[index] do diff --git a/db/migrate/20250627130628_create_webhook_events_and_attempts.rb b/db/migrate/20250627130628_create_webhook_events_and_attempts.rb new file mode 100644 index 00000000..1b879d6c --- /dev/null +++ b/db/migrate/20250627130628_create_webhook_events_and_attempts.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class CreateWebhookEventsAndAttempts < ActiveRecord::Migration[8.0] + def change + create_table :webhook_events do |t| + t.string :uuid, null: false + t.bigint :webhook_url_id, null: false + t.bigint :account_id, null: false + t.bigint :record_id, null: false + t.string :record_type, null: false + t.string :event_type, null: false + t.string :status, null: false + + t.index %i[uuid webhook_url_id], unique: true + t.index %i[webhook_url_id id] + t.index %i[webhook_url_id id], where: "status = 'error'", name: 'index_webhook_events_error' + + t.timestamps + end + + create_table :webhook_attempts do |t| + t.bigint :webhook_event_id, null: false, index: true + t.text :response_body + t.integer :response_status_code, null: false + t.integer :attempt, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 703b5df7..3fcb693a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_06_18_085322) do +ActiveRecord::Schema[8.0].define(version: 2025_06_27_130628) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gin" enable_extension "plpgsql" @@ -437,6 +437,31 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_18_085322) do t.index ["uuid"], name: "index_users_on_uuid", unique: true end + create_table "webhook_attempts", force: :cascade do |t| + t.bigint "webhook_event_id", null: false + t.text "response_body" + t.integer "response_status_code", null: false + t.integer "attempt", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["webhook_event_id"], name: "index_webhook_attempts_on_webhook_event_id" + end + + create_table "webhook_events", force: :cascade do |t| + t.string "uuid", null: false + t.bigint "webhook_url_id", null: false + t.bigint "account_id", null: false + t.bigint "record_id", null: false + t.string "record_type", null: false + t.string "event_type", null: false + t.string "status", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["uuid", "webhook_url_id"], name: "index_webhook_events_on_uuid_and_webhook_url_id", unique: true + t.index ["webhook_url_id", "id"], name: "index_webhook_events_error", where: "((status)::text = 'error'::text)" + t.index ["webhook_url_id", "id"], name: "index_webhook_events_on_webhook_url_id_and_id" + end + create_table "webhook_urls", force: :cascade do |t| t.bigint "account_id", null: false t.text "url", null: false diff --git a/lib/highlight_code.rb b/lib/highlight_code.rb new file mode 100644 index 00000000..ed6dfcca --- /dev/null +++ b/lib/highlight_code.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module HighlightCode + module_function + + def call(code, lexer, theme: 'base16.light') + require 'rouge/themes/base16' unless Rouge::Theme.registry[theme] + + formatter = Rouge::Formatters::HTMLInline.new(theme) + lexer = Rouge::Lexers.const_get(lexer.to_sym).new + formatted_code = formatter.format(lexer.lex(code)) + formatted_code = formatted_code.gsub('background-color: #181818', '') if theme == 'base16.dark' + formatted_code + end +end diff --git a/lib/send_webhook_request.rb b/lib/send_webhook_request.rb index 96442441..c39b685e 100644 --- a/lib/send_webhook_request.rb +++ b/lib/send_webhook_request.rb @@ -5,12 +5,16 @@ module SendWebhookRequest LOCALHOSTS = %w[0.0.0.0 127.0.0.1 localhost].freeze + MANUAL_ATTEMPT = 99_999 + AUTOMATED_RETRY_RANGE = 1..MANUAL_ATTEMPT - 1 + HttpsError = Class.new(StandardError) LocalhostError = Class.new(StandardError) module_function - def call(webhook_url, event_type:, data:) + # rubocop:disable Metrics/AbcSize + def call(webhook_url, event_uuid:, event_type:, record:, data:, attempt: 0) uri = begin URI(webhook_url.url) rescue URI::Error @@ -24,21 +28,79 @@ module SendWebhookRequest raise LocalhostError, "Can't send to localhost." if uri.host.in?(LOCALHOSTS) end - Faraday.post(uri) do |req| + webhook_event = create_webhook_event(webhook_url, event_uuid:, event_type:, record:) + + return if AUTOMATED_RETRY_RANGE.cover?(attempt.to_i) && webhook_event&.status == 'success' + + response = Faraday.post(uri) do |req| req.headers['Content-Type'] = 'application/json' req.headers['User-Agent'] = USER_AGENT req.headers.merge!(webhook_url.secret.to_h) if webhook_url.secret.present? req.body = { event_type: event_type, - timestamp: Time.current, + timestamp: webhook_event&.created_at || Time.current, data: data }.to_json req.options.read_timeout = 8 req.options.open_timeout = 8 end - rescue Faraday::Error + + handle_response(webhook_event, response:, attempt:) + rescue Faraday::SSLError, Faraday::TimeoutError, Faraday::ConnectionFailed => e + handle_error(webhook_event, attempt:, error_message: e.class.name.split('::').last) + rescue Faraday::Error => e + handle_error(webhook_event, attempt:, error_message: e.message&.truncate(100)) + end + # rubocop:enable Metrics/AbcSize + + def create_webhook_event(webhook_url, event_uuid:, event_type:, record:) + return if event_uuid.blank? + + WebhookEvent.create_with( + event_type:, + record:, + account_id: webhook_url.account_id, + status: 'pending' + ).find_or_create_by!(webhook_url:, uuid: event_uuid) + end + + def handle_response(webhook_event, response:, attempt:) + return response unless webhook_event + + WebhookAttempt.create!( + webhook_event:, + response_body: response.body&.truncate(100), + response_status_code: response.status, + attempt: + ) + + webhook_event.update!(status: response.success? ? 'success' : 'error') + + response + rescue StandardError + raise if Rails.env.local? + + nil + end + + def handle_error(webhook_event, error_message:, attempt:) + return unless webhook_event + + WebhookAttempt.create!( + webhook_event:, + response_body: error_message, + response_status_code: 0, + attempt: + ) + + webhook_event.update!(status: 'error') + + nil + rescue StandardError + raise if Rails.env.local? + nil end end diff --git a/lib/submitters/submit_values.rb b/lib/submitters/submit_values.rb index eb9e26a4..0216361c 100644 --- a/lib/submitters/submit_values.rb +++ b/lib/submitters/submit_values.rb @@ -16,10 +16,7 @@ module Submitters unless submitter.submission_events.exists?(event_type: 'start_form') SubmissionEvents.create_with_tracking_data(submitter, 'start_form', request) - WebhookUrls.for_account_id(submitter.account_id, 'form.started').each do |webhook_url| - SendFormStartedWebhookRequestJob.perform_async('submitter_id' => submitter.id, - 'webhook_url_id' => webhook_url.id) - end + WebhookUrls.enqueue_events(submitter, 'form.started') end update_submitter!(submitter, params, request, validate_required:) diff --git a/lib/webhook_urls.rb b/lib/webhook_urls.rb index e57c802b..50ab4785 100644 --- a/lib/webhook_urls.rb +++ b/lib/webhook_urls.rb @@ -1,6 +1,25 @@ # frozen_string_literal: true module WebhookUrls + EVENT_TYPE_TO_JOB_CLASS = { + 'form.started' => SendFormStartedWebhookRequestJob, + 'form.completed' => SendFormCompletedWebhookRequestJob, + 'form.declined' => SendFormDeclinedWebhookRequestJob, + 'form.viewed' => SendFormViewedWebhookRequestJob, + 'submission.created' => SendSubmissionCreatedWebhookRequestJob, + 'submission.completed' => SendSubmissionCompletedWebhookRequestJob, + 'submission.expired' => SendSubmissionExpiredWebhookRequestJob, + 'submission.archived' => SendSubmissionArchivedWebhookRequestJob, + 'template.created' => SendTemplateCreatedWebhookRequestJob, + 'template.updated' => SendTemplateUpdatedWebhookRequestJob + }.freeze + + EVENT_TYPE_ID_KEYS = { + 'form' => 'submitter_id', + 'submission' => 'submission_id', + 'template' => 'template_id' + }.freeze + module_function def for_account_id(account_id, events) @@ -24,4 +43,26 @@ module WebhookUrls (account_urls.present? ? WebhookUrl.none : linked_urls) end end + + def enqueue_events(records, event_type) + args = [] + + id_key = EVENT_TYPE_ID_KEYS.fetch(event_type.split('.').first) + + Array.wrap(records).group_by(&:account_id).each do |account_id, account_records| + webhook_urls = for_account_id(account_id, event_type) + + account_records.each do |record| + event_uuid = SecureRandom.uuid + + webhook_urls.each do |webhook_url| + next unless webhook_url.events.include?(event_type) + + args << [{ id_key => record.id, 'webhook_url_id' => webhook_url.id, 'event_uuid' => event_uuid }] + end + end + end + + Sidekiq::Client.push_bulk('class' => EVENT_TYPE_TO_JOB_CLASS[event_type], 'args' => args) + end end diff --git a/spec/jobs/send_form_completed_webhook_request_job_spec.rb b/spec/jobs/send_form_completed_webhook_request_job_spec.rb index cf09d1e1..b60a3d9b 100644 --- a/spec/jobs/send_form_completed_webhook_request_job_spec.rb +++ b/spec/jobs/send_form_completed_webhook_request_job_spec.rb @@ -21,7 +21,8 @@ RSpec.describe SendFormCompletedWebhookRequestJob do end it 'sends a webhook request' do - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -38,7 +39,8 @@ RSpec.describe SendFormCompletedWebhookRequestJob do it 'sends a webhook request with the secret' do webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -57,7 +59,8 @@ RSpec.describe SendFormCompletedWebhookRequestJob do it "doesn't send a webhook request if the event is not in the webhook's events" do webhook_url.update!(events: ['form.declined']) - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).not_to have_requested(:post, webhook_url.url) end @@ -65,8 +68,11 @@ RSpec.describe SendFormCompletedWebhookRequestJob do it 'sends again if the response status is 400 or higher' do stub_request(:post, webhook_url.url).to_return(status: 401) + event_uuid = SecureRandom.uuid + expect do - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => event_uuid) end.to change(described_class.jobs, :size).by(1) expect(WebMock).to have_requested(:post, webhook_url.url).once @@ -75,6 +81,7 @@ RSpec.describe SendFormCompletedWebhookRequestJob do expect(args['attempt']).to eq(1) expect(args['last_status']).to eq(401) + expect(args['event_uuid']).to eq(event_uuid) expect(args['webhook_url_id']).to eq(webhook_url.id) expect(args['submitter_id']).to eq(submitter.id) end @@ -83,7 +90,8 @@ RSpec.describe SendFormCompletedWebhookRequestJob do stub_request(:post, webhook_url.url).to_return(status: 401) expect do - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 21) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid, 'attempt' => 21) end.not_to change(described_class.jobs, :size) expect(WebMock).to have_requested(:post, webhook_url.url).once diff --git a/spec/jobs/send_form_declined_webhook_request_job_spec.rb b/spec/jobs/send_form_declined_webhook_request_job_spec.rb index 1dcfb181..8e9d2d0d 100644 --- a/spec/jobs/send_form_declined_webhook_request_job_spec.rb +++ b/spec/jobs/send_form_declined_webhook_request_job_spec.rb @@ -21,7 +21,8 @@ RSpec.describe SendFormDeclinedWebhookRequestJob do end it 'sends a webhook request' do - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -38,7 +39,8 @@ RSpec.describe SendFormDeclinedWebhookRequestJob do it 'sends a webhook request with the secret' do webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -57,7 +59,8 @@ RSpec.describe SendFormDeclinedWebhookRequestJob do it "doesn't send a webhook request if the event is not in the webhook's events" do webhook_url.update!(events: ['form.completed']) - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).not_to have_requested(:post, webhook_url.url) end @@ -65,8 +68,11 @@ RSpec.describe SendFormDeclinedWebhookRequestJob do it 'sends again if the response status is 400 or higher' do stub_request(:post, webhook_url.url).to_return(status: 401) + event_uuid = SecureRandom.uuid + expect do - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => event_uuid) end.to change(described_class.jobs, :size).by(1) expect(WebMock).to have_requested(:post, webhook_url.url).once @@ -75,6 +81,7 @@ RSpec.describe SendFormDeclinedWebhookRequestJob do expect(args['attempt']).to eq(1) expect(args['last_status']).to eq(401) + expect(args['event_uuid']).to eq(event_uuid) expect(args['webhook_url_id']).to eq(webhook_url.id) expect(args['submitter_id']).to eq(submitter.id) end @@ -83,7 +90,8 @@ RSpec.describe SendFormDeclinedWebhookRequestJob do stub_request(:post, webhook_url.url).to_return(status: 401) expect do - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid, 'attempt' => 11) end.not_to change(described_class.jobs, :size) expect(WebMock).to have_requested(:post, webhook_url.url).once diff --git a/spec/jobs/send_form_started_webhook_request_job_spec.rb b/spec/jobs/send_form_started_webhook_request_job_spec.rb index 77cd5bdd..e09f4205 100644 --- a/spec/jobs/send_form_started_webhook_request_job_spec.rb +++ b/spec/jobs/send_form_started_webhook_request_job_spec.rb @@ -21,7 +21,8 @@ RSpec.describe SendFormStartedWebhookRequestJob do end it 'sends a webhook request' do - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -38,7 +39,8 @@ RSpec.describe SendFormStartedWebhookRequestJob do it 'sends a webhook request with the secret' do webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -57,7 +59,8 @@ RSpec.describe SendFormStartedWebhookRequestJob do it "doesn't send a webhook request if the event is not in the webhook's events" do webhook_url.update!(events: ['form.declined']) - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).not_to have_requested(:post, webhook_url.url) end @@ -65,8 +68,11 @@ RSpec.describe SendFormStartedWebhookRequestJob do it 'sends again if the response status is 400 or higher' do stub_request(:post, webhook_url.url).to_return(status: 401) + event_uuid = SecureRandom.uuid + expect do - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => event_uuid) end.to change(described_class.jobs, :size).by(1) expect(WebMock).to have_requested(:post, webhook_url.url).once @@ -75,6 +81,7 @@ RSpec.describe SendFormStartedWebhookRequestJob do expect(args['attempt']).to eq(1) expect(args['last_status']).to eq(401) + expect(args['event_uuid']).to eq(event_uuid) expect(args['webhook_url_id']).to eq(webhook_url.id) expect(args['submitter_id']).to eq(submitter.id) end @@ -83,7 +90,8 @@ RSpec.describe SendFormStartedWebhookRequestJob do stub_request(:post, webhook_url.url).to_return(status: 401) expect do - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid, 'attempt' => 11) end.not_to change(described_class.jobs, :size) expect(WebMock).to have_requested(:post, webhook_url.url).once diff --git a/spec/jobs/send_form_viewed_webhook_request_job_spec.rb b/spec/jobs/send_form_viewed_webhook_request_job_spec.rb index c182af0d..31026341 100644 --- a/spec/jobs/send_form_viewed_webhook_request_job_spec.rb +++ b/spec/jobs/send_form_viewed_webhook_request_job_spec.rb @@ -21,7 +21,8 @@ RSpec.describe SendFormViewedWebhookRequestJob do end it 'sends a webhook request' do - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -38,7 +39,8 @@ RSpec.describe SendFormViewedWebhookRequestJob do it 'sends a webhook request with the secret' do webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -57,7 +59,8 @@ RSpec.describe SendFormViewedWebhookRequestJob do it "doesn't send a webhook request if the event is not in the webhook's events" do webhook_url.update!(events: ['form.started']) - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).not_to have_requested(:post, webhook_url.url) end @@ -65,8 +68,11 @@ RSpec.describe SendFormViewedWebhookRequestJob do it 'sends again if the response status is 400 or higher' do stub_request(:post, webhook_url.url).to_return(status: 401) + event_uuid = SecureRandom.uuid + expect do - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => event_uuid) end.to change(described_class.jobs, :size).by(1) expect(WebMock).to have_requested(:post, webhook_url.url).once @@ -75,6 +81,7 @@ RSpec.describe SendFormViewedWebhookRequestJob do expect(args['attempt']).to eq(1) expect(args['last_status']).to eq(401) + expect(args['event_uuid']).to eq(event_uuid) expect(args['webhook_url_id']).to eq(webhook_url.id) expect(args['submitter_id']).to eq(submitter.id) end @@ -83,7 +90,8 @@ RSpec.describe SendFormViewedWebhookRequestJob do stub_request(:post, webhook_url.url).to_return(status: 401) expect do - described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11) + described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid, 'attempt' => 11) end.not_to change(described_class.jobs, :size) expect(WebMock).to have_requested(:post, webhook_url.url).once diff --git a/spec/jobs/send_submission_archived_webhook_request_job_spec.rb b/spec/jobs/send_submission_archived_webhook_request_job_spec.rb index 233f1661..6124ebe3 100644 --- a/spec/jobs/send_submission_archived_webhook_request_job_spec.rb +++ b/spec/jobs/send_submission_archived_webhook_request_job_spec.rb @@ -18,7 +18,8 @@ RSpec.describe SendSubmissionArchivedWebhookRequestJob do end it 'sends a webhook request' do - described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -35,7 +36,8 @@ RSpec.describe SendSubmissionArchivedWebhookRequestJob do it 'sends a webhook request with the secret' do webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) - described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -54,7 +56,8 @@ RSpec.describe SendSubmissionArchivedWebhookRequestJob do it "doesn't send a webhook request if the event is not in the webhook's events" do webhook_url.update!(events: ['submission.created']) - described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).not_to have_requested(:post, webhook_url.url) end @@ -62,8 +65,11 @@ RSpec.describe SendSubmissionArchivedWebhookRequestJob do it 'sends again if the response status is 400 or higher' do stub_request(:post, webhook_url.url).to_return(status: 401) + event_uuid = SecureRandom.uuid + expect do - described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => event_uuid) end.to change(described_class.jobs, :size).by(1) expect(WebMock).to have_requested(:post, webhook_url.url).once @@ -72,6 +78,7 @@ RSpec.describe SendSubmissionArchivedWebhookRequestJob do expect(args['attempt']).to eq(1) expect(args['last_status']).to eq(401) + expect(args['event_uuid']).to eq(event_uuid) expect(args['webhook_url_id']).to eq(webhook_url.id) expect(args['submission_id']).to eq(submission.id) end @@ -81,7 +88,7 @@ RSpec.describe SendSubmissionArchivedWebhookRequestJob do expect do described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, - 'attempt' => 11) + 'event_uuid' => SecureRandom.uuid, 'attempt' => 11) end.not_to change(described_class.jobs, :size) expect(WebMock).to have_requested(:post, webhook_url.url).once diff --git a/spec/jobs/send_submission_completed_webhook_request_job_spec.rb b/spec/jobs/send_submission_completed_webhook_request_job_spec.rb index 3a7053df..97ec0b69 100644 --- a/spec/jobs/send_submission_completed_webhook_request_job_spec.rb +++ b/spec/jobs/send_submission_completed_webhook_request_job_spec.rb @@ -18,7 +18,8 @@ RSpec.describe SendSubmissionCompletedWebhookRequestJob do end it 'sends a webhook request' do - described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -35,7 +36,8 @@ RSpec.describe SendSubmissionCompletedWebhookRequestJob do it 'sends a webhook request with the secret' do webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) - described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -54,7 +56,8 @@ RSpec.describe SendSubmissionCompletedWebhookRequestJob do it "doesn't send a webhook request if the event is not in the webhook's events" do webhook_url.update!(events: ['submission.archived']) - described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).not_to have_requested(:post, webhook_url.url) end @@ -62,8 +65,11 @@ RSpec.describe SendSubmissionCompletedWebhookRequestJob do it 'sends again if the response status is 400 or higher' do stub_request(:post, webhook_url.url).to_return(status: 401) + event_uuid = SecureRandom.uuid + expect do - described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => event_uuid) end.to change(described_class.jobs, :size).by(1) expect(WebMock).to have_requested(:post, webhook_url.url).once @@ -72,6 +78,7 @@ RSpec.describe SendSubmissionCompletedWebhookRequestJob do expect(args['attempt']).to eq(1) expect(args['last_status']).to eq(401) + expect(args['event_uuid']).to eq(event_uuid) expect(args['webhook_url_id']).to eq(webhook_url.id) expect(args['submission_id']).to eq(submission.id) end @@ -81,7 +88,7 @@ RSpec.describe SendSubmissionCompletedWebhookRequestJob do expect do described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, - 'attempt' => 21) + 'event_uuid' => SecureRandom.uuid, 'attempt' => 21) end.not_to change(described_class.jobs, :size) expect(WebMock).to have_requested(:post, webhook_url.url).once diff --git a/spec/jobs/send_submission_created_webhook_request_job_spec.rb b/spec/jobs/send_submission_created_webhook_request_job_spec.rb index 1decf1b0..e80b97a6 100644 --- a/spec/jobs/send_submission_created_webhook_request_job_spec.rb +++ b/spec/jobs/send_submission_created_webhook_request_job_spec.rb @@ -18,7 +18,8 @@ RSpec.describe SendSubmissionCreatedWebhookRequestJob do end it 'sends a webhook request' do - described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -35,7 +36,8 @@ RSpec.describe SendSubmissionCreatedWebhookRequestJob do it 'sends a webhook request with the secret' do webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) - described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -54,7 +56,8 @@ RSpec.describe SendSubmissionCreatedWebhookRequestJob do it "doesn't send a webhook request if the event is not in the webhook's events" do webhook_url.update!(events: ['submission.completed']) - described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).not_to have_requested(:post, webhook_url.url) end @@ -62,8 +65,11 @@ RSpec.describe SendSubmissionCreatedWebhookRequestJob do it 'sends again if the response status is 400 or higher' do stub_request(:post, webhook_url.url).to_return(status: 401) + event_uuid = SecureRandom.uuid + expect do - described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => event_uuid) end.to change(described_class.jobs, :size).by(1) expect(WebMock).to have_requested(:post, webhook_url.url).once @@ -72,6 +78,7 @@ RSpec.describe SendSubmissionCreatedWebhookRequestJob do expect(args['attempt']).to eq(1) expect(args['last_status']).to eq(401) + expect(args['event_uuid']).to eq(event_uuid) expect(args['webhook_url_id']).to eq(webhook_url.id) expect(args['submission_id']).to eq(submission.id) end @@ -81,7 +88,7 @@ RSpec.describe SendSubmissionCreatedWebhookRequestJob do expect do described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, - 'attempt' => 11) + 'event_uuid' => SecureRandom.uuid, 'attempt' => 11) end.not_to change(described_class.jobs, :size) expect(WebMock).to have_requested(:post, webhook_url.url).once diff --git a/spec/jobs/send_submission_expired_webhook_request_job_spec.rb b/spec/jobs/send_submission_expired_webhook_request_job_spec.rb new file mode 100644 index 00000000..dbce55ba --- /dev/null +++ b/spec/jobs/send_submission_expired_webhook_request_job_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +RSpec.describe SendSubmissionExpiredWebhookRequestJob do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user) } + let(:submission) { create(:submission, :with_submitters, template:, created_by_user: user, expire_at: 1.day.ago) } + let(:webhook_url) { create(:webhook_url, account:, events: ['submission.expired']) } + + before do + create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY, + value: GenerateCertificate.call.transform_values(&:to_pem)) + end + + describe '#perform' do + before do + stub_request(:post, webhook_url.url).to_return(status: 200) + end + + it 'sends a webhook request' do + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: { + 'event_type' => 'submission.expired', + 'timestamp' => /.*/, + 'data' => JSON.parse(Submissions::SerializeForApi.call(submission.reload).to_json) + }, + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.com Webhook' + } + ).once + end + + it 'sends a webhook request with the secret' do + webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: { + 'event_type' => 'submission.expired', + 'timestamp' => /.*/, + 'data' => JSON.parse(Submissions::SerializeForApi.call(submission.reload).to_json) + }, + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.com Webhook', + 'X-Secret-Header' => 'secret_value' + } + ).once + end + + it "doesn't send a webhook request if the event is not in the webhook's events" do + webhook_url.update!(events: ['submission.archived']) + + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it 'sends again if the response status is 400 or higher' do + stub_request(:post, webhook_url.url).to_return(status: 401) + + event_uuid = SecureRandom.uuid + + expect do + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => event_uuid) + end.to change(described_class.jobs, :size).by(1) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + + args = described_class.jobs.last['args'].first + + expect(args['attempt']).to eq(1) + expect(args['last_status']).to eq(401) + expect(args['event_uuid']).to eq(event_uuid) + expect(args['webhook_url_id']).to eq(webhook_url.id) + expect(args['submission_id']).to eq(submission.id) + end + + it "doesn't send again if the max attempts is reached" do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid, 'attempt' => 21) + end.not_to change(described_class.jobs, :size) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + end + end +end diff --git a/spec/jobs/send_template_created_webhook_request_job_spec.rb b/spec/jobs/send_template_created_webhook_request_job_spec.rb index bb2b51c6..e5ce6f10 100644 --- a/spec/jobs/send_template_created_webhook_request_job_spec.rb +++ b/spec/jobs/send_template_created_webhook_request_job_spec.rb @@ -17,7 +17,8 @@ RSpec.describe SendTemplateCreatedWebhookRequestJob do end it 'sends a webhook request' do - described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -34,7 +35,8 @@ RSpec.describe SendTemplateCreatedWebhookRequestJob do it 'sends a webhook request with the secret' do webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) - described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -53,7 +55,8 @@ RSpec.describe SendTemplateCreatedWebhookRequestJob do it "doesn't send a webhook request if the event is not in the webhook's events" do webhook_url.update!(events: ['template.updated']) - described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).not_to have_requested(:post, webhook_url.url) end @@ -61,8 +64,11 @@ RSpec.describe SendTemplateCreatedWebhookRequestJob do it 'sends again if the response status is 400 or higher' do stub_request(:post, webhook_url.url).to_return(status: 401) + event_uuid = SecureRandom.uuid + expect do - described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => event_uuid) end.to change(described_class.jobs, :size).by(1) expect(WebMock).to have_requested(:post, webhook_url.url).once @@ -71,6 +77,7 @@ RSpec.describe SendTemplateCreatedWebhookRequestJob do expect(args['attempt']).to eq(1) expect(args['last_status']).to eq(401) + expect(args['event_uuid']).to eq(event_uuid) expect(args['webhook_url_id']).to eq(webhook_url.id) expect(args['template_id']).to eq(template.id) end @@ -79,7 +86,8 @@ RSpec.describe SendTemplateCreatedWebhookRequestJob do stub_request(:post, webhook_url.url).to_return(status: 401) expect do - described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11) + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid, 'attempt' => 11) end.not_to change(described_class.jobs, :size) expect(WebMock).to have_requested(:post, webhook_url.url).once diff --git a/spec/jobs/send_template_updated_webhook_request_job_spec.rb b/spec/jobs/send_template_updated_webhook_request_job_spec.rb index 2edcd280..f13675a1 100644 --- a/spec/jobs/send_template_updated_webhook_request_job_spec.rb +++ b/spec/jobs/send_template_updated_webhook_request_job_spec.rb @@ -17,7 +17,8 @@ RSpec.describe SendTemplateUpdatedWebhookRequestJob do end it 'sends a webhook request' do - described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -34,7 +35,8 @@ RSpec.describe SendTemplateUpdatedWebhookRequestJob do it 'sends a webhook request with the secret' do webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) - described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).to have_requested(:post, webhook_url.url).with( body: { @@ -53,7 +55,8 @@ RSpec.describe SendTemplateUpdatedWebhookRequestJob do it "doesn't send a webhook request if the event is not in the webhook's events" do webhook_url.update!(events: ['template.created']) - described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) expect(WebMock).not_to have_requested(:post, webhook_url.url) end @@ -61,8 +64,11 @@ RSpec.describe SendTemplateUpdatedWebhookRequestJob do it 'sends again if the response status is 400 or higher' do stub_request(:post, webhook_url.url).to_return(status: 401) + event_uuid = SecureRandom.uuid + expect do - described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id) + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => event_uuid) end.to change(described_class.jobs, :size).by(1) expect(WebMock).to have_requested(:post, webhook_url.url).once @@ -71,6 +77,7 @@ RSpec.describe SendTemplateUpdatedWebhookRequestJob do expect(args['attempt']).to eq(1) expect(args['last_status']).to eq(401) + expect(args['event_uuid']).to eq(event_uuid) expect(args['webhook_url_id']).to eq(webhook_url.id) expect(args['template_id']).to eq(template.id) end @@ -79,7 +86,8 @@ RSpec.describe SendTemplateUpdatedWebhookRequestJob do stub_request(:post, webhook_url.url).to_return(status: 401) expect do - described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11) + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid, 'attempt' => 11) end.not_to change(described_class.jobs, :size) expect(WebMock).to have_requested(:post, webhook_url.url).once diff --git a/spec/system/webhook_settings_spec.rb b/spec/system/webhook_settings_spec.rb index 5b4cbaf2..938b6eb9 100644 --- a/spec/system/webhook_settings_spec.rb +++ b/spec/system/webhook_settings_spec.rb @@ -175,9 +175,9 @@ RSpec.describe 'Webhook Settings' do expect do click_button 'Test Webhook' - end.to change(SendFormCompletedWebhookRequestJob.jobs, :size).by(1) + end.to change(SendTestWebhookRequestJob.jobs, :size).by(1) - args = SendFormCompletedWebhookRequestJob.jobs.last['args'].first + args = SendTestWebhookRequestJob.jobs.last['args'].first expect(args['webhook_url_id']).to eq(webhook_url.id) expect(args['submitter_id']).to eq(submitter.id)