From f995e1864cc607b21781bda04ac1c40cfade2a4f Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 13 Apr 2026 12:07:39 +0300 Subject: [PATCH 01/18] add shared link qr code --- .../templates_share_link_qr_controller.rb | 22 ++ app/views/icons/_printer.html.erb | 6 + app/views/icons/_qrcode.html.erb | 15 + app/views/templates_share_link/show.html.erb | 7 +- .../_branding.html.erb | 2 + .../templates_share_link_qr/_logo.html.erb | 2 + .../templates_share_link_qr/disabled.html.erb | 10 + .../templates_share_link_qr/show.html.erb | 288 ++++++++++++++++++ config/locales/i18n.yml | 21 ++ config/routes.rb | 1 + 10 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 app/controllers/templates_share_link_qr_controller.rb create mode 100644 app/views/icons/_printer.html.erb create mode 100644 app/views/icons/_qrcode.html.erb create mode 100644 app/views/templates_share_link_qr/_branding.html.erb create mode 100644 app/views/templates_share_link_qr/_logo.html.erb create mode 100644 app/views/templates_share_link_qr/disabled.html.erb create mode 100644 app/views/templates_share_link_qr/show.html.erb diff --git a/app/controllers/templates_share_link_qr_controller.rb b/app/controllers/templates_share_link_qr_controller.rb new file mode 100644 index 00000000..e16c0eec --- /dev/null +++ b/app/controllers/templates_share_link_qr_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class TemplatesShareLinkQrController < ApplicationController + load_and_authorize_resource :template + + def show + return render :disabled, layout: 'plain' unless @template.shared_link? + + shared_link_url = start_form_url(slug: @template.slug, host: form_link_host) + + @qr_svg_code = RQRCode::QRCode.new(shared_link_url, level: :m).as_svg(viewbox: true) + + @page_size = + if TimeUtils.timezone_abbr(current_account.timezone, Time.current.beginning_of_year).in?(TimeUtils::US_TIMEZONES) + 'Letter' + else + 'A4' + end + + render :show, layout: false + end +end diff --git a/app/views/icons/_printer.html.erb b/app/views/icons/_printer.html.erb new file mode 100644 index 00000000..feacbdbb --- /dev/null +++ b/app/views/icons/_printer.html.erb @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/views/icons/_qrcode.html.erb b/app/views/icons/_qrcode.html.erb new file mode 100644 index 00000000..43457966 --- /dev/null +++ b/app/views/icons/_qrcode.html.erb @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/views/templates_share_link/show.html.erb b/app/views/templates_share_link/show.html.erb index b654c8d7..910559b4 100644 --- a/app/views/templates_share_link/show.html.erb +++ b/app/views/templates_share_link/show.html.erb @@ -23,7 +23,12 @@ <% end %>
- + <%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug, host: form_link_host), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %> diff --git a/app/views/templates_share_link_qr/_branding.html.erb b/app/views/templates_share_link_qr/_branding.html.erb new file mode 100644 index 00000000..47822ac5 --- /dev/null +++ b/app/views/templates_share_link_qr/_branding.html.erb @@ -0,0 +1,2 @@ +<%= t('powered_by') %> +<%= Docuseal.product_name %> diff --git a/app/views/templates_share_link_qr/_logo.html.erb b/app/views/templates_share_link_qr/_logo.html.erb new file mode 100644 index 00000000..067e3227 --- /dev/null +++ b/app/views/templates_share_link_qr/_logo.html.erb @@ -0,0 +1,2 @@ +<%= render 'shared/logo' %> +<%= Docuseal.product_name %> diff --git a/app/views/templates_share_link_qr/disabled.html.erb b/app/views/templates_share_link_qr/disabled.html.erb new file mode 100644 index 00000000..79c12aef --- /dev/null +++ b/app/views/templates_share_link_qr/disabled.html.erb @@ -0,0 +1,10 @@ +
+

+ <%= t('share_link_is_currently_disabled') %> +

