diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue
index e8024ddcf..f576ffc84 100644
--- a/app/javascript/template_builder/builder.vue
+++ b/app/javascript/template_builder/builder.vue
@@ -63,6 +63,14 @@
name="buttons"
/>
+
+
+
+
@@ -348,6 +364,7 @@ import DocumentPreview from './preview'
import DocumentControls from './controls'
import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter'
+import SigningOrderModal from './signing_order_modal'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import { ref, computed, toRaw, watch } from 'vue'
@@ -376,7 +393,8 @@ export default {
IconChevronDown,
IconAdjustments,
IconEye,
- IconDeviceFloppy
+ IconDeviceFloppy,
+ SigningOrderModal
},
provide () {
return {
@@ -387,6 +405,7 @@ export default {
currencies: this.currencies,
locale: this.locale,
baseFetch: this.baseFetch,
+ authenticityToken: this.authenticityToken,
fieldTypes: this.fieldTypes,
backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
@@ -636,13 +655,18 @@ export default {
drawFieldType: null,
drawOption: null,
dragField: null,
- isDragFile: false
+ isDragFile: false,
+ isShowSigningOrderModal: false
}
},
computed: {
submitterDefaultNames: FieldSubmitter.computed.names,
selectedAreaRef: () => ref(),
fieldsDragFieldRef: () => ref(),
+ hasMultipleSubmitterFields () {
+ const submitterUuids = new Set(this.template.fields.map((f) => f.submitter_uuid).filter(Boolean))
+ return submitterUuids.size >= 2
+ },
language () {
return this.locale.split('-')[0].toLowerCase()
},
@@ -1823,7 +1847,11 @@ export default {
}
}),
headers: { 'Content-Type': 'application/json' }
- }).then(() => {
+ }).then((response) => response.json()).then((data) => {
+ if (data.preferences) {
+ this.template.preferences = data.preferences
+ }
+
if (this.onSave) {
this.onSave(this.template)
}
diff --git a/app/javascript/template_builder/i18n.js b/app/javascript/template_builder/i18n.js
index db5f89833..0bbea970e 100644
--- a/app/javascript/template_builder/i18n.js
+++ b/app/javascript/template_builder/i18n.js
@@ -79,6 +79,9 @@ const en = {
condition: 'Condition',
first_party: 'Employee',
second_party: 'Manager',
+ signing_order: 'Signing Order',
+ select_signing_order: 'Select Signing Order',
+ simultaneous_signing_description: 'Both parties may complete the form at the same time',
draw: 'Draw',
add: 'Add',
or_add_field_without_drawing: 'Or add field without drawing',
diff --git a/app/javascript/template_builder/signing_order_modal.vue b/app/javascript/template_builder/signing_order_modal.vue
new file mode 100644
index 000000000..f576075b3
--- /dev/null
+++ b/app/javascript/template_builder/signing_order_modal.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+ {{ t('select_signing_order') }}
+
+
×
+
+
+
+
+
+
+
diff --git a/app/jobs/process_submitter_completion_job.rb b/app/jobs/process_submitter_completion_job.rb
index 26367c5f1..832448d7f 100644
--- a/app/jobs/process_submitter_completion_job.rb
+++ b/app/jobs/process_submitter_completion_job.rb
@@ -24,7 +24,7 @@ def perform(params = {})
create_completed_documents!(submitter)
- if !is_all_completed && submitter.submission.submitters_order_preserved? && params['send_invitation_email'] != false
+ if !is_all_completed && submitter.submission.signing_order_enforced? && params['send_invitation_email'] != false
enqueue_next_submitter_request_notification(submitter)
end
diff --git a/app/jobs/send_template_preferences_updated_webhook_request_job.rb b/app/jobs/send_template_preferences_updated_webhook_request_job.rb
new file mode 100644
index 000000000..b16c31359
--- /dev/null
+++ b/app/jobs/send_template_preferences_updated_webhook_request_job.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class SendTemplatePreferencesUpdatedWebhookRequestJob
+ include Sidekiq::Job
+
+ sidekiq_options queue: :webhooks
+
+ def perform(params = {})
+ template = Template.find(params['template_id'])
+ webhook_url = WebhookUrl.find(params['webhook_url_id'])
+
+ attempt = params['attempt'].to_i
+
+ return if webhook_url.url.blank? || webhook_url.events.exclude?('template.preferences_updated')
+
+ data = {
+ id: template.id,
+ external_id: template.external_id,
+ application_key: template.application_key,
+ submitters_order: template.preferences['submitters_order']
+ }
+
+ resp = SendWebhookRequest.call(webhook_url, event_type: 'template.preferences_updated', data:)
+
+ return unless WebhookRetryLogic.should_retry?(response: resp, attempt: attempt, record: template)
+
+ SendTemplatePreferencesUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, {
+ 'template_id' => template.id,
+ 'webhook_url_id' => webhook_url.id,
+ 'attempt' => attempt + 1,
+ 'last_status' => resp&.status.to_i
+ })
+ end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
index e9acfa12f..8ea0e893a 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -78,11 +78,11 @@ def default_template_folder
private
def create_careerplug_webhook
- return if ENV['CAREERPLUG_WEBHOOK_SECRET'].blank?
+ return if ENV['CAREERPLUG_WEBHOOK_SECRET'].blank? || ENV['CAREERPLUG_WEBHOOK_URL'].blank?
webhook_urls.create!(
url: ENV.fetch('CAREERPLUG_WEBHOOK_URL'),
- events: %w[form.viewed form.started form.completed form.declined],
+ events: %w[form.viewed form.started form.completed form.declined template.preferences_updated],
secret: { 'X-CareerPlug-Secret' => ENV.fetch('CAREERPLUG_WEBHOOK_SECRET') }
)
end
diff --git a/app/models/partnership.rb b/app/models/partnership.rb
index 782317fe0..63a4851f9 100644
--- a/app/models/partnership.rb
+++ b/app/models/partnership.rb
@@ -22,6 +22,8 @@ class Partnership < ApplicationRecord
validates :external_partnership_id, presence: true, uniqueness: true
validates :name, presence: true
+ after_commit :create_careerplug_webhook, on: :create
+
def self.find_or_create_by_external_id(external_id, name, attributes = {})
find_by(external_partnership_id: external_id) ||
create!(attributes.merge(external_partnership_id: external_id, name: name))
@@ -34,4 +36,14 @@ def default_template_folder(author)
template_folders.create!(name: TemplateFolder::DEFAULT_NAME,
author: author)
end
+
+ def create_careerplug_webhook
+ return if ENV['CAREERPLUG_WEBHOOK_SECRET'].blank? || ENV['CAREERPLUG_WEBHOOK_URL'].blank?
+
+ webhook_urls.create!(
+ url: ENV.fetch('CAREERPLUG_WEBHOOK_URL'),
+ events: %w[template.preferences_updated],
+ secret: { 'X-CareerPlug-Secret' => ENV.fetch('CAREERPLUG_WEBHOOK_SECRET') }
+ )
+ end
end
diff --git a/app/models/webhook_url.rb b/app/models/webhook_url.rb
index 430b9e236..3a495755e 100644
--- a/app/models/webhook_url.rb
+++ b/app/models/webhook_url.rb
@@ -38,12 +38,12 @@ class WebhookUrl < ApplicationRecord
submission.archived
template.created
template.updated
+ template.preferences_updated
].freeze
# Partnership webhooks can only use template events since partnerships don't have submissions/submitters
PARTNERSHIP_EVENTS = %w[
- template.created
- template.updated
+ template.preferences_updated
].freeze
belongs_to :account, optional: true
diff --git a/app/views/templates_preferences/show.html.erb b/app/views/templates_preferences/show.html.erb
index 165def27f..9156f6237 100644
--- a/app/views/templates_preferences/show.html.erb
+++ b/app/views/templates_preferences/show.html.erb
@@ -337,14 +337,56 @@
<% end %>
<% unless current_account.account_configs.exists?(key: AccountConfig::ENFORCE_SIGNING_ORDER_KEY, value: true) %>
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-2' }, data: { close_on_submit: false } do |f| %>
-
-
- <%= t('enforce_recipients_order') %>
-
- <%= f.fields_for :preferences, Struct.new(:submitters_order).new(@template.preferences['submitters_order']) do |ff| %>
- <%= ff.check_box :submitters_order, { class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'preserved', '' %>
- <% end %>
-
+ <%= f.fields_for :preferences, Struct.new(:submitters_order).new(@template.preferences['submitters_order']) do |ff| %>
+
+
+
+ <%
+ first_party = @template.submitters.first['name'] || t('first_party')
+ second_party = @template.submitters.second&.[]('name') || t('second_party')
+ current_value = ff.object.submitters_order.presence || 'simultaneous'
+ %>
+
+
+
+
+
+
+
+
+ <% end %>
<% end %>
<% end %>
<% if can?(:manage, :personalization_advanced) %>
diff --git a/lib/params/submission_create_validator.rb b/lib/params/submission_create_validator.rb
index 6b7649afd..98203bc58 100644
--- a/lib/params/submission_create_validator.rb
+++ b/lib/params/submission_create_validator.rb
@@ -3,7 +3,6 @@
module Params
class SubmissionCreateValidator < BaseValidator
def call
- binding.pry
if params[:submission].blank? && (params[:emails].present? || params[:email].present?)
validate_creation_from_emails(params)
elsif params.key?(:submitters)
diff --git a/lib/tasks/webhooks.rake b/lib/tasks/webhooks.rake
index cd892c335..4b474989f 100644
--- a/lib/tasks/webhooks.rake
+++ b/lib/tasks/webhooks.rake
@@ -26,32 +26,49 @@ namespace :webhooks do
end
end
- desc 'Set up development webhook URLs for all accounts (creates URLs + configures secret)'
+ desc 'Set up development webhook URLs for all accounts and partnerships (creates URLs + configures secret)'
task setup_development: :environment do
abort 'This task is only for development' unless Rails.env.development?
url = 'http://localhost:3000/api/docuseal/events'
secret = { 'X-CareerPlug-Secret' => 'development_webhook_secret' }
- events = %w[form.viewed form.started form.completed form.declined]
+ sha1 = Digest::SHA1.hexdigest(url)
+ account_events = %w[form.viewed form.started form.completed form.declined template.preferences_updated]
+ partnership_events = %w[template.preferences_updated]
created = 0
updated = 0
Account.find_each do |account|
- webhook_url = WebhookUrl.find_or_initialize_by(account: account, sha1: Digest::SHA1.hexdigest(url))
+ webhook_url = WebhookUrl.find_or_initialize_by(account:, sha1:)
if webhook_url.new_record?
- webhook_url.assign_attributes(url: url, events: events, secret: secret)
+ webhook_url.assign_attributes(url:, events: account_events, secret:)
webhook_url.save!
created += 1
puts "Created webhook URL for account #{account.id}: #{account.name}"
elsif webhook_url.secret != secret
- webhook_url.update!(secret: secret)
+ webhook_url.update!(secret:)
updated += 1
puts "Updated webhook secret for account #{account.id}: #{account.name}"
end
end
+ Partnership.find_each do |partnership|
+ webhook_url = WebhookUrl.find_or_initialize_by(partnership:, sha1:)
+
+ if webhook_url.new_record?
+ webhook_url.assign_attributes(url:, events: partnership_events, secret:)
+ webhook_url.save!
+ created += 1
+ puts "Created webhook URL for partnership #{partnership.id}: #{partnership.name}"
+ elsif webhook_url.secret != secret
+ webhook_url.update!(secret:)
+ updated += 1
+ puts "Updated webhook secret for partnership #{partnership.id}: #{partnership.name}"
+ end
+ end
+
puts "Done: #{created} created, #{updated} updated"
end
end
diff --git a/lib/webhook_urls.rb b/lib/webhook_urls.rb
index 6f40354ac..fed21084b 100644
--- a/lib/webhook_urls.rb
+++ b/lib/webhook_urls.rb
@@ -4,12 +4,12 @@ module WebhookUrls
module_function
def for_template(template, events)
+ return WebhookUrl.none if template.partnership_id.blank? && template.account_id.blank?
+
if template.partnership_id.present?
for_partnership_id(template.partnership_id, events)
- elsif template.account_id.present?
- for_account_id(template.account_id, events)
else
- raise ArgumentError, 'Template must have either account_id or partnership_id'
+ for_account_id(template.account_id, events)
end
end
diff --git a/spec/jobs/send_template_preferences_updated_webhook_request_job_spec.rb b/spec/jobs/send_template_preferences_updated_webhook_request_job_spec.rb
new file mode 100644
index 000000000..04df9b763
--- /dev/null
+++ b/spec/jobs/send_template_preferences_updated_webhook_request_job_spec.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+RSpec.describe SendTemplatePreferencesUpdatedWebhookRequestJob do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account:) }
+ let(:template) { create(:template, account:, author: user) }
+ let(:webhook_url) { create(:webhook_url, account:, events: ['template.preferences_updated']) }
+
+ 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 with minimal submitters_order data' do
+ template.update!(preferences: { 'submitters_order' => 'employee_then_manager' })
+
+ described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
+
+ expect(WebMock).to have_requested(:post, webhook_url.url).with(
+ body: {
+ 'event_type' => 'template.preferences_updated',
+ 'timestamp' => /.*/,
+ 'data' => {
+ 'id' => template.id,
+ 'external_id' => template.external_id,
+ 'application_key' => template.application_key,
+ 'submitters_order' => 'employee_then_manager'
+ }
+ },
+ 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' })
+ template.update!(preferences: { 'submitters_order' => 'simultaneous' })
+
+ described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
+
+ expect(WebMock).to have_requested(:post, webhook_url.url).with(
+ body: {
+ 'event_type' => 'template.preferences_updated',
+ 'timestamp' => /.*/,
+ 'data' => {
+ 'id' => template.id,
+ 'external_id' => template.external_id,
+ 'application_key' => template.application_key,
+ 'submitters_order' => 'simultaneous'
+ }
+ },
+ 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: ['template.created'])
+
+ described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
+
+ 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)
+
+ expect do
+ described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
+ 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['webhook_url_id']).to eq(webhook_url.id)
+ expect(args['template_id']).to eq(template.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('template_id' => template.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11)
+ end.not_to change(described_class.jobs, :size)
+
+ expect(WebMock).to have_requested(:post, webhook_url.url).once
+ end
+
+ it 'sends webhook with single_sided submitters_order' do
+ template.update!(preferences: { 'submitters_order' => 'single_sided' })
+
+ described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
+
+ expect(WebMock).to have_requested(:post, webhook_url.url).with(
+ body: {
+ 'event_type' => 'template.preferences_updated',
+ 'timestamp' => /.*/,
+ 'data' => {
+ 'id' => template.id,
+ 'external_id' => template.external_id,
+ 'application_key' => template.application_key,
+ 'submitters_order' => 'single_sided'
+ }
+ },
+ headers: {
+ 'Content-Type' => 'application/json',
+ 'User-Agent' => 'DocuSeal.com Webhook'
+ }
+ ).once
+ end
+ end
+end
diff --git a/spec/lib/webhook_urls_spec.rb b/spec/lib/webhook_urls_spec.rb
index 4707ca2cf..dee765837 100644
--- a/spec/lib/webhook_urls_spec.rb
+++ b/spec/lib/webhook_urls_spec.rb
@@ -65,12 +65,12 @@
end
context 'with a template that has neither account nor partnership' do
- let(:template) { build(:template, account: nil, partnership: nil, author: user) }
+ let(:template) { build(:template, account_id: nil, partnership_id: nil, author: user) }
- it 'raises an ArgumentError' do
- expect do
- described_class.for_template(template, 'template.created')
- end.to raise_error(ArgumentError, 'Template must have either account_id or partnership_id')
+ it 'returns empty relation' do
+ webhooks = described_class.for_template(template, 'template.created')
+ expect(webhooks).to eq(WebhookUrl.none)
+ expect(webhooks.to_a).to be_empty
end
end
end
diff --git a/spec/models/account_create_careerplug_webhook_spec.rb b/spec/models/account_create_careerplug_webhook_spec.rb
deleted file mode 100644
index 140a9792a..000000000
--- a/spec/models/account_create_careerplug_webhook_spec.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Account, '#create_careerplug_webhook' do
- around do |example|
- original_secret = ENV.fetch('CAREERPLUG_WEBHOOK_SECRET', nil)
- original_url = ENV.fetch('CAREERPLUG_WEBHOOK_URL', nil)
-
- # Set required env vars for webhook creation
- ENV['CAREERPLUG_WEBHOOK_SECRET'] = 'test_secret'
- ENV['CAREERPLUG_WEBHOOK_URL'] = 'http://example.com/webhook'
-
- example.run
-
- # Restore original env vars
- ENV['CAREERPLUG_WEBHOOK_SECRET'] = original_secret
- ENV['CAREERPLUG_WEBHOOK_URL'] = original_url
- end
-
- describe 'CareerPlug webhook creation' do
- it 'creates webhook after successful account creation' do
- account = build(:account)
- expect(account.webhook_urls).to be_empty
-
- account.save!
-
- expect(account.webhook_urls.count).to eq(1)
- webhook = account.webhook_urls.first
- expect(webhook.url).to eq('http://example.com/webhook')
- expect(webhook.events).to eq(['form.viewed', 'form.started', 'form.completed', 'form.declined'])
- expect(webhook.secret).to eq({ 'X-CareerPlug-Secret' => 'test_secret' })
- end
-
- it 'does not create webhook if account creation fails' do
- # This test verifies that after_commit behavior works correctly
- # by simulating a transaction rollback
-
- expect do
- described_class.transaction do
- create(:account)
- # Simulate some error that would cause rollback
- raise ActiveRecord::Rollback
- end
- end.not_to change(described_class, :count)
-
- expect do
- described_class.transaction do
- create(:account)
- raise ActiveRecord::Rollback
- end
- end.not_to change(WebhookUrl, :count)
- end
-
- it 'does not create webhook when CAREERPLUG_WEBHOOK_SECRET is blank' do
- original_secret = ENV.fetch('CAREERPLUG_WEBHOOK_SECRET', nil)
- ENV['CAREERPLUG_WEBHOOK_SECRET'] = ''
-
- account = create(:account)
- expect(account.webhook_urls.count).to eq(0)
-
- ENV['CAREERPLUG_WEBHOOK_SECRET'] = original_secret
- end
- end
-end
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index 20f0296a2..14079ad25 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -63,6 +63,41 @@
end
end
+ describe '#create_careerplug_webhook' do
+ context 'when both env vars are present' do
+ before do
+ stub_const('ENV', ENV.to_h.merge(
+ 'CAREERPLUG_WEBHOOK_URL' => 'https://example.com/webhook',
+ 'CAREERPLUG_WEBHOOK_SECRET' => 'secret'
+ ))
+ end
+
+ it 'creates a webhook with the correct events on account creation' do
+ account = create(:account)
+ webhook = account.webhook_urls.last
+
+ expect(webhook).to be_present
+ expect(webhook.events).to match_array(%w[
+ form.viewed
+ form.started
+ form.completed
+ form.declined
+ template.preferences_updated
+ ])
+ end
+ end
+
+ context 'when env vars are missing' do
+ before do
+ stub_const('ENV', ENV.to_h.except('CAREERPLUG_WEBHOOK_URL', 'CAREERPLUG_WEBHOOK_SECRET'))
+ end
+
+ it 'does not create a webhook' do
+ expect { create(:account) }.not_to change(WebhookUrl, :count)
+ end
+ end
+ end
+
describe '#default_template_folder' do
it 'creates default folder when none exists' do
account = create(:account)
diff --git a/spec/models/partnership_spec.rb b/spec/models/partnership_spec.rb
index 48fd37ea3..afb190201 100644
--- a/spec/models/partnership_spec.rb
+++ b/spec/models/partnership_spec.rb
@@ -15,6 +15,35 @@
# index_partnerships_on_external_partnership_id (external_partnership_id) UNIQUE
#
describe Partnership do
+ describe '#create_careerplug_webhook' do
+ context 'when both env vars are present' do
+ before do
+ stub_const('ENV', ENV.to_h.merge(
+ 'CAREERPLUG_WEBHOOK_URL' => 'https://example.com/webhook',
+ 'CAREERPLUG_WEBHOOK_SECRET' => 'secret'
+ ))
+ end
+
+ it 'creates a webhook with the correct events on partnership creation' do
+ partnership = create(:partnership)
+ webhook = partnership.webhook_urls.last
+
+ expect(webhook).to be_present
+ expect(webhook.events).to match_array(%w[template.preferences_updated])
+ end
+ end
+
+ context 'when env vars are missing' do
+ before do
+ stub_const('ENV', ENV.to_h.except('CAREERPLUG_WEBHOOK_URL', 'CAREERPLUG_WEBHOOK_SECRET'))
+ end
+
+ it 'does not create a webhook' do
+ expect { create(:partnership) }.not_to change(WebhookUrl, :count)
+ end
+ end
+ end
+
describe 'validations' do
it 'validates presence of external_partnership_id' do
partnership = build(:partnership, external_partnership_id: nil)