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 %>
-
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) %>
+ -
+
+ <%= 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)) %>
+
+
+ <% end %>
+ <% end %>
+ <% if @webhook_attempts.present? %>
+ <% @webhook_attempts.each do |webhook_attempt| %>
+ -
+
+ <%= 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 %>
+
+
+ <% end %>
+ <% else %>
+ -
+
+ <%= 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) %>
+
+
+ <% 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 %>
+
+
+ <%= "#{@pagy.from}-#{@pagy.to} events" %>
+
+
+
+ <% 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)