+ <% if can?(:update, @template) %> + + <%= button_to button_title(title: t('enable_shared_link'), icon: svg_icon('lock_open', class: 'w-6 h-6')), template_share_link_path(@template), params: { template: { shared_link: true }, redir: template_share_link_qr_path(@template) }, method: :post, data: { turbo: false }, class: 'white-button w-full' %> + + <% end %> +
diff --git a/app/views/templates_share_link_qr/show.html.erb b/app/views/templates_share_link_qr/show.html.erb new file mode 100644 index 00000000..62fbd757 --- /dev/null +++ b/app/views/templates_share_link_qr/show.html.erb @@ -0,0 +1,288 @@ + +<% page_width_css = @page_size == 'Letter' ? 8.5 * 96.0 : 210.0 * 96.0 / 25.4 %> +<% page_height_css = @page_size == 'Letter' ? 11.0 * 96.0 : 297.0 * 96.0 / 25.4 %> +<% page_width = @page_size == 'Letter' ? '8.5in' : '210mm' %> +<% page_cqw = ->(px) { format('%.6fcqw', px / page_width_css * 100.0) } %> + + + + + <%= @template.name %> + + + +
+
+ +
+
<%= @template.name %>
+
+ <%== @qr_svg_code %> +
+ +
+
+ <%= render 'branding' %> +
+
+
+ + + + diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index da006683..3801b210 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -367,6 +367,9 @@ en: &en sign_out: Sign out page_number: 'Page %{number}' powered_by: Powered by + qr_code: QR Code + print: Print + scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scan the QR code above with your phone camera to open and sign this document. count_documents_signed_with_html: '%{count} documents signed with' storage: Storage notifications: Notifications @@ -1406,6 +1409,9 @@ es: &es sign_out: Cerrar sesión page_number: 'Página %{number}' powered_by: Desarrollado por + qr_code: Código QR + print: Imprimir + scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Escanea el código QR de arriba con la cámara de tu teléfono para abrir y firmar este documento. count_documents_signed_with_html: '%{count} documentos firmados con' storage: Almacenamiento notifications: Notificaciones @@ -2442,6 +2448,9 @@ it: &it sign_out: Esci page_number: 'Pagina %{number}' powered_by: Fornito da + qr_code: Codice QR + print: Stampa + scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scansiona il codice QR qui sopra con la fotocamera del tuo telefono per aprire e firmare questo documento. count_documents_signed_with_html: '%{count} documenti firmati con' storage: Archiviazione notifications: Notifiche @@ -3479,6 +3488,9 @@ fr: &fr sign_out: Se déconnecter page_number: Page %{number} powered_by: Propulsé par + qr_code: Code QR + print: Imprimer + scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scannez le code QR ci-dessus avec l'appareil photo de votre téléphone pour ouvrir et signer ce document. count_documents_signed_with_html: "%{count} documents signés avec" storage: Stockage notifications: Notifications @@ -4512,6 +4524,9 @@ pt: &pt sign_out: Sair page_number: 'Página %{number}' powered_by: Desenvolvido por + qr_code: Código QR + print: Imprimir + scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Escaneie o código QR acima com a câmera do seu telefone para abrir e assinar este documento. count_documents_signed_with_html: '%{count} documentos assinados com' storage: Armazenamento notifications: Notificações @@ -5548,6 +5563,9 @@ de: &de sign_out: Abmelden page_number: 'Seite %{number}' powered_by: Bereitgestellt von + qr_code: QR-Code + print: Drucken + scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scannen Sie den QR-Code oben mit Ihrer Handykamera, um dieses Dokument zu öffnen und zu unterzeichnen. count_documents_signed_with_html: '%{count} Dokumente signiert mit' storage: Speicher notifications: Benachrichtigungen @@ -6985,6 +7003,9 @@ nl: &nl sign_out: Afmelden page_number: Pagina %{number} powered_by: Aangedreven door + qr_code: QR-code + print: Afdrukken + scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scan de bovenstaande QR-code met je telefooncamera om dit document te openen en te ondertekenen. count_documents_signed_with_html: "%{count} documenten ondertekend met" storage: Opslag notifications: Meldingen diff --git a/config/routes.rb b/config/routes.rb index f9d3ae38..fc13ceaa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -108,6 +108,7 @@ Rails.application.routes.draw do resource :code_modal, only: %i[show], controller: 'templates_code_modal' resource :preferences, only: %i[show create destroy], controller: 'templates_preferences' resource :share_link, only: %i[show create], controller: 'templates_share_link' + resource :share_link_qr, only: %i[show], controller: 'templates_share_link_qr' resources :recipients, only: %i[create], controller: 'templates_recipients' resources :prefillable_fields, only: %i[create], controller: 'templates_prefillable_fields' resources :submissions_export, only: %i[index new] From fda911e178f4e81363af25731cf43b855af4490f Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 13 Apr 2026 16:32:03 +0300 Subject: [PATCH 02/18] add submitter field validation --- lib/submitters/submit_values.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/submitters/submit_values.rb b/lib/submitters/submit_values.rb index 1062c5a9..8c43e410 100644 --- a/lib/submitters/submit_values.rb +++ b/lib/submitters/submit_values.rb @@ -454,6 +454,8 @@ module Submitters end def validate_value!(_value, field, _params, submitter, _request) + raise ValidationError, 'Invalid field' if field.nil? || field['submitter_uuid'] != submitter.uuid + if field['readonly'] == true Rollbar.warning("Readonly field #{submitter.id}: #{field['uuid']}") if defined?(Rollbar) From 1355d350c5bb32b7943ca3bce2390b70ff79dd4a Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 13 Apr 2026 18:13:38 +0300 Subject: [PATCH 03/18] fix xlsx boolean value --- lib/submissions/generate_export_files.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/submissions/generate_export_files.rb b/lib/submissions/generate_export_files.rb index 23bc843d..f1096db2 100644 --- a/lib/submissions/generate_export_files.rb +++ b/lib/submissions/generate_export_files.rb @@ -150,6 +150,8 @@ module Submissions ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:) if attachment end + elsif submitter_value == true || submitter_value == false + submitter_value.to_s else submitter_value end From 3c3b61fb47016037211b40e4b185675646927701 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 13 Apr 2026 18:22:25 +0300 Subject: [PATCH 04/18] adjust reason field --- lib/submitters/submit_values.rb | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/lib/submitters/submit_values.rb b/lib/submitters/submit_values.rb index 8c43e410..2bb63d3c 100644 --- a/lib/submitters/submit_values.rb +++ b/lib/submitters/submit_values.rb @@ -107,24 +107,33 @@ module Submitters reason_field_uuid = params[:with_reason] signature_field_uuid = values.except(reason_field_uuid).keys.first - signature_field = submitter.submission.template_fields.find { |e| e['uuid'] == signature_field_uuid } - - signature_field['preferences'] ||= {} - signature_field['preferences']['reason_field_uuid'] = reason_field_uuid + signature_field = submitter.submission.template_fields.find do |e| + e['uuid'] == signature_field_uuid && e['submitter_uuid'] == submitter.uuid + end - reason_field = submitter.submission.template_fields.find { |e| e['uuid'] == reason_field_uuid } + reason_field = submitter.submission.template_fields.find do |e| + e['uuid'] == reason_field_uuid && e['submitter_uuid'] == submitter.uuid + end - unless reason_field + if reason_field + if reason_field.dig('preferences', 'signature_field_uuid') != signature_field['uuid'] + raise ValidationError, 'Invalid field' + end + else reason_field = { 'type' => 'text', 'uuid' => reason_field_uuid, 'name' => I18n.t(:reason), 'readonly' => true, + 'preferences' => { 'signature_field_uuid' => signature_field['uuid'] }, 'submitter_uuid' => submitter.uuid } submitter.submission.template_fields.insert(submitter.submission.template_fields.index(signature_field) + 1, reason_field) end + signature_field['preferences'] ||= {} + signature_field['preferences']['reason_field_uuid'] = reason_field_uuid + submitter.submission.save! reason_field From e68968780595b4db1137ca5fdf4296f48fe0ca70 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 13 Apr 2026 19:21:24 +0300 Subject: [PATCH 05/18] fix erblint --- app/views/templates_share_link_qr/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/templates_share_link_qr/show.html.erb b/app/views/templates_share_link_qr/show.html.erb index 62fbd757..322be0c2 100644 --- a/app/views/templates_share_link_qr/show.html.erb +++ b/app/views/templates_share_link_qr/show.html.erb @@ -26,7 +26,7 @@ container-type: size; width: min(100vw, <%= page_width %>); max-width: 100%; - aspect-ratio: <%= format('%.6f / %.6f', page_width_css, page_height_css) %>; + aspect-ratio: <%= format('%.6f / %.6f', width: page_width_css, height: page_height_css) %>; margin: 24px auto; } From 565e1eb2bcfb4bb317a418a885bd0ad835ae1df1 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 13 Apr 2026 19:55:07 +0300 Subject: [PATCH 06/18] adjust validation --- lib/submitters/submit_values.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/submitters/submit_values.rb b/lib/submitters/submit_values.rb index 2bb63d3c..8b5f7187 100644 --- a/lib/submitters/submit_values.rb +++ b/lib/submitters/submit_values.rb @@ -463,7 +463,8 @@ module Submitters end def validate_value!(_value, field, _params, submitter, _request) - raise ValidationError, 'Invalid field' if field.nil? || field['submitter_uuid'] != submitter.uuid + raise ValidationError, 'Missing field' unless field + raise ValidationError, 'Invalid field' if field['submitter_uuid'] != submitter.uuid if field['readonly'] == true Rollbar.warning("Readonly field #{submitter.id}: #{field['uuid']}") if defined?(Rollbar) From 46cf1e3067324449ff2693c317ee79a3eab8b66b Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 14 Apr 2026 09:13:33 +0300 Subject: [PATCH 07/18] add log --- app/controllers/submit_form_controller.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index 4155b9f6..a65c2237 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -79,6 +79,8 @@ class SubmitFormController < ApplicationController render json: { field_uuid: e.message }, status: :unprocessable_content rescue Submitters::SubmitValues::ValidationError => e + Rollbar.warning("Validation error #{@submitter.id}: #{e.message}") if defined?(Rollbar) + render json: { error: e.message }, status: :unprocessable_content end From a64bc3c618ecb4f8c9de054fda19b769040ecde2 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 14 Apr 2026 09:21:39 +0300 Subject: [PATCH 08/18] change button style --- app/views/templates_share_link_qr/disabled.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/templates_share_link_qr/disabled.html.erb b/app/views/templates_share_link_qr/disabled.html.erb index 79c12aef..ef1f929c 100644 --- a/app/views/templates_share_link_qr/disabled.html.erb +++ b/app/views/templates_share_link_qr/disabled.html.erb @@ -4,7 +4,7 @@

