CP-10379 ordered signing (#59)

* 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

* 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

* 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

* 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

* add template.preferences_updated webhook job

* 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

* rubocop and rspec fixes

* erb_lint violation fixes

* harden webhooks with account_id and partnership_id in payload

* we're requiring two points of contact in the db for multitenancy

* use external account id to match correctly in webhook payload

* PR comments

* handle submitter UUID not matching correctly with flash alert that surfaces to user
* add more testing for simultaneous and single sided orders
* add comment for skipping Devise auth for Iframe auth
* refactor template webhook enqueue to a shared concern
* use safe navigation for first_party name
* make default submitters_order  value consistent between `lib/submissions.rb` and `submission.rb`
* more descriptive error message for signing order error

* update to non-predicate method for rubocop

we used to just return true or false, but we are using nil to signify that the submitter uuid is not found for the controller so the error can be surfaced to the user.

* erb_lint formatting fix

* PR comment changes

* change current_submitter_order to validate_submitter_order for clarity
* add translations
pull/608/head
Ryan Arakawa 3 weeks ago committed by GitHub
parent 41e18f9484
commit d8f04ad115
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -13,7 +13,7 @@ module Api
submissions = Submissions.search(current_user, @submissions, params[:q])
submissions = filter_submissions(submissions, params)
submissions = paginate(submissions.preload(:created_by_user, :submitters,
submissions = paginate(submissions.preload(:account, :created_by_user, :submitters,
template: :folder,
combined_document_attachment: :blob,
audit_trail_attachment: :blob))
@ -167,7 +167,7 @@ module Api
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:
)

@ -0,0 +1,24 @@
# frozen_string_literal: true
module TemplateWebhooks
def enqueue_template_created_webhooks(template)
WebhookUrls.for_template(template, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id,
'webhook_url_id' => webhook_url.id)
end
end
def enqueue_template_updated_webhooks(template)
WebhookUrls.for_template(template, 'template.updated').each do |webhook_url|
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => template.id,
'webhook_url_id' => webhook_url.id)
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

@ -56,7 +56,7 @@ class SubmissionsController < ApplicationController
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

@ -20,9 +20,17 @@ class SubmitFormController < ApplicationController
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
return render :awaiting if (@form_configs[:enforce_signing_order] ||
submission.template&.preferences&.dig('submitters_order') == 'preserved') &&
!Submitters.current_submitter_order?(@submitter)
if @form_configs[:enforce_signing_order] ||
submission.template_signing_order.in?(%w[employee_then_manager manager_then_employee])
signing_order = Submitters.validate_submitter_order(@submitter)
if signing_order.nil?
flash.now[:alert] = I18n.t('user_id_did_not_match_please_try_again_or_contact_support')
return render :awaiting
end
return render :awaiting unless signing_order
end
Submissions.preload_with_pages(submission)

@ -4,6 +4,7 @@ class TemplatesController < ApplicationController
include PrefillFieldsHelper
include IframeAuthentication
include PartnershipContext
include TemplateWebhooks
skip_before_action :verify_authenticity_token
skip_before_action :authenticate_via_token!, only: [:update]
@ -99,8 +100,10 @@ class TemplatesController < ApplicationController
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 +112,13 @@ class TemplatesController < ApplicationController
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
@ -159,20 +168,6 @@ class TemplatesController < ApplicationController
end
end
def enqueue_template_created_webhooks(template)
WebhookUrls.for_template(template, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id,
'webhook_url_id' => webhook_url.id)
end
end
def enqueue_template_updated_webhooks(template)
WebhookUrls.for_template(template, 'template.updated').each do |webhook_url|
SendTemplateUpdatedWebhookRequestJob.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])

@ -1,6 +1,17 @@
# frozen_string_literal: true
class TemplatesPreferencesController < ApplicationController
include IframeAuthentication
include PartnershipContext
include TemplateWebhooks
# We use IframeAuthentication#authenticate_from_referer to authenticate the user.
# These are holdovers from legacy Docuseal that uses an actual login system
# and will be removed in a future ticket.
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 +19,23 @@ class TemplatesPreferencesController < ApplicationController
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? }
# 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

