diff --git a/app/controllers/account_configs_controller.rb b/app/controllers/account_configs_controller.rb index 21c380a2..4146c130 100644 --- a/app/controllers/account_configs_controller.rb +++ b/app/controllers/account_configs_controller.rb @@ -15,6 +15,7 @@ class AccountConfigsController < ApplicationController AccountConfig::DOWNLOAD_LINKS_AUTH_KEY, AccountConfig::FORCE_SSO_AUTH_KEY, AccountConfig::FLATTEN_RESULT_PDF_KEY, + AccountConfig::ENFORCE_SIGNING_ORDER_KEY, AccountConfig::WITH_SIGNATURE_ID, AccountConfig::COMBINE_PDF_RESULT_KEY, AccountConfig::REQUIRE_SIGNING_REASON_KEY, diff --git a/app/controllers/submissions_download_controller.rb b/app/controllers/submissions_download_controller.rb index 9ba81b91..e4af5f49 100644 --- a/app/controllers/submissions_download_controller.rb +++ b/app/controllers/submissions_download_controller.rb @@ -23,9 +23,9 @@ class SubmissionsDownloadController < ApplicationController last_submitter = submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last - Submissions::EnsureResultGenerated.call(last_submitter) + return head :not_found unless last_submitter - return head :not_found unless last_submitter.completed_at? + Submissions::EnsureResultGenerated.call(last_submitter) if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter) Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar) diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index 90343331..fcd0fdc8 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -20,7 +20,11 @@ class SubmitFormController < ApplicationController @submitter.account.archived_at? return render :expired if submission.expired? return render :declined if @submitter.declined_at? - return render :awaiting if submission.template.preferences['submitters_order'] == 'preserved' && + + @form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS) + + return render :awaiting if (@form_configs[:enforce_signing_order] || + submission.template.preferences['submitters_order'] == 'preserved') && !Submitters.current_submitter_order?(@submitter) Submitters.preload_with_pages(@submitter) @@ -29,8 +33,6 @@ class SubmitFormController < ApplicationController @attachments_index = build_attachments_index(submission) - @form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS) - return unless @form_configs[:prefill_signature] if (user_signature = UserConfigs.load_signature(current_user)) @@ -70,6 +72,10 @@ class SubmitFormController < ApplicationController Submitters::SubmitValues.call(submitter, params, request) head :ok + rescue Submitters::SubmitValues::RequiredFieldError => e + Rollbar.warning("Required field #{submitter.id}: #{e.message}") if defined?(Rollbar) + + render json: { field_uuid: e.message }, status: :unprocessable_entity rescue Submitters::SubmitValues::ValidationError => e render json: { error: e.message }, status: :unprocessable_entity end diff --git a/app/javascript/application.js b/app/javascript/application.js index d35641ad..7ae6836f 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -35,8 +35,6 @@ import IndeterminateCheckbox from './elements/indeterminate_checkbox' import * as TurboInstantClick from './lib/turbo_instant_click' -import './images/preview.png' - TurboInstantClick.start() document.addEventListener('turbo:before-cache', () => { diff --git a/app/javascript/draw.js b/app/javascript/draw.js index c42c3c72..a4936e8d 100644 --- a/app/javascript/draw.js +++ b/app/javascript/draw.js @@ -1,5 +1,6 @@ import SignaturePad from 'signature_pad' import { cropCanvasAndExportToPNG } from './submission_form/crop_canvas' +import { isValidSignatureCanvas } from './submission_form/validate_signature' window.customElements.define('draw-signature', class extends HTMLElement { connectedCallback () { @@ -43,6 +44,8 @@ window.customElements.define('draw-signature', class extends HTMLElement { return response }) + }).catch(error => { + console.log(error) }).finally(() => { this.submitButton.disabled = false }) @@ -65,26 +68,26 @@ window.customElements.define('draw-signature', class extends HTMLElement { } async submitImage () { - return new Promise((resolve, reject) => { - cropCanvasAndExportToPNG(this.canvas, { errorOnTooSmall: true }).then(async (blob) => { - const file = new File([blob], 'signature.png', { type: 'image/png' }) - - const formData = new FormData() - - formData.append('file', file) - formData.append('submitter_slug', this.dataset.slug) - formData.append('name', 'attachments') - formData.append('remember_signature', 'true') - - return fetch('/api/attachments', { - method: 'POST', - body: formData - }).then((resp) => resp.json()).then((attachment) => { - return resolve(attachment) - }) - }).catch((error) => { - return reject(error) - }) + if (!isValidSignatureCanvas(this.pad.toData())) { + alert('Signature is too small or simple. Please redraw.') + + return Promise.reject(new Error('Image too small or simple')) + } + + return cropCanvasAndExportToPNG(this.canvas).then(async (blob) => { + const file = new File([blob], 'signature.png', { type: 'image/png' }) + + const formData = new FormData() + + formData.append('file', file) + formData.append('submitter_slug', this.dataset.slug) + formData.append('name', 'attachments') + formData.append('remember_signature', 'true') + + return fetch('/api/attachments', { + method: 'POST', + body: formData + }).then(resp => resp.json()) }) } diff --git a/app/javascript/images/preview.png b/app/javascript/images/preview.png deleted file mode 100644 index 81acd3a9..00000000 Binary files a/app/javascript/images/preview.png and /dev/null differ diff --git a/app/javascript/submission_form/crop_canvas.js b/app/javascript/submission_form/crop_canvas.js index 472d2764..977c5ade 100644 --- a/app/javascript/submission_form/crop_canvas.js +++ b/app/javascript/submission_form/crop_canvas.js @@ -1,4 +1,4 @@ -function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSmall: false }) { +function cropCanvasAndExportToPNG (canvas) { const ctx = canvas.getContext('2d') const width = canvas.width @@ -33,10 +33,6 @@ function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSma croppedCanvas.height = croppedHeight const croppedCtx = croppedCanvas.getContext('2d') - if (errorOnTooSmall && (croppedWidth < 20 || croppedHeight < 20)) { - return Promise.reject(new Error('Image too small')) - } - croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight) return new Promise((resolve, reject) => { diff --git a/app/javascript/submission_form/form.vue b/app/javascript/submission_form/form.vue index e76ed805..01b7f4b7 100644 --- a/app/javascript/submission_form/form.vue +++ b/app/javascript/submission_form/form.vue @@ -1404,7 +1404,21 @@ export default { if (response.status === 422 || response.status === 500) { const data = await response.json() - if (data.error) { + if (data.field_uuid) { + const field = this.fieldsUuidIndex[data.field_uuid] + + if (field) { + const step = this.stepFields.findIndex((fields) => fields.includes(field)) + + if (step !== -1) { + this.goToStep(step, this.autoscrollFields) + + this.showFillAllRequiredFields = true + } + } + + return Promise.reject(new Error('Required field: ' + data.field_uuid)) + } else if (data.error) { const i18nKey = data.error.replace(/\s+/g, '_').toLowerCase() alert(this.t(i18nKey) !== i18nKey ? this.t(i18nKey) : data.error) @@ -1439,11 +1453,7 @@ export default { this.isSubmittingComplete = false }) }).catch(error => { - if (error?.message === 'Image too small') { - alert(this.t('signature_is_too_small_please_redraw')) - } else { - console.log(error) - } + console.log(error) }).finally(() => { this.isSubmitting = false this.isSubmittingComplete = false diff --git a/app/javascript/submission_form/i18n.js b/app/javascript/submission_form/i18n.js index ab1e0685..252058e7 100644 --- a/app/javascript/submission_form/i18n.js +++ b/app/javascript/submission_form/i18n.js @@ -93,7 +93,7 @@ const en = { reupload: 'Reupload', upload: 'Upload', files: 'Files', - signature_is_too_small_please_redraw: 'Signature is too small. Please redraw.', + signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.', wait_countdown_seconds: 'Wait {countdown} seconds' } @@ -191,7 +191,7 @@ const es = { reupload: 'Volver a subir', upload: 'Subir', files: 'Archivos', - signature_is_too_small_please_redraw: 'La firma es demasiado pequeña. Por favor, dibújala de nuevo.', + signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.', wait_countdown_seconds: 'Espera {countdown} segundos' } @@ -289,7 +289,7 @@ const it = { reupload: 'Ricarica', upload: 'Carica', files: 'File', - signature_is_too_small_please_redraw: 'La firma è troppo piccola. Ridisegnala per favore.', + signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.', wait_countdown_seconds: 'Attendi {countdown} secondi' } @@ -387,7 +387,7 @@ const de = { reupload: 'Erneut hochladen', upload: 'Hochladen', files: 'Dateien', - signature_is_too_small_please_redraw: 'Die Unterschrift ist zu klein. Bitte erneut zeichnen.', + signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte erneut zeichnen.', wait_countdown_seconds: 'Warte {countdown} Sekunden' } @@ -485,7 +485,7 @@ const fr = { reupload: 'Recharger', upload: 'Télécharger', files: 'Fichiers', - signature_is_too_small_please_redraw: 'La signature est trop petite. Veuillez la redessiner.', + signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.', wait_countdown_seconds: 'Attendez {countdown} secondes' } @@ -583,7 +583,7 @@ const pl = { reupload: 'Ponowne przesłanie', upload: 'Przesyłanie', files: 'Pliki', - signature_is_too_small_please_redraw: 'Podpis jest zbyt mały. Proszę narysować go ponownie.' + signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.' } const uk = { @@ -680,7 +680,7 @@ const uk = { reupload: 'Перезавантажити', upload: 'Завантажити', files: 'Файли', - signature_is_too_small_please_redraw: 'Підпис занадто малий. Будь ласка, перемалюйте його.', + signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.', wait_countdown_seconds: 'Зачекайте {countdown} секунд' } @@ -778,7 +778,7 @@ const cs = { reupload: 'Znovu nahrát', upload: 'Nahrát', files: 'Soubory', - signature_is_too_small_please_redraw: 'Podpis je příliš malý. Prosím, překreslete ho.', + signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.', wait_countdown_seconds: 'Počkejte {countdown} sekund' } @@ -876,7 +876,7 @@ const pt = { reupload: 'Reenviar', upload: 'Carregar', files: 'Arquivos', - signature_is_too_small_please_redraw: 'A assinatura é muito pequena. Por favor, redesenhe-a.', + signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.', wait_countdown_seconds: 'Aguarde {countdown} segundos' } @@ -975,7 +975,7 @@ const he = { reupload: 'העלה שוב', upload: 'העלאה', files: 'קבצים', - signature_is_too_small_please_redraw: 'החתימה קטנה מדי. אנא צייר מחדש.', + signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.', wait_countdown_seconds: 'המתן {countdown} שניות' } @@ -1074,7 +1074,7 @@ const nl = { reupload: 'Opnieuw uploaden', upload: 'Uploaden', files: 'Bestanden', - signature_is_too_small_please_redraw: 'De handtekening is te klein. Teken deze opnieuw, alstublieft.', + signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.', wait_countdown_seconds: 'Wacht {countdown} seconden' } @@ -1172,7 +1172,7 @@ const ar = { reupload: 'إعادة التحميل', upload: 'تحميل', files: 'الملفات', - signature_is_too_small_please_redraw: 'التوقيع صغير جدًا. يرجى إعادة الرسم.', + signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.', wait_countdown_seconds: 'انتظر {countdown} ثانية' } @@ -1269,7 +1269,7 @@ const ko = { reupload: '다시 업로드', upload: '업로드', files: '파일', - signature_is_too_small_please_redraw: '서명이 너무 작습니다. 다시 그려주세요.', + signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.', wait_countdown_seconds: '{countdown}초 기다리세요' } diff --git a/app/javascript/submission_form/initials_step.vue b/app/javascript/submission_form/initials_step.vue index 8abaab7b..99b1bc8a 100644 --- a/app/javascript/submission_form/initials_step.vue +++ b/app/javascript/submission_form/initials_step.vue @@ -118,11 +118,17 @@ :src="attachmentsIndex[modelValue || computedPreviousValue].url" class="mx-auto bg-white border border-base-300 rounded max-h-44" > - +
+
+ +
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue' import { cropCanvasAndExportToPNG } from './crop_canvas' +import { isValidSignatureCanvas } from './validate_signature' import SignaturePad from 'signature_pad' import AppearsOn from './appears_on' import FileDropzone from './dropzone' @@ -598,6 +599,7 @@ export default { }, drawImage (event) { this.remove() + this.clear() this.isSignatureStarted = true this.drawOnCanvas(event.target.files[0], this.$refs.canvas) @@ -680,8 +682,14 @@ export default { return Promise.resolve({}) } - return new Promise((resolve, reject) => { - cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => { + if (this.isSignatureStarted && this.pad.toData().length > 0 && !isValidSignatureCanvas(this.pad.toData())) { + alert(this.t('signature_is_too_small_or_simple_please_redraw')) + + return Promise.reject(new Error('Image too small or simple')) + } + + return new Promise((resolve) => { + cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => { const file = new File([blob], 'signature.png', { type: 'image/png' }) if (this.dryRun) { @@ -717,12 +725,6 @@ export default { return resolve(attachment) }) } - }).catch((error) => { - if (error.message === 'Image too small' && this.field.required === false) { - return resolve({}) - } else { - return reject(error) - } }) }) } diff --git a/app/javascript/submission_form/validate_signature.js b/app/javascript/submission_form/validate_signature.js new file mode 100644 index 00000000..26f04a23 --- /dev/null +++ b/app/javascript/submission_form/validate_signature.js @@ -0,0 +1,38 @@ +function isValidSignatureCanvas (data) { + if (data.length === 0) return false + + const strokes = data.filter(stroke => Array.isArray(stroke.points) && stroke.points.length > 2) + + if (strokes.length === 0) return false + + let skippedStraightLine = 0 + + const validStrokes = strokes.filter(stroke => { + const points = stroke.points + const first = points[0] + const last = points[points.length - 1] + const A = last.y - first.y + const B = first.x - last.x + const C = last.x * first.y - first.x * last.y + const lineLength = Math.sqrt(A * A + B * B) + + const totalDeviation = points.reduce((sum, p) => { + const distanceToLine = Math.abs(A * p.x + B * p.y + C) / lineLength + return sum + distanceToLine + }, 0) + + const avgDeviation = totalDeviation / points.length + + if (avgDeviation < 3 && skippedStraightLine < 2) { + skippedStraightLine++ + + return false + } + + return true + }) + + return validStrokes.length > 0 +} + +export { isValidSignatureCanvas } diff --git a/app/jobs/process_submitter_completion_job.rb b/app/jobs/process_submitter_completion_job.rb index 231a98d2..9c3a4b26 100644 --- a/app/jobs/process_submitter_completion_job.rb +++ b/app/jobs/process_submitter_completion_job.rb @@ -94,11 +94,27 @@ class ProcessSubmitterCompletionJob end end - to = build_to_addresses(submitter) + maybe_enqueue_copy_emails(submitter) + end + + def maybe_enqueue_copy_emails(submitter) + return if submitter.template.preferences['documents_copy_email_enabled'] == false + + configs = AccountConfigs.find_or_initialize_for_key(submitter.account, + AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY) + + return if configs.value['enabled'] == false - return if to.blank? || submitter.template.preferences['documents_copy_email_enabled'] == false + to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false } + .sort_by(&:completed_at).select(&:email?).map(&:friendly_name) - SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! + return if to.blank? + + if configs.value['bcc_recipients'] == true + to.each { |to| SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! } + else + SubmitterMailer.documents_copy_email(submitter, to: to.join(', ')).deliver_later! + end end def build_bcc_addresses(submission) @@ -110,11 +126,6 @@ class ProcessSubmitterCompletionJob bcc.to_s.scan(User::EMAIL_REGEXP) end - def build_to_addresses(submitter) - submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false } - .sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ') - end - def enqueue_next_submitter_request_notification(submitter) next_submitter_item = submitter.submission.template_submitters.find do |e| diff --git a/app/models/account_config.rb b/app/models/account_config.rb index adda8091..d573ad0f 100644 --- a/app/models/account_config.rb +++ b/app/models/account_config.rb @@ -30,6 +30,7 @@ class AccountConfig < ApplicationRecord ALLOW_TO_RESUBMIT = 'allow_to_resubmit' ALLOW_TO_DECLINE_KEY = 'allow_to_decline' SUBMITTER_REMINDERS = 'submitter_reminders' + ENFORCE_SIGNING_ORDER_KEY = 'enforce_signing_order' FORM_COMPLETED_BUTTON_KEY = 'form_completed_button' FORM_COMPLETED_MESSAGE_KEY = 'form_completed_message' FORM_WITH_CONFETTI_KEY = 'form_with_confetti' diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index b8e945d5..7c6db5eb 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -151,6 +151,20 @@
<% end %> <% end %> + <% if !Docuseal.multitenant? || can?(:manage, :personalization_advanced) %> + <% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ENFORCE_SIGNING_ORDER_KEY) %> + <% if can?(:manage, account_config) %> + <%= form_for account_config, url: account_configs_path, method: :post do |f| %> + <%= f.hidden_field :key %> +
+ + <%= t('always_enforce_signing_order') %> + + <%= f.check_box :value, class: 'toggle', checked: account_config.value, onchange: 'this.form.requestSubmit()' %> +
+ <% end %> + <% end %> + <% end %> <% end %> <%= render 'compliances' %> diff --git a/app/views/personalization_settings/_documents_copy_email_form.html.erb b/app/views/personalization_settings/_documents_copy_email_form.html.erb index fcb3b18a..c5af6c49 100644 --- a/app/views/personalization_settings/_documents_copy_email_form.html.erb +++ b/app/views/personalization_settings/_documents_copy_email_form.html.erb @@ -8,7 +8,7 @@
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %> <%= f.hidden_field :key %> - <%= f.fields_for :value, Struct.new(:subject, :body, :reply_to, :attach_audit_log, :attach_documents).new(*f.object.value.values_at('subject', 'body', 'reply_to', 'attach_audit_log', 'attach_documents')) do |ff| %> + <%= f.fields_for :value, Struct.new(:subject, :body, :reply_to, :attach_audit_log, :attach_documents, :bcc_recipients, :enabled).new(*f.object.value.values_at('subject', 'body', 'reply_to', 'attach_audit_log', 'attach_documents', 'bcc_recipients', 'enabled')) do |ff| %>
<%= ff.label :subject, t('subject'), class: 'label' %> <%= ff.text_field :subject, required: true, class: 'base-input', dir: 'auto' %> @@ -24,23 +24,41 @@ <%= ff.text_area :body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
- <% if can?(:manage, :reply_to) %> + <% if can?(:manage, :reply_to) || can?(:manage, :personalization_advanced) %>
<%= ff.label :reply_to, t('reply_to'), class: 'label' %> <%= ff.email_field :reply_to, class: 'base-input', dir: 'auto', placeholder: t(:email) %>
<% end %> -
- - <%= t('attach_documents') %> - - <%= ff.check_box :attach_documents, { checked: ff.object.attach_documents != false, class: 'toggle' }, 'true', 'false' %> -
-
- - <%= t('attach_audit_log_pdf') %> - - <%= ff.check_box :attach_audit_log, { checked: ff.object.attach_audit_log != false, class: 'toggle' }, 'true', 'false' %> +
+
+ + <%= t('attach_documents') %> + + <%= ff.check_box :attach_documents, { checked: ff.object.attach_documents != false, class: 'toggle' }, 'true', 'false' %> +
+
+ + <%= t('attach_audit_log_pdf') %> + + <%= ff.check_box :attach_audit_log, { checked: ff.object.attach_audit_log != false, class: 'toggle' }, 'true', 'false' %> +
+ <% unless Docuseal.multitenant? %> +
+ + <%= t('bcc_recipients') %> + + <%= ff.check_box :bcc_recipients, { checked: ff.object.bcc_recipients == true, class: 'toggle' }, 'true', 'false' %> +
+ <% end %> + <% if !Docuseal.multitenant? || can?(:manage, :personalization_advanced) %> +
+ + <%= t('send_emails_automatically_on_completion') %> + + <%= ff.check_box :enabled, { checked: ff.object.enabled != false, class: 'toggle' }, 'true', 'false' %> +
+ <% end %>
<% end %>
diff --git a/app/views/shared/_meta.html.erb b/app/views/shared/_meta.html.erb index 62bc3afa..7056b684 100644 --- a/app/views/shared/_meta.html.erb +++ b/app/views/shared/_meta.html.erb @@ -13,10 +13,10 @@ <% else %> - + - + <% end %> diff --git a/app/views/templates_preferences/show.html.erb b/app/views/templates_preferences/show.html.erb index 12c020b1..013e3918 100644 --- a/app/views/templates_preferences/show.html.erb +++ b/app/views/templates_preferences/show.html.erb @@ -206,7 +206,7 @@ <%= t('send_emails_automatically_on_completion') %> - <%= ff.check_box :documents_copy_email_enabled, { checked: ff.object.documents_copy_email_enabled != false, class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'true', 'false' %> + <%= ff.check_box :documents_copy_email_enabled, { checked: ff.object.documents_copy_email_enabled != false && configs['enabled'] != false, class: 'toggle', onchange: 'this.form.requestSubmit()', disabled: configs['enabled'] == false }, 'true', 'false' %>
<% end %>
@@ -322,15 +322,17 @@ <% end %>
<% end %> - <%= 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 %> -
+ <% 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 %> +
+ <% end %> <% end %>
<%= button_tag button_title(title: t('save'), disabled_with: t('updating')), class: 'base-button', form: :submitters_form %> diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index a37a59c0..e1d10cda 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -1,7 +1,7 @@
<%= render 'shared/settings_nav' %>
-
+

<% if params[:status] == 'archived' %> <%= t('archived_users') %> diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index 2bd3c902..4b758b49 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -20,6 +20,8 @@ en: &en language_ko: 한국어 hi_there: Hi there thanks: Thanks + bcc_recipients: BCC recipients + always_enforce_signing_order: Always enforce the signing order edit_per_party: Edit per party reply_to: Reply to pending_by_me: Pending by me @@ -432,7 +434,7 @@ en: &en share_template_with_test_mode: Share template with Test mode share_template_with_all_tenants: Share template with all Tenants use_following_placeholders_text_: 'Use following placeholders text:' - upgrade_plan_to_add_more_users: Upgrade plan to add more users + users_count_total_users_count_pro_users_limit_was_reached_to_invite_additional_users_please_purchase_more_pro_user_seats_via_the_manage_plan_button: '%{users_count}/%{total_users_count} Pro users limit was reached. To invite additional users, please purchase more Pro user seats via the "Manage plan" button.' move_into_folder: Move Into Folder new_folder_name: New Folder Name exit_preview: Exit Preview @@ -473,8 +475,6 @@ en: &en team_accounts: Team Accounts tenant_account: Tenant account tenant_accounts: Tenant Accounts - upgrade_plan: Upgrade Plan - add_user: Add User impersonate: Impersonate loading: Loading documents: Documents @@ -745,6 +745,8 @@ en: &en read: Read your data es: &es + always_enforce_signing_order: Siempre imponer el orden de firma + bcc_recipients: Destinatarios CCO edit_per_party: Editar por parte signed: Firmado reply_to: Responder a @@ -1159,7 +1161,7 @@ es: &es share_template_with_test_mode: Compartir plantilla con el modo de prueba share_template_with_all_tenants: Compartir plantilla con todos los inquilinos use_following_placeholders_text_: 'Usa los siguientes marcadores de posición:' - upgrade_plan_to_add_more_users: Actualiza el plan para agregar más usuarios + users_count_total_users_count_pro_users_limit_was_reached_to_invite_additional_users_please_purchase_more_pro_user_seats_via_the_manage_plan_button: 'Se alcanzó el límite de %{users_count}/%{total_users_count} usuarios Pro. Para invitar a más usuarios, compra más plazas Pro usando el botón "Gestionar plan".' move_into_folder: Mover a la carpeta new_folder_name: Nuevo nombre de la carpeta exit_preview: Salir de la vista previa @@ -1200,8 +1202,6 @@ es: &es team_accounts: Cuentas de equipo tenant_account: Cuenta de inquilino tenant_accounts: Cuentas de inquilino - upgrade_plan: Actualizar plan - add_user: Agregar usuario impersonate: Suplantar loading: Cargando documents: Documentos @@ -1472,6 +1472,8 @@ es: &es read: Leer tus datos it: &it + always_enforce_signing_order: Applicare sempre l'ordine di firma + bcc_recipients: Destinatari BCC edit_per_party: Modifica per partito signed: Firmato reply_to: Rispondi a @@ -1885,7 +1887,7 @@ it: &it share_template_with_test_mode: Condividi modello con la modalità di test share_template_with_all_tenants: Condividi il modello con tutti i tenant use_following_placeholders_text_: 'Usa i seguenti segnaposto:' - upgrade_plan_to_add_more_users: Aggiorna il piano per aggiungere più utenti + users_count_total_users_count_pro_users_limit_was_reached_to_invite_additional_users_please_purchase_more_pro_user_seats_via_the_manage_plan_button: 'È stato raggiunto il limite di %{users_count}/%{total_users_count} utenti Pro. Per invitare altri utenti, acquista più posti Pro tramite il pulsante "Gestisci piano".' move_into_folder: Sposta nella cartella new_folder_name: Nuovo nome della cartella exit_preview: "Esci dall'anteprima" @@ -1926,8 +1928,6 @@ it: &it team_accounts: Account di squadra tenant_account: Account tenant tenant_accounts: Account tenant - upgrade_plan: Aggiorna piano - add_user: Aggiungi utente impersonate: Impersona loading: Caricamento in corso documents: Documenti @@ -2198,6 +2198,8 @@ it: &it read: Leggi i tuoi dati fr: &fr + always_enforce_signing_order: Toujours appliquer l'ordre de signature + bcc_recipients: Destinataires en CCI edit_per_party: Éditer par partie signed: Signé reply_to: Répondre à @@ -2613,7 +2615,7 @@ fr: &fr share_template_with_test_mode: Partager le modèle avec le mode test share_template_with_all_tenants: Partager le modèle avec tous les locataires use_following_placeholders_text_: 'Utilisez les espaces réservés suivants:' - upgrade_plan_to_add_more_users: "Mettez à jour le plan pour ajouter plus d'utilisateurs" + users_count_total_users_count_pro_users_limit_was_reached_to_invite_additional_users_please_purchase_more_pro_user_seats_via_the_manage_plan_button: 'La limite de %{users_count}/%{total_users_count} utilisateurs Pro a été atteinte. Pour inviter d''autres utilisateurs, veuillez acheter plus de places Pro via le bouton "Gérer le plan".' move_into_folder: Déplacer dans le dossier new_folder_name: Nouveau nom du dossier exit_preview: "Quitter l'aperçu" @@ -2654,8 +2656,6 @@ fr: &fr team_accounts: "Comptes d'équipe" tenant_account: Compte locataire tenant_accounts: Comptes locataires - upgrade_plan: Mettre à jour le plan - add_user: Ajouter un utilisateur impersonate: Usurper loading: Chargement en cours documents: Documents @@ -2926,6 +2926,8 @@ fr: &fr read: Lire vos données pt: &pt + always_enforce_signing_order: Sempre impor a ordem de assinatura + bcc_recipients: Destinatários BCC edit_per_party: Edita por festa signed: Assinado reply_to: Responder a @@ -3340,7 +3342,7 @@ pt: &pt share_template_with_test_mode: Compartilhar modelo com o modo de teste share_template_with_all_tenants: Compartilhar modelo com todos os locatários use_following_placeholders_text_: 'Use os seguintes textos de substituição:' - upgrade_plan_to_add_more_users: Faça upgrade do plano para adicionar mais usuários + users_count_total_users_count_pro_users_limit_was_reached_to_invite_additional_users_please_purchase_more_pro_user_seats_via_the_manage_plan_button: 'O limite de %{users_count}/%{total_users_count} usuários Pro foi atingido. Para convidar mais usuários, adquira mais licenças Pro através do botão "Gerenciar plano".' move_into_folder: Mover para pasta new_folder_name: Novo nome da pasta exit_preview: Sair da pré-visualização @@ -3381,8 +3383,6 @@ pt: &pt team_accounts: Contas de equipe tenant_account: Conta de locatário tenant_accounts: Contas de locatário - upgrade_plan: Fazer upgrade do plano - add_user: Adicionar usuário impersonate: Usurpar loading: Carregando documents: Documentos @@ -3653,6 +3653,8 @@ pt: &pt read: Ler seus dados de: &de + always_enforce_signing_order: Immer die Reihenfolge der Unterschriften erzwingen + bcc_recipients: BCC-Empfänger edit_per_party: Bearbeiten pro Partei signed: Unterschrieben reply_to: Antworten auf @@ -4067,7 +4069,7 @@ de: &de share_template_with_test_mode: Vorlage mit dem Testmodus teilen share_template_with_all_tenants: Vorlage mit allen Mietern teilen use_following_placeholders_text_: 'Verwende die folgenden Platzhaltertexte:' - upgrade_plan_to_add_more_users: Upgrade des Plans, um weitere Benutzer hinzuzufügen + users_count_total_users_count_pro_users_limit_was_reached_to_invite_additional_users_please_purchase_more_pro_user_seats_via_the_manage_plan_button: 'Das Limit von %{users_count}/%{total_users_count} Pro-Benutzern wurde erreicht. Um weitere Benutzer einzuladen, kaufen Sie bitte zusätzliche Pro-Benutzerplätze über die Schaltfläche "Plan verwalten".' move_into_folder: In Ordner verschieben new_folder_name: Neuer Ordnername exit_preview: Vorschau beenden @@ -4108,8 +4110,6 @@ de: &de team_accounts: Teamkonten tenant_account: Mieterkonto tenant_accounts: Mieterkonten - upgrade_plan: Plan upgraden - add_user: Benutzer hinzufügen impersonate: Nachahmen loading: Wird geladen documents: Dokumente diff --git a/lib/action_mailer_configs_interceptor.rb b/lib/action_mailer_configs_interceptor.rb index f823fc3d..b81cb45d 100644 --- a/lib/action_mailer_configs_interceptor.rb +++ b/lib/action_mailer_configs_interceptor.rb @@ -28,7 +28,7 @@ module ActionMailerConfigsInterceptor end unless Docuseal.multitenant? - email_configs = EncryptedConfig.find_by(key: EncryptedConfig::EMAIL_SMTP_KEY) + email_configs = EncryptedConfig.order(:account_id).find_by(key: EncryptedConfig::EMAIL_SMTP_KEY) if email_configs message.delivery_method(:smtp, build_smtp_configs_hash(email_configs)) diff --git a/lib/submissions.rb b/lib/submissions.rb index 9843fd0f..ab9d65ea 100644 --- a/lib/submissions.rb +++ b/lib/submissions.rb @@ -196,4 +196,20 @@ module Submissions end end.exclude?(false) end + + def regenerate_documents(submission) + submitters = submission.submitters.where.not(completed_at: nil).preload(:documents_attachments) + + submitters.each { |submitter| submitter.documents.each(&:destroy!) } + + submission.submitters.where.not(completed_at: nil).order(:completed_at).each do |submitter| + GenerateResultAttachments.call(submitter) + end + + return if submission.combined_document_attachment.blank? + + submission.combined_document_attachment.destroy! + + Submissions::GenerateCombinedAttachment.call(submission.submitters.completed.order(:completed_at).last) + end end diff --git a/lib/submissions/ensure_result_generated.rb b/lib/submissions/ensure_result_generated.rb index dbbe5679..423f3097 100644 --- a/lib/submissions/ensure_result_generated.rb +++ b/lib/submissions/ensure_result_generated.rb @@ -7,11 +7,15 @@ module Submissions CHECK_COMPLETE_TIMEOUT = 90.seconds WaitForCompleteTimeout = Class.new(StandardError) + NotCompletedYet = Class.new(StandardError) module_function def call(submitter) return [] unless submitter + + raise NotCompletedYet unless submitter.completed_at? + return submitter.documents if ApplicationRecord.uncached { submitter.document_generation_events.complete.exists? } events = diff --git a/lib/submitters/form_configs.rb b/lib/submitters/form_configs.rb index 7b41afdf..ef4077e5 100644 --- a/lib/submitters/form_configs.rb +++ b/lib/submitters/form_configs.rb @@ -8,6 +8,7 @@ module Submitters AccountConfig::FORM_PREFILL_SIGNATURE_KEY, AccountConfig::WITH_SIGNATURE_ID, AccountConfig::ALLOW_TO_DECLINE_KEY, + AccountConfig::ENFORCE_SIGNING_ORDER_KEY, AccountConfig::REQUIRE_SIGNING_REASON_KEY, AccountConfig::REUSE_SIGNATURE_KEY, AccountConfig::ALLOW_TYPED_SIGNATURE, @@ -27,6 +28,7 @@ module Submitters with_decline = find_safe_value(configs, AccountConfig::ALLOW_TO_DECLINE_KEY) != false with_signature_id = find_safe_value(configs, AccountConfig::WITH_SIGNATURE_ID) == true require_signing_reason = find_safe_value(configs, AccountConfig::REQUIRE_SIGNING_REASON_KEY) == true + enforce_signing_order = find_safe_value(configs, AccountConfig::ENFORCE_SIGNING_ORDER_KEY) == true policy_links = find_safe_value(configs, AccountConfig::POLICY_LINKS_KEY) attrs = { @@ -36,6 +38,7 @@ module Submitters reuse_signature:, with_decline:, policy_links:, + enforce_signing_order:, completed_message:, require_signing_reason:, prefill_signature:, diff --git a/lib/submitters/submit_values.rb b/lib/submitters/submit_values.rb index 15f2da02..0fd499f0 100644 --- a/lib/submitters/submit_values.rb +++ b/lib/submitters/submit_values.rb @@ -3,12 +3,14 @@ module Submitters module SubmitValues ValidationError = Class.new(StandardError) + RequiredFieldError = Class.new(StandardError) VARIABLE_REGEXP = /\{\{?(\w+)\}\}?/ + NONEDITABLE_FIELD_TYPES = %w[stamp heading].freeze module_function - def call(submitter, params, request) + def call(submitter, params, request, validate_required: true) Submissions.update_template_fields!(submitter.submission) if submitter.submission.template_fields.blank? unless submitter.submission_events.exists?(event_type: 'start_form') @@ -20,7 +22,7 @@ module Submitters end end - update_submitter!(submitter, params, request) + update_submitter!(submitter, params, request, validate_required:) submitter.submission.save! @@ -29,13 +31,13 @@ module Submitters submitter end - def update_submitter!(submitter, params, request) + def update_submitter!(submitter, params, request, validate_required: true) values = normalized_values(params) submitter.values.merge!(values) submitter.opened_at ||= Time.current - assign_completed_attributes(submitter, request) if params[:completed] == 'true' + assign_completed_attributes(submitter, request, validate_required:) if params[:completed] == 'true' ApplicationRecord.transaction do maybe_set_signature_reason!(values, submitter, params) @@ -49,25 +51,36 @@ module Submitters submitter end - def assign_completed_attributes(submitter, request) + def assign_completed_attributes(submitter, request, validate_required: true) submitter.completed_at = Time.current submitter.ip = request.remote_ip submitter.ua = request.user_agent submitter.values = merge_default_values(submitter) - submitter.values = maybe_remove_condition_values(submitter) + + required_field_uuids_acc = Set.new + + submitter.values = maybe_remove_condition_values(submitter, required_field_uuids_acc:) formula_values = build_formula_values(submitter) if formula_values.present? submitter.values = submitter.values.merge(formula_values) - submitter.values = maybe_remove_condition_values(submitter) + submitter.values = maybe_remove_condition_values(submitter, required_field_uuids_acc:) end submitter.values = submitter.values.transform_values do |v| v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v end + required_field_uuids_acc.each do |uuid| + next if submitter.values[uuid].present? + + raise RequiredFieldError, uuid if validate_required + + Rollbar.warning("Required field #{submitter.id}: #{uuid}") if defined?(Rollbar) + end + submitter end @@ -188,37 +201,67 @@ module Submitters with_time:) end - def maybe_remove_condition_values(submitter) - fields_uuid_index = submitter.submission.template_fields.index_by { |e| e['uuid'] } + def maybe_remove_condition_values(submitter, required_field_uuids_acc: nil) + submission = submitter.submission - attachments_index = - Submissions.filtered_conditions_schema(submitter.submission).index_by { |i| i['attachment_uuid'] } + fields_uuid_index = submission.template_fields.index_by { |e| e['uuid'] } - submitter_values = nil - is_other_submitter_conditions = submitter.submission.template_submitters.size > 1 + submitters_values = nil + has_other_submitters = submission.template_submitters.size > 1 - submitter.submission.template_fields.each do |field| - next if field['submitter_uuid'] != submitter.uuid + has_document_conditions = + (submission.template_schema || submission.template.schema).any? { |e| e['conditions'].present? } + + attachments_index = + if has_document_conditions + Submissions.filtered_conditions_schema(submission).index_by { |i| i['attachment_uuid'] } + end - submitter_values ||= submitter.values + submission.template_fields.each do |field| + next if field['submitter_uuid'] != submitter.uuid - is_other_submitter_conditions &&= field_conditions_other_submitter?(submitter, field, fields_uuid_index) + required_field_uuids_acc.add(field['uuid']) if required_field_uuids_acc && required_editable_field?(field) - if is_other_submitter_conditions - submitter_values = submitter.submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } + if has_document_conditions && !check_field_areas_attachments(field, attachments_index) + submitter.values.delete(field['uuid']) + required_field_uuids_acc.delete(field['uuid']) end - submitter.values.delete(field['uuid']) unless check_field_conditions(submitter_values, field, fields_uuid_index) + if has_other_submitters && !submitters_values && + field_conditions_other_submitter?(submitter, field, fields_uuid_index) + submitters_values = merge_submitters_values(submitter) + end - if field['areas'].present? && field['areas'].none? { |area| attachments_index[area['attachment_uuid']] } + unless check_field_conditions(submitters_values || submitter.values, field, fields_uuid_index) submitter.values.delete(field['uuid']) + required_field_uuids_acc.delete(field['uuid']) end end submitter.values end + def required_editable_field?(field) + return false if NONEDITABLE_FIELD_TYPES.include?(field['type']) + + field['required'].present? && field['readonly'].blank? + end + + def check_field_areas_attachments(field, attachments_index) + return true if field['areas'].blank? + + field['areas'].any? { |area| attachments_index[area['attachment_uuid']] } + end + + def merge_submitters_values(submitter) + submitter.submission.submitters + .reduce({}) { |acc, sub| acc.merge(sub.values) } + .merge(submitter.values) + end + def field_conditions_other_submitter?(submitter, field, fields_uuid_index) + return false if field['conditions'].blank? + field['conditions'].to_a.any? do |c| fields_uuid_index.dig(c['field_uuid'], 'submitter_uuid') != submitter.uuid end diff --git a/public/preview.png b/public/preview.png new file mode 100644 index 00000000..9cbbfd0d Binary files /dev/null and b/public/preview.png differ diff --git a/spec/signing_form_helper.rb b/spec/signing_form_helper.rb index 6d86484b..b00d5f8b 100644 --- a/spec/signing_form_helper.rb +++ b/spec/signing_form_helper.rb @@ -4,21 +4,49 @@ module SigningFormHelper module_function def draw_canvas - page.find('canvas').click([], { x: 150, y: 100 }) page.execute_script <<~JS const canvas = document.getElementsByTagName('canvas')[0]; - const ctx = canvas.getContext('2d'); + const rect = canvas.getBoundingClientRect(); - ctx.beginPath(); - ctx.moveTo(150, 100); - ctx.lineTo(450, 100); - ctx.stroke(); + const startX = rect.left + 50; + const startY = rect.top + 100; - ctx.beginPath(); - ctx.moveTo(150, 100); - ctx.lineTo(150, 150); - ctx.stroke(); + const amplitude = 20; + const wavelength = 30; + const length = 300; + + function dispatchPointerEvent(type, x, y) { + const event = new PointerEvent(type, { + pointerId: 1, + pointerType: 'pen', + isPrimary: true, + clientX: x, + clientY: y, + bubbles: true, + pressure: 0.5 + }); + + canvas.dispatchEvent(event); + } + + dispatchPointerEvent('pointerdown', startX, startY); + + let x = 0; + function drawStep() { + if (x > length) { + dispatchPointerEvent('pointerup', startX + x, startY); + return; + } + + const y = startY + amplitude * Math.sin((x / wavelength) * 2 * Math.PI); + dispatchPointerEvent('pointermove', startX + x, y); + x += 5; + requestAnimationFrame(drawStep); + } + + drawStep(); JS + sleep 0.5 end diff --git a/spec/system/signing_form_spec.rb b/spec/system/signing_form_spec.rb index e9c41fce..bbf1d797 100644 --- a/spec/system/signing_form_spec.rb +++ b/spec/system/signing_form_spec.rb @@ -360,6 +360,19 @@ RSpec.describe 'Signing Form', type: :system do expect(field_value(submitter, 'Signature')).to be_present end + it 'shows an error message if the canvas is not drawn or too simple' do + visit submit_form_path(slug: submitter.slug) + + find('#expand_form_button').click + page.find('canvas').click([], { x: 150, y: 100 }) + + alert_text = page.accept_alert do + click_button 'Sign and Complete' + end + + expect(alert_text).to eq 'Signature is too small or simple. Please redraw.' + end + it 'completes the form if the canvas is typed' do visit submit_form_path(slug: submitter.slug)