Merge from docusealco/wip

pull/502/head 2.0.6
Alex Turchyn 4 months ago committed by GitHub
commit d7a895b98b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -6,6 +6,7 @@ jobs:
rubocop: rubocop:
name: Rubocop name: Rubocop
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Ruby - name: Install Ruby
@ -30,6 +31,7 @@ jobs:
erblint: erblint:
name: Erblint name: Erblint
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Ruby - name: Install Ruby
@ -54,6 +56,7 @@ jobs:
eslint: eslint:
name: ESLint name: ESLint
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Node.js - name: Install Node.js
@ -80,6 +83,7 @@ jobs:
brakeman: brakeman:
name: Brakeman name: Brakeman
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Ruby - name: Install Ruby
@ -107,6 +111,7 @@ jobs:
rspec: rspec:
name: RSpec name: RSpec
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
services: services:
postgres: postgres:

@ -8,6 +8,7 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-24.04-arm runs-on: ubuntu-24.04-arm
timeout-minutes: 20
steps: steps:
- name: Checkout code - name: Checkout code

@ -34,6 +34,7 @@ gem 'rails'
gem 'rails_autolink' gem 'rails_autolink'
gem 'rails-i18n' gem 'rails-i18n'
gem 'rotp' gem 'rotp'
gem 'rouge', require: false
gem 'rqrcode' gem 'rqrcode'
gem 'ruby-vips' gem 'ruby-vips'
gem 'rubyXL' gem 'rubyXL'

@ -453,6 +453,7 @@ GEM
retriable (3.1.2) retriable (3.1.2)
rexml (3.4.0) rexml (3.4.0)
rotp (6.3.0) rotp (6.3.0)
rouge (4.5.2)
rqrcode (2.2.0) rqrcode (2.2.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 1.0) rqrcode_core (~> 1.0)
@ -632,6 +633,7 @@ DEPENDENCIES
rails-i18n rails-i18n
rails_autolink rails_autolink
rotp rotp
rouge
rqrcode rqrcode
rspec-rails rspec-rails
rubocop rubocop

@ -63,12 +63,7 @@ module Api
submissions = create_submissions(@template, params) submissions = create_submissions(@template, params)
WebhookUrls.for_account_id(@template.account_id, 'submission.created').each do |webhook_url| WebhookUrls.enqueue_events(submissions, 'submission.created')
submissions.each do |submission|
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submission.id,
'webhook_url_id' => webhook_url.id)
end
end
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
@ -96,10 +91,7 @@ module Api
else else
@submission.update!(archived_at: Time.current) @submission.update!(archived_at: Time.current)
WebhookUrls.for_account_id(@submission.account_id, 'submission.archived').each do |webhook_url| WebhookUrls.enqueue_events(@submission, 'submission.archived')
SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id,
'webhook_url_id' => webhook_url.id)
end
end end
render json: @submission.as_json(only: %i[id archived_at]) render json: @submission.as_json(only: %i[id archived_at])
@ -112,9 +104,9 @@ module Api
submissions = submissions.where(slug: params[:slug]) if params[:slug].present? submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
if params[:template_folder].present? if params[:template_folder].present?
folder = TemplateFolder.accessible_by(current_ability).find_by(name: params[:template_folder]) folder_ids = TemplateFolder.accessible_by(current_ability).where(name: params[:template_folder]).pluck(:id)
submissions = folder ? submissions.joins(:template).where(template: { folder_id: folder.id }) : submissions.none submissions = submissions.joins(:template).where(template: { folder_id: folder_ids })
end end
if params.key?(:archived) if params.key?(:archived)

@ -13,10 +13,7 @@ module Api
SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request) SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request)
WebhookUrls.for_account_id(@submitter.account_id, 'form.viewed').each do |webhook_url| WebhookUrls.enqueue_events(@submitter, 'form.viewed')
SendFormViewedWebhookRequestJob.perform_async('submitter_id' => @submitter.id,
'webhook_url_id' => webhook_url.id)
end
render json: {} render json: {}
end end

@ -28,10 +28,7 @@ module Api
cloned_template.save! cloned_template.save!
WebhookUrls.for_account_id(cloned_template.account_id, 'template.created').each do |webhook_url| WebhookUrls.enqueue_events(cloned_template, 'template.created')
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id,
'webhook_url_id' => webhook_url.id)
end
SearchEntries.enqueue_reindex(cloned_template) SearchEntries.enqueue_reindex(cloned_template)

@ -67,10 +67,7 @@ module Api
SearchEntries.enqueue_reindex(@template) SearchEntries.enqueue_reindex(@template)
WebhookUrls.for_account_id(@template.account_id, 'template.updated').each do |webhook_url| WebhookUrls.enqueue_events(@template, 'template.updated')
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id,
'webhook_url_id' => webhook_url.id)
end
render json: @template.as_json(only: %i[id updated_at]) render json: @template.as_json(only: %i[id updated_at])
end end
@ -95,9 +92,9 @@ module Api
templates = templates.where(slug: params[:slug]) if params[:slug].present? templates = templates.where(slug: params[:slug]) if params[:slug].present?
if params[:folder].present? if params[:folder].present?
folder = TemplateFolder.accessible_by(current_ability).find_by(name: params[:folder]) folder_ids = TemplateFolder.accessible_by(current_ability).where(name: params[:folder]).pluck(:id)
templates = folder ? templates.where(folder:) : templates.none templates = templates.where(folder_id: folder_ids)
end end
templates templates

@ -51,7 +51,7 @@ class StartFormController < ApplicationController
if @submitter.errors.blank? && @submitter.save if @submitter.errors.blank? && @submitter.save
if is_new_record if is_new_record
enqueue_submission_create_webhooks(@submitter) WebhookUrls.enqueue_events(@submitter.submission, 'submission.created')
SearchEntries.enqueue_reindex(@submitter) SearchEntries.enqueue_reindex(@submitter)
@ -107,13 +107,6 @@ class StartFormController < ApplicationController
redirect_to start_form_path(@template.slug) redirect_to start_form_path(@template.slug)
end 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) def find_or_initialize_submitter(template, submitter_params)
required_fields = template.preferences.fetch('link_form_fields', ['email']) required_fields = template.preferences.fetch('link_form_fields', ['email'])

@ -54,7 +54,7 @@ class SubmissionsController < ApplicationController
params: params.merge('send_completed_email' => true)) params: params.merge('send_completed_email' => true))
end end
enqueue_submission_created_webhooks(@template, submissions) WebhookUrls.enqueue_events(submissions, 'submission.created')
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
@ -77,10 +77,7 @@ class SubmissionsController < ApplicationController
else else
@submission.update!(archived_at: Time.current) @submission.update!(archived_at: Time.current)
WebhookUrls.for_account_id(@submission.account_id, 'submission.archived').each do |webhook_url| WebhookUrls.enqueue_events(@submission, 'submission.archived')
SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id,
'webhook_url_id' => webhook_url.id)
end
I18n.t('submission_has_been_archived') I18n.t('submission_has_been_archived')
end end
@ -97,15 +94,6 @@ class SubmissionsController < ApplicationController
template.save! template.save!
end 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 def submissions_params
params.permit(submission: { submitters: [%i[uuid email phone name]] }) params.permit(submission: { submitters: [%i[uuid email phone name]] })
end end

@ -28,7 +28,7 @@ class SubmissionsPreviewController < ApplicationController
raise ActionController::RoutingError, I18n.t('not_found') raise ActionController::RoutingError, I18n.t('not_found')
end end
if !submission_valid_ttl?(@submission) && !signature_valid if use_signature?(@submission) && !signature_valid
Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar) Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar)
return redirect_to submissions_preview_completed_path(@submission.slug) return redirect_to submissions_preview_completed_path(@submission.slug)
@ -48,9 +48,15 @@ class SubmissionsPreviewController < ApplicationController
private private
def submission_valid_ttl?(submission) def use_signature?(submission)
return true if current_user && current_user.account.submissions.exists?(id: submission.id) return false if current_user && can?(:read, submission)
return true if submission.submitters.any? { |e| e.preferences['require_phone_2fa'] }
return true if submission.template&.preferences&.dig('require_phone_2fa')
!submission_valid_ttl?(submission)
end
def submission_valid_ttl?(submission)
last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at) last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
last_submitter && last_submitter.completed_at > TTL.ago last_submitter && last_submitter.completed_at > TTL.ago

@ -25,10 +25,7 @@ class SubmitFormDeclineController < ApplicationController
SubmitterMailer.declined_email(submitter, user).deliver_later! SubmitterMailer.declined_email(submitter, user).deliver_later!
end end
WebhookUrls.for_account_id(submitter.account_id, 'form.declined').each do |webhook_url| WebhookUrls.enqueue_events(submitter, 'form.declined')
SendFormDeclinedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id)
end
redirect_to submit_form_path(submitter.slug) redirect_to submit_form_path(submitter.slug)
end end

@ -3,11 +3,13 @@
class TemplateFoldersController < ApplicationController class TemplateFoldersController < ApplicationController
load_and_authorize_resource :template_folder load_and_authorize_resource :template_folder
helper_method :selected_order
def show def show
@templates = @template_folder.templates.active.accessible_by(current_ability) @templates = @template_folder.templates.active.accessible_by(current_ability)
.preload(:author, :template_accesses) .preload(:author, :template_accesses)
@templates = Templates.search(current_user, @templates, params[:q]) @templates = Templates.search(current_user, @templates, params[:q])
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order]) @templates = Templates::Order.call(@templates, current_user, selected_order)
@pagy, @templates = pagy_auto(@templates, limit: 12) @pagy, @templates = pagy_auto(@templates, limit: 12)
end end
@ -25,6 +27,16 @@ class TemplateFoldersController < ApplicationController
private private
def selected_order
@selected_order ||=
if cookies.permanent[:dashboard_templates_order].blank? ||
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
'created_at'
else
cookies.permanent[:dashboard_templates_order]
end
end
def template_folder_params def template_folder_params
params.require(:template_folder).permit(:name) params.require(:template_folder).permit(:name)
end end

