diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb index f1b515ea..788fcc7e 100644 --- a/app/controllers/api/submissions_controller.rb +++ b/app/controllers/api/submissions_controller.rb @@ -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: ) diff --git a/app/controllers/concerns/template_webhooks.rb b/app/controllers/concerns/template_webhooks.rb new file mode 100644 index 00000000..ffb0619e --- /dev/null +++ b/app/controllers/concerns/template_webhooks.rb @@ -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 diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 66940486..76cd0a43 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -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 diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index 1e0e8e0e..2588c711 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -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) diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index c98836d1..1de0cab7 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -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]) diff --git a/app/controllers/templates_preferences_controller.rb b/app/controllers/templates_preferences_controller.rb index e2ec9ee3..dc9b1e8d 100644 --- a/app/controllers/templates_preferences_controller.rb +++ b/app/controllers/templates_preferences_controller.rb @@ -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 diff --git a/app/controllers/templates_uploads_controller.rb b/app/controllers/templates_uploads_controller.rb index f50dbfa7..7991a74a 100644 --- a/app/controllers/templates_uploads_controller.rb +++ b/app/controllers/templates_uploads_controller.rb @@ -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 diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index e8024ddc..f576ffc8 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 db5f8983..489e2257 100644 --- a/app/javascript/template_builder/i18n.js +++ b/app/javascript/template_builder/i18n.js @@ -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', 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 00000000..8cb15efc --- /dev/null +++ b/app/javascript/template_builder/signing_order_modal.vue @@ -0,0 +1,123 @@ + + + diff --git a/app/jobs/process_submitter_completion_job.rb b/app/jobs/process_submitter_completion_job.rb index 26367c5f..832448d7 100644 --- a/app/jobs/process_submitter_completion_job.rb +++ b/app/jobs/process_submitter_completion_job.rb @@ -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 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 00000000..457d3967 --- /dev/null +++ b/app/jobs/send_template_preferences_updated_webhook_request_job.rb @@ -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 diff --git a/app/models/account.rb b/app/models/account.rb index e9acfa12..8ea0e893 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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 diff --git a/app/models/partnership.rb b/app/models/partnership.rb index 782317fe..63a4851f 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 @@ 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 diff --git a/app/models/submission.rb b/app/models/submission.rb index 2abfbb5b..a70df4c8 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: '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 diff --git a/app/models/template.rb b/app/models/template.rb index e7399124..18976a6a 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 @@ 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 diff --git a/app/models/webhook_url.rb b/app/models/webhook_url.rb index 430b9e23..3a495755 100644 --- a/app/models/webhook_url.rb +++ b/app/models/webhook_url.rb @@ -38,12 +38,12 @@ class WebhookUrl < ApplicationRecord submission.archived template.created template.updated + template.preferences_updated ].freeze # Partnership webhooks can only use template events since partnerships don't have submissions/submitters PARTNERSHIP_EVENTS = %w[ - template.created - template.updated + template.preferences_updated ].freeze belongs_to :account, optional: true diff --git a/app/views/layouts/form.html.erb b/app/views/layouts/form.html.erb index 64b4a537..d298ef4d 100644 --- a/app/views/layouts/form.html.erb +++ b/app/views/layouts/form.html.erb @@ -15,6 +15,7 @@ <%= render 'shared/posthog' if ENV['POSTHOG_TOKEN'] %> + <% if flash.present? %><%= render 'shared/flash' %><% end %> <%= yield %> diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb index 5eee11bd..234ce214 100644 --- a/app/views/shared/_flash.html.erb +++ b/app/views/shared/_flash.html.erb @@ -1,5 +1,5 @@