@ -1,6 +1,8 @@
# frozen_string_literal: true
class TemplatesUploadsController < ApplicationController
include TemplateWebhooks
skip_before_action :verify_authenticity_token, only: [:create]
load_and_authorize_resource :template, parent: false
@ -70,11 +72,4 @@ class TemplatesUploadsController < ApplicationController
{ files: [file] }
end
def enqueue_template_created_webhooks(template)
WebhookUrls.for_template(template, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id,
'webhook_url_id' => webhook_url.id)
end
end
end

@ -63,6 +63,14 @@
name="buttons"
/>
<template v-else>
<button
v-if="hasMultipleSubmitterFields"
class="base-button"
@click.prevent="isShowSigningOrderModal = true"
>
<IconAdjustments class="w-6 h-6 flex-shrink-0" />
<span class="whitespace-nowrap">{{ t('signing_order') }}</span>
</button>
<a
:href="formPreviewUrl"
data-turbo="false"
@ -331,6 +339,14 @@
id="docuseal_modal_container"
class="modal-container"
/>
<Teleport
v-if="isShowSigningOrderModal"
to="#docuseal_modal_container"
>
<SigningOrderModal
@close="isShowSigningOrderModal = false"
/>
</Teleport>
</div>
</template>
@ -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)
}

@ -79,6 +79,10 @@ 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',
failed_to_save_signing_order_please_try_again_or_contact_support: 'Failed to save signing order. Please try again or contact support for assistance.',
draw: 'Draw',
add: 'Add',
or_add_field_without_drawing: 'Or add field without drawing',

@ -0,0 +1,123 @@
<template>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
>
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title text-lg">
{{ t('select_signing_order') }}
</span>
<a
href="#"
class="text-xl modal-close-button"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<div>
<form @submit.prevent="saveAndClose">
<div class="space-y-2 mb-4">
<label class="flex items-start space-x-3 p-3 border border-base-300 rounded-lg cursor-pointer hover:border-primary transition-colors">
<input
v-model="signingOrder"
type="radio"
value="employee_then_manager"
class="radio radio-primary mt-1"
>
<div class="flex-1">
<div>{{ firstParty }} completes the form first, then {{ secondParty }}</div>
</div>
</label>
<label class="flex items-start space-x-3 p-3 border border-base-300 rounded-lg cursor-pointer hover:border-primary transition-colors">
<input
v-model="signingOrder"
type="radio"
value="manager_then_employee"
class="radio radio-primary mt-1"
>
<div class="flex-1">
<div>{{ secondParty }} completes the form first, then {{ firstParty }}</div>
</div>
</label>
<label class="flex items-start space-x-3 p-3 border border-base-300 rounded-lg cursor-pointer hover:border-primary transition-colors">
<input
v-model="signingOrder"
type="radio"
value="simultaneous"
class="radio radio-primary mt-1"
>
<div class="flex-1">
<div>{{ t('simultaneous_signing_description') }}</div>
</div>
</label>
</div>
<button class="base-button w-full mt-4 modal-save-button">
{{ t('save') }}
</button>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'SigningOrderModal',
inject: ['t', 'template', 'baseFetch', 'authenticityToken'],
emits: ['close'],
data () {
return {
signingOrder: this.template.preferences?.submitters_order || 'employee_then_manager'
}
},
computed: {
firstParty () {
return this.template.submitters[0]?.name || this.t('first_party')
},
secondParty () {
return this.template.submitters[1]?.name || this.t('second_party')
}
},
methods: {
saveAndClose () {
if (!this.template.preferences) {
this.template.preferences = {}
}
this.template.preferences.submitters_order = this.signingOrder
const formData = new FormData()
formData.append('template[preferences][submitters_order]', this.signingOrder)
if (this.template?.partnership_context) {
const context = this.template.partnership_context
if (context.accessible_partnership_ids) {
context.accessible_partnership_ids.forEach(id => {
formData.append('accessible_partnership_ids[]', id)
})
}
if (context.external_partnership_id) {
formData.append('external_partnership_id', context.external_partnership_id)
}
if (context.external_account_id) {
formData.append('external_account_id', context.external_account_id)
}
}
this.baseFetch(`/templates/${this.template.id}/preferences`, {
method: 'POST',
body: formData
}).then(() => {
this.$emit('close')
}).catch((error) => {
console.error('Error saving signing order:', error)
alert(this.t('failed_to_save_signing_order_please_try_again_or_contact_support'))
})
}
}
}
</script>