<% if can?(:update, @template) %> - <%= button_to button_title(title: t('enable_shared_link'), icon: svg_icon('lock_open', class: 'w-6 h-6')), template_share_link_path(@template), params: { template: { shared_link: true }, redir: template_share_link_qr_path(@template) }, method: :post, data: { turbo: false }, class: 'white-button w-full' %> + <%= button_to button_title(title: t('enable_shared_link'), icon: svg_icon('lock_open', class: 'w-6 h-6')), template_share_link_path(@template), params: { template: { shared_link: true }, redir: template_share_link_qr_path(@template) }, method: :post, data: { turbo: false }, class: 'base-button w-full' %> <% end %>
From 6c289cf273428dbc1369769b56bbf6f20db8dd14 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 14 Apr 2026 09:37:27 +0300 Subject: [PATCH 09/18] optimize build --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index fedff0f5..f8c4398f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,7 +48,7 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf WORKDIR /app -RUN apk add --no-cache libpq vips redis vips-heif fontconfig onnxruntime +RUN apk add --no-cache libpq vips redis vips-heif onnxruntime RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal From 6b85c2894452c287219a1442f6e9132b186e3691 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 14 Apr 2026 10:51:12 +0300 Subject: [PATCH 10/18] add error message --- lib/submissions/create_from_submitters.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/submissions/create_from_submitters.rb b/lib/submissions/create_from_submitters.rb index aabb2e49..e04167ad 100644 --- a/lib/submissions/create_from_submitters.rb +++ b/lib/submissions/create_from_submitters.rb @@ -56,6 +56,8 @@ module Submissions template_submitter = template_submitters.find { |e| e['uuid'] == uuid } end + raise BaseError, 'Invalid submitter params' unless template_submitter + template_submitter = template_submitter.except('optional_invite_by_uuid', 'invite_by_uuid', 'invite_via_field_uuid') From 70015ce1c4d09e339b29bd92aa52f168206af37e Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 14 Apr 2026 12:39:42 +0300 Subject: [PATCH 11/18] adjust dynamic editor --- app/javascript/template_builder/dynamic_editor.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/javascript/template_builder/dynamic_editor.js b/app/javascript/template_builder/dynamic_editor.js index 745f95d0..fa47f47c 100644 --- a/app/javascript/template_builder/dynamic_editor.js +++ b/app/javascript/template_builder/dynamic_editor.js @@ -188,6 +188,8 @@ const CustomHeading = Node.create({ const SectionNode = createBlockNode('section', 'section') const ArticleNode = createBlockNode('article', 'article') +const HeaderNode = createBlockNode('header', 'header') +const FooterNode = createBlockNode('footer', 'footer') const DivNode = createBlockNode('div', 'div') const BlockquoteNode = createBlockNode('blockquote', 'blockquote') const PreNode = createBlockNode('pre', 'pre') @@ -738,14 +740,12 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlFor History, Gapcursor, Dropcursor, - CustomBold, - CustomItalic, - CustomUnderline, - CustomStrike, CustomParagraph, CustomHeading, SectionNode, ArticleNode, + HeaderNode, + FooterNode, DivNode, BlockquoteNode, PreNode, @@ -764,6 +764,10 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlFor EmptySpanNode, LinkMark, SpanMark, + CustomBold, + CustomItalic, + CustomUnderline, + CustomStrike, SubscriptMark, SuperscriptMark, VariableHighlight, From d4a79ca5dbfa30254b1576f5cbe86a17be137498 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 15 Apr 2026 10:06:41 +0300 Subject: [PATCH 12/18] adjust dynamic editor --- app/javascript/template_builder/dynamic_editor.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/javascript/template_builder/dynamic_editor.js b/app/javascript/template_builder/dynamic_editor.js index fa47f47c..79698e17 100644 --- a/app/javascript/template_builder/dynamic_editor.js +++ b/app/javascript/template_builder/dynamic_editor.js @@ -25,6 +25,13 @@ tiptapStylesheet.replaceSync( -webkit-font-variant-ligatures: none; font-variant-ligatures: none; font-feature-settings: "liga" 0; + display: flex; + flex-flow: column nowrap; + min-height: inherit; +} + +.ProseMirror > article { + margin-bottom: auto; } .ProseMirror [contenteditable="false"] { From 5ea6289b7a85974c1c0d7f1d55281fa2170710ae Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 16 Apr 2026 09:00:43 +0300 Subject: [PATCH 13/18] fix test mode modal --- .../webhook_settings_controller.rb | 8 +++++-- app/views/testing_api_settings/index.html.erb | 22 ++++++++++++++----- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/app/controllers/webhook_settings_controller.rb b/app/controllers/webhook_settings_controller.rb index f9245f49..77fa22bf 100644 --- a/app/controllers/webhook_settings_controller.rb +++ b/app/controllers/webhook_settings_controller.rb @@ -32,9 +32,13 @@ class WebhookSettingsController < ApplicationController def new; end def create - @webhook_url.save! + if @webhook_url.url.present? + @webhook_url.save! - redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved') + redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved') + else + redirect_back fallback_location: settings_webhooks_path + end end def update diff --git a/app/views/testing_api_settings/index.html.erb b/app/views/testing_api_settings/index.html.erb index 956aa970..a385139d 100644 --- a/app/views/testing_api_settings/index.html.erb +++ b/app/views/testing_api_settings/index.html.erb @@ -8,11 +8,21 @@ <%= render 'shared/clipboard_copy', icon: 'copy', text: current_user.access_token.token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %> - <%= form_for @webhook_url, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %> - <%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %> -
- <%= f.url_field :url, class: 'base-input w-full', placeholder: 'https://example.com/hook' %> - <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %> -
+ <% if @webhook_url.new_record? %> + <%= form_for @webhook_url, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %> + <%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %> +
+ <%= f.url_field :url, class: 'base-input w-full', placeholder: 'https://example.com/hook' %> + <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %> +
+ <% end %> + <% else %> + <%= form_for @webhook_url, url: settings_webhook_path(@webhook_url), method: :put, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %> + <%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %> +
+ <%= f.url_field :url, class: 'base-input w-full', placeholder: 'https://example.com/hook', required: true %> + <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %> +
+ <% end %> <% end %> <% end %> From c95a8616ac371be6babed789f32ad9c6e3a625de Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 17 Apr 2026 22:01:03 +0300 Subject: [PATCH 14/18] deduplicate submitter uuids --- app/javascript/template_builder/builder.vue | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 220248fb..4830f601 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -1097,6 +1097,16 @@ export default { } }) + const deduplicateUuidsIndex = {} + + this.template.submitters.forEach((submitter) => { + if (deduplicateUuidsIndex[submitter.uuid]) { + submitter.uuid = v4() + } + + deduplicateUuidsIndex[submitter.uuid] = true + }) + this.selectedSubmitter = this.template.submitters[0] }, mounted () { From 888f1ec6df109af8bb932e1fbad6c4eb649ec139 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sat, 11 Apr 2026 12:33:16 +0300 Subject: [PATCH 15/18] adjust mcp --- lib/mcp/handle_request.rb | 1 + lib/mcp/tools/create_template.rb | 77 ++++++++++++++------------------ lib/mcp/tools/load_template.rb | 67 +++++++++++++++++++++++++++ lib/mcp/tools/send_documents.rb | 35 +++++++++++++-- 4 files changed, 134 insertions(+), 46 deletions(-) create mode 100644 lib/mcp/tools/load_template.rb diff --git a/lib/mcp/handle_request.rb b/lib/mcp/handle_request.rb index 7149fd06..83d6725f 100644 --- a/lib/mcp/handle_request.rb +++ b/lib/mcp/handle_request.rb @@ -4,6 +4,7 @@ module Mcp module HandleRequest TOOLS = [ Mcp::Tools::SearchTemplates, + Mcp::Tools::LoadTemplate, Mcp::Tools::CreateTemplate, Mcp::Tools::SendDocuments, Mcp::Tools::SearchDocuments diff --git a/lib/mcp/tools/create_template.rb b/lib/mcp/tools/create_template.rb index 0a945264..e45ee66e 100644 --- a/lib/mcp/tools/create_template.rb +++ b/lib/mcp/tools/create_template.rb @@ -6,27 +6,22 @@ module Mcp SCHEMA = { name: 'create_template', title: 'Create Template', - description: 'Create a template from a PDF. Provide a URL or base64-encoded file content.', + description: 'Create a document template. Provide a URL to upload a PDF/DOCX file, or provide only a name ' \ + 'to create an empty template and receive an edit URL where the file can be uploaded via the UI.', inputSchema: { type: 'object', properties: { - url: { - type: 'string', - description: 'URL of the document file to upload' - }, - file: { - type: 'string', - description: 'Base64-encoded file content' - }, - filename: { + name: { type: 'string', - description: 'Filename with extension (required when using file)' + description: 'Template name (used as the template name and required when url is not provided)' }, - name: { + url: { type: 'string', - description: 'Template name (defaults to filename)' + description: 'Optional URL of a PDF or DOCX file to upload. If omitted, an empty template is ' \ + 'created and the returned edit_url can be used to upload a file via the UI.' } - } + }, + required: %w[name] }, annotations: { readOnlyHint: false, @@ -44,48 +39,44 @@ module Mcp account = current_user.account - if arguments['file'].present? - tempfile = Tempfile.new - tempfile.binmode - tempfile.write(Base64.decode64(arguments['file'])) - tempfile.rewind + template = Template.new( + account:, + author: current_user, + folder: account.default_template_folder, + name: arguments['name'].to_s.presence || 'New Template', + fields: [], + schema: [] + ) - filename = arguments['filename'] || 'document.pdf' - elsif arguments['url'].present? + if arguments['url'].present? tempfile = Tempfile.new tempfile.binmode tempfile.write(DownloadUtils.call(arguments['url'], validate: true).body) tempfile.rewind filename = File.basename(URI.decode_www_form_component(arguments['url'])) - else - return { content: [{ type: 'text', text: 'Provide either url or file' }], isError: true } - end - file = ActionDispatch::Http::UploadedFile.new( - tempfile:, - filename:, - type: Marcel::MimeType.for(tempfile) - ) + file = ActionDispatch::Http::UploadedFile.new( + tempfile:, + filename:, + type: Marcel::MimeType.for(tempfile) + ) - template = Template.new( - account:, - author: current_user, - folder: account.default_template_folder, - name: arguments['name'].presence || File.basename(filename, '.*') - ) + template.name = arguments['name'].presence || File.basename(filename, '.*') + template.save! - template.save! + documents, = Templates::CreateAttachments.call(template, { files: [file] }, extract_fields: true) + schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } } - documents, = Templates::CreateAttachments.call(template, { files: [file] }, extract_fields: true) - schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } } + if template.fields.blank? + template.fields = Templates::ProcessDocument.normalize_attachment_fields(template, documents) + end - if template.fields.blank? - template.fields = Templates::ProcessDocument.normalize_attachment_fields(template, documents) + template.update!(schema:) + else + template.save! end - template.update!(schema:) - WebhookUrls.enqueue_events(template, 'template.created') SearchEntries.enqueue_reindex(template) @@ -104,7 +95,7 @@ module Mcp ] } end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength end - # rubocop:enable Metrics/AbcSize, Metrics/MethodLength end end diff --git a/lib/mcp/tools/load_template.rb b/lib/mcp/tools/load_template.rb new file mode 100644 index 00000000..7cfcc3ff --- /dev/null +++ b/lib/mcp/tools/load_template.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Mcp + module Tools + module LoadTemplate + SCHEMA = { + name: 'load_template', + title: 'Load Template', + description: 'Load a template with its fields. Each field includes name, type, and the signing role name.', + inputSchema: { + type: 'object', + properties: { + template_id: { + type: 'integer', + description: 'Template identifier' + } + }, + required: %w[template_id] + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + } + }.freeze + + module_function + + def call(arguments, _current_user, current_ability) + template = Template.accessible_by(current_ability).find_by(id: arguments['template_id']) + + return { content: [{ type: 'text', text: 'Template not found' }], isError: true } unless template + + current_ability.authorize!(:read, template) + + submitters_index = template.submitters.index_by { |s| s['uuid'] } + + roles = template.submitters.pluck('name') + + fields = template.fields.filter_map do |field| + next if field['name'].blank? + + { + name: field['name'], + type: field['type'], + role: submitters_index[field['submitter_uuid']]&.dig('name') + } + end + + { + content: [ + { + type: 'text', + text: { + id: template.id, + name: template.name, + roles: roles, + fields: fields + }.to_json + } + ] + } + end + end + end +end diff --git a/lib/mcp/tools/send_documents.rb b/lib/mcp/tools/send_documents.rb index 25b1be72..461728e6 100644 --- a/lib/mcp/tools/send_documents.rb +++ b/lib/mcp/tools/send_documents.rb @@ -31,6 +31,27 @@ module Mcp phone: { type: 'string', description: 'Submitter phone number in E.164 format' + }, + role: { + type: 'string', + description: 'Signing role name from the template' + }, + fields: { + type: 'array', + description: 'Prefill field values for this submitter (fields become readonly)', + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Field name' + }, + value: { + description: 'Prefilled value for the field' + } + }, + required: %w[name value] + } } } } @@ -59,9 +80,17 @@ module Mcp return { content: [{ type: 'text', text: 'Template has no fields' }], isError: true } if template.fields.blank? submitters = (arguments['submitters'] || []).map do |s| - s.slice('email', 'name', 'role', 'phone') - .compact_blank - .with_indifferent_access + attrs = s.slice('email', 'name', 'role', 'phone').compact_blank + + fields = Array.wrap(s['fields']).filter_map do |f| + next if f['name'].blank? + + { 'name' => f['name'], 'default_value' => f['value'], 'readonly' => true } + end + + attrs['fields'] = fields if fields.present? + + attrs.with_indifferent_access end submissions = Submissions.create_from_submitters( From 0af6ccf35f70500dca7ed775d948fcf66143cf14 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sat, 18 Apr 2026 22:06:58 +0300 Subject: [PATCH 16/18] pipeline field detection --- .rubocop.yml | 2 +- lib/templates/detect_fields.rb | 58 +++++++++++++++++++++----------- lib/templates/image_to_fields.rb | 12 +++++++ 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 02967126..0e332ff6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -28,7 +28,7 @@ Lint/MissingSuper: Enabled: false Metrics/ParameterLists: - Max: 10 + Max: 12 Metrics/MethodLength: Max: 30 diff --git a/lib/templates/detect_fields.rb b/lib/templates/detect_fields.rb index a0c81334..4d2fa0a7 100755 --- a/lib/templates/detect_fields.rb +++ b/lib/templates/detect_fields.rb @@ -114,36 +114,44 @@ module Templates head_node = PageNode.new(elem: ''.b, page: 0, attachment_uuid: attachment&.uuid) tail_node = head_node - page_range = page_number ? [page_number] : (0...doc.page_count) + page_indexes = page_number ? [page_number] : (0...doc.page_count).to_a - fields = page_range.flat_map do |current_page_number| - next [] if current_page_number >= doc.page_count + prep_opts = { aspect_ratio:, padding:, split_page: } + infer_opts = { confidence: confidence / 3.0, nms:, nmm:, temperature: } - page = doc.get_page(current_page_number) + image = prepare_page_image(doc.get_page(page_indexes.first), inference:, padding:) + current_args = inference.prepare_input(image, **prep_opts) + current_wait = inference.enqueue(**current_args, **infer_opts) - size_key = page.width > page.height ? :width : :height - size = padding ? inference.resolution * 1.5 : inference.resolution + all_fields = [] - data, width, height = page.render_to_bitmap(size_key => size) + page_indexes.each_with_index do |current_page_number, i| + next_n = page_indexes[i + 1] - image = Vips::Image.new_from_memory(data, width, height, 4, :uchar) + next_image = next_n ? prepare_page_image(doc.get_page(next_n), inference:, padding:) : nil + next_args = next_image ? inference.prepare_input(next_image, **prep_opts) : nil - fields = inference.call(image, confidence: confidence / 3.0, nms:, nmm:, split_page:, - temperature:, aspect_ratio:, padding:) + outputs = current_wait.call - text_fields = extract_text_fields_from_page(page) - line_fields = extract_line_fields_from_page(page) + next_wait = next_args ? inference.enqueue(**next_args, **infer_opts) : nil - fields = sort_fields(fields, y_threshold: 10.0 / image.height) + fields = inference.process_outputs(outputs, **current_args, **infer_opts) + + current_page = doc.get_page(current_page_number) + + fields = sort_fields(fields, y_threshold: 10.0 / current_args[:image].height) + + text_fields = extract_text_fields_from_page(current_page) + line_fields = extract_line_fields_from_page(current_page) fields = increase_confidence_for_overlapping_fields(fields, text_fields, confidence:) fields = increase_confidence_for_overlapping_fields(fields, line_fields, confidence:) fields = fields.reject { |f| f.confidence < confidence } - field_nodes, tail_node = build_page_nodes(page, fields, tail_node, attachment_uuid: attachment&.uuid) + field_nodes, tail_node = build_page_nodes(current_page, fields, tail_node, attachment_uuid: attachment&.uuid) - fields = field_nodes.map do |node| + page_fields = field_nodes.map do |node| field = node.elem type = regexp_type ? type_from_page_node(node) : field.type @@ -162,20 +170,32 @@ module Templates } end - yield [attachment&.uuid, current_page_number, fields] if block_given? + yield [attachment&.uuid, current_page_number, page_fields] if block_given? - fields + all_fields.concat(page_fields) + + current_args = next_args + current_wait = next_wait ensure - page.close + current_page&.close end print_debug(head_node) if Rails.env.development? - [fields, head_node] + [all_fields, head_node] ensure doc.close end + def prepare_page_image(page, inference:, padding:) + size_key = page.width > page.height ? :width : :height + size = padding ? inference.resolution * 1.5 : inference.resolution + + data, width, height = page.render_to_bitmap(size_key => size) + + Vips::Image.new_from_memory(data, width, height, 4, :uchar) + end + def sort_fields(fields, y_threshold: 0.01) fields.sort do |a, b| (a.endy - b.endy).abs < y_threshold ? a.x <=> b.x : a.endy <=> b.endy diff --git a/lib/templates/image_to_fields.rb b/lib/templates/image_to_fields.rb index c25bd2fe..3dcd97d3 100755 --- a/lib/templates/image_to_fields.rb +++ b/lib/templates/image_to_fields.rb @@ -73,6 +73,18 @@ module Templates build_fields_from_detections(detections, image) end + def prepare_input(image, **opts) + { image:, **opts } + end + + def enqueue(image:, **infer_opts) + -> { call(image, **infer_opts) } + end + + def process_outputs(outputs, **) + outputs + end + def call_v2(image, offset_x, offset_y, split_page, confidence:, resolution:) if split_page && image.height > image.width regions = build_split_image_regions(image) From ee65a5693c2fd2b6b0d58e13185c4a76acd4dff6 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 16 Apr 2026 12:41:42 +0300 Subject: [PATCH 17/18] improve accessibility --- app/javascript/elements/download_button.js | 6 + app/javascript/elements/modal_button.js | 18 +++ app/javascript/elements/scroll_buttons.js | 2 + app/javascript/form.js | 2 + app/javascript/submission_form/area.vue | 17 ++- .../submission_form/attachment_step.vue | 12 +- app/javascript/submission_form/completed.vue | 7 +- app/javascript/submission_form/date_step.vue | 4 +- app/javascript/submission_form/dropzone.vue | 3 +- app/javascript/submission_form/form.vue | 36 ++++-- app/javascript/submission_form/i18n.js | 71 ++++++++++++ app/javascript/submission_form/image_step.vue | 3 +- .../submission_form/initials_step.vue | 56 +++++----- .../submission_form/multi_select_step.vue | 2 + .../submission_form/number_step.vue | 2 + app/javascript/submission_form/phone_step.vue | 18 +-- .../submission_form/signature_step.vue | 103 ++++++++++++------ app/javascript/submission_form/text_step.vue | 14 ++- .../submission_form/verification_step.vue | 1 + app/views/icons/_circle_check.html.erb | 2 +- app/views/icons/_download.html.erb | 2 +- app/views/icons/_info_circle.html.erb | 2 +- app/views/icons/_loader.html.erb | 2 +- app/views/icons/_mail_forward.html.erb | 2 +- app/views/icons/_reload.html.erb | 2 +- app/views/icons/_user_share.html.erb | 2 +- app/views/icons/_writing_sign.html.erb | 2 +- app/views/icons/_x.html.erb | 2 +- app/views/shared/_html_modal.html.erb | 16 ++- app/views/start_form/completed.html.erb | 4 +- .../start_form/email_verification.html.erb | 4 +- app/views/start_form/private.html.erb | 4 +- app/views/start_form/show.html.erb | 10 +- app/views/submissions/_value.html.erb | 4 +- app/views/submissions/show.html.erb | 28 ++--- app/views/submit_form/_decline_form.html.erb | 6 +- app/views/submit_form/completed.html.erb | 6 +- app/views/submit_form/email_2fa.html.erb | 8 +- app/views/submit_form/show.html.erb | 90 ++++++++------- config/locales/i18n.yml | 14 +++ 40 files changed, 408 insertions(+), 181 deletions(-) create mode 100644 app/javascript/elements/modal_button.js diff --git a/app/javascript/elements/download_button.js b/app/javascript/elements/download_button.js index 160fc770..2a4b6210 100644 --- a/app/javascript/elements/download_button.js +++ b/app/javascript/elements/download_button.js @@ -5,6 +5,12 @@ export default targetable(class extends HTMLElement { connectedCallback () { this.addEventListener('click', () => this.downloadFiles()) + this.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + this.downloadFiles() + } + }) } toggleState () { diff --git a/app/javascript/elements/modal_button.js b/app/javascript/elements/modal_button.js new file mode 100644 index 00000000..0dbc75a8 --- /dev/null +++ b/app/javascript/elements/modal_button.js @@ -0,0 +1,18 @@ +export default class extends HTMLElement { + connectedCallback () { + const dialog = document.getElementById(this.dataset.target) + + this.querySelector('button').addEventListener('click', () => { + if (dialog) { + dialog.inert = false + dialog.showModal() + } + }) + + if (dialog) { + dialog.addEventListener('close', () => { + dialog.inert = true + }) + } + } +} diff --git a/app/javascript/elements/scroll_buttons.js b/app/javascript/elements/scroll_buttons.js index 6525adf9..09e4796e 100644 --- a/app/javascript/elements/scroll_buttons.js +++ b/app/javascript/elements/scroll_buttons.js @@ -47,11 +47,13 @@ export default class extends HTMLElement { this.classList.remove('hidden', '-translate-y-10', 'opacity-0') this.classList.add('translate-y-0', 'opacity-100') + this.querySelectorAll('[tabindex]').forEach((el) => { el.tabIndex = 0 }) } hideButtons () { this.classList.remove('translate-y-0', 'opacity-100') this.classList.add('-translate-y-10', 'opacity-0') + this.querySelectorAll('[tabindex]').forEach((el) => { el.tabIndex = -1 }) setTimeout(() => { if (this.classList.contains('-translate-y-10')) { diff --git a/app/javascript/form.js b/app/javascript/form.js index f8561dc0..c8d5790e 100644 --- a/app/javascript/form.js +++ b/app/javascript/form.js @@ -7,6 +7,7 @@ import FetchForm from './elements/fetch_form' import ScrollButtons from './elements/scroll_buttons' import PageContainer from './elements/page_container' import SubmitForm from './elements/submit_form' +import ModalButton from './elements/modal_button' const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options) @@ -16,6 +17,7 @@ safeRegisterElement('fetch-form', FetchForm) safeRegisterElement('scroll-buttons', ScrollButtons) safeRegisterElement('page-container', PageContainer) safeRegisterElement('submit-form', SubmitForm) +safeRegisterElement('modal-button', ModalButton) safeRegisterElement('submission-form', class extends HTMLElement { connectedCallback () { this.appElem = document.createElement('div') diff --git a/app/javascript/submission_form/area.vue b/app/javascript/submission_form/area.vue index affd57a6..a3e83b62 100644 --- a/app/javascript/submission_form/area.vue +++ b/app/javascript/submission_form/area.vue @@ -1,9 +1,14 @@ diff --git a/app/javascript/submission_form/verification_step.vue b/app/javascript/submission_form/verification_step.vue index 0e0bb767..59ae6c4c 100644 --- a/app/javascript/submission_form/verification_step.vue +++ b/app/javascript/submission_form/verification_step.vue @@ -30,6 +30,7 @@