From f18649893a979e66dd68464d1186130e15446ef1 Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Thu, 19 Feb 2026 01:01:30 -0600 Subject: [PATCH 01/14] add named signing order values and defer to template signing order * the enum changes and the default in submission.rb don't REALLY matter since almost all of our changes in future commits defer to templates. * add template methods to know how many actual submitters there are and add complex default logic based on when fields are added or removed. For example: If only 1 employee field it's single sided. If we add a manager field it automatically changes to employee_then_manager unless manually changed to a different dual sided. If either field is removed, it automatically switches back to single_sided --- app/models/submission.rb | 12 +- app/models/template.rb | 29 ++++ lib/params/submission_create_validator.rb | 9 +- spec/models/submission_spec.rb | 30 +++++ spec/models/template_spec.rb | 153 ++++++++++++++++++++++ 5 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 spec/models/submission_spec.rb create mode 100644 spec/models/template_spec.rb diff --git a/app/models/submission.rb b/app/models/submission.rb index 2abfbb5b4..c2e852dae 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -53,7 +53,7 @@ class Submission < ApplicationRecord serialize :preferences, coder: JSON attribute :source, :string, default: 'link' - attribute :submitters_order, :string, default: 'random' + attribute :submitters_order, :string, default: 'employee_then_manager' attribute :slug, :string, default: -> { SecureRandom.base58(14) } @@ -94,10 +94,16 @@ class Submission < ApplicationRecord }, scope: false, prefix: true enum :submitters_order, { - random: 'random', - preserved: 'preserved' + single_sided: 'single_sided', + employee_then_manager: 'employee_then_manager', + manager_then_employee: 'manager_then_employee', + simultaneous: 'simultaneous' }, scope: false, prefix: true + def signing_order_enforced? + template&.preferences&.dig('submitters_order').in?(%w[employee_then_manager manager_then_employee]) + end + def expired? expire_at && expire_at <= Time.current end diff --git a/app/models/template.rb b/app/models/template.rb index e73991248..18976a6a7 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -54,6 +54,7 @@ class Template < ApplicationRecord has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy before_validation :maybe_set_default_folder, on: :create + before_save :update_submitters_order, if: :fields_changed? attribute :preferences, :string, default: -> { {} } attribute :fields, :string, default: -> { [] } @@ -87,6 +88,15 @@ def application_key external_id end + def unique_submitter_uuids + fields.filter_map { |f| f['submitter_uuid'] }.uniq + end + + def effective_submitters_order + preferences['submitters_order'].presence || + (unique_submitter_uuids.size < 2 ? 'single_sided' : 'employee_then_manager') + end + private def maybe_set_default_folder @@ -96,4 +106,23 @@ def maybe_set_default_folder self.folder ||= partnership.default_template_folder(author) end end + + def update_submitters_order + submitter_count = unique_submitter_uuids.size + current_order = preferences['submitters_order'] + + if submitter_count < 2 + # Always set to single_sided for templates with 0 or 1 submitter + preferences['submitters_order'] = 'single_sided' + elsif submitter_count == 2 + # Set to employee_then_manager when there are exactly 2 submitters + # Only set if not already configured to something else + if current_order.blank? || current_order == 'single_sided' + preferences['submitters_order'] = 'employee_then_manager' + end + elsif current_order == 'single_sided' + # Clear single_sided if template now has 3+ submitters + preferences.delete('submitters_order') + end + end end diff --git a/lib/params/submission_create_validator.rb b/lib/params/submission_create_validator.rb index 5737e1dd8..6b7649afd 100644 --- a/lib/params/submission_create_validator.rb +++ b/lib/params/submission_create_validator.rb @@ -3,6 +3,7 @@ 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) @@ -56,7 +57,9 @@ def validate_creation_from_submitters(params) required(message_params, :body) end - value_in(params, :order, %w[preserved random], allow_nil: true) + value_in( + params, :order, %w[employee_then_manager manager_then_employee simultaneous single_sided], allow_nil: true + ) if params[:submitters].present? in_path(params, :submitters) do |submitters_params| @@ -117,7 +120,9 @@ def validate_creation_from_submission(params) required(message_params, :body) end - value_in(params, :order, %w[preserved random], allow_nil: true) + value_in( + params, :order, %w[employee_then_manager manager_then_employee simultaneous single_sided], allow_nil: true + ) return true if params[:submission].is_a?(Array) diff --git a/spec/models/submission_spec.rb b/spec/models/submission_spec.rb new file mode 100644 index 000000000..764b3347b --- /dev/null +++ b/spec/models/submission_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +RSpec.describe Submission do + describe '#signing_order_enforced?' do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user) } + let(:submission) { create(:submission, template:, created_by_user: user) } + + it 'returns true for employee_then_manager' do + template.update!(preferences: { 'submitters_order' => 'employee_then_manager' }) + expect(submission.signing_order_enforced?).to be true + end + + it 'returns true for manager_then_employee' do + template.update!(preferences: { 'submitters_order' => 'manager_then_employee' }) + expect(submission.signing_order_enforced?).to be true + end + + it 'returns false for simultaneous' do + template.update!(preferences: { 'submitters_order' => 'simultaneous' }) + expect(submission.signing_order_enforced?).to be false + end + + it 'returns false for single_sided' do + template.update!(preferences: { 'submitters_order' => 'single_sided' }) + expect(submission.signing_order_enforced?).to be false + end + end +end diff --git a/spec/models/template_spec.rb b/spec/models/template_spec.rb new file mode 100644 index 000000000..945b62d71 --- /dev/null +++ b/spec/models/template_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +RSpec.describe Template do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + + describe '#unique_submitter_uuids' do + it 'returns unique submitter UUIDs from fields' do + template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0) + template.update!(fields: [ + { 'submitter_uuid' => 'uuid1', 'type' => 'text' }, + { 'submitter_uuid' => 'uuid1', 'type' => 'signature' }, + { 'submitter_uuid' => 'uuid2', 'type' => 'date' } + ]) + + expect(template.unique_submitter_uuids).to match_array(%w[uuid1 uuid2]) + end + + it 'filters out nil submitter_uuids' do + template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0) + template.update!(fields: [ + { 'submitter_uuid' => 'uuid1', 'type' => 'text' }, + { 'submitter_uuid' => nil, 'type' => 'image' }, + { 'type' => 'signature' } + ]) + + expect(template.unique_submitter_uuids).to eq(['uuid1']) + end + + it 'returns empty array when no fields' do + template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0) + template.update!(fields: []) + + expect(template.unique_submitter_uuids).to eq([]) + end + end + + describe '#effective_submitters_order' do + it 'returns preferences submitters_order when set' do + template = create(:template, account:, author: user) + template.update_column(:preferences, { 'submitters_order' => 'manager_then_employee' }) + + expect(template.effective_submitters_order).to eq('manager_then_employee') + end + + it 'returns single_sided when preferences not set and template has fewer than 2 unique submitters' do + template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0) + template.update_column(:preferences, {}) + template.update_column(:fields, [{ 'submitter_uuid' => 'uuid1', 'type' => 'text' }]) + + expect(template.reload.effective_submitters_order).to eq('single_sided') + end + + it 'returns employee_then_manager when preferences not set and template has 2+ unique submitters' do + template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0) + template.update_column(:preferences, {}) + template.update_column(:fields, [ + { 'submitter_uuid' => 'uuid1', 'type' => 'text' }, + { 'submitter_uuid' => 'uuid2', 'type' => 'signature' } + ]) + + expect(template.reload.effective_submitters_order).to eq('employee_then_manager') + end + end + + describe '#update_submitters_order' do + context 'when template has less than 2 unique submitters' do + it 'sets submitters_order to single_sided' do + template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0) + template.update!(preferences: { 'submitters_order' => 'employee_then_manager' }, + fields: [ + { 'submitter_uuid' => 'uuid1', 'type' => 'text' }, + { 'submitter_uuid' => 'uuid2', 'type' => 'signature' } + ]) + + # Remove uuid2 fields to trigger single_sided + template.update!(fields: [{ 'submitter_uuid' => 'uuid1', 'type' => 'text' }]) + + expect(template.reload.preferences['submitters_order']).to eq('single_sided') + end + + it 'always sets single_sided when only one unique submitter' do + template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0) + template.update!(fields: [{ 'submitter_uuid' => 'uuid1', 'type' => 'text' }]) + + expect(template.reload.preferences['submitters_order']).to eq('single_sided') + + template.update!(fields: [{ 'submitter_uuid' => 'uuid1', 'type' => 'signature' }]) + + expect(template.reload.preferences['submitters_order']).to eq('single_sided') + end + end + + context 'when template has 2 or more unique submitters' do + it 'sets employee_then_manager when adding second submitter' do + template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0) + template.update!(preferences: { 'submitters_order' => 'single_sided' }, + fields: [{ 'submitter_uuid' => 'uuid1', 'type' => 'text' }]) + + # Add second submitter + template.update!(fields: [ + { 'submitter_uuid' => 'uuid1', 'type' => 'text' }, + { 'submitter_uuid' => 'uuid2', 'type' => 'signature' } + ]) + + expect(template.reload.preferences['submitters_order']).to eq('employee_then_manager') + end + + it 'sets employee_then_manager when submitters_order is blank and 2 submitters added' do + template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0) + template.update!(fields: [ + { 'submitter_uuid' => 'uuid1', 'type' => 'text' }, + { 'submitter_uuid' => 'uuid2', 'type' => 'signature' } + ]) + + expect(template.reload.preferences['submitters_order']).to eq('employee_then_manager') + end + + it 'preserves employee_then_manager when multiple submitters' do + template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0) + template.update!(preferences: { 'submitters_order' => 'employee_then_manager' }, + fields: [ + { 'submitter_uuid' => 'uuid1', 'type' => 'text' }, + { 'submitter_uuid' => 'uuid2', 'type' => 'signature' } + ]) + + template.update!(fields: [ + { 'submitter_uuid' => 'uuid1', 'type' => 'text' }, + { 'submitter_uuid' => 'uuid2', 'type' => 'signature' }, + { 'submitter_uuid' => 'uuid1', 'type' => 'date' } + ]) + + expect(template.reload.preferences['submitters_order']).to eq('employee_then_manager') + end + end + + context 'when removing fields transitions from multi to single submitter' do + it 'changes from employee_then_manager to single_sided' do + template = create(:template, account:, author: user, submitter_count: 0, attachment_count: 0) + template.update!(preferences: { 'submitters_order' => 'employee_then_manager' }, + fields: [ + { 'submitter_uuid' => 'uuid1', 'type' => 'text' }, + { 'submitter_uuid' => 'uuid2', 'type' => 'signature' } + ]) + + # Remove all uuid2 fields + template.update!(fields: [{ 'submitter_uuid' => 'uuid1', 'type' => 'text' }]) + + expect(template.reload.preferences['submitters_order']).to eq('single_sided') + end + end + end +end From 4a24f4ea946dd82461516211247f6d66ba0aae04 Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Thu, 19 Feb 2026 13:34:56 -0600 Subject: [PATCH 02/14] enforce new signing order logic - replace submitters_order_preserved? with signing_order_enforced? in send_signature_requests - add manager_then_employee branch to send_signature_requests to send to second submitter first, while we don't send out emails with Docuseal, there are changes further down the line required - skip submitters without fields for single_sided in create_from_submitters, this is mostly necessary for single_sided manager forms - refactor current_submitter_order? to reverse submitter_items for manager_then_employee instead of special-casing index --- app/models/submission.rb | 6 +- lib/submissions.rb | 17 +++-- lib/submissions/create_from_submitters.rb | 23 ++++++- lib/submitters.rb | 9 ++- .../create_from_submitters_spec.rb | 68 +++++++++++++++++++ spec/lib/submissions_spec.rb | 58 ++++++++++++++++ spec/lib/submitters_spec.rb | 59 ++++++++++++++++ spec/models/submission_spec.rb | 27 ++++++-- spec/requests/submissions_spec.rb | 4 +- 9 files changed, 255 insertions(+), 16 deletions(-) create mode 100644 spec/lib/submissions/create_from_submitters_spec.rb create mode 100644 spec/lib/submissions_spec.rb create mode 100644 spec/lib/submitters_spec.rb diff --git a/app/models/submission.rb b/app/models/submission.rb index c2e852dae..08424cb08 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -101,7 +101,7 @@ class Submission < ApplicationRecord }, scope: false, prefix: true def signing_order_enforced? - template&.preferences&.dig('submitters_order').in?(%w[employee_then_manager manager_then_employee]) + template_signing_order.in?(%w[employee_then_manager manager_then_employee]) end def expired? @@ -112,6 +112,10 @@ def last_completed_submitter submitters.where.not(completed_at: nil).order(:completed_at).last end + def template_signing_order + template&.preferences&.dig('submitters_order') + end + def schema_documents if template_id? template_schema_documents diff --git a/lib/submissions.rb b/lib/submissions.rb index 0fb3b0e08..1ffba7956 100644 --- a/lib/submissions.rb +++ b/lib/submissions.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Submissions - DEFAULT_SUBMITTERS_ORDER = 'random' + DEFAULT_SUBMITTERS_ORDER = 'single_sided' PRELOAD_ALL_PAGES_AMOUNT = 200 @@ -143,9 +143,18 @@ def send_signature_requests(submissions, delay: nil) submitters = submission.submitters.reject(&:completed_at?) - if submission.submitters_order_preserved? - first_submitter = - submission.template_submitters.filter_map { |s| submitters.find { |e| e.uuid == s['uuid'] } }.first + if submission.signing_order_enforced? + first_submitter = if submission.template_signing_order == 'manager_then_employee' + # For manager_then_employee, send to the second submitter first + submission.template_submitters[1..].filter_map do |s| + submitters.find { |e| e.uuid == s['uuid'] } + end.first + else + # For employee_then_manager and preserved, send to the first submitter + submission.template_submitters.filter_map do |s| + submitters.find { |e| e.uuid == s['uuid'] } + end.first + end Submitters.send_signature_requests([first_submitter], delay_seconds:) if first_submitter else diff --git a/lib/submissions/create_from_submitters.rb b/lib/submissions/create_from_submitters.rb index 4ad407df9..1bf6cca28 100644 --- a/lib/submissions/create_from_submitters.rb +++ b/lib/submissions/create_from_submitters.rb @@ -36,6 +36,9 @@ def call(template:, user:, submissions_attrs:, source:, submitters_order:, param submission.template_schema = submission.template.schema if submission.template_schema.blank? uuid = template_submitter['uuid'] + + # Skip submitters without fields for single_sided forms + next if submitters_order == 'single_sided' && template.unique_submitter_uuids.exclude?(uuid) else if submitter_attrs[:roles].present? && submitter_attrs[:roles].size == 1 submitter_attrs[:role] = submitter_attrs[:roles].first @@ -46,6 +49,9 @@ def call(template:, user:, submissions_attrs:, source:, submitters_order:, param next if uuid.blank? next if submitter_attrs.slice('email', 'phone', 'name').compact_blank.blank? + # Skip submitters without fields for single_sided forms + next if submitters_order == 'single_sided' && template.unique_submitter_uuids.exclude?(uuid) + submission.template_fields = submission.template.fields if submitter_attrs[:completed].present? && submission.template_fields.blank? @@ -54,7 +60,22 @@ def call(template:, user:, submissions_attrs:, source:, submitters_order:, param submission.template_submitters << template_submitter.except('optional_invite_by_uuid', 'invite_by_uuid') - is_order_sent = submitters_order == 'random' || index.zero? + # Find the position of this submitter in the original template submitters array + template_submitter_index = template.submitters.index { |s| s['uuid'] == uuid } + + is_order_sent = case submitters_order + # Legacy + when 'random', 'simultaneous' + true + when 'manager_then_employee' + # Send to second party (index 1) first + template_submitter_index == 1 + when 'employee_then_manager' + # Send to first party (index 0) first + template_submitter_index.zero? + else # 'preserved' Legacy + index.zero? + end build_submitter(submission:, attrs: submitter_attrs, uuid:, is_order_sent:, user:, params:, diff --git a/lib/submitters.rb b/lib/submitters.rb index 7ad012479..21cc7821b 100644 --- a/lib/submitters.rb +++ b/lib/submitters.rb @@ -164,11 +164,14 @@ def send_signature_requests(submitters, delay_seconds: nil) def current_submitter_order?(submitter) submitter_items = submitter.submission.template_submitters || submitter.submission.template.submitters + submitters_order = submitter.submission.template_signing_order - before_items = submitter_items[0...(submitter_items.find_index { |e| e['uuid'] == submitter.uuid })] + ordered_items = submitters_order == 'manager_then_employee' ? submitter_items.reverse : submitter_items - before_items.reduce(true) do |acc, item| - acc && submitter.submission.submitters.find { |e| e.uuid == item['uuid'] }&.completed_at? + before_items = ordered_items[0...ordered_items.find_index { |e| e['uuid'] == submitter.uuid }] + + before_items.all? do |item| + submitter.submission.submitters.find { |e| e.uuid == item['uuid'] }&.completed_at? end end diff --git a/spec/lib/submissions/create_from_submitters_spec.rb b/spec/lib/submissions/create_from_submitters_spec.rb new file mode 100644 index 000000000..9f93fb01b --- /dev/null +++ b/spec/lib/submissions/create_from_submitters_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Submissions::CreateFromSubmitters do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user, submitter_count: 2) } + + let(:submitter_attrs) do + template.submitters.map do |s| + HashWithIndifferentAccess.new({ 'uuid' => s['uuid'], 'email' => Faker::Internet.email }) + end + end + + def call(template:, submitters_order:) + described_class.call( + template:, + user:, + submissions_attrs: [HashWithIndifferentAccess.new({ 'submitters' => submitter_attrs })], + source: :api, + submitters_order: + ) + end + + describe 'is_order_sent for employee_then_manager' do + it 'sets sent_at only on the first submitter (Employee)' do + submissions = call(template:, submitters_order: 'employee_then_manager') + submitters = submissions.first.submitters.sort_by { |s| template.submitters.index { |ts| ts['uuid'] == s.uuid } } + + expect(submitters[0].sent_at).not_to be_nil + expect(submitters[1].sent_at).to be_nil + end + end + + describe 'is_order_sent for manager_then_employee' do + it 'sets sent_at only on the second submitter (Manager)' do + submissions = call(template:, submitters_order: 'manager_then_employee') + submitters = submissions.first.submitters.sort_by { |s| template.submitters.index { |ts| ts['uuid'] == s.uuid } } + + expect(submitters[0].sent_at).to be_nil + expect(submitters[1].sent_at).not_to be_nil + end + end + + describe 'is_order_sent for simultaneous' do + it 'sets sent_at on all submitters' do + submissions = call(template:, submitters_order: 'simultaneous') + + expect(submissions.first.submitters).to all(have_attributes(sent_at: be_present)) + end + end + + describe 'single_sided skipping' do + before do + manager_uuid = template.submitters[1]['uuid'] + template.update_column(:fields, template.fields.reject { |f| f['submitter_uuid'] == manager_uuid }) + end + + it 'skips submitters without fields' do + submissions = call(template:, submitters_order: 'single_sided') + submitter_uuids = submissions.first.submitters.map(&:uuid) + + expect(submitter_uuids).to include(template.submitters[0]['uuid']) + expect(submitter_uuids).not_to include(template.submitters[1]['uuid']) + end + end +end diff --git a/spec/lib/submissions_spec.rb b/spec/lib/submissions_spec.rb new file mode 100644 index 000000000..2f89d42eb --- /dev/null +++ b/spec/lib/submissions_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Submissions do + describe '.send_signature_requests' do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user, submitter_count: 2) } + let(:submission) { create(:submission, template:, created_by_user: user) } + + let(:employee_uuid) { template.submitters[0]['uuid'] } + let(:manager_uuid) { template.submitters[1]['uuid'] } + + let!(:employee) { create(:submitter, submission:, uuid: employee_uuid) } + let!(:manager) { create(:submitter, submission:, uuid: manager_uuid) } + + before do + allow(Submitters).to receive(:send_signature_requests) + end + + def set_order(order) + template.update_column(:preferences, { 'submitters_order' => order }) + submission.reload + end + + context 'with employee_then_manager order' do + before { set_order('employee_then_manager') } + + it 'sends signature request only to the employee first' do + described_class.send_signature_requests([submission]) + + expect(Submitters).to have_received(:send_signature_requests).with([employee], delay_seconds: nil) + end + end + + context 'with manager_then_employee order' do + before { set_order('manager_then_employee') } + + it 'sends signature request only to the manager first' do + described_class.send_signature_requests([submission]) + + expect(Submitters).to have_received(:send_signature_requests).with([manager], delay_seconds: nil) + end + end + + context 'with simultaneous order' do + before { set_order('simultaneous') } + + it 'sends signature requests to all submitters' do + described_class.send_signature_requests([submission]) + + expect(Submitters).to have_received(:send_signature_requests).with(match_array([employee, manager]), + delay_seconds: nil) + end + end + end +end diff --git a/spec/lib/submitters_spec.rb b/spec/lib/submitters_spec.rb new file mode 100644 index 000000000..b01e4ede2 --- /dev/null +++ b/spec/lib/submitters_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Submitters do + describe '.current_submitter_order?' do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user, submitter_count: 2) } + let(:submission) { create(:submission, template:, created_by_user: user) } + + let(:employee_uuid) { template.submitters[0]['uuid'] } + let(:manager_uuid) { template.submitters[1]['uuid'] } + + let!(:employee) { create(:submitter, submission:, uuid: employee_uuid) } + let!(:manager) { create(:submitter, submission:, uuid: manager_uuid) } + + def set_order(order) + template.update_column(:preferences, { 'submitters_order' => order }) + submission.reload + end + + context 'with manager_then_employee order' do + before { set_order('manager_then_employee') } + + it 'returns true for the manager (index 1)' do + expect(described_class.current_submitter_order?(manager.reload)).to be true + end + + it 'returns false for the employee when manager has not completed' do + manager.update!(completed_at: nil) + expect(described_class.current_submitter_order?(employee.reload)).to be false + end + + it 'returns true for the employee when manager has completed' do + manager.update!(completed_at: Time.current) + expect(described_class.current_submitter_order?(employee.reload)).to be true + end + end + + context 'with employee_then_manager order' do + before { set_order('employee_then_manager') } + + it 'returns true for the first submitter (Employee)' do + expect(described_class.current_submitter_order?(employee.reload)).to be true + end + + it 'returns false for the manager when employee has not completed' do + employee.update!(completed_at: nil) + expect(described_class.current_submitter_order?(manager.reload)).to be false + end + + it 'returns true for the manager when employee has completed' do + employee.update!(completed_at: Time.current) + expect(described_class.current_submitter_order?(manager.reload)).to be true + end + end + end +end diff --git a/spec/models/submission_spec.rb b/spec/models/submission_spec.rb index 764b3347b..99b3a6820 100644 --- a/spec/models/submission_spec.rb +++ b/spec/models/submission_spec.rb @@ -1,12 +1,29 @@ # frozen_string_literal: true RSpec.describe Submission do - describe '#signing_order_enforced?' do - let(:account) { create(:account) } - let(:user) { create(:user, account:) } - let(:template) { create(:template, account:, author: user) } - let(:submission) { create(:submission, template:, created_by_user: user) } + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user) } + let(:submission) { create(:submission, template:, created_by_user: user) } + + describe '#template_signing_order' do + it 'returns the submitters_order from template preferences' do + template.update!(preferences: { 'submitters_order' => 'employee_then_manager' }) + expect(submission.template_signing_order).to eq('employee_then_manager') + end + + it 'returns nil when template has no submitters_order preference' do + template.update_column(:preferences, {}) + expect(submission.reload.template_signing_order).to be_nil + end + it 'returns nil when submission has no template' do + submission.update!(template: nil) + expect(submission.template_signing_order).to be_nil + end + end + + describe '#signing_order_enforced?' do it 'returns true for employee_then_manager' do template.update!(preferences: { 'submitters_order' => 'employee_then_manager' }) expect(submission.signing_order_enforced?).to be true diff --git a/spec/requests/submissions_spec.rb b/spec/requests/submissions_spec.rb index 28b5a9bba..1796ca784 100644 --- a/spec/requests/submissions_spec.rb +++ b/spec/requests/submissions_spec.rb @@ -287,7 +287,7 @@ def index_submission_body(submission) id: submission.id, name: submission.name, source: 'link', - submitters_order: 'random', + submitters_order: 'employee_then_manager', slug: submission.slug, audit_log_url: nil, combined_document_url: nil, @@ -347,7 +347,7 @@ def show_submission_body(submission) name: submission.name, source: 'link', status: 'pending', - submitters_order: 'random', + submitters_order: 'employee_then_manager', slug: submission.slug, audit_log_url: nil, combined_document_url: nil, From 8cea5011cd40172317baa89b62ba973bf180d5c7 Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Thu, 19 Feb 2026 15:01:26 -0600 Subject: [PATCH 03/14] wire up named signing order through controllers * when saving a template, check if preferences have changed, if it has changed, fire webhook event. * changes in templates_controller.rb are for automatic updates based on field types. So if only 1 field type (employee fields only) this automatically updates * template_preferences_controller.rb handles manual updates to signing order from user --- app/controllers/api/submissions_controller.rb | 2 +- app/controllers/submissions_controller.rb | 2 +- app/controllers/submit_form_controller.rb | 4 ++- app/controllers/templates_controller.rb | 19 ++++++++++-- .../templates_preferences_controller.rb | 29 ++++++++++++++++++- 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb index f1b515eaa..261b9852d 100644 --- a/app/controllers/api/submissions_controller.rb +++ b/app/controllers/api/submissions_controller.rb @@ -167,7 +167,7 @@ def create_submissions(template, params) template:, user: current_user, source: :api, - submitters_order: params[:submitters_order] || params[:order] || 'preserved', + submitters_order: params[:submitters_order] || params[:order] || template.effective_submitters_order, submissions_attrs:, params: ) diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 66940486a..76cd0a439 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -56,7 +56,7 @@ def create Submissions.create_from_submitters(template: @template, user: current_user, source: :invite, - submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random', + submitters_order: @template.effective_submitters_order, submissions_attrs: submissions_params[:submission].to_h.values, params: params.merge('send_completed_email' => true)) end diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index 1e0e8e0e8..521ef3cee 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -21,7 +21,9 @@ def show @form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS) return render :awaiting if (@form_configs[:enforce_signing_order] || - submission.template&.preferences&.dig('submitters_order') == 'preserved') && + submission.template_signing_order.in?( + %w[employee_then_manager manager_then_employee] + )) && !Submitters.current_submitter_order?(@submitter) Submissions.preload_with_pages(submission) diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index c98836d18..371f3490a 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -99,8 +99,10 @@ def create end def update - @template.assign_attributes(template_params) + # Capture current submitters_order before any changes + old_submitters_order = @template.preferences['submitters_order'] + @template.assign_attributes(template_params) is_name_changed = @template.name_changed? @template.save! @@ -109,7 +111,13 @@ def update enqueue_template_updated_webhooks(@template) - head :ok + # If submitters_order changed (e.g., fields removed making it single_sided), fire preferences webhook + new_submitters_order = @template.preferences['submitters_order'] + if old_submitters_order != new_submitters_order && new_submitters_order.present? + enqueue_template_preferences_updated_webhooks(@template) + end + + render json: { preferences: @template.preferences } end def destroy @@ -173,6 +181,13 @@ def enqueue_template_updated_webhooks(template) end end + def enqueue_template_preferences_updated_webhooks(template) + WebhookUrls.for_template(template, 'template.preferences_updated').each do |webhook_url| + SendTemplatePreferencesUpdatedWebhookRequestJob.perform_async('template_id' => template.id, + 'webhook_url_id' => webhook_url.id) + end + end + def handle_account_override return unless authorized_clone_account_id?(params[:account_id]) diff --git a/app/controllers/templates_preferences_controller.rb b/app/controllers/templates_preferences_controller.rb index e2ec9ee3b..22cde5711 100644 --- a/app/controllers/templates_preferences_controller.rb +++ b/app/controllers/templates_preferences_controller.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true class TemplatesPreferencesController < ApplicationController + include IframeAuthentication + include PartnershipContext + + skip_before_action :verify_authenticity_token + skip_before_action :authenticate_via_token! + + before_action :authenticate_from_referer load_and_authorize_resource :template def show; end @@ -8,10 +15,23 @@ def show; end def create authorize!(:update, @template) + old_submitters_order = @template.preferences['submitters_order'] @template.preferences = @template.preferences.merge(template_params[:preferences]) - @template.preferences = @template.preferences.reject { |_, v| (v.is_a?(String) || v.is_a?(Hash)) && v.blank? } + @templahttp://app.lvh.me:3000/retain/team/tasks_list_builder/13/editte.preferences = @template.preferences.reject { |_, v| (v.is_a?(String) || v.is_a?(Hash)) && v.blank? } + + # Handle single_sided case (when template has < 2 unique submitters) + if @template.unique_submitter_uuids.size < 2 && @template.preferences['submitters_order'].present? + @template.preferences['submitters_order'] = 'single_sided' + end + @template.save! + # Enqueue webhook if submitters_order changed + new_submitters_order = @template.preferences['submitters_order'] + if old_submitters_order != new_submitters_order && new_submitters_order.present? + enqueue_template_preferences_updated_webhooks(@template) + end + head :ok end @@ -51,4 +71,11 @@ def template_params end end end + + def enqueue_template_preferences_updated_webhooks(template) + WebhookUrls.for_template(template, 'template.preferences_updated').each do |webhook_url| + SendTemplatePreferencesUpdatedWebhookRequestJob.perform_async('template_id' => template.id, + 'webhook_url_id' => webhook_url.id) + end + end end From 882d7dd83b25371ebc90d9f8da697e6f34160b0d Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Thu, 19 Feb 2026 15:05:34 -0600 Subject: [PATCH 04/14] add signing order UI - add SigningOrderModal component for selecting signing order from within the template builder - show signing order button in builder toolbar only when template has 2+ submitter fields --- app/javascript/template_builder/builder.vue | 34 ++++- app/javascript/template_builder/i18n.js | 3 + .../template_builder/signing_order_modal.vue | 123 ++++++++++++++++++ app/views/templates_preferences/show.html.erb | 58 +++++++-- 4 files changed, 207 insertions(+), 11 deletions(-) create mode 100644 app/javascript/template_builder/signing_order_modal.vue 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/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) %> From 19abefb1cbd23e8a5efcf384397141db6a9bb612 Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Thu, 19 Feb 2026 15:06:58 -0600 Subject: [PATCH 05/14] add template.preferences_updated webhook job --- app/jobs/process_submitter_completion_job.rb | 2 +- ...preferences_updated_webhook_request_job.rb | 34 +++++ ...rences_updated_webhook_request_job_spec.rb | 125 ++++++++++++++++++ 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 app/jobs/send_template_preferences_updated_webhook_request_job.rb create mode 100644 spec/jobs/send_template_preferences_updated_webhook_request_job_spec.rb 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/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 From f21c8d34c5bb731650259d295c8b799115461041 Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Thu, 19 Feb 2026 15:13:08 -0600 Subject: [PATCH 06/14] add template.preferences_updated webhook support - add template.preferences_updated to account default webhook events - guard account create_careerplug_webhook against missing CAREERPLUG_WEBHOOK_URL env var - create partnership-scoped webhook for template.preferences_updated on partnership creation - add template.preferences_updated to WebhookUrl::EVENTS - update PARTNERSHIP_EVENTS to only include template.preferences_updated - return WebhookUrl.none instead of raising for templates with neither account nor partnership - extend webhooks:setup_development rake task to create partnership webhooks --- app/models/account.rb | 4 +- app/models/partnership.rb | 12 ++++ app/models/webhook_url.rb | 4 +- lib/params/submission_create_validator.rb | 1 - lib/tasks/webhooks.rake | 27 ++++++-- lib/webhook_urls.rb | 6 +- spec/lib/webhook_urls_spec.rb | 10 +-- .../account_create_careerplug_webhook_spec.rb | 65 ------------------- spec/models/account_spec.rb | 35 ++++++++++ spec/models/partnership_spec.rb | 29 +++++++++ 10 files changed, 110 insertions(+), 83 deletions(-) delete mode 100644 spec/models/account_create_careerplug_webhook_spec.rb 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/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/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) From d09bb557e06449cd019fb1dc2bdc15de3a1bc2a1 Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Thu, 19 Feb 2026 22:04:41 -0600 Subject: [PATCH 07/14] rubocop and rspec fixes --- app/controllers/templates_preferences_controller.rb | 2 +- lib/submitters.rb | 2 +- spec/lib/submissions/create_from_submitters_spec.rb | 4 ++-- spec/lib/submissions_spec.rb | 10 +++++----- spec/lib/submitters_spec.rb | 6 +++--- spec/requests/templates_spec.rb | 3 ++- 6 files changed, 14 insertions(+), 13 deletions(-) diff --git a/app/controllers/templates_preferences_controller.rb b/app/controllers/templates_preferences_controller.rb index 22cde5711..58ad64298 100644 --- a/app/controllers/templates_preferences_controller.rb +++ b/app/controllers/templates_preferences_controller.rb @@ -17,7 +17,7 @@ def create old_submitters_order = @template.preferences['submitters_order'] @template.preferences = @template.preferences.merge(template_params[:preferences]) - @templahttp://app.lvh.me:3000/retain/team/tasks_list_builder/13/editte.preferences = @template.preferences.reject { |_, v| (v.is_a?(String) || v.is_a?(Hash)) && v.blank? } + @template.preferences = @template.preferences.reject { |_, v| (v.is_a?(String) || v.is_a?(Hash)) && v.blank? } # Handle single_sided case (when template has < 2 unique submitters) if @template.unique_submitter_uuids.size < 2 && @template.preferences['submitters_order'].present? diff --git a/lib/submitters.rb b/lib/submitters.rb index 21cc7821b..c44f0200b 100644 --- a/lib/submitters.rb +++ b/lib/submitters.rb @@ -168,7 +168,7 @@ def current_submitter_order?(submitter) ordered_items = submitters_order == 'manager_then_employee' ? submitter_items.reverse : submitter_items - before_items = ordered_items[0...ordered_items.find_index { |e| e['uuid'] == submitter.uuid }] + before_items = ordered_items[0...(ordered_items.find_index { |e| e['uuid'] == submitter.uuid })] before_items.all? do |item| submitter.submission.submitters.find { |e| e.uuid == item['uuid'] }&.completed_at? diff --git a/spec/lib/submissions/create_from_submitters_spec.rb b/spec/lib/submissions/create_from_submitters_spec.rb index 9f93fb01b..e287edf96 100644 --- a/spec/lib/submissions/create_from_submitters_spec.rb +++ b/spec/lib/submissions/create_from_submitters_spec.rb @@ -9,7 +9,7 @@ let(:submitter_attrs) do template.submitters.map do |s| - HashWithIndifferentAccess.new({ 'uuid' => s['uuid'], 'email' => Faker::Internet.email }) + { 'uuid' => s['uuid'], 'email' => Faker::Internet.email }.with_indifferent_access end end @@ -17,7 +17,7 @@ def call(template:, submitters_order:) described_class.call( template:, user:, - submissions_attrs: [HashWithIndifferentAccess.new({ 'submitters' => submitter_attrs })], + submissions_attrs: [{ 'submitters' => submitter_attrs }.with_indifferent_access], source: :api, submitters_order: ) diff --git a/spec/lib/submissions_spec.rb b/spec/lib/submissions_spec.rb index 2f89d42eb..d9377a245 100644 --- a/spec/lib/submissions_spec.rb +++ b/spec/lib/submissions_spec.rb @@ -19,13 +19,13 @@ allow(Submitters).to receive(:send_signature_requests) end - def set_order(order) + def update_order(order) template.update_column(:preferences, { 'submitters_order' => order }) submission.reload end context 'with employee_then_manager order' do - before { set_order('employee_then_manager') } + before { update_order('employee_then_manager') } it 'sends signature request only to the employee first' do described_class.send_signature_requests([submission]) @@ -35,7 +35,7 @@ def set_order(order) end context 'with manager_then_employee order' do - before { set_order('manager_then_employee') } + before { update_order('manager_then_employee') } it 'sends signature request only to the manager first' do described_class.send_signature_requests([submission]) @@ -45,12 +45,12 @@ def set_order(order) end context 'with simultaneous order' do - before { set_order('simultaneous') } + before { update_order('simultaneous') } it 'sends signature requests to all submitters' do described_class.send_signature_requests([submission]) - expect(Submitters).to have_received(:send_signature_requests).with(match_array([employee, manager]), + expect(Submitters).to have_received(:send_signature_requests).with(contain_exactly(employee, manager), delay_seconds: nil) end end diff --git a/spec/lib/submitters_spec.rb b/spec/lib/submitters_spec.rb index b01e4ede2..0ead288e0 100644 --- a/spec/lib/submitters_spec.rb +++ b/spec/lib/submitters_spec.rb @@ -15,13 +15,13 @@ let!(:employee) { create(:submitter, submission:, uuid: employee_uuid) } let!(:manager) { create(:submitter, submission:, uuid: manager_uuid) } - def set_order(order) + def update_order(order) template.update_column(:preferences, { 'submitters_order' => order }) submission.reload end context 'with manager_then_employee order' do - before { set_order('manager_then_employee') } + before { update_order('manager_then_employee') } it 'returns true for the manager (index 1)' do expect(described_class.current_submitter_order?(manager.reload)).to be true @@ -39,7 +39,7 @@ def set_order(order) end context 'with employee_then_manager order' do - before { set_order('employee_then_manager') } + before { update_order('employee_then_manager') } it 'returns true for the first submitter (Employee)' do expect(described_class.current_submitter_order?(employee.reload)).to be true diff --git a/spec/requests/templates_spec.rb b/spec/requests/templates_spec.rb index 7d7f03b1e..9f4e247c4 100644 --- a/spec/requests/templates_spec.rb +++ b/spec/requests/templates_spec.rb @@ -253,7 +253,8 @@ def template_body(template) ], preferences: { 'request_email_subject' => 'Subject text', - 'request_email_body' => 'Body Text' + 'request_email_body' => 'Body Text', + 'submitters_order' => 'single_sided' }, schema: [ { From eb4e6da6002d2e78918eb3d30e7d16f5730f2ec5 Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Thu, 19 Feb 2026 22:12:00 -0600 Subject: [PATCH 08/14] erb_lint violation fixes --- app/views/templates_preferences/show.html.erb | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/app/views/templates_preferences/show.html.erb b/app/views/templates_preferences/show.html.erb index 9156f6237..8af67cc50 100644 --- a/app/views/templates_preferences/show.html.erb +++ b/app/views/templates_preferences/show.html.erb @@ -346,17 +346,16 @@
- <% - first_party = @template.submitters.first['name'] || t('first_party') +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' %>