@ -74,7 +74,7 @@ class TemplatesController < ApplicationController
SearchEntries.enqueue_reindex(@template) SearchEntries.enqueue_reindex(@template)
enqueue_template_created_webhooks(@template) WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template) maybe_redirect_to_template(@template)
else else
@ -91,7 +91,7 @@ class TemplatesController < ApplicationController
SearchEntries.enqueue_reindex(@template) if is_name_changed SearchEntries.enqueue_reindex(@template) if is_name_changed
enqueue_template_updated_webhooks(@template) WebhookUrls.enqueue_events(@template, 'template.updated')
head :ok head :ok
end end
@ -142,20 +142,6 @@ class TemplatesController < ApplicationController
end end
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 def load_base_template
return if params[:base_template_id].blank? return if params[:base_template_id].blank?

@ -8,12 +8,13 @@ class TemplatesDashboardController < ApplicationController
TEMPLATES_PER_PAGE = 12 TEMPLATES_PER_PAGE = 12
FOLDERS_PER_PAGE = 18 FOLDERS_PER_PAGE = 18
helper_method :selected_order
def index def index
@template_folders = @template_folders.where(id: @templates.active.select(:folder_id)) @template_folders = @template_folders.where(id: @templates.active.select(:folder_id))
@template_folders = TemplateFolders.search(@template_folders, params[:q]) @template_folders = TemplateFolders.search(@template_folders, params[:q])
@template_folders = sort_template_folders(@template_folders, current_user, @template_folders = sort_template_folders(@template_folders, current_user, selected_order)
cookies.permanent[:dashboard_templates_order])
@pagy, @template_folders = pagy( @pagy, @template_folders = pagy(
@template_folders, @template_folders,
@ -26,7 +27,7 @@ class TemplatesDashboardController < ApplicationController
else else
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME } @template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
@templates = filter_templates(@templates).preload(:author, :template_accesses) @templates = filter_templates(@templates).preload(:author, :template_accesses)
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order]) @templates = Templates::Order.call(@templates, current_user, selected_order)
limit = limit =
if @template_folders.size < 4 if @template_folders.size < 4
@ -56,7 +57,7 @@ class TemplatesDashboardController < ApplicationController
rel = Template.where( rel = Template.where(
Template.arel_table[:id].in( Template.arel_table[:id].in(
rel.where(folder_id: current_account.default_template_folder.id).select(:id).arel rel.where(folder_id: current_account.default_template_folder.id).select(:id).arel
.union(shared_template_ids.arel) .union(:all, shared_template_ids.arel)
) )
) )
else else
@ -101,6 +102,16 @@ class TemplatesDashboardController < ApplicationController
end end
end end
def selected_order
@selected_order ||=
if cookies.permanent[:dashboard_templates_order].blank? ||
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
'created_at'
else
cookies.permanent[:dashboard_templates_order]
end
end
def load_related_submissions def load_related_submissions
@related_submissions = Submission.accessible_by(current_ability) @related_submissions = Submission.accessible_by(current_ability)
.left_joins(:template) .left_joins(:template)

@ -23,7 +23,7 @@ class TemplatesUploadsController < ApplicationController
@template.update!(schema:) @template.update!(schema:)
enqueue_template_created_webhooks(@template) WebhookUrls.enqueue_events(@template, 'template.created')
SearchEntries.enqueue_reindex(@template) SearchEntries.enqueue_reindex(@template)
@ -68,11 +68,4 @@ class TemplatesUploadsController < ApplicationController
{ files: [file] } { files: [file] }
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
end end

@ -0,0 +1,66 @@
# frozen_string_literal: true
class WebhookEventsController < ApplicationController
load_and_authorize_resource :webhook_url, parent: false, id_param: :webhook_id
before_action :load_webhook_event
def show
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
id_key = WebhookUrls::EVENT_TYPE_ID_KEYS.fetch(@webhook_event.event_type.split('.').first)
last_attempt_id = @webhook_event.webhook_attempts.maximum(:id)
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
)
render turbo_stream: [
turbo_stream.after(
params[:button_id],
helpers.tag.submit_form(
helpers.button_to('', refresh_settings_webhook_event_path(@webhook_url.id, @webhook_event.uuid),
params: { last_attempt_id: }),
class: 'hidden', data: { interval: 3_000 }
)
)
]
end
def refresh
return head :ok if @webhook_event.webhook_attempts.maximum(:id) == params[:last_attempt_id].to_i
render turbo_stream: [
turbo_stream.replace(helpers.dom_id(@webhook_event),
partial: 'event_row',
locals: { with_status: true, webhook_url: @webhook_url, webhook_event: @webhook_event }),
turbo_stream.replace("drawer_events_#{helpers.dom_id(@webhook_event)}",
partial: 'drawer_events',
locals: { webhook_url: @webhook_url, webhook_event: @webhook_event })
]
end
private
def load_webhook_event
@webhook_event = @webhook_url.webhook_events.find_by!(uuid: params[:id])
end
end

@ -8,10 +8,26 @@ class WebhookSettingsController < ApplicationController
@webhook_urls = @webhook_urls.order(id: :desc) @webhook_urls = @webhook_urls.order(id: :desc)
@webhook_url = @webhook_urls.first_or_initialize @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 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 def new; end
@ -37,13 +53,18 @@ class WebhookSettingsController < ApplicationController
def resend def resend
submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last
authorize!(:read, submitter)
if submitter.blank? || @webhook_url.blank? if submitter.blank? || @webhook_url.blank?
return redirect_back(fallback_location: settings_webhooks_path, return redirect_back(fallback_location: settings_webhooks_path,
alert: I18n.t('unable_to_resend_webhook_request')) alert: I18n.t('unable_to_resend_webhook_request'))
end end
SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id, SendTestWebhookRequestJob.perform_async(
'webhook_url_id' => @webhook_url.id) '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')) redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_request_has_been_sent'))
end end

@ -1,5 +1,17 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.querySelector('form').requestSubmit() if (this.dataset.interval) {
this.interval = setInterval(() => {
this.querySelector('form').requestSubmit()
}, parseInt(this.dataset.interval))
} else {
this.querySelector('form').requestSubmit()
}
}
disconnectedCallback () {
if (this.interval) {
clearInterval(this.interval)
}
} }
} }