@ -24,7 +24,7 @@ class ProcessSubmitterCompletionJob
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

@ -0,0 +1,36 @@
# 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_account_id: template.account&.external_account_id,
external_partnership_id: template.partnership&.external_partnership_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

@ -78,11 +78,11 @@ class Account < ApplicationRecord
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

@ -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 @@ class Partnership < ApplicationRecord
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

@ -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: 'single_sided'
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_signing_order.in?(%w[employee_then_manager manager_then_employee])
end
def expired?
expire_at && expire_at <= Time.current
end
@ -106,6 +112,10 @@ class Submission < ApplicationRecord
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

@ -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 @@ class Template < ApplicationRecord
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 @@ class Template < ApplicationRecord
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

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

@ -15,6 +15,7 @@
<%= render 'shared/posthog' if ENV['POSTHOG_TOKEN'] %>
</head>
<body>
<% if flash.present? %><%= render 'shared/flash' %><% end %>
<%= yield %>
</body>
</html>

@ -1,5 +1,5 @@
<div id="flash" class="absolute top-0 w-full h-0 z-20">
<div class="max-w-xl mx-auto mt-1.5">
<div class="max-w-xl mx-auto mt-3">
<div class="px-4 py-3 rounded-2xl bg-base-200 flex items-center justify-between mx-4 md:mx-0">
<div class="flex items-center space-x-3">
<% if flash[:notice] %>

@ -337,14 +337,54 @@
<% 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| %>
<div class="flex items-center pt-4 mt-4 justify-between border-t w-full">
<span>
<%= t('enforce_recipients_order') %>
</span>
<%= 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 %>
</div>
<%= f.fields_for :preferences, Struct.new(:submitters_order).new(@template.preferences['submitters_order']) do |ff| %>
<div class="pt-4 mt-4 border-t w-full">
<label class="label">
<span class="label-text font-semibold"><%= t('select_signing_order') %></span>
<span class="tooltip" data-tip="<%= t('choose_the_order_in_which_parties_will_receive_and_complete_the_form') %>">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</label>
<div class="space-y-2">
<% 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' %>
<label class="flex items-start space-x-3 p-3 border border-base-300 rounded-lg cursor-pointer hover:border-primary transition-colors">
<%= ff.radio_button :submitters_order, 'employee_then_manager',
checked: current_value == 'employee_then_manager',
class: 'radio radio-primary mt-0.5',
onchange: 'this.form.requestSubmit()' %>
<div class="flex-1">
<div class="font-medium"><%= t('employee_then_manager_title', first_party: first_party, second_party: second_party) %></div>
<div class="text-sm text-base-content/70"><%= t('employee_then_manager_description', first_party: first_party, second_party: second_party) %></div>
</div>
</label>
<label class="flex items-start space-x-3 p-3 border border-base-300 rounded-lg cursor-pointer hover:border-primary transition-colors">
<%= ff.radio_button :submitters_order, 'manager_then_employee',
checked: current_value == 'manager_then_employee',
class: 'radio radio-primary mt-0.5',
onchange: 'this.form.requestSubmit()' %>
<div class="flex-1">
<div class="font-medium"><%= t('manager_then_employee_title', first_party: first_party, second_party: second_party) %></div>
<div class="text-sm text-base-content/70"><%= t('manager_then_employee_description', first_party: first_party, second_party: second_party) %></div>
</div>
</label>
<label class="flex items-start space-x-3 p-3 border border-base-300 rounded-lg cursor-pointer hover:border-primary transition-colors">
<%= ff.radio_button :submitters_order, 'simultaneous',
checked: current_value == 'simultaneous',
class: 'radio radio-primary mt-0.5',
onchange: 'this.form.requestSubmit()' %>
<div class="flex-1">
<div class="font-medium"><%= t('simultaneous_signing_title') %></div>
<div class="text-sm text-base-content/70"><%= t('simultaneous_signing_description') %></div>
</div>
</label>
</div>
</div>
<% end %>
<% end %>
<% end %>
<% if can?(:manage, :personalization_advanced) %>

