From a3391a970eda1e3fe62e310b71a1685202841ffe Mon Sep 17 00:00:00 2001 From: aqilaziz Date: Fri, 8 May 2026 05:23:28 +0700 Subject: [PATCH 01/22] docs: fix README typos --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9e6b2f04..64a917ad 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d - Company logo and white-label - User roles - Automated reminders -- Invitation and identify verification via SMS +- Invitation and identity verification via SMS - Conditional fields and formulas - Bulk send with CSV, XLSX spreadsheet import - SSO / SAML @@ -74,7 +74,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d docker run --name docuseal -p 3000:3000 -v.:/data docuseal/docuseal ``` -By default DocuSeal docker container uses an SQLite database to store data and configurations. Alternatively, it is possible use PostgreSQL or MySQL databases by specifying the `DATABASE_URL` env variable. +By default DocuSeal docker container uses an SQLite database to store data and configurations. Alternatively, it is possible to use PostgreSQL or MySQL databases by specifying the `DATABASE_URL` env variable. #### Docker Compose From 6557329e9764e7d0517219024f39c5b4904dc3d4 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 4 May 2026 21:00:35 +0300 Subject: [PATCH 02/22] gh 13k --- app/views/shared/_github.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/_github.html.erb b/app/views/shared/_github.html.erb index 62eca00a..f4a606b6 100644 --- a/app/views/shared/_github.html.erb +++ b/app/views/shared/_github.html.erb @@ -1,6 +1,6 @@ <%= svg_icon('start', class: 'h-3 w-3') %> - 12k + 13k From 3aa15d6ea6d5358bb4d60e47eb144ee425b5e7a5 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 5 May 2026 11:22:38 +0300 Subject: [PATCH 03/22] update gem --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 3e602949..46363d46 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -318,7 +318,7 @@ GEM multi_json (1.19.1) net-http (0.9.1) uri (>= 0.11.1) - net-imap (0.6.3) + net-imap (0.6.4) date net-protocol net-pop (0.1.2) From 0875faa0796045356573518eca31f1680543cecf Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 5 May 2026 20:54:00 +0300 Subject: [PATCH 04/22] fix date preview --- lib/time_utils.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/time_utils.rb b/lib/time_utils.rb index c7c26bcc..4971b77c 100644 --- a/lib/time_utils.rb +++ b/lib/time_utils.rb @@ -92,9 +92,9 @@ module TimeUtils end def format_date_preview(format, locale, timezone) - return '' if format.blank? + format = format.upcase if format && !format_with_time?(format) + format = format.presence || (locale.to_s.ends_with?('US') ? DEFAULT_DATE_FORMAT_US : DEFAULT_DATE_FORMAT) - format = format.upcase unless format_with_time?(format) preview_pattern = format.gsub(TOKEN_REGEX) { |token| TIME_FORMATS.key?(token) ? '--' : ALL_FORMATS[token] } I18n.l(Time.current.in_time_zone(timezone.presence || Time.zone.name), format: preview_pattern, locale:) @@ -122,7 +122,7 @@ module TimeUtils def format_date_string(string, format, locale, timezone: nil) format = format.upcase if format && !format_with_time?(format) - format ||= locale.to_s.ends_with?('US') ? DEFAULT_DATE_FORMAT_US : DEFAULT_DATE_FORMAT + format = format.presence || (locale.to_s.ends_with?('US') ? DEFAULT_DATE_FORMAT_US : DEFAULT_DATE_FORMAT) date = if format_with_time?(format) From 1304849b55dca2f2b9f5084e66d4c02713e6b3d8 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 6 May 2026 08:18:12 +0300 Subject: [PATCH 05/22] update gh stars --- app/views/shared/_github.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/_github.html.erb b/app/views/shared/_github.html.erb index f4a606b6..bd1b10e7 100644 --- a/app/views/shared/_github.html.erb +++ b/app/views/shared/_github.html.erb @@ -1,6 +1,6 @@ <%= svg_icon('start', class: 'h-3 w-3') %> - 13k + 14k From 45ae954c0c5359d7f333ab0dd1d3b93c9545441a Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 6 May 2026 11:45:35 +0300 Subject: [PATCH 06/22] add hmac webhook secret --- app/controllers/webhook_hmac_controller.rb | 7 ++++ app/models/webhook_url.rb | 24 +++++++---- app/views/webhook_hmac/show.html.erb | 20 +++++++++ app/views/webhook_secret/show.erb | 8 +++- app/views/webhook_settings/show.html.erb | 2 +- config/locales/i18n.yml | 42 +++++++++++++++++++ config/routes.rb | 1 + ...20260506120000_add_hmac_to_webhook_urls.rb | 23 ++++++++++ db/schema.rb | 3 +- lib/send_webhook_request.rb | 14 ++++--- lib/webhook_urls/signatures.rb | 40 ++++++++++++++++++ ...ission_created_webhook_request_job_spec.rb | 17 ++++++++ spec/system/webhook_settings_spec.rb | 26 +++++++++--- 13 files changed, 205 insertions(+), 22 deletions(-) create mode 100644 app/controllers/webhook_hmac_controller.rb create mode 100644 app/views/webhook_hmac/show.html.erb create mode 100644 db/migrate/20260506120000_add_hmac_to_webhook_urls.rb create mode 100644 lib/webhook_urls/signatures.rb diff --git a/app/controllers/webhook_hmac_controller.rb b/app/controllers/webhook_hmac_controller.rb new file mode 100644 index 00000000..f0710b15 --- /dev/null +++ b/app/controllers/webhook_hmac_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class WebhookHmacController < ApplicationController + load_and_authorize_resource :webhook_url, parent: false + + def show; end +end diff --git a/app/models/webhook_url.rb b/app/models/webhook_url.rb index 6e92161d..7c7b48cc 100644 --- a/app/models/webhook_url.rb +++ b/app/models/webhook_url.rb @@ -4,14 +4,15 @@ # # Table name: webhook_urls # -# id :bigint not null, primary key -# events :text not null -# secret :text not null -# sha1 :string not null -# url :text not null -# created_at :datetime not null -# updated_at :datetime not null -# account_id :bigint not null +# id :bigint not null, primary key +# events :text not null +# hmac_secret :text not null +# secret :text not null +# sha1 :string not null +# url :text not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null # # Indexes # @@ -47,10 +48,15 @@ class WebhookUrl < ApplicationRecord serialize :secret, coder: JSON before_validation :set_sha1 + before_validation :set_hmac_secret - encrypts :url, :secret + encrypts :url, :secret, :hmac_secret def set_sha1 self.sha1 = Digest::SHA1.hexdigest(url) end + + def set_hmac_secret + self.hmac_secret ||= WebhookUrls::Signatures.generate_secret + end end diff --git a/app/views/webhook_hmac/show.html.erb b/app/views/webhook_hmac/show.html.erb new file mode 100644 index 00000000..e5d526d0 --- /dev/null +++ b/app/views/webhook_hmac/show.html.erb @@ -0,0 +1,20 @@ +<%= render 'shared/turbo_modal', title: t('webhook_security') do %> +
+
+ <%= link_to t('secret'), webhook_secret_path(@webhook_url), class: 'block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-l-3xl', data: { turbo_frame: 'modal' } %> + <%= link_to t('hmac'), webhook_hmac_path(@webhook_url), class: 'block bg-base-300 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-r-3xl', data: { turbo_frame: 'modal' } %> +
+
+
+ + <% token = @webhook_url.hmac_secret %> + <% obscured = "#{token[0, 10]}#{'*' * [token.length - 10, 0].max}" %> +
+ + + + <%= render 'shared/clipboard_copy', icon: 'copy', text: token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %> +
+

<%= t('hmac_signature_header_hint_html', header: 'X-Docuseal-Signature'.html_safe) %>

+
+<% end %> diff --git a/app/views/webhook_secret/show.erb b/app/views/webhook_secret/show.erb index 88b996c5..05b54b6a 100644 --- a/app/views/webhook_secret/show.erb +++ b/app/views/webhook_secret/show.erb @@ -1,4 +1,10 @@ -<%= render 'shared/turbo_modal', title: t('webhook_secret') do %> +<%= render 'shared/turbo_modal', title: t('webhook_security') do %> +
+
+ <%= link_to t('secret'), webhook_secret_path(@webhook_url), class: 'block bg-base-300 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-l-3xl', data: { turbo_frame: 'modal' } %> + <%= link_to t('hmac'), webhook_hmac_path(@webhook_url), class: 'block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-r-3xl', data: { turbo_frame: 'modal' } %> +
+
<%= form_for @webhook_url, url: webhook_secret_path, method: :patch, html: { class: 'space-y-4' }, data: { turbo_frame: :_top } do |f| %>
<%= f.fields_for :secret, Struct.new(:key, :value).new(*@webhook_url.secret.to_a.first) do |ff| %> diff --git a/app/views/webhook_settings/show.html.erb b/app/views/webhook_settings/show.html.erb index e552396d..c40a3628 100644 --- a/app/views/webhook_settings/show.html.erb +++ b/app/views/webhook_settings/show.html.erb @@ -23,7 +23,7 @@
<%= link_to webhook_secret_path(@webhook_url), class: 'btn btn-outline btn-sm bg-white', data: { turbo_frame: 'modal' } do %> <%= svg_icon('lock', class: 'w-4 h-4') %> - <%= @webhook_url.secret.present? ? t('edit_secret') : t('add_secret') %> + <%= t('security') %> <% end %>
<%= button_to settings_webhook_path(@webhook_url), class: 'btn btn-warning btn-sm', method: :delete, data: { turbo_confirm: t('are_you_sure_') } do %> diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index a2839022..8bfc1f35 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -593,6 +593,12 @@ en: &en unable_to_resend_webhook_request: Unable to resend webhook request. new_webhook: New Webhook delete_webhook: Delete webhook + security: Security + webhook_security: Webhook Security + secret: Secret + hmac: HMAC + hmac_signing_secret: HMAC Signing Secret + hmac_signature_header_hint_html: 'Each request is signed with the %{header} header: <timestamp>.<sha256>.' count_submissions_have_been_created: '%{count} submissions have been created.' sms_length_cant_be_longer_than_120_bytes: SMS length can't be longer than 120 bytes connected_successfully: Connected successfully. @@ -1637,6 +1643,12 @@ es: &es unable_to_resend_webhook_request: No se pudo reenviar la solicitud del webhook. new_webhook: Nuevo Webhook delete_webhook: Eliminar webhook + security: Seguridad + webhook_security: Seguridad del Webhook + secret: Secreto + hmac: HMAC + hmac_signing_secret: Secreto de firma HMAC + hmac_signature_header_hint_html: 'Cada solicitud se firma con el encabezado %{header}: <timestamp>.<sha256>.' count_submissions_have_been_created: '%{count} envíos han sido creados.' sms_length_cant_be_longer_than_120_bytes: La longitud del SMS no puede ser mayor a 120 bytes. connected_successfully: Conectado con éxito. @@ -2678,6 +2690,12 @@ it: &it unable_to_resend_webhook_request: Impossibile reinviare la richiesta del webhook. new_webhook: Nuovo Webhook delete_webhook: Elimina webhook + security: Sicurezza + webhook_security: Sicurezza del Webhook + secret: Segreto + hmac: HMAC + hmac_signing_secret: Segreto di firma HMAC + hmac_signature_header_hint_html: 'Ogni richiesta viene firmata con l''intestazione %{header}: <timestamp>.<sha256>.' count_submissions_have_been_created: '%{count} invii sono stati creati.' sms_length_cant_be_longer_than_120_bytes: "La lunghezza dell'SMS non può superare i 120 byte." connected_successfully: Collegamento avvenuto con successo. @@ -3720,6 +3738,12 @@ fr: &fr unable_to_resend_webhook_request: Impossible de renvoyer la requête webhook. new_webhook: Nouveau webhook delete_webhook: Supprimer le webhook + security: Sécurité + webhook_security: Sécurité du webhook + secret: Secret + hmac: HMAC + hmac_signing_secret: Secret de signature HMAC + hmac_signature_header_hint_html: 'Chaque requête est signée avec l''en-tête %{header} : <timestamp>.<sha256>.' count_submissions_have_been_created: "%{count} soumissions ont été créées." sms_length_cant_be_longer_than_120_bytes: La longueur du SMS ne peut pas dépasser 120 octets connected_successfully: Connecté avec succès. @@ -4758,6 +4782,12 @@ pt: &pt unable_to_resend_webhook_request: Não foi possível reenviar a solicitação do webhook. new_webhook: Novo Webhook delete_webhook: Excluir webhook + security: Segurança + webhook_security: Segurança do Webhook + secret: Segredo + hmac: HMAC + hmac_signing_secret: Segredo de assinatura HMAC + hmac_signature_header_hint_html: 'Cada requisição é assinada com o cabeçalho %{header}: <timestamp>.<sha256>.' count_submissions_have_been_created: '%{count} submissões foram criadas.' sms_length_cant_be_longer_than_120_bytes: O comprimento do SMS não pode ultrapassar 120 bytes connected_successfully: Conectado com sucesso. @@ -5799,6 +5829,12 @@ de: &de unable_to_resend_webhook_request: Webhook-Anfrage konnte nicht erneut gesendet werden. new_webhook: Neuer Webhook delete_webhook: Webhook löschen + security: Sicherheit + webhook_security: Webhook-Sicherheit + secret: Geheimnis + hmac: HMAC + hmac_signing_secret: HMAC-Signaturgeheimnis + hmac_signature_header_hint_html: 'Jede Anfrage wird mit dem %{header}-Header signiert: <timestamp>.<sha256>.' count_submissions_have_been_created: '%{count} Einreichungen wurden erstellt.' sms_length_cant_be_longer_than_120_bytes: Die SMS-Länge darf 120 Bytes nicht überschreiten. connected_successfully: Erfolgreich verbunden. @@ -7245,6 +7281,12 @@ nl: &nl unable_to_resend_webhook_request: Kan webhook-verzoek niet opnieuw verzenden. new_webhook: Nieuwe webhook delete_webhook: Webhook verwijderen + security: Beveiliging + webhook_security: Webhook-beveiliging + secret: Geheim + hmac: HMAC + hmac_signing_secret: HMAC-ondertekeningsgeheim + hmac_signature_header_hint_html: 'Elk verzoek wordt ondertekend met de %{header}-header: <timestamp>.<sha256>.' count_submissions_have_been_created: "%{count} inzendingen zijn aangemaakt." sms_length_cant_be_longer_than_120_bytes: SMS-lengte mag niet langer zijn dan 120 bytes connected_successfully: Succesvol verbonden. diff --git a/config/routes.rb b/config/routes.rb index 6b569394..8b330ba0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -83,6 +83,7 @@ Rails.application.routes.draw do resources :submitters_resubmit, only: %i[update] resources :template_folders_autocomplete, only: %i[index] resources :webhook_secret, only: %i[show update] + resources :webhook_hmac, only: %i[show] resources :webhook_preferences, only: %i[update] resource :templates_upload, only: %i[create] authenticated do diff --git a/db/migrate/20260506120000_add_hmac_to_webhook_urls.rb b/db/migrate/20260506120000_add_hmac_to_webhook_urls.rb new file mode 100644 index 00000000..0419ad19 --- /dev/null +++ b/db/migrate/20260506120000_add_hmac_to_webhook_urls.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AddHmacToWebhookUrls < ActiveRecord::Migration[8.1] + class MigrationWebhookUrl < ApplicationRecord + self.table_name = 'webhook_urls' + + encrypts :hmac_secret + end + + def up + add_column :webhook_urls, :hmac_secret, :text + + MigrationWebhookUrl.find_each do |webhook_url| + webhook_url.update_columns(hmac_secret: WebhookUrls::Signatures.generate_secret) + end + + change_column_null :webhook_urls, :hmac_secret, false + end + + def down + remove_column :webhook_urls, :hmac_secret + end +end diff --git a/db/schema.rb b/db/schema.rb index e0ba7a4f..138adba4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do +ActiveRecord::Schema[8.1].define(version: 2026_05_06_120000) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gin" enable_extension "pg_catalog.plpgsql" @@ -545,6 +545,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do t.bigint "account_id", null: false t.datetime "created_at", null: false t.text "events", null: false + t.text "hmac_secret", null: false t.text "secret", null: false t.string "sha1", null: false t.datetime "updated_at", null: false diff --git a/lib/send_webhook_request.rb b/lib/send_webhook_request.rb index 87de376a..2eb346e9 100644 --- a/lib/send_webhook_request.rb +++ b/lib/send_webhook_request.rb @@ -15,11 +15,7 @@ module SendWebhookRequest # rubocop:disable Metrics/AbcSize def call(webhook_url, event_uuid:, event_type:, record:, data:, attempt: 0) - uri = begin - URI(webhook_url.url) - rescue URI::Error - Addressable::URI.parse(webhook_url.url).normalize - end + uri = parse_uri(webhook_url.url) if Docuseal.multitenant? raise HttpsError, 'Only HTTPS is allowed.' if (uri.scheme != 'https' || [443, nil].exclude?(uri.port)) && @@ -43,6 +39,8 @@ module SendWebhookRequest data: data }.to_json + req.headers['X-Docuseal-Signature'] = WebhookUrls::Signatures.sign(webhook_url.hmac_secret, body: req.body) + req.options.read_timeout = 15 req.options.open_timeout = 8 end @@ -55,6 +53,12 @@ module SendWebhookRequest end # rubocop:enable Metrics/AbcSize + def parse_uri(url) + URI(url) + rescue URI::Error + Addressable::URI.parse(url).normalize + end + def create_webhook_event(webhook_url, event_uuid:, event_type:, record:) return if event_uuid.blank? diff --git a/lib/webhook_urls/signatures.rb b/lib/webhook_urls/signatures.rb new file mode 100644 index 00000000..937a316e --- /dev/null +++ b/lib/webhook_urls/signatures.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module WebhookUrls + module Signatures + SECRET_PREFIX = 'whsec_' + SECRET_BYTES = 24 + TOLERANCE = 5 * 60 + + InvalidSignatureError = Class.new(StandardError) + TimestampError = Class.new(StandardError) + + module_function + + def generate_secret + SECRET_PREFIX + Base64.strict_encode64(SecureRandom.bytes(SECRET_BYTES)) + end + + def sign(secret, body:, timestamp: Time.current.to_i) + "#{timestamp}.#{OpenSSL::HMAC.hexdigest('sha256', secret, "#{timestamp}.#{body}")}" + end + + def verify(secret, body:, header:, tolerance: TOLERANCE) + ts, sig = header.to_s.split('.', 2) + ts = Integer(ts, exception: false) + + raise InvalidSignatureError unless ts && sig + + now = Time.current.to_i + + raise TimestampError, 'Too old' if ts < now - tolerance + raise TimestampError, 'In future' if ts > now + tolerance + + expected = OpenSSL::HMAC.hexdigest('sha256', secret, "#{ts}.#{body}") + + raise InvalidSignatureError unless ActiveSupport::SecurityUtils.secure_compare(expected, sig) + + true + end + end +end diff --git a/spec/jobs/send_submission_created_webhook_request_job_spec.rb b/spec/jobs/send_submission_created_webhook_request_job_spec.rb index 62a1d432..8a70c6fb 100644 --- a/spec/jobs/send_submission_created_webhook_request_job_spec.rb +++ b/spec/jobs/send_submission_created_webhook_request_job_spec.rb @@ -57,6 +57,23 @@ RSpec.describe SendSubmissionCreatedWebhookRequestJob do ).once end + it 'signs the request with the HMAC secret' do + captured_body = nil + captured_signature = nil + stub_request(:post, webhook_url.url).with do |req| + captured_body = req.body + captured_signature = req.headers['X-Docuseal-Signature'] + end.to_return(status: 200) + + described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) + + expect(captured_signature).to be_present + expect(WebhookUrls::Signatures.verify(webhook_url.hmac_secret, + body: captured_body, + header: captured_signature)).to be(true) + end + it "doesn't send a webhook request if the event is not in the webhook's events" do webhook_url.update!(events: ['submission.completed']) diff --git a/spec/system/webhook_settings_spec.rb b/spec/system/webhook_settings_spec.rb index 938b6eb9..9a27cb31 100644 --- a/spec/system/webhook_settings_spec.rb +++ b/spec/system/webhook_settings_spec.rb @@ -49,7 +49,7 @@ RSpec.describe 'Webhook Settings' do expect(page).to have_field('webhook_url[url]', type: 'url', with: webhook_url.url) expect(page).to have_button('Save') expect(page).to have_button('Delete') - expect(page).to have_link('Add Secret') + expect(page).to have_link('Security') WebhookUrl::EVENTS.each do |event| expect(page).to have_field(event, type: 'checkbox', checked: webhook_url.events.include?(event)) @@ -123,7 +123,7 @@ RSpec.describe 'Webhook Settings' do expect(webhook_url.secret).to eq({}) - click_link 'Add Secret' + click_link 'Security' within '#modal' do fill_in 'Key', with: 'X-Signature' @@ -136,7 +136,7 @@ RSpec.describe 'Webhook Settings' do expect(webhook_url.secret).to eq({ 'X-Signature' => 'secret-value' }) end - expect(page).to have_link('Edit Secret') + expect(page).to have_link('Security') expect(page).to have_content('Webhook Secret has been saved.') end @@ -145,7 +145,7 @@ RSpec.describe 'Webhook Settings' do visit settings_webhooks_path - click_link 'Edit Secret' + click_link 'Security' within '#modal' do fill_in 'Key', with: '' @@ -158,10 +158,26 @@ RSpec.describe 'Webhook Settings' do expect(webhook_url.secret).to eq({}) end - expect(page).to have_link('Add Secret') + expect(page).to have_link('Security') expect(page).to have_content('Webhook Secret has been saved.') end + it 'shows the HMAC signing secret on the HMAC tab' do + webhook_url = create(:webhook_url, account:) + + visit settings_webhooks_path + + click_link 'Security' + + within '#modal' do + click_link 'HMAC' + + expect(page).to have_field('hmac_secret') + end + + expect(webhook_url.reload.hmac_secret).to start_with('whsec_') + end + context 'when testing the webhook' do let!(:webhook_url) { create(:webhook_url, account:) } let!(:template) { create(:template, account:, author: user) } From 01dd3fefe59457da39c92087d9836fad29372231 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 6 May 2026 19:09:35 +0300 Subject: [PATCH 07/22] retry webhooks in test mode --- .../send_form_completed_webhook_request_job.rb | 15 +++++++-------- .../send_form_declined_webhook_request_job.rb | 15 +++++++-------- app/jobs/send_form_started_webhook_request_job.rb | 15 +++++++-------- app/jobs/send_form_viewed_webhook_request_job.rb | 15 +++++++-------- ...end_submission_archived_webhook_request_job.rb | 15 +++++++-------- ...nd_submission_completed_webhook_request_job.rb | 15 +++++++-------- ...send_submission_created_webhook_request_job.rb | 15 +++++++-------- ...send_submission_expired_webhook_request_job.rb | 15 +++++++-------- .../send_template_archived_webhook_request_job.rb | 15 +++++++-------- .../send_template_created_webhook_request_job.rb | 15 +++++++-------- .../send_template_updated_webhook_request_job.rb | 15 +++++++-------- app/views/webhook_events/_drawer_events.html.erb | 5 +++-- 12 files changed, 80 insertions(+), 90 deletions(-) diff --git a/app/jobs/send_form_completed_webhook_request_job.rb b/app/jobs/send_form_completed_webhook_request_job.rb index e025bb4c..b9deb7a5 100644 --- a/app/jobs/send_form_completed_webhook_request_job.rb +++ b/app/jobs/send_form_completed_webhook_request_job.rb @@ -30,13 +30,12 @@ class SendFormCompletedWebhookRequestJob attempt:, data: Submitters::SerializeForWebhook.call(submitter)) - if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && - (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) - SendFormCompletedWebhookRequestJob.perform_in((2**attempt).minutes, { - **params, - 'attempt' => attempt + 1, - 'last_status' => resp&.status.to_i - }) - end + return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400) + + SendFormCompletedWebhookRequestJob.perform_in((2**attempt).minutes, { + **params, + 'attempt' => attempt + 1, + 'last_status' => resp&.status.to_i + }) end end diff --git a/app/jobs/send_form_declined_webhook_request_job.rb b/app/jobs/send_form_declined_webhook_request_job.rb index 32d63bde..c1f84383 100644 --- a/app/jobs/send_form_declined_webhook_request_job.rb +++ b/app/jobs/send_form_declined_webhook_request_job.rb @@ -28,13 +28,12 @@ class SendFormDeclinedWebhookRequestJob attempt:, data: Submitters::SerializeForWebhook.call(submitter)) - if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && - (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) - SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, { - **params, - 'attempt' => attempt + 1, - 'last_status' => resp&.status.to_i - }) - end + return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400) + + SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, { + **params, + 'attempt' => attempt + 1, + 'last_status' => resp&.status.to_i + }) end end diff --git a/app/jobs/send_form_started_webhook_request_job.rb b/app/jobs/send_form_started_webhook_request_job.rb index cc278d2a..42bcd26b 100644 --- a/app/jobs/send_form_started_webhook_request_job.rb +++ b/app/jobs/send_form_started_webhook_request_job.rb @@ -28,13 +28,12 @@ class SendFormStartedWebhookRequestJob attempt:, data: Submitters::SerializeForWebhook.call(submitter)) - if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && - (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) - SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, { - **params, - 'attempt' => attempt + 1, - 'last_status' => resp&.status.to_i - }) - end + return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400) + + SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, { + **params, + 'attempt' => attempt + 1, + 'last_status' => resp&.status.to_i + }) end end diff --git a/app/jobs/send_form_viewed_webhook_request_job.rb b/app/jobs/send_form_viewed_webhook_request_job.rb index 8f6f0d6d..4ac5a112 100644 --- a/app/jobs/send_form_viewed_webhook_request_job.rb +++ b/app/jobs/send_form_viewed_webhook_request_job.rb @@ -28,13 +28,12 @@ class SendFormViewedWebhookRequestJob attempt:, data: Submitters::SerializeForWebhook.call(submitter)) - if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && - (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) - SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, { - **params, - 'attempt' => attempt + 1, - 'last_status' => resp&.status.to_i - }) - end + return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400) + + SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, { + **params, + 'attempt' => attempt + 1, + 'last_status' => resp&.status.to_i + }) end end diff --git a/app/jobs/send_submission_archived_webhook_request_job.rb b/app/jobs/send_submission_archived_webhook_request_job.rb index 77ba6d57..3cd9f67e 100644 --- a/app/jobs/send_submission_archived_webhook_request_job.rb +++ b/app/jobs/send_submission_archived_webhook_request_job.rb @@ -26,13 +26,12 @@ class SendSubmissionArchivedWebhookRequestJob attempt:, data: submission.as_json(only: %i[id archived_at])) - if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && - (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) - SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, { - **params, - 'attempt' => attempt + 1, - 'last_status' => resp&.status.to_i - }) - end + return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400) + + SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, { + **params, + 'attempt' => attempt + 1, + 'last_status' => resp&.status.to_i + }) end end diff --git a/app/jobs/send_submission_completed_webhook_request_job.rb b/app/jobs/send_submission_completed_webhook_request_job.rb index 3832eb35..833472d6 100644 --- a/app/jobs/send_submission_completed_webhook_request_job.rb +++ b/app/jobs/send_submission_completed_webhook_request_job.rb @@ -26,13 +26,12 @@ class SendSubmissionCompletedWebhookRequestJob attempt:, data: Submissions::SerializeForApi.call(submission)) - if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && - (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) - SendSubmissionCompletedWebhookRequestJob.perform_in((2**attempt).minutes, { - **params, - 'attempt' => attempt + 1, - 'last_status' => resp&.status.to_i - }) - end + return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400) + + SendSubmissionCompletedWebhookRequestJob.perform_in((2**attempt).minutes, { + **params, + 'attempt' => attempt + 1, + 'last_status' => resp&.status.to_i + }) end end diff --git a/app/jobs/send_submission_created_webhook_request_job.rb b/app/jobs/send_submission_created_webhook_request_job.rb index c9fb1ff6..c506b2cc 100644 --- a/app/jobs/send_submission_created_webhook_request_job.rb +++ b/app/jobs/send_submission_created_webhook_request_job.rb @@ -26,13 +26,12 @@ class SendSubmissionCreatedWebhookRequestJob attempt:, data: Submissions::SerializeForApi.call(submission)) - if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && - (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) - SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, { - **params, - 'attempt' => attempt + 1, - 'last_status' => resp&.status.to_i - }) - end + return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400) + + SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, { + **params, + 'attempt' => attempt + 1, + 'last_status' => resp&.status.to_i + }) end end diff --git a/app/jobs/send_submission_expired_webhook_request_job.rb b/app/jobs/send_submission_expired_webhook_request_job.rb index d89b4f3a..0fde8cba 100644 --- a/app/jobs/send_submission_expired_webhook_request_job.rb +++ b/app/jobs/send_submission_expired_webhook_request_job.rb @@ -26,13 +26,12 @@ class SendSubmissionExpiredWebhookRequestJob attempt:, data: Submissions::SerializeForApi.call(submission)) - if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && - (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) - SendSubmissionExpiredWebhookRequestJob.perform_in((2**attempt).minutes, { - **params, - 'attempt' => attempt + 1, - 'last_status' => resp&.status.to_i - }) - end + return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400) + + SendSubmissionExpiredWebhookRequestJob.perform_in((2**attempt).minutes, { + **params, + 'attempt' => attempt + 1, + 'last_status' => resp&.status.to_i + }) end end diff --git a/app/jobs/send_template_archived_webhook_request_job.rb b/app/jobs/send_template_archived_webhook_request_job.rb index 1b1c7c92..9d93a72f 100644 --- a/app/jobs/send_template_archived_webhook_request_job.rb +++ b/app/jobs/send_template_archived_webhook_request_job.rb @@ -26,13 +26,12 @@ class SendTemplateArchivedWebhookRequestJob attempt:, data: template.as_json(only: %i[id archived_at])) - if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && - (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) - SendTemplateArchivedWebhookRequestJob.perform_in((2**attempt).minutes, { - **params, - 'attempt' => attempt + 1, - 'last_status' => resp&.status.to_i - }) - end + return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400) + + SendTemplateArchivedWebhookRequestJob.perform_in((2**attempt).minutes, { + **params, + 'attempt' => attempt + 1, + 'last_status' => resp&.status.to_i + }) end end diff --git a/app/jobs/send_template_created_webhook_request_job.rb b/app/jobs/send_template_created_webhook_request_job.rb index cb0af29f..cfae0963 100644 --- a/app/jobs/send_template_created_webhook_request_job.rb +++ b/app/jobs/send_template_created_webhook_request_job.rb @@ -26,13 +26,12 @@ class SendTemplateCreatedWebhookRequestJob attempt:, data: Templates::SerializeForApi.call(template)) - if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && - (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) - SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, { - **params, - 'attempt' => attempt + 1, - 'last_status' => resp&.status.to_i - }) - end + return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400) + + SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, { + **params, + 'attempt' => attempt + 1, + 'last_status' => resp&.status.to_i + }) end end diff --git a/app/jobs/send_template_updated_webhook_request_job.rb b/app/jobs/send_template_updated_webhook_request_job.rb index 46a67dcc..ad728a7a 100644 --- a/app/jobs/send_template_updated_webhook_request_job.rb +++ b/app/jobs/send_template_updated_webhook_request_job.rb @@ -26,13 +26,12 @@ class SendTemplateUpdatedWebhookRequestJob attempt:, data: Templates::SerializeForApi.call(template)) - if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && - (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) - SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, { - **params, - 'attempt' => attempt + 1, - 'last_status' => resp&.status.to_i - }) - end + return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400) + + SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, { + **params, + 'attempt' => attempt + 1, + 'last_status' => resp&.status.to_i + }) end end diff --git a/app/views/webhook_events/_drawer_events.html.erb b/app/views/webhook_events/_drawer_events.html.erb index 9858be96..1f79804f 100644 --- a/app/views/webhook_events/_drawer_events.html.erb +++ b/app/views/webhook_events/_drawer_events.html.erb @@ -3,13 +3,14 @@ <% webhook_attempts = webhook_event.webhook_attempts.sort_by { |e| -e.id } %> <% if webhook_event.status == 'error' %> <% last_attempt = webhook_attempts.select { |e| e.attempt < SendWebhookRequest::MANUAL_ATTEMPT }.max_by(&:attempt) %> - <% if webhook_event.webhook_attempts.none?(&:success?) && last_attempt.attempt <= 10 %> + <% next_attempt_at = last_attempt.created_at + (2**last_attempt.attempt).minutes %> + <% if webhook_event.webhook_attempts.none?(&:success?) && last_attempt.attempt <= 10 && next_attempt_at > 30.seconds.ago %>
  • <%= svg_icon('clock', class: 'w-4 h-4 shrink-0') %>

    - <%= t('next_attempt_in_time_in_words', time_in_words: distance_of_time_in_words(Time.current, last_attempt.created_at + (2**last_attempt.attempt).minutes)) %> + <%= t('next_attempt_in_time_in_words', time_in_words: distance_of_time_in_words(Time.current, next_attempt_at)) %>

  • <% end %> From d828d79574fbb614288faf8e2383fb90987d9dba Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 6 May 2026 21:02:15 +0300 Subject: [PATCH 08/22] improve log --- app/controllers/api/attachments_controller.rb | 16 +++++++-------- .../submit_form_invite_controller.rb | 20 +++++++++---------- .../submit_form_metadata_controller.rb | 20 +++++++++---------- .../submit_form_values_controller.rb | 18 ++++++++--------- 4 files changed, 37 insertions(+), 37 deletions(-) diff --git a/app/controllers/api/attachments_controller.rb b/app/controllers/api/attachments_controller.rb index c81aa0f9..2f615878 100644 --- a/app/controllers/api/attachments_controller.rb +++ b/app/controllers/api/attachments_controller.rb @@ -8,10 +8,10 @@ module Api COOKIE_STORE_LIMIT = 10 def create - submitter = Submitter.find_by!(slug: params[:submitter_slug]) + @submitter = Submitter.find_by!(slug: params[:submitter_slug]) - unless can_upload?(submitter) - Rollbar.error("Can't upload: #{submitter.id}") if defined?(Rollbar) + unless can_upload?(@submitter) + Rollbar.error("Can't upload: #{@submitter.id}") if defined?(Rollbar) return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content end @@ -20,23 +20,23 @@ module Api image = Vips::Image.new_from_file(params[:file].path) if ImageUtils.blank?(image) - Rollbar.error("Empty signature: #{submitter.id}") if defined?(Rollbar) + Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar) return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content end if ImageUtils.error?(image) - Rollbar.error("Error signature: #{submitter.id}") if defined?(Rollbar) + Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar) return render json: { error: "#{params[:type]} error, try to sign on another device" }, status: :unprocessable_content end end - attachment = Submitters.create_attachment!(submitter, params) + attachment = Submitters.create_attachment!(@submitter, params) - if params[:remember_signature] == 'true' && submitter.email.present? - cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(submitter, attachment) + if params[:remember_signature] == 'true' && @submitter.email.present? + cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment) end render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type]) diff --git a/app/controllers/submit_form_invite_controller.rb b/app/controllers/submit_form_invite_controller.rb index 413e2b9a..ac7f6db0 100644 --- a/app/controllers/submit_form_invite_controller.rb +++ b/app/controllers/submit_form_invite_controller.rb @@ -5,12 +5,12 @@ class SubmitFormInviteController < ApplicationController skip_authorization_check def create - submitter = Submitter.find_by!(slug: params[:submit_form_slug]) + @submitter = Submitter.find_by!(slug: params[:submit_form_slug]) - return head :unprocessable_content unless can_invite?(submitter) + return head :unprocessable_content unless can_invite?(@submitter) - invite_submitters = filter_invite_submitters(submitter, 'invite_by_uuid') - optional_invite_submitters = filter_invite_submitters(submitter, 'optional_invite_by_uuid') + invite_submitters = filter_invite_submitters(@submitter, 'invite_by_uuid') + optional_invite_submitters = filter_invite_submitters(@submitter, 'optional_invite_by_uuid') ApplicationRecord.transaction do (invite_submitters + optional_invite_submitters).each do |item| @@ -21,18 +21,18 @@ class SubmitFormInviteController < ApplicationController email = Submissions.normalize_email(attrs[:email]) - submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: submitter.account_id) + @submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: @submitter.account_id) - SubmissionEvents.create_with_tracking_data(submitter, 'invite_party', request, { uuid: submitter.uuid }) + SubmissionEvents.create_with_tracking_data(@submitter, 'invite_party', request, { uuid: @submitter.uuid }) end - submitter.submission.update!(submitters_order: :preserved) + @submitter.submission.update!(submitters_order: :preserved) end - submitter.submission.submitters.reload + @submitter.submission.submitters.reload - if invite_submitters.all? { |s| submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } } - Submitters::SubmitValues.call(submitter, ActionController::Parameters.new(completed: 'true'), request) + if invite_submitters.all? { |s| @submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } } + Submitters::SubmitValues.call(@submitter, ActionController::Parameters.new(completed: 'true'), request) head :ok else diff --git a/app/controllers/submit_form_metadata_controller.rb b/app/controllers/submit_form_metadata_controller.rb index 49bf8666..dcdeaa20 100644 --- a/app/controllers/submit_form_metadata_controller.rb +++ b/app/controllers/submit_form_metadata_controller.rb @@ -5,19 +5,19 @@ class SubmitFormMetadataController < ApplicationController skip_authorization_check def index - submitter = Submitter.find_by!(slug: params[:submit_form_slug]) + @submitter = Submitter.find_by!(slug: params[:submit_form_slug]) - return head :not_found if submitter.declined_at? || - submitter.completed_at? || - submitter.submission.archived_at? || - submitter.submission.expired? || - submitter.submission.template&.archived_at? || - submitter.account.archived_at? || - !Submitters::AuthorizedForForm.call(submitter, current_user, request) + return head :not_found if @submitter.declined_at? || + @submitter.completed_at? || + @submitter.submission.archived_at? || + @submitter.submission.expired? || + @submitter.submission.template&.archived_at? || + @submitter.account.archived_at? || + !Submitters::AuthorizedForForm.call(@submitter, current_user, request) - submission = submitter.submission + submission = @submitter.submission values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } - schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: submitter.uuid) + schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: @submitter.uuid) documents = schema.filter_map do |item| submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } diff --git a/app/controllers/submit_form_values_controller.rb b/app/controllers/submit_form_values_controller.rb index affd37ba..4242db5f 100644 --- a/app/controllers/submit_form_values_controller.rb +++ b/app/controllers/submit_form_values_controller.rb @@ -5,17 +5,17 @@ class SubmitFormValuesController < ApplicationController skip_authorization_check def index - submitter = Submitter.find_by!(slug: params[:submit_form_slug]) + @submitter = Submitter.find_by!(slug: params[:submit_form_slug]) - return render json: {} if submitter.completed_at? || - submitter.declined_at? || - submitter.submission.template&.archived_at? || - submitter.submission.archived_at? || - submitter.submission.expired? || - !Submitters::AuthorizedForForm.call(submitter, current_user, request) + return render json: {} if @submitter.completed_at? || + @submitter.declined_at? || + @submitter.submission.template&.archived_at? || + @submitter.submission.archived_at? || + @submitter.submission.expired? || + !Submitters::AuthorizedForForm.call(@submitter, current_user, request) - value = submitter.values[params['field_uuid']] - attachment = submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present? + value = @submitter.values[params['field_uuid']] + attachment = @submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present? render json: { value:, From 04ec2f82606cfb86a8ba5be124d008e74ae58701 Mon Sep 17 00:00:00 2001 From: Alex Turchyn Date: Wed, 6 May 2026 21:32:38 +0300 Subject: [PATCH 09/22] allow permanent delete submissions in archived templates --- app/views/templates/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/templates/show.html.erb b/app/views/templates/show.html.erb index 508b8a23..6f3f37f1 100644 --- a/app/views/templates/show.html.erb +++ b/app/views/templates/show.html.erb @@ -75,7 +75,7 @@ <% end %> <% if @submissions.present? %>
    - <%= render partial: 'submission', collection: @submissions, locals: { template: @template } %> + <%= render partial: 'submission', collection: @submissions, locals: { template: @template, archived: @template.archived_at? } %>
    <% else %>
    From 10fd624bece124f611ecfbc1be22feb5c7e7bb78 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 7 May 2026 09:48:25 +0300 Subject: [PATCH 10/22] add revisions --- app/controllers/templates_controller.rb | 2 + .../templates_versions_controller.rb | 17 ++ app/javascript/application.js | 2 + app/javascript/template_builder/builder.vue | 146 +++++++++++++++++- .../template_builder/conditions_modal.vue | 2 +- .../template_builder/dynamic_document.vue | 3 + .../template_builder/dynamic_section.vue | 3 + app/javascript/template_builder/i18n.js | 42 ++++- .../template_builder/revisions_modal.vue | 104 +++++++++++++ app/models/template.rb | 1 + app/models/template_version.rb | 44 ++++++ app/views/templates/edit.html.erb | 2 +- config/routes.rb | 1 + ...20260506121640_create_template_versions.rb | 16 ++ db/schema.rb | 18 ++- lib/template_versions.rb | 76 +++++++++ 16 files changed, 462 insertions(+), 17 deletions(-) create mode 100644 app/controllers/templates_versions_controller.rb create mode 100644 app/javascript/template_builder/revisions_modal.vue create mode 100644 app/models/template_version.rb create mode 100644 db/migrate/20260506121640_create_template_versions.rb create mode 100644 lib/template_versions.rb diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index f32e1e1e..11968e4f 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -77,6 +77,8 @@ class TemplatesController < ApplicationController WebhookUrls.enqueue_events(@template, 'template.updated') + TemplateVersions.find_or_create_for(@template, author: current_user) if params[:revision] + head :ok end diff --git a/app/controllers/templates_versions_controller.rb b/app/controllers/templates_versions_controller.rb new file mode 100644 index 00000000..c9a67b8f --- /dev/null +++ b/app/controllers/templates_versions_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class TemplatesVersionsController < ApplicationController + load_and_authorize_resource :template + + def index + versions = @template.template_versions.order(id: :desc).preload(:author) + + render json: versions.as_json(TemplateVersions::SERIALIZE_PARAMS) + end + + def show + version = @template.template_versions.find(params[:id]) + + render json: TemplateVersions.serialize(version) + end +end diff --git a/app/javascript/application.js b/app/javascript/application.js index ff617caa..af25f2b4 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -170,6 +170,8 @@ safeRegisterElement('template-builder', class extends HTMLElement { withLogo: this.dataset.withLogo !== 'false', withFieldsDetection: this.dataset.withFieldsDetection === 'true', withDetectExistingFields: this.dataset.withDetectExistingFields === 'true', + withRevisions: true, + withRevisionsMenu: this.dataset.withRevisionsMenu === 'true', editable: this.dataset.editable !== 'false', authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content, withCustomFields: true, diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index b805af92..549cb1c6 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -51,6 +51,30 @@
    +
    {{ t('preferences') }} +
  • + +
  • @@ -618,7 +663,8 @@ import DocumentPreview from './preview' import DocumentControls from './controls' import MobileFields from './mobile_fields' import FieldSubmitter from './field_submitter' -import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload } from '@tabler/icons-vue' +import RevisionsModal from './revisions_modal' +import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory } from '@tabler/icons-vue' import { v4 } from 'uuid' import { ref, computed, toRaw, defineAsyncComponent } from 'vue' import * as i18n from './i18n' @@ -658,7 +704,9 @@ export default { IconDownload, IconAdjustments, IconEye, - IconDeviceFloppy + IconHistory, + IconDeviceFloppy, + RevisionsModal }, provide () { return { @@ -980,6 +1028,16 @@ export default { type: Boolean, required: false, default: false + }, + withRevisions: { + type: Boolean, + required: false, + default: false + }, + withRevisionsMenu: { + type: Boolean, + required: false, + default: false } }, data () { @@ -1002,7 +1060,10 @@ export default { drawOption: null, dragField: null, isDragFile: false, - isMathLoaded: false + isMathLoaded: false, + isRevisionsModalOpen: false, + revisions: [], + beforeRevisionSnapshot: null } }, computed: { @@ -1767,6 +1828,72 @@ export default { closeDropdown () { document.activeElement.blur() }, + preloadRevisions () { + this.loadRevisionsPromise ||= this.baseFetch(`/templates/${this.template.id}/versions`) + }, + openRevisionsModal () { + this.closeDropdown() + + this.loadRevisionsPromise ||= this.baseFetch(`/templates/${this.template.id}/versions`) + + this.loadRevisionsPromise.then(async (resp) => { + this.revisions = await resp.json() + + this.isRevisionsModalOpen = true + this.loadRevisionsPromise = null + }) + }, + onRevisionApply (revision) { + this.beforeRevisionSnapshot = { + template: JSON.parse(JSON.stringify(this.template)), + dynamicDocuments: JSON.parse(JSON.stringify(this.dynamicDocuments)), + revision + } + + const { dynamic_documents: nextDynamicDocs = [], ...nextTemplate } = revision.data + + Object.assign(this.template, nextTemplate) + + this.dynamicDocuments.splice(0, this.dynamicDocuments.length, ...nextDynamicDocs) + + this.$nextTick(() => this.reloadDynamicDocumentContent()) + + this.isRevisionsModalOpen = false + }, + cancelRevision () { + Object.assign(this.template, this.beforeRevisionSnapshot.template) + + this.dynamicDocuments.splice(0, this.dynamicDocuments.length, ...this.beforeRevisionSnapshot.dynamicDocuments) + + this.beforeRevisionSnapshot = null + + this.$nextTick(() => this.reloadDynamicDocumentContent()) + }, + applyRevision () { + this.beforeRevisionSnapshot = null + + const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic) + + dynamicDocumentRefs.forEach((ref) => ref.update()) + + this.rebuildVariablesSchema({ disable: false }) + + return Promise.all([this.save({ force: true }), ...dynamicDocumentRefs.map((ref) => ref.saveBody())]) + }, + reloadDynamicDocumentContent () { + this.documentRefs.forEach((ref) => { + if (ref.isDynamic) ref.reloadContent() + }) + }, + formatRevisionTime (string) { + return new Date(string).toLocaleString(this.locale || undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) + }, t (key) { return this.i18n[key] || i18n[this.language]?.[key] || i18n.en[key] || key }, @@ -3013,7 +3140,7 @@ export default { const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody()) - Promise.all([this.save(), ...dynamicDocumentSaves]).then(() => { + Promise.all([this.save({ force: true, revision: this.withRevisions }), ...dynamicDocumentSaves]).then(() => { window.Turbo.visit(`/templates/${this.template.id}`) }).finally(() => { this.isSaving = false @@ -3244,9 +3371,13 @@ export default { } }) }, - save ({ force } = { force: false }) { + save ({ force = false, revision = false } = {}) { this.pendingFieldAttachmentUuids = [] + if (this.beforeRevisionSnapshot) { + this.beforeRevisionSnapshot = null + } + if (this.onChange) { this.onChange(this.template) } @@ -3272,7 +3403,8 @@ export default { submitters: this.template.submitters, fields: this.template.fields, variables_schema: this.template.variables_schema - } + }, + ...(revision ? { revision: true } : {}) }), headers: { 'Content-Type': 'application/json' } }).then(() => { diff --git a/app/javascript/template_builder/conditions_modal.vue b/app/javascript/template_builder/conditions_modal.vue index d8219ff6..0b17affc 100644 --- a/app/javascript/template_builder/conditions_modal.vue +++ b/app/javascript/template_builder/conditions_modal.vue @@ -6,7 +6,7 @@ class="absolute top-0 bottom-0 right-0 left-0" @click.prevent="$emit('close')" /> -