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