@ -31,6 +31,12 @@ en: &en
bcc_recipients: BCC recipients
resend_pending: Re-send pending
always_enforce_signing_order: Always enforce the signing order
employee_then_manager_title: "%{first_party} then %{second_party}"
employee_then_manager_description: "%{first_party} completes the form first, then %{second_party}"
manager_then_employee_title: "%{second_party} then %{first_party}"
manager_then_employee_description: "%{second_party} completes the form first, then %{first_party}"
simultaneous_signing_title: Simultaneous signing
simultaneous_signing_description: Both parties may complete the form at the same time
ensure_unique_recipients: Ensure unique recipients
edit_per_party: Edit per party
reply_to: Reply to
@ -527,6 +533,7 @@ en: &en
submission_has_been_removed: Submission has been removed.
submission_has_been_archived: Submission has been archived.
form_has_been_completed_already: Form has been completed already.
user_id_did_not_match_please_try_again_or_contact_support: User ID did not match. Please try again or contact support for assistance.
form_has_been_archived: Form has been archived.
form_has_been_expired: Form has been expired.
form_has_been_declined: Form has been declined.

@ -56,7 +56,9 @@ module 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 +119,9 @@ module 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)

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

@ -36,6 +36,9 @@ module Submissions
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 @@ module Submissions
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 @@ module Submissions
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:,