@ -1,4 +1,5 @@
const en = { const en = {
must_be_characters_length: 'Must be {number} characters length',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.', complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
verify_id: 'Verify ID', verify_id: 'Verify ID',
identity_verification: 'Identity verification', identity_verification: 'Identity verification',
@ -98,6 +99,7 @@ const en = {
} }
const es = { const es = {
must_be_characters_length: 'Debe tener {number} caracteres de longitud',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.', complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
verify_id: 'Verificar ID', verify_id: 'Verificar ID',
identity_verification: 'Verificación de identidad', identity_verification: 'Verificación de identidad',
@ -197,6 +199,7 @@ const es = {
} }
const it = { const it = {
must_be_characters_length: 'Deve essere lungo {number} caratteri',
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.", complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
verify_id: 'Verifica ID', verify_id: 'Verifica ID',
identity_verification: "Verifica dell'identità", identity_verification: "Verifica dell'identità",
@ -296,6 +299,7 @@ const it = {
} }
const de = { const de = {
must_be_characters_length: 'Muss {number} Zeichen lang sein',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vervollständigen Sie alle erforderlichen Felder, um mit der Identitätsverifizierung fortzufahren.', complete_all_required_fields_to_proceed_with_identity_verification: 'Vervollständigen Sie alle erforderlichen Felder, um mit der Identitätsverifizierung fortzufahren.',
verify_id: 'ID überprüfen', verify_id: 'ID überprüfen',
identity_verification: 'Identitätsüberprüfung', identity_verification: 'Identitätsüberprüfung',
@ -395,6 +399,7 @@ const de = {
} }
const fr = { const fr = {
must_be_characters_length: 'Doit contenir {number} caractères',
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour continuer la vérification de l'identité.", complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour continuer la vérification de l'identité.",
verify_id: "Vérification de l'ID", verify_id: "Vérification de l'ID",
identity_verification: "Vérification de l'identité", identity_verification: "Vérification de l'identité",
@ -494,6 +499,7 @@ const fr = {
} }
const pl = { const pl = {
must_be_characters_length: 'Musi mieć długość {number} znaków',
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.', complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
verify_id: 'Zweryfikuj ID', verify_id: 'Zweryfikuj ID',
identity_verification: 'Weryfikacja tożsamości', identity_verification: 'Weryfikacja tożsamości',
@ -593,6 +599,7 @@ const pl = {
} }
const uk = { const uk = {
must_be_characters_length: 'Має містити {number} символів',
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.", complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
verify_id: 'Підтвердження ідентичності', verify_id: 'Підтвердження ідентичності',
identity_verification: 'Ідентифікація особи', identity_verification: 'Ідентифікація особи',
@ -692,6 +699,7 @@ const uk = {
} }
const cs = { const cs = {
must_be_characters_length: 'Musí mít délku {number} znaků',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.', complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
verify_id: 'Ověřit ID', verify_id: 'Ověřit ID',
identity_verification: 'Ověření identity', identity_verification: 'Ověření identity',
@ -791,6 +799,7 @@ const cs = {
} }
const pt = { const pt = {
must_be_characters_length: 'Deve ter {number} caracteres',
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.', complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
verify_id: 'Verificar ID', verify_id: 'Verificar ID',
identity_verification: 'Verificação de identidade', identity_verification: 'Verificação de identidade',
@ -890,6 +899,7 @@ const pt = {
} }
const he = { const he = {
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.', complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
verify_id: 'אמת מזהה', verify_id: 'אמת מזהה',
identity_verification: 'אימות זהות', identity_verification: 'אימות זהות',
@ -989,6 +999,7 @@ const he = {
} }
const nl = { const nl = {
must_be_characters_length: 'Moet {number} tekens lang zijn',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.', complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
verify_id: 'Verifiëren ID', verify_id: 'Verifiëren ID',
identity_verification: 'Identiteitsverificatie', identity_verification: 'Identiteitsverificatie',
@ -1088,6 +1099,7 @@ const nl = {
} }
const ar = { const ar = {
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.', complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
verify_id: 'تحقق من الهوية', verify_id: 'تحقق من الهوية',
identity_verification: 'التحقق من الهوية', identity_verification: 'التحقق من الهوية',
@ -1187,6 +1199,7 @@ const ar = {
} }
const ko = { const ko = {
must_be_characters_length: '{number}자여야 합니다',
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.', complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
verify_id: '아이디 확인', verify_id: '아이디 확인',
identity_verification: '신원 확인', identity_verification: '신원 확인',
@ -1286,6 +1299,7 @@ const ko = {
} }
const ja = { const ja = {
must_be_characters_length: '{number}文字でなければなりません',
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。', complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
verify_id: '本人確認', verify_id: '本人確認',
identity_verification: '本人確認', identity_verification: '本人確認',

@ -43,8 +43,8 @@
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`" :placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text" type="text"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@invalid="field.validation?.message ? $event.target.setCustomValidity(field.validation.message) : ''" @invalid="validationMessage ? $event.target.setCustomValidity(validationMessage) : ''"
@input="field.validation?.message ? $event.target.setCustomValidity('') : ''" @input="validationMessage ? $event.target.setCustomValidity('') : ''"
@focus="$emit('focus')" @focus="$emit('focus')"
> >
<textarea <textarea
@ -127,6 +127,28 @@ export default {
return null return null
} }
}, },
lengthValidation () {
if (this.field.validation?.pattern) {
return this.field.validation.pattern.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups
} else {
return null
}
},
validationMessage () {
if (this.field.validation?.message) {
return this.field.validation.message
} else if (this.lengthValidation) {
const number =
[this.lengthValidation.min, this.lengthValidation.max]
.filter(Boolean)
.filter((v, i, a) => a.indexOf(v) === i)
.join('-')
return this.t('must_be_characters_length').replace('{number}', number)
} else {
return ''
}
},
text: { text: {
set (value) { set (value) {
this.$emit('update:model-value', value) this.$emit('update:model-value', value)

@ -153,13 +153,13 @@
<option <option
v-for="(key, value) in validations" v-for="(key, value) in validations"
:key="key" :key="key"
:selected="field.validation?.pattern ? value === field.validation.pattern : value === 'none'" :selected="lengthValidation ? key == 'length' : (field.validation?.pattern ? value === field.validation.pattern : key === 'none')"
:value="value" :value="key"
> >
{{ t(key) }} {{ t(key) }}
</option> </option>
<option <option
:selected="field.validation && !validations[field.validation.pattern]" :selected="field.validation && !validations[field.validation.pattern] && !lengthValidation"
:value="validations[field.validation?.pattern] || !field.validation?.pattern ? 'custom' : field.validation?.pattern" :value="validations[field.validation?.pattern] || !field.validation?.pattern ? 'custom' : field.validation?.pattern"
> >
{{ t('custom') }} {{ t('custom') }}
@ -174,7 +174,51 @@
</label> </label>
</div> </div>
<div <div
v-if="['text', 'cells'].includes(field.type) && field.validation && !validations[field.validation.pattern]" v-if="['text', 'cells'].includes(field.type) && field.validation && lengthValidation"
class="py-1.5 px-1 relative flex space-x-1"
@click.stop
>
<div class="w-1/2 relative">
<input
:placeholder="t('min')"
type="number"
min="0"
:value="lengthValidation.min"
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
@input="field.validation.pattern = `.{${$event.target.value || 0},${lengthValidation.max || ''}}`"
@blur="save"
>
<label
v-if="lengthValidation.min"
:style="{ backgroundColor }"
class="absolute -top-2.5 left-1.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('min') }}
</label>
</div>
<div class="w-1/2 relative">
<input
:placeholder="t('max')"
type="number"
min="1"
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
:value="lengthValidation.max"
@input="field.validation.pattern = `.{${lengthValidation.min},${$event.target.value || ''}}`"
@blur="save"
>
<label
v-if="lengthValidation.max"
:style="{ backgroundColor }"
class="absolute -top-2.5 left-1.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('max') }}
</label>
</div>
</div>
<div
v-if="['text', 'cells'].includes(field.type) && field.validation && !validations[field.validation.pattern] && !lengthValidation"
class="py-1.5 px-1 relative" class="py-1.5 px-1 relative"
@click.stop @click.stop
> >
@ -195,6 +239,27 @@
{{ t('regexp_validation') }} {{ t('regexp_validation') }}
</label> </label>
</div> </div>
<div
v-if="['text', 'cells'].includes(field.type) && field.validation && !validations[field.validation.pattern] && !lengthValidation"
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="field.validation.message"
:placeholder="t('error_message')"
dir="auto"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
>
<label
v-if="field.validation.message"
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('error_message') }}
</label>
</div>
<div <div
v-if="field.type === 'date'" v-if="field.type === 'date'"
class="py-1.5 px-1 relative" class="py-1.5 px-1 relative"
@ -330,7 +395,7 @@
v-if="field.type != 'stamp'" v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5" class="pb-0.5 mt-0.5"
> >
<li v-if="['text', 'number', 'date'].includes(field.type)"> <li v-if="['text', 'number', 'date', 'select'].includes(field.type)">
<label <label
class="label-text cursor-pointer text-center w-full flex items-center" class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-font')" @click="$emit('click-font')"
@ -489,6 +554,7 @@ export default {
emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition', 'click-font', 'remove-area'], emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition', 'click-font', 'remove-area'],
data () { data () {
return { return {
selectedValidation: ''
} }
}, },
computed: { computed: {
@ -533,8 +599,16 @@ export default {
return formats return formats
}, },
lengthValidation () {
if (this.field.validation?.pattern && this.selectedValidation !== 'custom') {
return this.field.validation.pattern.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups
} else {
return null
}
},
validations () { validations () {
return { return {
'.{0,}': 'length',
'^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'ssn', '^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'ssn',
'^[0-9]{2}-[0-9]{7}$': 'ein', '^[0-9]{2}-[0-9]{7}$': 'ein',
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'email', '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'email',
@ -553,13 +627,21 @@ export default {
methods: { methods: {
onChangeValidation (event) { onChangeValidation (event) {
if (event.target.value === 'custom') { if (event.target.value === 'custom') {
this.field.validation = { pattern: '' } this.selectedValidation = 'custom'
this.field.validation = { pattern: '', message: '' }
this.$nextTick(() => this.$refs.validationCustom.focus()) this.$nextTick(() => this.$refs.validationCustom.focus())
} else if (event.target.value) { } else if (event.target.value) {
this.field.validation ||= {} this.field.validation ||= {}
this.field.validation.pattern = event.target.value this.field.validation.pattern =
Object.keys(this.validations).find(key => this.validations[key] === event.target.value)
this.selectedValidation = event.target.value
delete this.field.validation.message
} else { } else {
this.selectedValidation = ''
delete this.field.validation delete this.field.validation
} }

@ -1,4 +1,8 @@
const en = { const en = {
error_message: 'Error message',
length: 'Length',
min: 'Min',
max: 'Max',
font: 'Font', font: 'Font',
party: 'Party', party: 'Party',
date_signed: 'Date Signed', date_signed: 'Date Signed',
@ -168,6 +172,10 @@ const en = {
} }
const es = { const es = {
error_message: 'Mensaje de error',
length: 'Longitud',
min: 'Mín',
max: 'Máx',
date_signed: 'Fecha actual', date_signed: 'Fecha actual',
fuente: 'Fuente', fuente: 'Fuente',
party: 'Parte', party: 'Parte',
@ -337,6 +345,10 @@ const es = {
} }
const it = { const it = {
error_message: 'Messaggio di errore',
length: 'Lunghezza',
min: 'Min',
max: 'Max',
date_signed: 'Data attuale', date_signed: 'Data attuale',
font: 'Carattere', font: 'Carattere',
party: 'Parte', party: 'Parte',
@ -506,6 +518,10 @@ const it = {
} }
const pt = { const pt = {
error_message: 'Mensagem de erro',
length: 'Comprimento',
min: 'Mín',
max: 'Máx',
date_signed: 'Data atual', date_signed: 'Data atual',
fonte: 'Fonte', fonte: 'Fonte',
party: 'Parte', party: 'Parte',
@ -675,6 +691,10 @@ const pt = {
} }
const fr = { const fr = {
error_message: 'Message d\'erreur',
length: 'Longueur',
min: 'Min',
max: 'Max',
date_signed: 'Date actuelle', date_signed: 'Date actuelle',
font: 'Police', font: 'Police',
party: 'Partie', party: 'Partie',
@ -844,6 +864,10 @@ const fr = {
} }
const de = { const de = {
error_message: 'Fehlermeldung',
length: 'Länge',
min: 'Min',
max: 'Max',
date_now: 'Akt. Datum', date_now: 'Akt. Datum',
font: 'Schriftart', font: 'Schriftart',
party: 'Partei', party: 'Partei',

@ -11,9 +11,6 @@ class ProcessSubmissionExpiredJob
return if submission.submitters.where.not(declined_at: nil).exists? return if submission.submitters.where.not(declined_at: nil).exists?
return unless submission.submitters.exists?(completed_at: nil) return unless submission.submitters.exists?(completed_at: nil)
WebhookUrls.for_account_id(submission.account_id, %w[submission.expired]).each do |webhook| WebhookUrls.enqueue_events(submission, 'submission.expired')
SendSubmissionExpiredWebhookRequestJob.perform_async('submission_id' => submission.id,
'webhook_url_id' => webhook.id)
end
end end
end end

@ -63,16 +63,24 @@ class ProcessSubmitterCompletionJob
end end
def enqueue_completed_webhooks(submitter, is_all_completed: false) 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| WebhookUrls.for_account_id(submitter.account_id, %w[form.completed submission.completed]).each do |webhook|
if webhook.events.include?('form.completed') if webhook.events.include?('form.completed')
event_uuids['form.completed'] ||= SecureRandom.uuid
SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id, SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'event_uuid' => event_uuids['form.completed'],
'webhook_url_id' => webhook.id) 'webhook_url_id' => webhook.id)
end end
if webhook.events.include?('submission.completed') && is_all_completed next unless webhook.events.include?('submission.completed') && is_all_completed
SendSubmissionCompletedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
'webhook_url_id' => webhook.id) event_uuids['submission.completed'] ||= SecureRandom.uuid
end
SendSubmissionCompletedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
'event_uuid' => event_uuids['submission.completed'],
'webhook_url_id' => webhook.id)
end end
end end

@ -20,6 +20,9 @@ class SendFormCompletedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.completed', resp = SendWebhookRequest.call(webhook_url, event_type: 'form.completed',
event_uuid: params['event_uuid'],
record: submitter,
attempt:,
data: Submitters::SerializeForWebhook.call(submitter)) data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&

@ -18,13 +18,15 @@ class SendFormDeclinedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.declined', resp = SendWebhookRequest.call(webhook_url, event_type: 'form.declined',
event_uuid: params['event_uuid'],
record: submitter,
attempt:,
data: Submitters::SerializeForWebhook.call(submitter)) data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, { SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submitter_id' => submitter.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })

@ -18,13 +18,15 @@ class SendFormStartedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.started', resp = SendWebhookRequest.call(webhook_url, event_type: 'form.started',
event_uuid: params['event_uuid'],
record: submitter,
attempt:,
data: Submitters::SerializeForWebhook.call(submitter)) data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, { SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submitter_id' => submitter.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })

@ -18,13 +18,15 @@ class SendFormViewedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.viewed', resp = SendWebhookRequest.call(webhook_url, event_type: 'form.viewed',
event_uuid: params['event_uuid'],
record: submitter,
attempt:,
data: Submitters::SerializeForWebhook.call(submitter)) data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, { SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submitter_id' => submitter.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })

@ -16,13 +16,15 @@ class SendSubmissionArchivedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.archived') return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.archived')
resp = SendWebhookRequest.call(webhook_url, event_type: '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])) data: submission.as_json(only: %i[id archived_at]))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, { SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submission_id' => submission.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })

@ -16,6 +16,9 @@ class SendSubmissionCompletedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.completed') return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.completed')
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.completed', resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.completed',
event_uuid: params['event_uuid'],
record: submission,
attempt:,
data: Submissions::SerializeForApi.call(submission)) data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&

@ -16,13 +16,15 @@ class SendSubmissionCreatedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.created') return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.created')
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.created', resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.created',
event_uuid: params['event_uuid'],
record: submission,
attempt:,
data: Submissions::SerializeForApi.call(submission)) data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, { SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submission_id' => submission.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })

@ -16,13 +16,15 @@ class SendSubmissionExpiredWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.expired') return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.expired')
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.expired', resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.expired',
event_uuid: params['event_uuid'],
record: submission,
attempt:,
data: Submissions::SerializeForApi.call(submission)) data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionExpiredWebhookRequestJob.perform_in((2**attempt).minutes, { SendSubmissionExpiredWebhookRequestJob.perform_in((2**attempt).minutes, {
'submission_id' => submission.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })

@ -16,13 +16,15 @@ class SendTemplateCreatedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.created') return if webhook_url.url.blank? || webhook_url.events.exclude?('template.created')
resp = SendWebhookRequest.call(webhook_url, event_type: 'template.created', resp = SendWebhookRequest.call(webhook_url, event_type: 'template.created',
event_uuid: params['event_uuid'],
record: template,
attempt:,
data: Templates::SerializeForApi.call(template)) data: Templates::SerializeForApi.call(template))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, { SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
'template_id' => template.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })

@ -16,13 +16,15 @@ class SendTemplateUpdatedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.updated') return if webhook_url.url.blank? || webhook_url.events.exclude?('template.updated')
resp = SendWebhookRequest.call(webhook_url, event_type: 'template.updated', resp = SendWebhookRequest.call(webhook_url, event_type: 'template.updated',
event_uuid: params['event_uuid'],
record: template,
attempt:,
data: Templates::SerializeForApi.call(template)) data: Templates::SerializeForApi.call(template))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, { SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, {
'template_id' => template.id, **params,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })

@ -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

@ -33,6 +33,7 @@ class Account < ApplicationRecord
has_many :account_linked_accounts, dependent: :destroy has_many :account_linked_accounts, dependent: :destroy
has_many :email_events, dependent: :destroy has_many :email_events, dependent: :destroy
has_many :webhook_urls, dependent: :destroy has_many :webhook_urls, dependent: :destroy
has_many :webhook_events, dependent: nil
has_many :account_accesses, dependent: :destroy has_many :account_accesses, dependent: :destroy
has_many :account_testing_accounts, -> { testing }, dependent: :destroy, has_many :account_testing_accounts, -> { testing }, dependent: :destroy,
class_name: 'AccountLinkedAccount', class_name: 'AccountLinkedAccount',

@ -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?
[2, 3].include?(response_status_code.to_i / 100)
end
end

@ -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

@ -37,6 +37,7 @@ class WebhookUrl < ApplicationRecord
].freeze ].freeze
belongs_to :account belongs_to :account
has_many :webhook_events, dependent: nil
attribute :events, :string, default: -> { %w[form.viewed form.started form.completed form.declined] } attribute :events, :string, default: -> { %w[form.viewed form.started form.completed form.declined] }
attribute :secret, :string, default: -> { {} } attribute :secret, :string, default: -> { {} }

@ -9,6 +9,10 @@
if (this.field.scrollHeight > this.field.clientHeight) { if (this.field.scrollHeight > this.field.clientHeight) {
this.field.classList.add('text-[0.8vw]', 'lg:text-[0.50rem]'); this.field.classList.add('text-[0.8vw]', 'lg:text-[0.50rem]');
} }
if (this.field.scrollHeight > this.field.clientHeight) {
this.field.classList.add('text-[0.7vw]', 'lg:text-[0.45rem]');
}
} }
} }
get field() { get field() {

@ -1,4 +1,3 @@
<% dashboard_templates_order = cookies.permanent[:dashboard_templates_order] || 'created_at' %>
<form action="<%= url_for %>" method="get" class="dropdown dropdown-top hidden md:inline"> <form action="<%= url_for %>" method="get" class="dropdown dropdown-top hidden md:inline">
<label tabindex="0" class="btn btn-sm h-10"> <label tabindex="0" class="btn btn-sm h-10">
<%= svg_icon('arrow_sort', class: 'w-5 h-5') %> <%= svg_icon('arrow_sort', class: 'w-5 h-5') %>
@ -6,7 +5,7 @@
<ul tabindex="0" class="dropdown-content z-[10] menu p-2 shadow bg-base-100 rounded-box mb-1 min-w-48"> <ul tabindex="0" class="dropdown-content z-[10] menu p-2 shadow bg-base-100 rounded-box mb-1 min-w-48">
<toggle-cookies data-value="created_at" data-key="dashboard_templates_order"> <toggle-cookies data-value="created_at" data-key="dashboard_templates_order">
<li> <li>
<button class="<%= 'bg-base-200' if dashboard_templates_order == 'created_at' %>"> <button class="<%= 'bg-base-200' if selected_order == 'created_at' %>">
<%= svg_icon('sort_descending_numbers', class: 'w-4 h-4') %> <%= svg_icon('sort_descending_numbers', class: 'w-4 h-4') %>
<span class="whitespace-nowrap"><%= t('newest_first') %></span> <span class="whitespace-nowrap"><%= t('newest_first') %></span>
</button> </button>
@ -15,7 +14,7 @@
<% if local_assigns[:with_recently_used] != false %> <% if local_assigns[:with_recently_used] != false %>
<toggle-cookies data-value="used_at" data-key="dashboard_templates_order"> <toggle-cookies data-value="used_at" data-key="dashboard_templates_order">
<li> <li>
<button class="<%= 'bg-base-200' if dashboard_templates_order == 'used_at' %>"> <button class="<%= 'bg-base-200' if selected_order == 'used_at' %>">
<%= svg_icon('sort_descending_small_big', class: 'w-4 h-4') %> <%= svg_icon('sort_descending_small_big', class: 'w-4 h-4') %>
<span class="whitespace-nowrap"><%= t('recently_used') %></span> <span class="whitespace-nowrap"><%= t('recently_used') %></span>
</button> </button>
@ -24,7 +23,7 @@
<% end %> <% end %>
<toggle-cookies data-value="name" data-key="dashboard_templates_order"> <toggle-cookies data-value="name" data-key="dashboard_templates_order">
<li> <li>
<button class="<%= 'bg-base-200' if dashboard_templates_order == 'name' %>"> <button class="<%= 'bg-base-200' if selected_order == 'name' %>">
<%= svg_icon('sort_ascending_letters', class: 'w-4 h-4') %> <%= svg_icon('sort_ascending_letters', class: 'w-4 h-4') %>
<span class="whitespace-nowrap"><%= t('name_a_z') %></span> <span class="whitespace-nowrap"><%= t('name_a_z') %></span>
</button> </button>

@ -14,7 +14,7 @@
&times; &times;
</span> </span>
<% end %> <% end %>
<div class="w-full md:w-[620px] overflow-y-auto" style="max-height: calc(100vh - <%= local_assigns[:title] ? '45px' : '0px' %>)"> <div class="w-screen md:w-[620px] overflow-y-auto" style="max-height: calc(100vh - <%= local_assigns[:title] ? '45px' : '0px' %>)">
<%= yield %> <%= yield %>
</div> </div>
</div> </div>

@ -45,7 +45,7 @@
</div> </div>
<% templates_order_select_html = capture do %> <% templates_order_select_html = capture do %>
<% if params[:q].blank? && @pagy.pages > 1 %> <% if params[:q].blank? && @pagy.pages > 1 %>
<%= render('shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000) %> <%= render 'shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000 && !can?(:manage, :countless), selected_order: %>
<% end %> <% end %>
<% end %> <% end %>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', right_additional_html: templates_order_select_html %> <%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', right_additional_html: templates_order_select_html %>

@ -45,7 +45,7 @@
<% end %> <% end %>
<% templates_order_select_html = capture do %> <% templates_order_select_html = capture do %>
<% if params[:q].blank? && @pagy.pages > 1 %> <% if params[:q].blank? && @pagy.pages > 1 %>
<%= render('shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000) %> <%= render 'shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000 && !can?(:manage, :countless), selected_order: %>
<% end %> <% end %>
<% end %> <% end %>
<% if @template_folders.present? %> <% if @template_folders.present? %>

@ -15,7 +15,7 @@
<%= tag.input name: ff.field_name(:email), value: ff.object.email, type: :email, class: 'base-input', multiple: true, autocomplete: 'off', placeholder: t('default_email'), disabled: ff.object.is_requester || ff.object.invite_by_uuid.present? || ff.object.optional_invite_by_uuid.present?, id: field_uuid = SecureRandom.uuid %> <%= tag.input name: ff.field_name(:email), value: ff.object.email, type: :email, class: 'base-input', multiple: true, autocomplete: 'off', placeholder: t('default_email'), disabled: ff.object.is_requester || ff.object.invite_by_uuid.present? || ff.object.optional_invite_by_uuid.present?, id: field_uuid = SecureRandom.uuid %>
<% else %> <% else %>
<toggle-attribute data-target-id="<%= email_field_uuid = SecureRandom.uuid %>" data-class-name="hidden" data-value="email"> <toggle-attribute data-target-id="<%= email_field_uuid = SecureRandom.uuid %>" data-class-name="hidden" data-value="email">
<%= ff.select :option, [[t('not_specified'), 'not_set'], (local_assigns[:with_submission_requester] != false ? [t('submission_requester'), 'is_requester'] : nil), [t('specified_email'), 'email'], *(template.submitters - [submitter]).flat_map { |e| [[t('invite_by_name', name: e['name']), "invite_by_#{e['uuid']}"], [t('invite_by_name', name: e['name']) + " (#{t(:optional).capitalize})", "optional_invite_by_#{e['uuid']}"]] }, *(template.submitters - [submitter]).map { |e| [t('same_as_name', name: e['name']), "linked_to_#{e['uuid']}"] }].compact, {}, class: 'base-select mb-3' %> <%= ff.select :option, [[t('not_specified'), 'not_set'], (local_assigns[:with_submission_requester] == false ? nil : [t('submission_requester'), 'is_requester']), [t('specified_email'), 'email'], *(template.submitters - [submitter]).flat_map { |e| [[t('invite_by_name', name: e['name']), "invite_by_#{e['uuid']}"], [t('invite_by_name', name: e['name']) + " (#{t(:optional).capitalize})", "optional_invite_by_#{e['uuid']}"]] }, *(template.submitters - [submitter]).map { |e| [t('same_as_name', name: e['name']), "linked_to_#{e['uuid']}"] }].compact, {}, class: 'base-select mb-3' %>
</toggle-attribute> </toggle-attribute>
<%= tag.input name: ff.field_name(:email), type: :email, value: ff.object.email, multiple: true, class: "base-input #{'hidden' if item.option != 'email'}", autocomplete: 'off', placeholder: t('default_email'), id: email_field_uuid %> <%= tag.input name: ff.field_name(:email), type: :email, value: ff.object.email, multiple: true, class: "base-input #{'hidden' if item.option != 'email'}", autocomplete: 'off', placeholder: t('default_email'), id: email_field_uuid %>
<% end %> <% end %>

@ -0,0 +1,57 @@
<div id="drawer_events_<%= dom_id(webhook_event) %>">
<ol class="relative border-s border-base-300 space-y-6 ml-3">
<% webhook_attempts = webhook_event.webhook_attempts.sort_by { |e| -e.id } %>
<% if webhook_event.status == 'error' %>
<% last_attempt = webhook_attempts.select { |e| e.attempt < SendWebhookRequest::MANUAL_ATTEMPT }.max_by(&:attempt) %>
<% if webhook_event.webhook_attempts.none?(&:success?) && last_attempt.attempt <= 10 %>
<li class="ml-7">
<span class="btn btn-outline btn-xs btn-circle pointer-events-none absolute justify-center border-base-content-/60 text-base-content/60 bg-base-100" style="left: -12px;">
<%= svg_icon('clock', class: 'w-4 h-4 shrink-0') %>
</span>
<p class="leading-none text-base-content/90 pt-1">
<%= 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)) %>
</p>
</li>
<% end %>
<% end %>
<% if webhook_attempts.present? %>
<% webhook_attempts.each do |webhook_attempt| %>
<li class="ml-7">
<span class="btn btn-outline btn-xs btn-circle pointer-events-none absolute justify-center <%= webhook_attempt.success? ? 'btn-success bg-lime-50' : 'btn-error bg-red-50' %>" style="left: -12px;">
<%= svg_icon(webhook_attempt.success? ? 'check' : 'x', class: 'w-4 h-4 shrink-0') %>
</span>
<p class="leading-none text-sm text-base-content/60 pt-1">
<%= l(webhook_attempt.created_at.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %>
</p>
<div class="mt-2">
<p class="text-sm font-bold text-base-content/80">
<span><%= Rack::Utils::HTTP_STATUS_CODES[webhook_attempt.response_status_code] %></span>
<% if webhook_attempt.response_status_code.positive? %>
<span>(<%= webhook_attempt.response_status_code %>)</span>
<% end %>
</p>
<% unless webhook_attempt.success? %>
<p class="text-sm text-base-content/80 mt-1">
<%= webhook_attempt.response_body.presence || Rack::Utils::HTTP_STATUS_CODES[webhook_attempt.response_status_code] %>
</p>
<% end %>
</div>
</li>
<% end %>
<% else %>
<li class="ml-7">
<span class="btn btn-outline btn-xs btn-circle pointer-events-none absolute justify-center btn-info bg-blue-50" style="left: -12px;">
<%= svg_icon('clock', class: 'w-4 h-4 shrink-0') %>
</span>
<p class="leading-none text-base-content/60 pt-1">
<%= l(webhook_event.created_at.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %>
</p>
</li>
<% end %>
</ol>
<% unless webhook_event.status == 'pending' %>
<div class="absolute right-4 top-3">
<%= button_to button_title(title: t('resend'), disabled_with: t('awaiting'), 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), form: { id: button_uuid = SecureRandom.uuid }, params: { button_id: button_uuid }, class: 'btn btn-neutral btn-sm text-white', method: :post, onclick: '[this.form.requestSubmit(), this.disabled = true]' %>
</div>
<% end %>
</div>

@ -0,0 +1,28 @@
<div id="<%= dom_id(webhook_event) %>" class="group relative hover:cursor-pointer hover:bg-base-200">
<a href="<%= settings_webhook_event_path(webhook_url.id, webhook_event.uuid) %>" data-turbo-frame="drawer" class="top-0 bottom-0 left-0 right-0 absolute"></a>
<div class="min-h-12 flex flex-col md:flex-row md:items-center md:justify-between gap-2 px-3 py-2">
<div class="flex items-center gap-4">
<% if webhook_event.status == 'success' %>
<div class="btn btn-outline btn-xs btn-success bg-lime-50 gap-1">
<%= svg_icon('check', class: 'w-4 h-4 shrink-0 stroke-2') %>
<%= webhook_event.webhook_attempts.max_by(&:id)&.response_status_code if local_assigns[:with_status] %>
</div>
<% elsif webhook_event.status == 'pending' %>
<div class="btn btn-outline btn-xs btn-info bg-blue-50 gap-1">
<%= svg_icon('clock', class: 'w-4 h-4 shrink-0 stroke-2') %>
<%= webhook_event.webhook_attempts.max_by(&:id)&.response_status_code if local_assigns[:with_status] %>
</div>
<% elsif webhook_event.status == 'error' %>
<div class="btn btn-outline btn-xs btn-error bg-red-50 gap-1">
<%= svg_icon('x', class: 'w-4 h-4 shrink-0') %>
<%= webhook_event.webhook_attempts.max_by(&:id)&.response_status_code if local_assigns[:with_status] %>
</div>
<% end %>
<div><%= webhook_event.event_type %></div>
</div>
<div class="flex items-center gap-3">
<%= button_to button_title(title: t('resend'), disabled_with: t('awaiting'), 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), form: { id: button_uuid = SecureRandom.uuid }, params: { button_id: button_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, onclick: "[this.form.requestSubmit(), this.disabled = true, this.classList.remove('hidden')]" %>
<span><%= l(webhook_event.created_at, locale: current_account.locale, format: :short) %></span>
</div>
</div>
</div>

@ -0,0 +1,14 @@
<%= render 'shared/turbo_drawer', title: @webhook_event.event_type, close_after_submit: false do %>
<div class="relative px-4 py-4">
<%= render 'drawer_events', webhook_url: @webhook_url, webhook_event: @webhook_event %>
<% if @data %>
<div class="mockup-code overflow-hidden relative pb-0 mt-6">
<% response = JSON.pretty_generate({ event_type: @webhook_event.event_type, timestamp: @webhook_event.created_at.as_json, data: @data }) %>
<span class="top-0 right-0 absolute">
<%= 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') %>
</span>
<pre class="before:!m-0 pl-4 pb-4"><code class="overflow-hidden text-sm w-full"><%== HighlightCode.call(response, 'JSON', theme: 'base16.dark') %></code></pre>
</div>
<% end %>
</div>
<% end %>

@ -79,8 +79,49 @@
<% end %> <% end %>
</div> </div>
</div> </div>
<% submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last %> <% if @webhook_events.present? || params[:status].present? %>
<% if submitter %> <div class="mt-6">
<h2 id="log" class="text-3xl font-bold"><%= t('events_log') %></h2>
<div class="tabs border-b mt-4">
<%= 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]'}" %>
</div>
<% if @webhook_events.present? %>
<div class="divide-y divide-base-300 rounded-lg">
<%= render partial: 'webhook_events/event_row', collection: @webhook_events, as: :webhook_event, locals: { webhook_url: @webhook_url } %>
</div>
<% else %>
<div class="text-center py-4">
<%= t('there_are_no_events') %>
</div>
<% end %>
<% if @pagy.pages > 1 %>
<div class="flex my-4 justify-center md:justify-between">
<div class="hidden md:block text-sm">
<%= "#{@pagy.from}-#{@pagy.to} events" %>
</div>
<div class="flex items-center space-x-1.5">
<div class="join">
<% if @pagy.prev %>
<%= link_to '«', url_for(page: @pagy.prev, anchor: 'log'), class: 'join-item btn min-h-full h-10' %>
<% else %>
<span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">«</span>
<% end %>
<span class="join-item btn font-medium uppercase min-h-full h-10">
<%= "Page #{@pagy.page}" %>
</span>
<% if @pagy.next %>
<%= link_to '»', url_for(page: @pagy.next, anchor: 'log'), class: 'join-item btn min-h-full h-10' %>
<% else %>
<span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">»</span>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
<% elsif (submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last) && can?(:read, submitter) %>
<div class="space-y-4 mt-4"> <div class="space-y-4 mt-4">
<div class="collapse collapse-open bg-base-200 px-1"> <div class="collapse collapse-open bg-base-200 px-1">
<div class="p-4 text-xl font-medium"> <div class="p-4 text-xl font-medium">
@ -98,7 +139,7 @@
<span class="top-0 right-0 absolute"> <span class="top-0 right-0 absolute">
<%= 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') %> <%= 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') %>
</span> </span>
<pre><code class="overflow-hidden w-full"><%= code %></code></pre> <pre><code class="overflow-hidden w-full"><%== HighlightCode.call(code, 'JSON', theme: 'base16.dark') %></code></pre>
</div> </div>
</div> </div>
</div> </div>

@ -1,7 +1,7 @@
{ {
"ignored_warnings": [ "ignored_warnings": [
{ {
"fingerprint": "25f4ce5fee1e1180fa1919dc4ee78db3ab3457a956e4679503aa745771a43836", "fingerprint": "bbd1bdad94998e53a48921859065e06cd1595502d4dc40362afcaa90307b591a",
"note": "Permitted parameters are necessary for creating submitters via API" "note": "Permitted parameters are necessary for creating submitters via API"
}, },
{ {
@ -19,6 +19,10 @@
{ {
"fingerprint": "5f52190d03ee922bba9792012d8fcbeb7d4736006bb899b3be9cc10d679e0af1", "fingerprint": "5f52190d03ee922bba9792012d8fcbeb7d4736006bb899b3be9cc10d679e0af1",
"note": "Safe Param" "note": "Safe Param"
},
{
"fingerprint": "dbbfb4a4ace7f43d8247cbb44afa8b628e005e6194ca5552e029b200f725a2d5",
"message": "Unescaped find_by!(uuid: params[:id]) is not risky"
} }
] ]
} }

@ -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

@ -763,6 +763,12 @@ en: &en
link_form_fields: Link form fields 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. 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. 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: submission_sources:
api: API api: API
bulk: Bulk Send bulk: Bulk Send
@ -863,6 +869,9 @@ en: &en
items: items:
range_with_total: "%{from}-%{to} of %{count} items" range_with_total: "%{from}-%{to} of %{count} items"
range_without_total: "%{from}-%{to} items" range_without_total: "%{from}-%{to} items"
events:
range_with_total: "%{from}-%{to} of %{count} events"
range_without_total: "%{from}-%{to} events"
es: &es es: &es
default_parties: Partes predeterminadas default_parties: Partes predeterminadas
@ -1609,6 +1618,12 @@ es: &es
link_form_fields: Vincular campos del formulario 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. 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. 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: submission_sources:
api: API api: API
bulk: Envío masivo bulk: Envío masivo
@ -1709,6 +1724,9 @@ es: &es
items: items:
range_with_total: "%{from}-%{to} de %{count} elementos" range_with_total: "%{from}-%{to} de %{count} elementos"
range_without_total: "%{from}-%{to} elementos" range_without_total: "%{from}-%{to} elementos"
events:
range_with_total: "%{from}-%{to} de %{count} eventos"
range_without_total: "%{from}-%{to} eventos"
it: &it it: &it
default_parties: Parti predefiniti default_parties: Parti predefiniti
@ -2453,6 +2471,12 @@ it: &it
link_form_fields: Collega i campi del modulo 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. 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 luso di un link di condivisione perché non è chiaro quale parte sia responsabile di campi specifici. Per risolvere, definisci i dettagli predefiniti della parte. this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: Questo modello ha più parti, il che impedisce luso 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: submission_sources:
api: API api: API
bulk: Invio massivo bulk: Invio massivo
@ -2553,6 +2577,9 @@ it: &it
items: items:
range_with_total: "%{from}-%{to} di %{count} elementi" range_with_total: "%{from}-%{to} di %{count} elementi"
range_without_total: "%{from}-%{to} elementi" range_without_total: "%{from}-%{to} elementi"
events:
range_with_total: "%{from}-%{to} di %{count} eventi"
range_without_total: "%{from}-%{to} eventi"
fr: &fr fr: &fr
default_parties: Parties par défaut default_parties: Parties par défaut
@ -3300,6 +3327,12 @@ fr: &fr
link_form_fields: Lier les champs du formulaire 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. 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 lutilisation dun lien de partage car il nest pas clair quelle partie est responsable de certains champs. Pour résoudre cela, définissez les détails de la partie par défaut. this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: Ce modèle contient plusieurs parties, ce qui empêche lutilisation dun lien de partage car il nest 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: submission_sources:
api: API api: API
bulk: Envoi en masse bulk: Envoi en masse
@ -3400,6 +3433,9 @@ fr: &fr
items: items:
range_with_total: "%{from} à %{to} sur %{count} éléments" range_with_total: "%{from} à %{to} sur %{count} éléments"
range_without_total: "%{from} à %{to} é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 pt: &pt
default_parties: Partes padrão default_parties: Partes padrão
@ -4146,6 +4182,12 @@ pt: &pt
link_form_fields: Vincular campos do formulário 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. 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. 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: submission_sources:
api: API api: API
bulk: Envio em massa bulk: Envio em massa
@ -4247,6 +4289,9 @@ pt: &pt
items: items:
range_with_total: "%{from}-%{to} de %{count} itens" range_with_total: "%{from}-%{to} de %{count} itens"
range_without_total: "%{from}-%{to} itens" range_without_total: "%{from}-%{to} itens"
events:
range_with_total: "%{from}-%{to} de %{count} eventos"
range_without_total: "%{from}-%{to} eventos"
de: &de de: &de
default_parties: Standardparteien default_parties: Standardparteien
@ -4993,6 +5038,12 @@ de: &de
link_form_fields: Formularfelder verknüpfen link_form_fields: Formularfelder verknüpfen
at_least_one_field_must_be_displayed_in_the_form: Mindestens ein Feld muss im Formular angezeigt werden. 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. 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: submission_sources:
api: API api: API
bulk: Massenversand bulk: Massenversand
@ -5093,6 +5144,9 @@ de: &de
items: items:
range_with_total: "%{from}-%{to} von %{count} Elementen" range_with_total: "%{from}-%{to} von %{count} Elementen"
range_without_total: "%{from}-%{to} Elemente" range_without_total: "%{from}-%{to} Elemente"
events:
range_with_total: "%{from}-%{to} von %{count} Ereignissen"
range_without_total: "%{from}-%{to} Ereignisse"
pl: pl:
require_phone_2fa_to_open: Wymagaj uwierzytelniania telefonicznego 2FA do otwarcia require_phone_2fa_to_open: Wymagaj uwierzytelniania telefonicznego 2FA do otwarcia

@ -179,6 +179,11 @@ Rails.application.routes.draw do
resources :api, only: %i[index create], controller: 'api_settings' resources :api, only: %i[index create], controller: 'api_settings'
resources :webhooks, only: %i[index show new create update destroy], controller: 'webhook_settings' do resources :webhooks, only: %i[index show new create update destroy], controller: 'webhook_settings' do
post :resend post :resend
resources :events, only: %i[show], controller: 'webhook_events' do
post :resend, on: :member
post :refresh, on: :member
end
end end
resource :account, only: %i[show update destroy] resource :account, only: %i[show update destroy]
resources :profile, only: %i[index] do resources :profile, only: %i[index] do

@ -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

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "btree_gin" enable_extension "btree_gin"
enable_extension "plpgsql" 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 t.index ["uuid"], name: "index_users_on_uuid", unique: true
end 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| create_table "webhook_urls", force: :cascade do |t|
t.bigint "account_id", null: false t.bigint "account_id", null: false
t.text "url", null: false t.text "url", null: false

@ -13,7 +13,7 @@ module Abilities
TemplateSharing.where({ ability:, account_id: [user.account_id, TemplateSharing::ALL_ID] }.compact) TemplateSharing.where({ ability:, account_id: [user.account_id, TemplateSharing::ALL_ID] }.compact)
.select(:template_id) .select(:template_id)
Template.where(Template.arel_table[:id].in(templates.select(:id).arel.union(shared_ids.arel))) Template.where(Template.arel_table[:id].in(templates.select(:id).arel.union(:all, shared_ids.arel)))
end end
def entity(template, user:, ability: nil) def entity(template, user:, ability: nil)

@ -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

@ -4,8 +4,10 @@ module ImageUtils
module_function module_function
def blank?(image) def blank?(image)
min = (0...image.bands).map { |i| image.stats.getpoint(0, i)[0] } stats = image.stats
max = (0...image.bands).map { |i| image.stats.getpoint(1, i)[0] }
min = (0...image.bands).map { |i| stats.getpoint(0, i)[0] }
max = (0...image.bands).map { |i| stats.getpoint(1, i)[0] }
return true if min.all?(255) && max.all?(255) return true if min.all?(255) && max.all?(255)
return true if min.all?(0) && max.all?(0) return true if min.all?(0) && max.all?(0)

@ -56,7 +56,7 @@ module SearchEntries
end end
[sql, number, number.length > 1 ? number.delete_prefix('0') : number, keyword] [sql, number, number.length > 1 ? number.delete_prefix('0') : number, keyword]
elsif keyword.match?(/[^\p{L}\d&@.\-]/) || keyword.match?(/\A['"].*['"]\z/) elsif keyword.match?(/[^\p{L}\d&@.\-]/) || keyword.match?(/\A['"].*['"]\z/) || keyword.match?(/[.\-]{2,}/)
['tsvector @@ plainto_tsquery(?)', TextUtils.transliterate(keyword.downcase)] ['tsvector @@ plainto_tsquery(?)', TextUtils.transliterate(keyword.downcase)]
else else
keyword = TextUtils.transliterate(keyword.downcase).squish keyword = TextUtils.transliterate(keyword.downcase).squish

@ -5,12 +5,16 @@ module SendWebhookRequest
LOCALHOSTS = %w[0.0.0.0 127.0.0.1 localhost].freeze 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) HttpsError = Class.new(StandardError)
LocalhostError = Class.new(StandardError) LocalhostError = Class.new(StandardError)
module_function 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 = begin
URI(webhook_url.url) URI(webhook_url.url)
rescue URI::Error rescue URI::Error
@ -24,21 +28,81 @@ module SendWebhookRequest
raise LocalhostError, "Can't send to localhost." if uri.host.in?(LOCALHOSTS) raise LocalhostError, "Can't send to localhost." if uri.host.in?(LOCALHOSTS)
end 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['Content-Type'] = 'application/json'
req.headers['User-Agent'] = USER_AGENT req.headers['User-Agent'] = USER_AGENT
req.headers.merge!(webhook_url.secret.to_h) if webhook_url.secret.present? req.headers.merge!(webhook_url.secret.to_h) if webhook_url.secret.present?
req.body = { req.body = {
event_type: event_type, event_type: event_type,
timestamp: Time.current, timestamp: webhook_event&.created_at || Time.current,
data: data data: data
}.to_json }.to_json
req.options.read_timeout = 8 req.options.read_timeout = 8
req.options.open_timeout = 8 req.options.open_timeout = 8
end 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
is_error = response.status.to_i >= 400
WebhookAttempt.create!(
webhook_event:,
response_body: is_error ? response.body&.truncate(100) : nil,
response_status_code: response.status,
attempt:
)
webhook_event.update!(status: is_error ? 'error' : 'success')
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 nil
end end
end end

@ -36,7 +36,7 @@ module Submissions
TEXT_LEFT_MARGIN = 1 TEXT_LEFT_MARGIN = 1
TEXT_TOP_MARGIN = 1 TEXT_TOP_MARGIN = 1
MAX_PAGE_ROTATE = 20 MAX_PAGE_ROTATE = 50
A4_SIZE = [595, 842].freeze A4_SIZE = [595, 842].freeze

@ -53,7 +53,7 @@ module Submitters
end end
[sql, number, weight, number.length > 1 ? number.delete_prefix('0') : number, weight] [sql, number, weight, number.length > 1 ? number.delete_prefix('0') : number, weight]
elsif keyword.match?(/[^\p{L}\d&@.\-]/) elsif keyword.match?(/[^\p{L}\d&@.\-]/) || keyword.match?(/[.\-]{2,}/)
terms = TextUtils.transliterate(keyword.downcase).split(/\b/).map(&:squish).compact_blank.uniq terms = TextUtils.transliterate(keyword.downcase).split(/\b/).map(&:squish).compact_blank.uniq
if terms.size > 1 if terms.size > 1

@ -16,10 +16,7 @@ module Submitters
unless submitter.submission_events.exists?(event_type: 'start_form') unless submitter.submission_events.exists?(event_type: 'start_form')
SubmissionEvents.create_with_tracking_data(submitter, 'start_form', request) SubmissionEvents.create_with_tracking_data(submitter, 'start_form', request)
WebhookUrls.for_account_id(submitter.account_id, 'form.started').each do |webhook_url| WebhookUrls.enqueue_events(submitter, 'form.started')
SendFormStartedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id)
end
end end
update_submitter!(submitter, params, request, validate_required:) update_submitter!(submitter, params, request, validate_required:)

@ -1,6 +1,25 @@
# frozen_string_literal: true # frozen_string_literal: true
module WebhookUrls 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 module_function
def for_account_id(account_id, events) def for_account_id(account_id, events)
@ -24,4 +43,26 @@ module WebhookUrls
(account_urls.present? ? WebhookUrl.none : linked_urls) (account_urls.present? ? WebhookUrl.none : linked_urls)
end end
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 end

@ -21,7 +21,8 @@ RSpec.describe SendFormCompletedWebhookRequestJob do
end end
it 'sends a webhook request' do 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { body: {
@ -38,7 +39,8 @@ RSpec.describe SendFormCompletedWebhookRequestJob do
it 'sends a webhook request with the secret' do it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { 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 it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['form.declined']) 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) expect(WebMock).not_to have_requested(:post, webhook_url.url)
end end
@ -65,8 +68,11 @@ RSpec.describe SendFormCompletedWebhookRequestJob do
it 'sends again if the response status is 400 or higher' do it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401) stub_request(:post, webhook_url.url).to_return(status: 401)
event_uuid = SecureRandom.uuid
expect do 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) end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once 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['attempt']).to eq(1)
expect(args['last_status']).to eq(401) 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['webhook_url_id']).to eq(webhook_url.id)
expect(args['submitter_id']).to eq(submitter.id) expect(args['submitter_id']).to eq(submitter.id)
end end
@ -83,7 +90,8 @@ RSpec.describe SendFormCompletedWebhookRequestJob do
stub_request(:post, webhook_url.url).to_return(status: 401) stub_request(:post, webhook_url.url).to_return(status: 401)
expect do 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) end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once expect(WebMock).to have_requested(:post, webhook_url.url).once

@ -21,7 +21,8 @@ RSpec.describe SendFormDeclinedWebhookRequestJob do
end end
it 'sends a webhook request' do 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { body: {
@ -38,7 +39,8 @@ RSpec.describe SendFormDeclinedWebhookRequestJob do
it 'sends a webhook request with the secret' do it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { 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 it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['form.completed']) 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) expect(WebMock).not_to have_requested(:post, webhook_url.url)
end end
@ -65,8 +68,11 @@ RSpec.describe SendFormDeclinedWebhookRequestJob do
it 'sends again if the response status is 400 or higher' do it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401) stub_request(:post, webhook_url.url).to_return(status: 401)
event_uuid = SecureRandom.uuid
expect do 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) end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once 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['attempt']).to eq(1)
expect(args['last_status']).to eq(401) 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['webhook_url_id']).to eq(webhook_url.id)
expect(args['submitter_id']).to eq(submitter.id) expect(args['submitter_id']).to eq(submitter.id)
end end
@ -83,7 +90,8 @@ RSpec.describe SendFormDeclinedWebhookRequestJob do
stub_request(:post, webhook_url.url).to_return(status: 401) stub_request(:post, webhook_url.url).to_return(status: 401)
expect do 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) end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once expect(WebMock).to have_requested(:post, webhook_url.url).once

@ -21,7 +21,8 @@ RSpec.describe SendFormStartedWebhookRequestJob do
end end
it 'sends a webhook request' do 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { body: {
@ -38,7 +39,8 @@ RSpec.describe SendFormStartedWebhookRequestJob do
it 'sends a webhook request with the secret' do it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { 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 it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['form.declined']) 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) expect(WebMock).not_to have_requested(:post, webhook_url.url)
end end
@ -65,8 +68,11 @@ RSpec.describe SendFormStartedWebhookRequestJob do
it 'sends again if the response status is 400 or higher' do it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401) stub_request(:post, webhook_url.url).to_return(status: 401)
event_uuid = SecureRandom.uuid
expect do 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) end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once 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['attempt']).to eq(1)
expect(args['last_status']).to eq(401) 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['webhook_url_id']).to eq(webhook_url.id)
expect(args['submitter_id']).to eq(submitter.id) expect(args['submitter_id']).to eq(submitter.id)
end end
@ -83,7 +90,8 @@ RSpec.describe SendFormStartedWebhookRequestJob do
stub_request(:post, webhook_url.url).to_return(status: 401) stub_request(:post, webhook_url.url).to_return(status: 401)
expect do 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) end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once expect(WebMock).to have_requested(:post, webhook_url.url).once

@ -21,7 +21,8 @@ RSpec.describe SendFormViewedWebhookRequestJob do
end end
it 'sends a webhook request' do 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { body: {
@ -38,7 +39,8 @@ RSpec.describe SendFormViewedWebhookRequestJob do
it 'sends a webhook request with the secret' do it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { 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 it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['form.started']) 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) expect(WebMock).not_to have_requested(:post, webhook_url.url)
end end
@ -65,8 +68,11 @@ RSpec.describe SendFormViewedWebhookRequestJob do
it 'sends again if the response status is 400 or higher' do it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401) stub_request(:post, webhook_url.url).to_return(status: 401)
event_uuid = SecureRandom.uuid
expect do 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) end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once 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['attempt']).to eq(1)
expect(args['last_status']).to eq(401) 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['webhook_url_id']).to eq(webhook_url.id)
expect(args['submitter_id']).to eq(submitter.id) expect(args['submitter_id']).to eq(submitter.id)
end end
@ -83,7 +90,8 @@ RSpec.describe SendFormViewedWebhookRequestJob do
stub_request(:post, webhook_url.url).to_return(status: 401) stub_request(:post, webhook_url.url).to_return(status: 401)
expect do 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) end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once expect(WebMock).to have_requested(:post, webhook_url.url).once

@ -18,7 +18,8 @@ RSpec.describe SendSubmissionArchivedWebhookRequestJob do
end end
it 'sends a webhook request' do 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { body: {
@ -35,7 +36,8 @@ RSpec.describe SendSubmissionArchivedWebhookRequestJob do
it 'sends a webhook request with the secret' do it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { 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 it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['submission.created']) 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) expect(WebMock).not_to have_requested(:post, webhook_url.url)
end end
@ -62,8 +65,11 @@ RSpec.describe SendSubmissionArchivedWebhookRequestJob do
it 'sends again if the response status is 400 or higher' do it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401) stub_request(:post, webhook_url.url).to_return(status: 401)
event_uuid = SecureRandom.uuid
expect do 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) end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once 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['attempt']).to eq(1)
expect(args['last_status']).to eq(401) 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['webhook_url_id']).to eq(webhook_url.id)
expect(args['submission_id']).to eq(submission.id) expect(args['submission_id']).to eq(submission.id)
end end
@ -81,7 +88,7 @@ RSpec.describe SendSubmissionArchivedWebhookRequestJob do
expect do 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,
'attempt' => 11) 'event_uuid' => SecureRandom.uuid, 'attempt' => 11)
end.not_to change(described_class.jobs, :size) end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once expect(WebMock).to have_requested(:post, webhook_url.url).once

@ -18,7 +18,8 @@ RSpec.describe SendSubmissionCompletedWebhookRequestJob do
end end
it 'sends a webhook request' do 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { body: {
@ -35,7 +36,8 @@ RSpec.describe SendSubmissionCompletedWebhookRequestJob do
it 'sends a webhook request with the secret' do it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { 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 it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['submission.archived']) 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) expect(WebMock).not_to have_requested(:post, webhook_url.url)
end end
@ -62,8 +65,11 @@ RSpec.describe SendSubmissionCompletedWebhookRequestJob do
it 'sends again if the response status is 400 or higher' do it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401) stub_request(:post, webhook_url.url).to_return(status: 401)
event_uuid = SecureRandom.uuid
expect do 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) end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once 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['attempt']).to eq(1)
expect(args['last_status']).to eq(401) 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['webhook_url_id']).to eq(webhook_url.id)
expect(args['submission_id']).to eq(submission.id) expect(args['submission_id']).to eq(submission.id)
end end
@ -81,7 +88,7 @@ RSpec.describe SendSubmissionCompletedWebhookRequestJob do
expect do 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,
'attempt' => 21) 'event_uuid' => SecureRandom.uuid, 'attempt' => 21)
end.not_to change(described_class.jobs, :size) end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once expect(WebMock).to have_requested(:post, webhook_url.url).once

@ -18,7 +18,8 @@ RSpec.describe SendSubmissionCreatedWebhookRequestJob do
end end
it 'sends a webhook request' do 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { body: {
@ -35,7 +36,8 @@ RSpec.describe SendSubmissionCreatedWebhookRequestJob do
it 'sends a webhook request with the secret' do it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { 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 it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['submission.completed']) 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) expect(WebMock).not_to have_requested(:post, webhook_url.url)
end end
@ -62,8 +65,11 @@ RSpec.describe SendSubmissionCreatedWebhookRequestJob do
it 'sends again if the response status is 400 or higher' do it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401) stub_request(:post, webhook_url.url).to_return(status: 401)
event_uuid = SecureRandom.uuid
expect do 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) end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once 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['attempt']).to eq(1)
expect(args['last_status']).to eq(401) 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['webhook_url_id']).to eq(webhook_url.id)
expect(args['submission_id']).to eq(submission.id) expect(args['submission_id']).to eq(submission.id)
end end
@ -81,7 +88,7 @@ RSpec.describe SendSubmissionCreatedWebhookRequestJob do
expect do 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,
'attempt' => 11) 'event_uuid' => SecureRandom.uuid, 'attempt' => 11)
end.not_to change(described_class.jobs, :size) end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once expect(WebMock).to have_requested(:post, webhook_url.url).once

@ -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

@ -17,7 +17,8 @@ RSpec.describe SendTemplateCreatedWebhookRequestJob do
end end
it 'sends a webhook request' do 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { body: {
@ -34,7 +35,8 @@ RSpec.describe SendTemplateCreatedWebhookRequestJob do
it 'sends a webhook request with the secret' do it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { 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 it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['template.updated']) 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) expect(WebMock).not_to have_requested(:post, webhook_url.url)
end end
@ -61,8 +64,11 @@ RSpec.describe SendTemplateCreatedWebhookRequestJob do
it 'sends again if the response status is 400 or higher' do it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401) stub_request(:post, webhook_url.url).to_return(status: 401)
event_uuid = SecureRandom.uuid
expect do 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) end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once 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['attempt']).to eq(1)
expect(args['last_status']).to eq(401) 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['webhook_url_id']).to eq(webhook_url.id)
expect(args['template_id']).to eq(template.id) expect(args['template_id']).to eq(template.id)
end end
@ -79,7 +86,8 @@ RSpec.describe SendTemplateCreatedWebhookRequestJob do
stub_request(:post, webhook_url.url).to_return(status: 401) stub_request(:post, webhook_url.url).to_return(status: 401)
expect do 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) end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once expect(WebMock).to have_requested(:post, webhook_url.url).once

@ -17,7 +17,8 @@ RSpec.describe SendTemplateUpdatedWebhookRequestJob do
end end
it 'sends a webhook request' do 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { body: {
@ -34,7 +35,8 @@ RSpec.describe SendTemplateUpdatedWebhookRequestJob do
it 'sends a webhook request with the secret' do it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) 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( expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: { 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 it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['template.created']) 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) expect(WebMock).not_to have_requested(:post, webhook_url.url)
end end
@ -61,8 +64,11 @@ RSpec.describe SendTemplateUpdatedWebhookRequestJob do
it 'sends again if the response status is 400 or higher' do it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401) stub_request(:post, webhook_url.url).to_return(status: 401)
event_uuid = SecureRandom.uuid
expect do 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) end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once 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['attempt']).to eq(1)
expect(args['last_status']).to eq(401) 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['webhook_url_id']).to eq(webhook_url.id)
expect(args['template_id']).to eq(template.id) expect(args['template_id']).to eq(template.id)
end end
@ -79,7 +86,8 @@ RSpec.describe SendTemplateUpdatedWebhookRequestJob do
stub_request(:post, webhook_url.url).to_return(status: 401) stub_request(:post, webhook_url.url).to_return(status: 401)
expect do 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) end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once expect(WebMock).to have_requested(:post, webhook_url.url).once

@ -1026,20 +1026,23 @@ RSpec.describe 'Signing Form' do
end end
end end
it 'sends completed email' do context 'when a form is completed' do
template = create(:template, account:, author:, only_field_types: %w[text signature]) let(:template) { create(:template, account:, author:, only_field_types: %w[text signature]) }
submission = create(:submission, template:) let(:submission) { create(:submission, template:) }
submitter = create(:submitter, submission:, uuid: template.submitters.first['uuid'], account:) let(:submitter) { create(:submitter, submission:, uuid: template.submitters.first['uuid'], account:) }
visit submit_form_path(slug: submitter.slug) before do
visit submit_form_path(slug: submitter.slug)
end
fill_in 'First Name', with: 'Adam' it 'sends completed email' do
click_on 'next' fill_in 'First Name', with: 'Adam'
click_link 'Type' click_on 'next'
fill_in 'signature_text_input', with: 'Adam' draw_canvas
expect do expect do
click_on 'Sign and Complete' click_on 'Sign and Complete'
end.to change(ProcessSubmitterCompletionJob.jobs, :size).by(1) end.to change(ProcessSubmitterCompletionJob.jobs, :size).by(1)
end
end end
end end

@ -175,9 +175,9 @@ RSpec.describe 'Webhook Settings' do
expect do expect do
click_button 'Test Webhook' 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['webhook_url_id']).to eq(webhook_url.id)
expect(args['submitter_id']).to eq(submitter.id) expect(args['submitter_id']).to eq(submitter.id)

Loading…
Cancel
Save