@ -3,7 +3,7 @@
module Submissions
module SerializeForApi
SERIALIZE_PARAMS = {
only: %i[id name slug source submitters_order expire_at created_at updated_at archived_at],
only: %i[id account_id name slug source submitters_order expire_at created_at updated_at archived_at],
methods: %i[audit_log_url combined_document_url],
include: {
submitters: { only: %i[id] },
@ -24,6 +24,7 @@ module Submissions
json = submission.as_json(SERIALIZE_PARAMS)
json['external_account_id'] = submission.account&.external_account_id
json['created_by_user'] ||= nil
if with_events

@ -162,13 +162,22 @@ module Submitters
end
end
def current_submitter_order?(submitter)
def validate_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
index = ordered_items.find_index { |e| e['uuid'] == submitter.uuid }
before_items.reduce(true) do |acc, item|
acc && submitter.submission.submitters.find { |e| e.uuid == item['uuid'] }&.completed_at?
if index.nil?
Rails.logger.error("Submitter UUID #{submitter.uuid} not found in submission #{submitter.submission_id}")
return nil
end
before_items = ordered_items[0...index]
before_items.all? do |item|
submitter.submission.submitters.find { |e| e.uuid == item['uuid'] }&.completed_at?
end
end

@ -4,7 +4,7 @@ module Submitters
module SerializeForWebhook
SERIALIZE_PARAMS = {
methods: %i[status application_key],
only: %i[id submission_id email phone name ua ip sent_at opened_at
only: %i[id submission_id account_id email phone name ua ip sent_at opened_at
completed_at declined_at created_at updated_at external_id metadata]
}.freeze
@ -13,7 +13,7 @@ module Submitters
def call(submitter)
ActiveRecord::Associations::Preloader.new(
records: [submitter],
associations: [documents_attachments: :blob, attachments_attachments: :blob]
associations: [:account, { documents_attachments: :blob, attachments_attachments: :blob }]
).call
values = build_values_array(submitter)
@ -26,7 +26,8 @@ module Submitters
submitter.declined_at? ? submitter.submission_events.find_by(event_type: :decline_form).data['reason'] : nil
submitter.as_json(SERIALIZE_PARAMS)
.merge('decline_reason' => decline_reason,
.merge('external_account_id' => submitter.account&.external_account_id,
'decline_reason' => decline_reason,
'role' => submitter_name,
'preferences' => submitter.preferences.except('default_values'),
'values' => values,

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

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

@ -0,0 +1,131 @@
# 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_account_id' => template.account&.external_account_id,
'external_partnership_id' => template.partnership&.external_partnership_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_account_id' => template.account&.external_account_id,
'external_partnership_id' => template.partnership&.external_partnership_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_account_id' => template.account&.external_account_id,
'external_partnership_id' => template.partnership&.external_partnership_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

@ -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|
{ 'uuid' => s['uuid'], 'email' => Faker::Internet.email }.with_indifferent_access
end
end
def call(template:, submitters_order:)
described_class.call(
template:,
user:,
submissions_attrs: [{ 'submitters' => submitter_attrs }.with_indifferent_access],
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

@ -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 update_order(order)
template.update_column(:preferences, { 'submitters_order' => order })
submission.reload
end
context 'with employee_then_manager order' do
before { update_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 { update_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 { 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(contain_exactly(employee, manager),
delay_seconds: nil)
end
end
end
end

@ -0,0 +1,95 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Submitters do
describe '.validate_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 update_order(order)
template.update_column(:preferences, { 'submitters_order' => order })
submission.reload
end
context 'with manager_then_employee order' do
before { update_order('manager_then_employee') }
it 'returns true for the manager (index 1)' do
expect(described_class.validate_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.validate_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.validate_submitter_order(employee.reload)).to be true
end
end
context 'with employee_then_manager order' do
before { update_order('employee_then_manager') }
it 'returns true for the first submitter (Employee)' do
expect(described_class.validate_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.validate_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.validate_submitter_order(manager.reload)).to be true
end
end
context 'with simultaneous order' do
before { update_order('simultaneous') }
it 'returns true for the first submitter' do
expect(described_class.validate_submitter_order(employee.reload)).to be true
end
it 'returns true for the second submitter when the first has completed' do
employee.update!(completed_at: Time.current)
expect(described_class.validate_submitter_order(manager.reload)).to be true
end
it 'returns false for the second submitter when the first has not completed' do
employee.update!(completed_at: nil)
expect(described_class.validate_submitter_order(manager.reload)).to be false
end
end
context 'with single_sided order' do
before { update_order('single_sided') }
it 'returns true for the first submitter' do
expect(described_class.validate_submitter_order(employee.reload)).to be true
end
it 'returns true for the second submitter when the first has completed' do
employee.update!(completed_at: Time.current)
expect(described_class.validate_submitter_order(manager.reload)).to be true
end
it 'returns false for the second submitter when the first has not completed' do
employee.update!(completed_at: nil)
expect(described_class.validate_submitter_order(manager.reload)).to be false
end
end
end
end

@ -65,12 +65,12 @@ RSpec.describe WebhookUrls do
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

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

@ -63,6 +63,41 @@ RSpec.describe Account do
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)

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

@ -0,0 +1,47 @@
# frozen_string_literal: true
RSpec.describe Submission 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) }
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
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

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

@ -285,9 +285,11 @@ describe 'Submission API' do
{
id: submission.id,
account_id: submission.account_id,
external_account_id: submission.account&.external_account_id,
name: submission.name,
source: 'link',
submitters_order: 'random',
submitters_order: 'single_sided',
slug: submission.slug,
audit_log_url: nil,
combined_document_url: nil,
@ -344,10 +346,12 @@ describe 'Submission API' do
{
id: submission.id,
account_id: submission.account_id,
external_account_id: submission.account&.external_account_id,
name: submission.name,
source: 'link',
status: 'pending',
submitters_order: 'random',
submitters_order: 'single_sided',
slug: submission.slug,
audit_log_url: nil,
combined_document_url: nil,

@ -253,7 +253,8 @@ describe 'Templates API' do
],
preferences: {
'request_email_subject' => 'Subject text',
'request_email_body' => 'Body Text'
'request_email_body' => 'Body Text',
'submitters_order' => 'single_sided'
},
schema: [
{

Loading…
Cancel
Save