Compare commits

..

15 Commits

Author SHA1 Message Date
Alex Turchyn d86b16de6b
Merge from docusealco/wip
4 weeks ago
Pete Matsyburka d41710f507 fix spec
1 month ago
Pete Matsyburka 2b53166763 add completed check
1 month ago
Pete Matsyburka 4e56a16a58 adjust filters
1 month ago
Pete Matsyburka ac7cc77017 use submission completed_at
1 month ago
Pete Matsyburka 9194879c43 populate completed_at
1 month ago
Pete Matsyburka 1902cdaa55 submission completed_at
1 month ago
Alex Turchyn 6314987e69 make possible to unarchive completed submission
1 month ago
Pete Matsyburka 65bcbd7737 adjust authorize
1 month ago
Pete Matsyburka 9b25b8538e radio group font settings
1 month ago
Pete Matsyburka bbf8bb2a94 fix autocomplete index use
1 month ago
Pete Matsyburka 2085227cd9 fix dynamic document areas
1 month ago
Alex Turchyn 93e0730210 update docs
1 month ago
Pete Matsyburka 25c5c11ab3 change footer url
1 month ago
Pete Matsyburka 6ed5b5d35d extract email assets
1 month ago

@ -9,7 +9,7 @@ module Api
(@submission.schema_documents || @submission.template.schema_documents).size > 1
documents =
if @submission.submitters.all?(&:completed_at?)
if @submission.completed_at?
build_completed_documents(@submission, merge: is_merge)
else
build_preview_documents(@submission, merge: is_merge)

@ -5,7 +5,7 @@ module Api
load_and_authorize_resource :submission, parent: false
def index
submissions = build_completed_query(@submissions)
submissions = @submissions.active.where.not(completed_at: nil)
params[:after] = Time.zone.at(params[:after].to_i) if params[:after].present?
params[:before] = Time.zone.at(params[:before].to_i) if params[:before].present?
@ -36,20 +36,5 @@ module Api
}
}
end
private
def build_completed_query(submissions)
submissions = submissions.where(
Submitter.where(completed_at: nil).where(
Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
).select(1).arel.exists.not
)
submissions.joins(:submitters)
.group(:id)
.select(Submission.arel_table[Arel.star],
Submitter.arel_table[:completed_at].maximum.as('completed_at'))
end
end
end

@ -2,7 +2,7 @@
module Api
class SubmissionsController < ApiBaseController
SUBMISSION_COLUMNS = %i[id name slug source submitters_order expire_at created_at updated_at
SUBMISSION_COLUMNS = %i[id name slug source submitters_order expire_at completed_at created_at updated_at
archived_at variables template_id template_submitters created_by_user_id].freeze
TEMPLATE_COLUMNS = %i[id name external_id created_at updated_at folder_id submitters].freeze
@ -13,6 +13,8 @@ module Api
authorize!(:create, Submission)
end
before_action :maybe_return_template_error, only: :create
def index
submissions = Submissions.search(current_user, @submissions, params[:q])
submissions = filter_submissions(submissions, params)
@ -58,7 +60,7 @@ module Api
end
end
if @submission.audit_trail_attachment.blank? && submitters.all?(&:completed_at?)
if @submission.audit_trail_attachment.blank? && @submission.completed_at?
@submission.audit_trail_attachment = Submissions::EnsureAuditGenerated.call(@submission)
end
@ -68,20 +70,6 @@ module Api
def create
Params::SubmissionCreateValidator.call(params)
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
if @template.archived_at?
Rollbar.warning("Archived template submission: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template has been archived' }, status: :unprocessable_content
end
if @template.fields.blank?
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template does not contain fields' }, status: :unprocessable_content
end
params[:send_email] = true unless params.key?(:send_email)
params[:send_sms] = false unless params.key?(:send_sms)
@ -92,10 +80,16 @@ module Api
Submissions.send_signature_requests(submissions)
submissions.each do |submission|
if submission.submitters.all?(&:completed_at?) && Submissions.maybe_update_completed_at(submission)
last_submitter = submission.submitters.max_by(&:completed_at)
end
submission.submitters.each do |submitter|
next unless submitter.completed_at?
ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id, 'send_invitation_email' => false)
ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id,
'is_last' => submitter == last_submitter,
'send_invitation_email' => false)
end
end
@ -123,6 +117,22 @@ module Api
private
def maybe_return_template_error
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
if @template.archived_at?
Rollbar.warning("Archived template submission: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template has been archived' }, status: :unprocessable_content
end
return if @template.fields.present?
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
render json: { error: 'Template does not contain fields' }, status: :unprocessable_content
end
def filter_submissions(submissions, params)
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
submissions = submissions.where(slug: params[:slug]) if params[:slug].present?

@ -4,6 +4,8 @@ module Api
class SubmittersController < ApiBaseController
load_and_authorize_resource :submitter
before_action :maybe_return_submitter_error, only: :update
def index
submitters = Submitters.search(current_user, @submitters, params[:q])
@ -36,14 +38,6 @@ module Api
# rubocop:disable Metrics/MethodLength
def update
if @submitter.completed_at?
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
end
if @submitter.declined_at?
return render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
end
submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
@ -73,7 +67,9 @@ module Api
end
if @submitter.completed_at?
ProcessSubmitterCompletionJob.perform_async('submitter_id' => @submitter.id)
is_last = Submissions.maybe_update_completed_at(@submitter.submission)
ProcessSubmitterCompletionJob.perform_async('submitter_id' => @submitter.id, 'is_last' => is_last)
elsif normalized_params[:send_email] || normalized_params[:send_sms]
Submitters.send_signature_requests([@submitter])
end
@ -104,6 +100,16 @@ module Api
private
def maybe_return_submitter_error
if @submitter.completed_at?
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
end
return unless @submitter.declined_at?
render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
end
def maybe_filter_by_completed_at(submitters, params)
if params[:completed_after].present?
submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..)

@ -12,11 +12,12 @@ class SubmissionsArchivedController < ApplicationController
@submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params)
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
else
@submissions.order(id: :desc)
end
@submissions =
if params[:status] == 'completed' || params[:completed_at_from].present? || params[:completed_at_to].present?
@submissions.order(completed_at: :desc)
else
@submissions.order(id: :desc)
end
@pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))

@ -21,7 +21,7 @@ class SubmissionsController < ApplicationController
def show
@submission = Submissions.preload_with_pages(@submission)
unless @submission.submitters.all?(&:completed_at?)
unless @submission.completed_at?
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [{ submitters: :start_form_submission_events }]

@ -13,11 +13,12 @@ class SubmissionsDashboardController < ApplicationController
@submissions = Submissions.search(current_user, @submissions, params[:q], search_template: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params)
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
else
@submissions.order(id: :desc)
end
@submissions =
if params[:status] == 'completed' || params[:completed_at_from].present? || params[:completed_at_to].present?
@submissions.order(completed_at: :desc)
else
@submissions.order(id: :desc)
end
@pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))

@ -25,7 +25,7 @@ class SubmissionsPreviewController < ApplicationController
raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at?
if !@submission.submitters.all?(&:completed_at?) && !signature_valid &&
if !@submission.completed_at? && !signature_valid &&
(!current_user || !current_ability.can?(:read, @submission))
raise ActionController::RoutingError, I18n.t('not_found')
end

@ -4,7 +4,7 @@ class SubmissionsUnarchiveController < ApplicationController
load_and_authorize_resource :submission
def create
authorize!(:update, @submission)
authorize!(:destroy, @submission)
@submission.update!(archived_at: nil)

@ -10,7 +10,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
@submissions = Submissions::Filter.call(@submissions, current_user, params)
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
@submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
@submissions.order(completed_at: :desc)
else
@submissions.order(id: :desc)
end

@ -14,7 +14,7 @@ class TemplatesController < ApplicationController
submissions = Submissions::Filter.filter_by_status(submissions, params)
submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?
submissions.order(Submitter.arel_table[:completed_at].maximum.desc)
submissions.order(completed_at: :desc)
else
submissions.order(id: :desc)
end

@ -600,7 +600,8 @@ export default {
}
},
showFont () {
return ['text', 'number', 'date', 'select', 'heading', 'cells'].includes(this.field.type)
return ['text', 'number', 'date', 'select', 'heading', 'cells'].includes(this.field.type) ||
(['radio', 'multiple'].includes(this.field.type) && this.field.areas?.every((a) => !a.option_uuid))
},
showDescription () {
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)

@ -452,7 +452,7 @@
class="pb-0.5 mt-0.5"
>
<li
v-if="['text', 'number', 'date', 'select', 'heading', 'cells'].includes(field.type)"
v-if="['text', 'number', 'date', 'select', 'heading', 'cells'].includes(field.type) || (['radio', 'multiple'].includes(field.type) && field.areas?.every((a) => !a.option_uuid))"
class="field-settings-font"
>
<label

@ -11,7 +11,7 @@ class ProcessSubmissionExpiredJob
return if submission.archived_at?
return if submission.template&.archived_at?
return if submission.submitters.where.not(declined_at: nil).exists?
return unless submission.submitters.exists?(completed_at: nil)
return if submission.completed_at?
WebhookUrls.enqueue_events(submission, 'submission.expired')
end

@ -5,30 +5,38 @@ class ProcessSubmitterCompletionJob
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
submission = submitter.submission
create_completed_submitter!(submitter)
is_all_completed = !submitter.submission.submitters.exists?(completed_at: nil)
is_last =
if params.key?('is_last')
params['is_last']
else
!submission.submitters.exists?(completed_at: nil) &&
submitter.completed_at == submission.submitters.maximum(:completed_at)
end
Submissions::EnsureResultGenerated.call(submitter)
if is_all_completed && submitter.completed_at == submitter.submission.submitters.maximum(:completed_at)
if submitter.submission.account.account_configs.exists?(key: AccountConfig::COMBINE_PDF_RESULT_KEY, value: true)
if is_last
if submission.account.account_configs.exists?(key: AccountConfig::COMBINE_PDF_RESULT_KEY, value: true)
Submissions::EnsureCombinedGenerated.call(submitter)
end
Submissions::EnsureAuditGenerated.call(submitter.submission)
Submissions::EnsureAuditGenerated.call(submission)
enqueue_completed_emails(submitter)
end
create_completed_documents!(submitter)
if !is_all_completed && submitter.submission.submitters_order_preserved? && params['send_invitation_email'] != false
if !submission.completed_at && submission.submitters_order_preserved? && params['send_invitation_email'] != false &&
Submission.exists?(id: submission.id, completed_at: nil)
enqueue_next_submitter_request_notification(submitter)
end
enqueue_completed_webhooks(submitter, is_all_completed:)
enqueue_completed_webhooks(submitter, is_last:)
end
def create_completed_submitter!(submitter)
@ -77,7 +85,7 @@ class ProcessSubmitterCompletionJob
end
end
def enqueue_completed_webhooks(submitter, is_all_completed: false)
def enqueue_completed_webhooks(submitter, is_last: false)
event_uuids = {}
WebhookUrls.for_account_id(submitter.account_id, %w[form.completed submission.completed]).each do |webhook|
@ -89,7 +97,7 @@ class ProcessSubmitterCompletionJob
'webhook_url_id' => webhook.id)
end
next unless webhook.events.include?('submission.completed') && is_all_completed
next unless webhook.events.include?('submission.completed') && is_last
event_uuids['submission.completed'] ||= SecureRandom.uuid

@ -16,7 +16,7 @@ class SubmitterMailer < ApplicationMailer
template_submitters_index = @email_message.blank? ? build_submitter_preferences_index(@submitter) : {}
@body = @email_message&.body.presence ||
@body = @email_message&.normalized_body.presence ||
template_submitters_index.dig(@submitter.uuid, 'request_email_body').presence ||
@submitter.template&.preferences&.dig('request_email_body').presence

@ -24,6 +24,7 @@ class Account < ApplicationRecord
has_many :encrypted_configs, dependent: :destroy
has_many :account_configs, dependent: :destroy
has_many :email_messages, dependent: :destroy
has_many :email_message_assets, dependent: :destroy
has_many :templates, dependent: :destroy
has_many :template_folders, dependent: :destroy
has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) },

@ -33,6 +33,15 @@ class EmailMessage < ApplicationRecord
before_validation :set_sha1, on: :create
def normalized_body
@normalized_body ||=
if body&.include?(EmailMessages::ASSET_PREFIX)
EmailMessages.rebuild_body_with_assets(account_id, body)
else
body
end
end
def set_sha1
self.sha1 = Digest::SHA1.hexdigest({ subject:, body: }.to_json)
end

@ -0,0 +1,30 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: email_message_assets
#
# id :bigint not null, primary key
# data :text not null
# sha1 :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_email_message_assets_on_account_id_and_sha1 (account_id,sha1) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
#
class EmailMessageAsset < ApplicationRecord
belongs_to :account
before_validation :set_sha1, on: :create
def set_sha1
self.sha1 = Digest::SHA1.hexdigest(data.to_s)
end
end

@ -6,6 +6,7 @@
#
# id :bigint not null, primary key
# archived_at :datetime
# completed_at :datetime
# expire_at :datetime
# name :text
# preferences :text not null
@ -25,7 +26,9 @@
#
# Indexes
#
# index_submissions_on_account_id_and_completed_at (account_id,completed_at) WHERE ((completed_at IS NOT NULL) AND (archived_at IS NULL))
# index_submissions_on_account_id_and_id (account_id,id)
# index_submissions_on_account_id_and_id_pending (account_id,id) WHERE ((completed_at IS NULL) AND (archived_at IS NULL))
# index_submissions_on_account_id_and_template_id_and_id (account_id,template_id,id) WHERE (archived_at IS NULL)
# index_submissions_on_account_id_and_template_id_and_id_archived (account_id,template_id,id) WHERE (archived_at IS NOT NULL)
# index_submissions_on_created_by_user_id (created_by_user_id)
@ -89,26 +92,17 @@ class Submission < ApplicationRecord
scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
scope :pending, lambda {
where(expire_at: nil).or(where(expire_at: Time.current..))
.where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
}
scope :completed, lambda {
where.not(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
where(expire_at: nil).or(where(expire_at: Time.current..)).where(completed_at: nil)
}
scope :completed, -> { where.not(completed_at: nil) }
scope :declined, lambda {
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:declined_at].not_eq(nil))).select(1).arel.exists)
}
scope :expired, lambda {
where(expire_at: ..Time.current)
.where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]))
.where.not(declined_at: nil).limit(1).arel.exists)
}
scope :expired, -> { where(expire_at: ..Time.current).where(completed_at: nil) }
scope :select_for_list, lambda {
select(:id, :name, :created_by_user_id, :account_id,
select(:id, :name, :created_by_user_id, :account_id, :completed_at,
:created_at, :archived_at, :expire_at, :template_id, :template_submitters)
}

@ -4,7 +4,9 @@
<p>
<% if @current_account&.testing? %>
<%= t('sent_using_product_name_in_testing_mode_html', product_url: "#{Docuseal::PRODUCT_EMAIL_URL}/start", product_name: Docuseal.product_name) %>
<% else %>
<% elsif Docuseal.multitenant? %>
<%= t('sent_using_product_name_free_document_signing_html', product_url: "#{Docuseal::PRODUCT_EMAIL_URL}/start", product_name: Docuseal.product_name) %>
<% else %>
<%= t('sent_using_product_name_open_source_software_html', product_url: "#{Docuseal::PRODUCT_EMAIL_URL}/open", product_name: Docuseal.product_name) %>
<% end %>
</p>

@ -71,10 +71,11 @@
<% end %>
<% end %>
<li>
<%= button_to destroy_user_session_path, method: :delete, data: { turbo: false }, class: 'flex items-center' do %>
<%= svg_icon('logout', class: 'w-5 h-5 flex-shrink-0 stroke-2 mr-2 inline') %>
<span class="mr-1 whitespace-nowrap"><%= t('sign_out') %></span>
<% end %>
<button form="destroy_user_session_form">
<%= svg_icon('logout', class: 'w-5 h-5 flex-shrink-0 stroke-2 inline') %>
<span class="whitespace-nowrap"><%= t('sign_out') %></span>
</button>
<%= button_to '', destroy_user_session_path, method: :delete, data: { turbo: false }, form: { id: 'destroy_user_session_form' }, form_class: 'hidden' %>
</li>
</ul>
</div>

@ -56,7 +56,7 @@
<div class="form-control">
<%= f.label :message, t('body'), class: 'label' %>
<% body_variables = AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY] %>
<%= render 'personalization_settings/markdown_editor', name: f.field_name(:body), value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], variables: body_variables %>
<%= render 'personalization_settings/markdown_editor', name: f.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], variables: body_variables %>
<% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %>
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
<%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %>
@ -91,7 +91,7 @@
</div>
<div class="form-control">
<%= ff.label :message, t('body'), class: 'label' %>
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:body), value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], variables: body_variables %>
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], variables: body_variables %>
</div>
</div>
<% end %>

@ -15,8 +15,7 @@
<h1 class="text-xl md:text-3xl font-semibold focus:text-clip" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"><% (@submission.name || @submission.template&.name).to_s.split(/(_)/).each do |item| %><%= item %><wbr><% end %></h1>
</a>
<div class="space-x-3 flex items-center">
<% is_all_completed = @submission.submitters.to_a.all?(&:completed_at?) %>
<% if signed_in? && can?(:create, @submission) && @submission.archived_at? && !is_all_completed %>
<% if signed_in? && can?(:destroy, @submission) && @submission.archived_at? && !@submission.completed_at? %>
<%= button_to button_title(title: t('unarchive'), disabled_with: t('unarchive')[0..-2], icon: svg_icon('rotate', class: 'w-6 h-6')), submission_unarchive_index_path(@submission), class: 'btn btn-primary btn-ghost text-base hidden md:flex' %>
<% end %>
<% if @submission.audit_trail.present? %>
@ -30,16 +29,19 @@
<span class="hidden md:block"><%= t('event_log') %></span>
<% end %>
<% end %>
<% if signed_in? && !is_all_completed && can?(:manage, :resend_all) && @submission.submitters.to_a.size > 3 && !@submission.archived_at? && !@submission.template&.archived_at? && !@submission.expired? && can?(:update, @submission) %>
<% if signed_in? && !@submission.completed_at? && can?(:manage, :resend_all) && @submission.submitters.to_a.size > 3 && !@submission.archived_at? && !@submission.template&.archived_at? && !@submission.expired? && can?(:update, @submission) %>
<% pending_submitters_count = @submission.submitters.to_a.count { |s| !s.completed_at? && s.email.present? && !s.declined_at? } %>
<% if pending_submitters_count.positive? %>
<%= button_to button_title(title: t('re_send_emails'), title_class: 'hidden md:inline', disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), submission_resend_email_index_path(@submission), class: 'white-button', data: { turbo_confirm: t('are_you_sure_you_want_to_re_send_email_to_n_recipients', count: pending_submitters_count) } %>
<% end %>
<% end %>
<% if @submission.submitters.to_a.any?(&:completed_at?) %>
<% if is_all_completed || !is_combined_enabled %>
<% show_combined_download = @submission.completed_at? && !is_combined_enabled %>
<% show_unarchive = signed_in? && can?(:destroy, @submission) && @submission.archived_at? && @submission.completed_at? %>
<% show_download_dropdown = show_combined_download || show_unarchive %>
<% if @submission.completed_at? || !is_combined_enabled %>
<div class="join relative">
<download-button role="button" tabindex="0" aria-label="<%= t('download') %>" data-src="<%= local_assigns[:is_preview] ? (@sig_submitter ? submit_form_documents_path(@sig_submitter.slug, { sig: params[:sig], combined: is_combined_enabled }.compact_blank) : submissions_preview_download_index_path(@submission.slug, combined: is_combined_enabled.presence)) : submission_download_index_path(@submission, combined: is_combined_enabled.presence) %>" class="base-button <%= '!rounded-r-none !pr-2' if is_all_completed && !is_combined_enabled %>">
<download-button role="button" tabindex="0" aria-label="<%= t('download') %>" data-src="<%= local_assigns[:is_preview] ? (@sig_submitter ? submit_form_documents_path(@sig_submitter.slug, { sig: params[:sig], combined: is_combined_enabled }.compact_blank) : submissions_preview_download_index_path(@submission.slug, combined: is_combined_enabled.presence)) : submission_download_index_path(@submission, combined: is_combined_enabled.presence) %>" class="base-button <%= '!rounded-r-none !pr-2' if show_download_dropdown %>">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %>
<span class="hidden md:inline"><%= t('download') %></span>
@ -49,26 +51,36 @@
<span class="hidden md:inline"><%= t('downloading') %></span>
</span>
</download-button>
<% if is_all_completed && !is_combined_enabled %>
<div class="dropdown dropdown-end">
<% if show_download_dropdown %>
<div class="dropdown dropdown-end has-[button:disabled]:dropdown-open">
<label tabindex="0" aria-label="<%= t('download') %>" class="base-button !rounded-l-none !pl-1 !pr-2 !border-l-neutral-500">
<span class="text-sm align-text-top">
<%= svg_icon('chevron_down', class: 'w-6 h-6 flex-shrink-0 stroke-2') %>
</span>
</label>
<ul class="z-10 dropdown-content p-2 mt-2 shadow menu text-base bg-base-100 rounded-box text-right">
<li>
<download-button role="button" tabindex="0" data-src="<%= local_assigns[:is_preview] ? (@sig_submitter ? submit_form_documents_path(@sig_submitter.slug, { sig: params[:sig], combined: true }.compact) : submissions_preview_download_index_path(@submission.slug, combined: true)) : submission_download_index_path(@submission, combined: true) %>" class="flex items-center">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 flex-shrink-0') %>
<span class="whitespace-nowrap"><%= t('download_combined_pdf') %></span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span><%= t('downloading') %></span>
</span>
</download-button>
</li>
<% if show_combined_download %>
<li>
<download-button role="button" tabindex="0" data-src="<%= local_assigns[:is_preview] ? (@sig_submitter ? submit_form_documents_path(@sig_submitter.slug, { sig: params[:sig], combined: true }.compact) : submissions_preview_download_index_path(@submission.slug, combined: true)) : submission_download_index_path(@submission, combined: true) %>" class="flex items-center">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 flex-shrink-0') %>
<span class="whitespace-nowrap"><%= t('download_combined_pdf') %></span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span><%= t('downloading') %></span>
</span>
</download-button>
</li>
<% end %>
<% if show_unarchive %>
<li>
<button form="submission_unarchive_form">
<%= button_title(title: t('unarchive'), disabled_with: t('unarchive'), icon: svg_icon('rotate', class: 'w-6 h-6 flex-shrink-0')) %>
</button>
<%= button_to '', submission_unarchive_index_path(@submission), form: { id: 'submission_unarchive_form' }, form_class: 'hidden' %>
</li>
<% end %>
</ul>
</div>
<% end %>

@ -28,7 +28,7 @@
<% end %>
<div class="w-full flex flex-col md:flex-row space-y-4 md:space-y-0 md:justify-between px-5 md:px-6 pb-5 md:items-center pt-5 relative cursor-pointer">
<% submitters = (submission.template_submitters || submission.template.submitters).filter_map { |item| submission.submitters.find { |e| e.uuid == item['uuid'] } } %>
<% is_submission_completed = submitters.all?(&:completed_at?) && submitters.size.positive? %>
<% is_submission_completed = submission.completed_at? %>
<% if submitters.size == 1 %>
<div>
<% submitter = submitters.first %>

@ -37,7 +37,7 @@
</div>
<% unless can?(:manage, :countless) %>
<div class="badge badge-neutral badge-outline font-medium">
<%= params[:status].blank? && filter_params.blank? ? @pagy.count : @base_submissions.unscope(:group, :order).select(:id).distinct.count %>
<%= params[:status].blank? && filter_params.blank? ? @pagy.count : @base_submissions.count %>
</div>
<% end %>
</a>
@ -48,7 +48,7 @@
</div>
<% unless can?(:manage, :countless) %>
<div class="badge badge-neutral badge-outline font-medium">
<%= params[:status] == 'pending' && filter_params.blank? ? @pagy.count : @base_submissions.pending.unscope(:group, :order).select(:id).distinct.count %>
<%= params[:status] == 'pending' && filter_params.blank? ? @pagy.count : @base_submissions.pending.count %>
</div>
<% end %>
</a>
@ -59,7 +59,7 @@
</div>
<% unless can?(:manage, :countless) %>
<div class="badge badge-neutral badge-outline font-medium">
<%= params[:status] == 'completed' && filter_params.blank? ? @pagy.count : @base_submissions.completed.unscope(:group, :order).select(:id).distinct.count %>
<%= params[:status] == 'completed' && filter_params.blank? ? @pagy.count : @base_submissions.completed.count %>
</div>
<% end %>
</a>

@ -86,6 +86,7 @@ en: &en
you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'You have been invited to %{account_name} %{product_name}. Please sign up using the link below:'
sent_using_product_name_in_testing_mode_html: 'Sent using <a href="%{product_url}">%{product_name}</a> in testing mode'
sent_using_product_name_free_document_signing_html: 'Sent using <a href="%{product_url}">%{product_name}</a> free document signing.'
sent_using_product_name_open_source_software_html: 'Sent using <a href="%{product_url}">%{product_name}</a> open-source software.'
sent_with_docuseal_pro_html: 'Sent with <a href="%{product_url}">DocuSeal Pro</a>'
show_send_with_docuseal_pro_attribution_in_emails_html: Show "Sent with <span class="link">DocuSeal Pro</span>" attribution in emails
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Sign documents with trusted certificate provided by DocuSeal. Your documents and data are never shared with DocuSeal. PDF checksum is provided to generate a trusted signature.
@ -1142,6 +1143,7 @@ es: &es
you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'Has sido invitado a %{account_name} %{product_name}. Por favor, regístrate usando el enlace a continuación:'
sent_using_product_name_in_testing_mode_html: 'Enviado usando <a href="%{product_url}">%{product_name}</a> en Modo de Prueba'
sent_using_product_name_free_document_signing_html: 'Enviado usando la firma de documentos gratuita de <a href="%{product_url}">%{product_name}</a>.'
sent_using_product_name_open_source_software_html: 'Enviado usando el software de código abierto de <a href="%{product_url}">%{product_name}</a>.'
sent_with_docuseal_pro_html: 'Enviado con <a href="%{product_url}">DocuSeal Pro</a>'
show_send_with_docuseal_pro_attribution_in_emails_html: Mostrar el mensaje "Enviado con <span class="link">DocuSeal Pro</span>" en los correos electrónicos
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Firme documentos con un certificado de confianza proporcionado por DocuSeal. Sus documentos y datos nunca se comparten con DocuSeal. Se proporciona un checksum de PDF para generar una firma de confianza.
@ -2195,6 +2197,7 @@ it: &it
you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'Sei stato invitato a %{account_name} %{product_name}. Registrati utilizzando il link qui sotto:'
sent_using_product_name_in_testing_mode_html: 'Inviato utilizzando <a href="%{product_url}">%{product_name}</a> in Modalità di Test'
sent_using_product_name_free_document_signing_html: 'Inviato utilizzando la firma di documenti gratuita di <a href="%{product_url}">%{product_name}</a>.'
sent_using_product_name_open_source_software_html: 'Inviato utilizzando il software open source di <a href="%{product_url}">%{product_name}</a>.'
sent_with_docuseal_pro_html: 'Inviato con <a href="%{product_url}">DocuSeal Pro</a>'
show_send_with_docuseal_pro_attribution_in_emails_html: Mostra la dicitura "Inviato con <span class="link">DocuSeal Pro</span>" nelle email
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: "Firma documenti con un certificato di fiducia fornito da DocuSeal. I tuoi documenti e i tuoi dati non vengono mai condivisi con DocuSeal. Il checksum PDF è fornito per generare una firma di fiducia."
@ -3248,6 +3251,7 @@ fr: &fr
you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'Vous avez été invité à %{account_name} %{product_name}. Veuillez vous inscrire en utilisant le lien ci-dessous :'
sent_using_product_name_in_testing_mode_html: Envoyé avec <a href="%{product_url}">%{product_name}</a> en mode test
sent_using_product_name_free_document_signing_html: Envoyé avec <a href="%{product_url}">%{product_name}</a> signature de documents gratuite.
sent_using_product_name_open_source_software_html: Envoyé avec le logiciel open source <a href="%{product_url}">%{product_name}</a>.
sent_with_docuseal_pro_html: Envoyé avec <a href="%{product_url}">DocuSeal Pro</a>
show_send_with_docuseal_pro_attribution_in_emails_html: Afficher lattribution "Envoyé avec <span class="link">DocuSeal Pro</span>" dans les emails
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Signez des documents avec un certificat de confiance fourni par DocuSeal. Vos documents et données ne sont jamais partagés avec DocuSeal. Une empreinte (checksum) PDF est fournie pour générer une signature de confiance.
@ -4298,6 +4302,7 @@ pt: &pt
you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'Você foi convidado para %{account_name} %{product_name}. Inscreva-se usando o link abaixo:'
sent_using_product_name_in_testing_mode_html: 'Enviado usando <a href="%{product_url}">%{product_name}</a> no Modo de Teste'
sent_using_product_name_free_document_signing_html: 'Enviado usando a assinatura gratuita de documentos de <a href="%{product_url}">%{product_name}</a>.'
sent_using_product_name_open_source_software_html: 'Enviado usando o software de código aberto <a href="%{product_url}">%{product_name}</a>.'
sent_with_docuseal_pro_html: 'Enviado com <a href="%{product_url}">DocuSeal Pro</a>'
show_send_with_docuseal_pro_attribution_in_emails_html: Mostrar "Enviado com <span class="link">DocuSeal Pro</span>" nos e-mails
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Assine documentos com certificado confiável fornecido pela DocuSeal. Seus documentos e dados nunca são compartilhados com a DocuSeal. O checksum do PDF é fornecido para gerar uma assinatura confiável.
@ -5351,6 +5356,7 @@ de: &de
you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'Sie wurden zu %{account_name} %{product_name} eingeladen. Bitte registrieren Sie sich über den folgenden Link:'
sent_using_product_name_in_testing_mode_html: 'Gesendet über <a href="%{product_url}">%{product_name}</a> im Testmodus'
sent_using_product_name_free_document_signing_html: 'Gesendet mit der kostenlosen Dokumentensignierung von <a href="%{product_url}">%{product_name}</a>.'
sent_using_product_name_open_source_software_html: 'Gesendet mit der Open-Source-Software von <a href="%{product_url}">%{product_name}</a>.'
sent_with_docuseal_pro_html: Gesendet mit <a href="%{product_url}">DocuSeal Pro</a>
show_send_with_docuseal_pro_attribution_in_emails_html: '"Gesendet mit <span class="link">DocuSeal Pro</span>" in E-Mails anzeigen'
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Unterzeichnen Sie Dokumente mit einem vertrauenswürdigen Zertifikat von DocuSeal. Ihre Dokumente und Daten werden niemals mit DocuSeal geteilt. Eine PDF-Prüfsumme wird bereitgestellt, um eine vertrauenswürdige Signatur zu generieren.
@ -6808,6 +6814,7 @@ nl: &nl
you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'U bent uitgenodigd voor %{account_name} %{product_name}. Meld u aan via de onderstaande link:'
sent_using_product_name_in_testing_mode_html: Verzonden met <a href="%{product_url}">%{product_name}</a> in testmodus
sent_using_product_name_free_document_signing_html: Verzonden met <a href="%{product_url}">%{product_name}</a> gratis documentondertekening.
sent_using_product_name_open_source_software_html: Verzonden met de open-source software <a href="%{product_url}">%{product_name}</a>.
sent_with_docuseal_pro_html: Verzonden met <a href="%{product_url}">DocuSeal Pro</a>
show_send_with_docuseal_pro_attribution_in_emails_html: Toon de vermelding 'Verzonden met <span class="link">DocuSeal Pro</span>' in e-mails
? sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature

@ -0,0 +1,14 @@
# frozen_string_literal: true
class CreateEmailMessageAssets < ActiveRecord::Migration[8.1]
def change
create_table :email_message_assets do |t|
t.references :account, null: false, foreign_key: true, index: false
t.text :data, null: false
t.string :sha1, null: false
t.timestamps
end
add_index :email_message_assets, %i[account_id sha1], unique: true
end
end

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddSubmissionCompletedAt < ActiveRecord::Migration[8.1]
def change
add_column :submissions, :completed_at, :datetime
end
end

@ -0,0 +1,39 @@
# frozen_string_literal: true
class PopulateSubmissionCompletedAt < ActiveRecord::Migration[8.1]
disable_ddl_transaction!
class MigrationSubmission < ApplicationRecord
self.table_name = 'submissions'
end
def up
max_id = MigrationSubmission.maximum(:id)
return unless max_id
max_completed_at =
Arel::Nodes::Grouping.new(
Submitter.arel_table.project(Submitter.arel_table[:completed_at].maximum)
.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]))
.ast
)
(1..max_id).step(10_000) do |start_id|
range = start_id...(start_id + 10_000)
incomplete_submitter =
Submitter.where(completed_at: nil, submission_id: range)
.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]))
.select(1)
MigrationSubmission.where(completed_at: nil, id: range)
.where.not(incomplete_submitter.arel.exists)
.update_all(completed_at: max_completed_at)
end
end
def down
nil
end
end

@ -0,0 +1,17 @@
# frozen_string_literal: true
class AddIndexOnSubmissionsCompletedAt < ActiveRecord::Migration[8.1]
def change
add_index :submissions, %i[account_id completed_at],
where: 'completed_at IS NOT NULL AND archived_at IS NULL',
name: 'index_submissions_on_account_id_and_completed_at',
if_not_exists: true
return unless connection.supports_partial_index?
add_index :submissions, %i[account_id id],
where: 'completed_at IS NULL AND archived_at IS NULL',
name: 'index_submissions_on_account_id_and_id_pending',
if_not_exists: true
end
end

@ -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_06_27_083558) do
ActiveRecord::Schema[8.1].define(version: 2026_07_01_165617) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@ -214,6 +214,15 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_27_083558) do
t.index ["message_id"], name: "index_email_events_on_message_id"
end
create_table "email_message_assets", force: :cascade do |t|
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.text "data", null: false
t.string "sha1", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "sha1"], name: "index_email_message_assets_on_account_id_and_sha1", unique: true
end
create_table "email_messages", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "author_id", null: false
@ -349,6 +358,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_27_083558) do
create_table "submissions", force: :cascade do |t|
t.bigint "account_id", null: false
t.datetime "archived_at"
t.datetime "completed_at"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.datetime "expire_at"
@ -364,7 +374,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_27_083558) do
t.datetime "updated_at", null: false
t.text "variables"
t.text "variables_schema"
t.index ["account_id", "completed_at"], name: "index_submissions_on_account_id_and_completed_at", where: "((completed_at IS NOT NULL) AND (archived_at IS NULL))"
t.index ["account_id", "id"], name: "index_submissions_on_account_id_and_id"
t.index ["account_id", "id"], name: "index_submissions_on_account_id_and_id_pending", where: "((completed_at IS NULL) AND (archived_at IS NULL))"
t.index ["account_id", "template_id", "id"], name: "index_submissions_on_account_id_and_template_id_and_id", where: "(archived_at IS NULL)"
t.index ["account_id", "template_id", "id"], name: "index_submissions_on_account_id_and_template_id_and_id_archived", where: "(archived_at IS NOT NULL)"
t.index ["created_by_user_id"], name: "index_submissions_on_created_by_user_id"
@ -579,6 +591,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_27_083558) do
add_foreign_key "dynamic_document_versions", "dynamic_documents"
add_foreign_key "dynamic_documents", "templates"
add_foreign_key "email_events", "accounts"
add_foreign_key "email_message_assets", "accounts"
add_foreign_key "email_messages", "accounts"
add_foreign_key "email_messages", "users", column: "author_id"
add_foreign_key "encrypted_configs", "accounts"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -152,9 +152,16 @@ const token = jwt.sign({
"payment",
"phone",
"verification",
"kba",
"strikethrough"
]
},
"dateFormats": {
"type": "array",
"required": false,
"description": "A list of formats to be used for the date field. Formats may include date ('YYYY', 'MM', 'DD'), time ('HH', 'hh', 'mm', 'ss', 'A') and timezone ('z') parts. The first format in the list is used as the default.",
"example": "[\"MM/DD/YYYY\", \"YYYY-MM-DD HH:mm:ss z\"]"
},
"drawFieldType": {
"type": "string",
"required": false,
@ -196,6 +203,7 @@ const token = jwt.sign({
"payment",
"phone",
"verification",
"kba",
"strikethrough"
]
},
@ -324,6 +332,7 @@ const token = jwt.sign({
"payment",
"phone",
"verification",
"kba",
"strikethrough"
]
},
@ -439,13 +448,19 @@ const token = jwt.sign({
"type": "boolean",
"required": false,
"default": true,
"description": "Set `false` to now show the documents list on the left. Documents list is displayed by default."
"description": "Set `false` to not show the documents list on the left. Documents list is displayed by default."
},
"withDynamicDocuments": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set `true` to allow converting DOCX files to editable dynamic documents."
},
"withFieldsList": {
"type": "boolean",
"required": false,
"default": true,
"description": "Set `false` to now show the fields list on the right. Fields list is displayed by default."
"description": "Set `false` to not show the fields list on the right. Fields list is displayed by default."
},
"withFieldsDetection": {
"type": "boolean",
@ -453,6 +468,12 @@ const token = jwt.sign({
"default": false,
"description": "Display a button to automatically detect and add fields to the document with AI."
},
"withCustomFieldsTab": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set `true` to display a separate \"Custom\" fields tab in the fields list. Custom fields can be configured using the `fields` or `requiredFields` prop."
},
"withFieldPlaceholder": {
"type": "boolean",
"required": false,
@ -464,6 +485,12 @@ const token = jwt.sign({
"required": false,
"description": "Set to `true` to enable Signature ID by default for newly added fields. If set to `false`, the Signature ID toggle will be displayed under field settings, with the Signature ID turned off by default."
},
"withRevisions": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set `true` to save revisions and display a dropdown next to the Save button that provides access to the template revisions history."
},
"onlyDefinedFields": {
"type": "boolean",
"required": false,

@ -119,6 +119,7 @@
"payment",
"phone",
"verification",
"kba",
"strikethrough"
]
},
@ -251,6 +252,7 @@
"payment",
"phone",
"verification",
"kba",
"strikethrough"
]
},
@ -339,9 +341,16 @@
"payment",
"phone",
"verification",
"kba",
"strikethrough"
]
},
"data-date-formats": {
"type": "string",
"required": false,
"description": "Comma separated list of formats to be used for the date field. Formats may include date ('YYYY', 'MM', 'DD'), time ('HH', 'hh', 'mm', 'ss', 'A') and timezone ('z') parts. The first format in the list is used as the default.",
"example": "MM/DD/YYYY,YYYY-MM-DD HH:mm:ss z"
},
"data-draw-field-type": {
"type": "string",
"required": false,
@ -403,13 +412,19 @@
"type": "boolean",
"required": false,
"default": true,
"description": "Set `false` to now show the documents list on the left. Documents list is displayed by default."
"description": "Set `false` to not show the documents list on the left. Documents list is displayed by default."
},
"data-with-dynamic-documents": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set `true` to allow converting DOCX files to editable dynamic documents."
},
"data-with-fields-list": {
"type": "boolean",
"required": false,
"default": true,
"description": "Set `false` to now show the fields list on the right. Fields list is displayed by default."
"description": "Set `false` to not show the fields list on the right. Fields list is displayed by default."
},
"data-with-fields-detection": {
"type": "boolean",
@ -417,6 +432,12 @@
"default": false,
"description": "Display a button to automatically detect and add fields to the document with AI."
},
"data-with-custom-fields-tab": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set `true` to display a separate \"Custom\" fields tab in the fields list. Custom fields can be configured using the `data-fields` or `data-required-fields` attribute."
},
"data-with-field-placeholder": {
"type": "boolean",
"required": false,
@ -428,6 +449,12 @@
"required": false,
"description": "Set to `true` to enable Signature ID by default for newly added fields. If set to `false`, the Signature ID toggle will be displayed under field settings, with the Signature ID turned off by default."
},
"data-with-revisions": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set `true` to save revisions and display a dropdown next to the Save button that provides access to the template revisions history."
},
"data-preview": {
"type": "boolean",
"required": false,

@ -143,9 +143,16 @@ const token = jwt.sign({
"payment",
"phone",
"verification",
"kba",
"strikethrough"
]
},
"dateFormats": {
"type": "array",
"required": false,
"description": "A list of formats to be used for the date field. Formats may include date ('YYYY', 'MM', 'DD'), time ('HH', 'hh', 'mm', 'ss', 'A') and timezone ('z') parts. The first format in the list is used as the default.",
"example": "[\"MM/DD/YYYY\", \"YYYY-MM-DD HH:mm:ss z\"]"
},
"drawFieldType": {
"type": "string",
"required": false,
@ -187,6 +194,7 @@ const token = jwt.sign({
"payment",
"phone",
"verification",
"kba",
"strikethrough"
]
},
@ -315,6 +323,7 @@ const token = jwt.sign({
"payment",
"phone",
"verification",
"kba",
"strikethrough"
]
},
@ -430,13 +439,19 @@ const token = jwt.sign({
"type": "boolean",
"required": false,
"default": true,
"description": "Set `false` to now show the documents list on the left. Documents list is displayed by default."
"description": "Set `false` to not show the documents list on the left. Documents list is displayed by default."
},
"withDynamicDocuments": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set `true` to allow converting DOCX files to editable dynamic documents."
},
"withFieldsList": {
"type": "boolean",
"required": false,
"default": true,
"description": "Set `false` to now show the fields list on the right. Fields list is displayed by default."
"description": "Set `false` to not show the fields list on the right. Fields list is displayed by default."
},
"withFieldsDetection": {
"type": "boolean",
@ -444,6 +459,12 @@ const token = jwt.sign({
"default": false,
"description": "Display a button to automatically detect and add fields to the document with AI."
},
"withCustomFieldsTab": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set `true` to display a separate \"Custom\" fields tab in the fields list. Custom fields can be configured using the `fields` or `requiredFields` prop."
},
"withFieldPlaceholder": {
"type": "boolean",
"required": false,
@ -455,6 +476,12 @@ const token = jwt.sign({
"required": false,
"description": "Set to `true` to enable Signature ID by default for newly added fields. If set to `false`, the Signature ID toggle will be displayed under field settings, with the Signature ID turned off by default."
},
"withRevisions": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set `true` to save revisions and display a dropdown next to the Save button that provides access to the template revisions history."
},
"onlyDefinedFields": {
"type": "boolean",
"required": false,

@ -164,9 +164,16 @@ const token = jwt.sign({
"payment",
"phone",
"verification",
"kba",
"strikethrough"
]
},
"date-formats": {
"type": "array",
"required": false,
"description": "A list of formats to be used for the date field. Formats may include date ('YYYY', 'MM', 'DD'), time ('HH', 'hh', 'mm', 'ss', 'A') and timezone ('z') parts. The first format in the list is used as the default.",
"example": "[\"MM/DD/YYYY\", \"YYYY-MM-DD HH:mm:ss z\"]"
},
"draw-field-type": {
"type": "string",
"required": false,
@ -208,6 +215,7 @@ const token = jwt.sign({
"payment",
"phone",
"verification",
"kba",
"strikethrough"
]
},
@ -336,6 +344,7 @@ const token = jwt.sign({
"payment",
"phone",
"verification",
"kba",
"strikethrough"
]
},
@ -445,13 +454,19 @@ const token = jwt.sign({
"type": "boolean",
"required": false,
"default": true,
"description": "Set `false` to now show the documents list on the left. Documents list is displayed by default."
"description": "Set `false` to not show the documents list on the left. Documents list is displayed by default."
},
"with-dynamic-documents": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set `true` to allow converting DOCX files to editable dynamic documents."
},
"with-fields-list": {
"type": "boolean",
"required": false,
"default": true,
"description": "Set `false` to now show the fields list on the right. Fields list is displayed by default."
"description": "Set `false` to not show the fields list on the right. Fields list is displayed by default."
},
"with-fields-detection": {
"type": "boolean",
@ -459,6 +474,12 @@ const token = jwt.sign({
"default": false,
"description": "Display a button to automatically detect and add fields to the document with AI."
},
"with-custom-fields-tab": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set `true` to display a separate \"Custom\" fields tab in the fields list. Custom fields can be configured using the `:fields` or `:required-fields` prop."
},
"with-field-placeholder": {
"type": "boolean",
"required": false,
@ -470,6 +491,12 @@ const token = jwt.sign({
"required": false,
"description": "Set to `true` to enable Signature ID by default for newly added fields. If set to `false`, the Signature ID toggle will be displayed under field settings, with the Signature ID turned off by default."
},
"with-revisions": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set `true` to save revisions and display a dropdown next to the Save button that provides access to the template revisions history."
},
"autosave": {
"type": "boolean",
"required": false,

@ -51,7 +51,7 @@ export class AppComponent {}
"token": {
"type": "string",
"doc_type": "object",
"description": "JSON Web Token (JWT HS256) with a payload signed using the API key. <b>JWT can be generated only on the backend.</b>.",
"description": "JSON Web Token (JWT HS256) with a payload signed using the API key. <b>JWT can be generated only on the backend</b>.",
"required": false,
"properties": {
"slug": {
@ -109,7 +109,7 @@ export class AppComponent {}
"language": {
"type": "string",
"required": false,
"description": "UI language: en, es, it, de, fr, nl, pl, uk, cs, pt, he, ar, kr, ja languages are available. Be default the form is displayed in the user browser language automatically."
"description": "UI language: en, es, it, de, fr, nl, pl, uk, cs, pt, he, ar, kr, ja languages are available. By default the form is displayed in the user browser language automatically."
},
"i18n": {
"type": "object",
@ -127,7 +127,7 @@ export class AppComponent {}
"type": "boolean",
"required": false,
"default": true,
"description": "Set `false` to hide field name. Hidding field names can be useful for when they are not in the human readable format. Field names are displayed by default."
"description": "Set `false` to hide field name. Hiding field names can be useful for when they are not in the human readable format. Field names are displayed by default."
},
"withFieldPlaceholder": {
"type": "boolean",
@ -232,6 +232,12 @@ export class AppComponent {}
"default": false,
"description": "Set `true` to display the complete button in the form header."
},
"onlyRequiredFields": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set to `true` to display only required fields in the step-by-step form, hiding all optional fields."
},
"allowToResubmit": {
"type": "boolean",
"required": false,
@ -241,7 +247,7 @@ export class AppComponent {}
"signature": {
"type": "string",
"required": false,
"description": "Allows pre-filling signature fields. The value can be a base64 encoded image string, a public URL to an image, or plain text that will be rendered as a typed signature using a standard font."
"description": "Allows pre-filling signature fields. The value can be a base64 encoded data:image/ string, a public URL to an image, or plain text that will be rendered as a typed signature using a standard font."
},
"rememberSignature": {
"type": "boolean",
@ -311,7 +317,7 @@ export class AppComponent {}
"onComplete": {
"type": "event emitter",
"required": false,
"description": "Event emitted the form completion.",
"description": "Event emitted on form completion.",
"example": "handleComplete($event)"
},
"onDecline": {

@ -47,7 +47,7 @@
"data-token": {
"type": "string",
"doc_type": "object",
"description": "JSON Web Token (JWT HS256) with a payload signed using the API key. <b>JWT can be generated only on the backend.</b>.",
"description": "JSON Web Token (JWT HS256) with a payload signed using the API key. <b>JWT can be generated only on the backend</b>.",
"required": false,
"properties": {
"slug": {
@ -105,7 +105,7 @@
"data-language": {
"type": "string",
"required": false,
"description": "UI language: en, es, it, de, fr, nl, pl, uk, cs, pt, he, ar, kr, ja languages are available. Be default the form is displayed in the user browser language automatically."
"description": "UI language: en, es, it, de, fr, nl, pl, uk, cs, pt, he, ar, kr, ja languages are available. By default the form is displayed in the user browser language automatically."
},
"data-i18n": {
"type": "string",
@ -153,7 +153,7 @@
"type": "boolean",
"required": false,
"default": true,
"description": "Set `false` to hide field name. Hidding field names can be useful for when they are not in the human readable format. Field names are displayed by default."
"description": "Set `false` to hide field name. Hiding field names can be useful for when they are not in the human readable format. Field names are displayed by default."
},
"data-with-field-placeholder": {
"type": "boolean",
@ -179,6 +179,12 @@
"default": false,
"description": "Set `true` to display the complete button in the form header."
},
"data-only-required-fields": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set to `true` to display only required fields in the step-by-step form, hiding all optional fields."
},
"data-allow-to-resubmit": {
"type": "boolean",
"required": false,
@ -194,7 +200,7 @@
"data-signature": {
"type": "string",
"required": false,
"description": "Allows pre-filling signature fields. The value can be a base64 encoded image string, a public URL to an image, or plain text that will be rendered as a typed signature using a standard font."
"description": "Allows pre-filling signature fields. The value can be a base64 encoded data:image/ string, a public URL to an image, or plain text that will be rendered as a typed signature using a standard font."
},
"data-remember-signature": {
"type": "boolean",

@ -48,7 +48,7 @@ export function App() {
"token": {
"type": "string",
"doc_type": "object",
"description": "JSON Web Token (JWT HS256) with a payload signed using the API key. <b>JWT can be generated only on the backend.</b>.",
"description": "JSON Web Token (JWT HS256) with a payload signed using the API key. <b>JWT can be generated only on the backend</b>.",
"required": false,
"properties": {
"slug": {
@ -106,7 +106,7 @@ export function App() {
"language": {
"type": "string",
"required": false,
"description": "UI language: en, es, it, de, fr, nl, pl, uk, cs, pt, he, ar, kr, ja languages are available. Be default the form is displayed in the user browser language automatically."
"description": "UI language: en, es, it, de, fr, nl, pl, uk, cs, pt, he, ar, kr, ja languages are available. By default the form is displayed in the user browser language automatically."
},
"i18n": {
"type": "object",
@ -124,7 +124,7 @@ export function App() {
"type": "boolean",
"required": false,
"default": true,
"description": "Set `false` to hide field name. Hidding field names can be useful for when they are not in the human readable format. Field names are displayed by default."
"description": "Set `false` to hide field name. Hiding field names can be useful for when they are not in the human readable format. Field names are displayed by default."
},
"withFieldPlaceholder": {
"type": "boolean",
@ -229,6 +229,12 @@ export function App() {
"default": false,
"description": "Set `true` to display the complete button in the form header."
},
"onlyRequiredFields": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set to `true` to display only required fields in the step-by-step form, hiding all optional fields."
},
"allowToResubmit": {
"type": "boolean",
"required": false,
@ -244,7 +250,7 @@ export function App() {
"signature": {
"type": "string",
"required": false,
"description": "Allows pre-filling signature fields. The value can be a base64 encoded image string, a public URL to an image, or plain text that will be rendered as a typed signature using a standard font."
"description": "Allows pre-filling signature fields. The value can be a base64 encoded data:image/ string, a public URL to an image, or plain text that will be rendered as a typed signature using a standard font."
},
"rememberSignature": {
"type": "boolean",

@ -57,7 +57,7 @@ export default {
"token": {
"type": "string",
"doc_type": "object",
"description": "JSON Web Token (JWT HS256) with a payload signed using the API key. <b>JWT can be generated only on the backend.</b>.",
"description": "JSON Web Token (JWT HS256) with a payload signed using the API key. <b>JWT can be generated only on the backend</b>.",
"required": false,
"properties": {
"slug": {
@ -115,7 +115,7 @@ export default {
"language": {
"type": "string",
"required": false,
"description": "UI language: en, es, it, de, fr, nl, pl, uk, cs, pt, he, ar, kr, ja languages are available. Be default the form is displayed in the user browser language automatically."
"description": "UI language: en, es, it, de, fr, nl, pl, uk, cs, pt, he, ar, kr, ja languages are available. By default the form is displayed in the user browser language automatically."
},
"i18n": {
"type": "object",
@ -145,7 +145,7 @@ export default {
"type": "boolean",
"required": false,
"default": true,
"description": "Set `false` to hide field name. Hidding field names can be useful for when they are not in the human readable format. Field names are displayed by default."
"description": "Set `false` to hide field name. Hiding field names can be useful for when they are not in the human readable format. Field names are displayed by default."
},
"with-field-placeholder": {
"type": "boolean",
@ -195,6 +195,12 @@ export default {
"default": false,
"description": "Set `true` to display the complete button in the form header."
},
"only-required-fields": {
"type": "boolean",
"required": false,
"default": false,
"description": "Set to `true` to display only required fields in the step-by-step form, hiding all optional fields."
},
"allow-to-resubmit": {
"type": "boolean",
"required": false,
@ -204,7 +210,7 @@ export default {
"signature": {
"type": "string",
"required": false,
"description": "Allows pre-filling signature fields. The value can be a base64 encoded image string, a public URL to an image, or plain text that will be rendered as a typed signature using a standard font."
"description": "Allows pre-filling signature fields. The value can be a base64 encoded data:image/ string, a public URL to an image, or plain text that will be rendered as a typed signature using a standard font."
},
"remember-signature": {
"type": "boolean",

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
# Form Webhook
During the form filling and signing process, 3 types of events may occur and are dispatched at different stages:
During the form filling and signing process, 4 types of events may occur and are dispatched at different stages:
- **'form.viewed'** event is triggered when the submitter first opens the form.
- **'form.started'** event is triggered when the submitter initiates filling out the form.
@ -19,13 +19,16 @@ During the form filling and signing process, 3 types of events may occur and are
"enum": [
"form.viewed",
"form.started",
"form.completed"
"form.completed",
"form.declined"
]
},
"timestamp": {
"type": "string",
"description": "The event timestamp.",
"example": "2023-09-24T11:20:42Z",
"examples": [
"2023-09-24T11:20:42Z"
],
"format": "date-time"
},
"data": {
@ -36,20 +39,20 @@ During the form filling and signing process, 3 types of events may occur and are
"type": "number",
"description": "The submitter's unique identifier."
},
"submission_id": {
"type": "number",
"description": "The unique submission identifier."
},
"email": {
"type": "string",
"description": "The submitter's email address",
"format": "email",
"example": "john.doe@example.com"
"examples": [
"john.doe@example.com"
]
},
"ua": {
"type": "string",
"description": "The user agent string that provides information about the submitter's web browser.",
"example": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
"examples": [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
]
},
"ip": {
"type": "string",
@ -62,27 +65,28 @@ During the form filling and signing process, 3 types of events may occur and are
"phone": {
"type": "string",
"description": "The submitter's phone number, formatted according to the E.164 standard.",
"example": "+1234567890"
"examples": [
"+1234567890"
]
},
"role": {
"type": "string",
"description": "The submitter's role name or title.",
"example": "First Party"
"examples": [
"First Party"
]
},
"external_id": {
"type": "string",
"description": "Your application-specific unique string key to identify submitter within your app."
},
"application_key": {
"type": "string",
"description": "Your application-specific unique string key to identify submitter within your app. Backward compatibility with the previous version of the API. Use external_id instead."
},
"decline_reason": {
"type": "string",
"description": "Submitter provided decline message."
},
"sent_at": {
"type": "string",
"description": "The date and time when the signing request was sent to the submitter.",
"format": "date-time"
},
"status": {
@ -98,22 +102,27 @@ During the form filling and signing process, 3 types of events may occur and are
},
"opened_at": {
"type": "string",
"description": "The date and time when the submitter opened the signing form.",
"format": "date-time"
},
"completed_at": {
"type": "string",
"description": "The date and time when the submitter completed the signing form.",
"format": "date-time"
},
"declined_at": {
"type": "string",
"description": "The date and time when the submitter declined the signing form.",
"format": "date-time"
},
"created_at": {
"type": "string",
"description": "The date and time when the submitter was created.",
"format": "date-time"
},
"updated_at": {
"type": "string",
"description": "The date and time when the submitter was last updated.",
"format": "date-time"
},
"submission": {
@ -189,6 +198,7 @@ During the form filling and signing process, 3 types of events may occur and are
},
"preferences": {
"type": "object",
"description": "Submitter preferences for notifications.",
"properties": {
"send_email": {
"type": "boolean",
@ -210,7 +220,7 @@ During the form filling and signing process, 3 types of events may occur and are
"type": "string",
"description": "The field name."
},
"values": {
"value": {
"type": "string",
"description": "The field value."
}
@ -231,6 +241,7 @@ During the form filling and signing process, 3 types of events may occur and are
},
"documents": {
"type": "array",
"description": "List of completed documents signed by the submitter.",
"items": {
"type": "object",
"properties": {

@ -16,13 +16,17 @@ Get submission creation, completion, expiration, and archiving notifications usi
"description": "The event type.",
"enum": [
"submission.created",
"submission.completed",
"submission.expired",
"submission.archived"
]
},
"timestamp": {
"type": "string",
"description": "The event timestamp.",
"example": "2023-09-24T11:20:42Z",
"examples": [
"2023-09-24T11:20:42Z"
],
"format": "date-time"
},
"data": {
@ -33,8 +37,26 @@ Get submission creation, completion, expiration, and archiving notifications usi
"type": "number",
"description": "The submission's unique identifier."
},
"archived_at": {
"name": {
"type": "string",
"description": "Name of the document submission."
},
"slug": {
"type": "string",
"description": "Unique slug of the submission."
},
"expire_at": {
"type": [
"string",
"null"
],
"description": "The date and time when the submission will expire."
},
"archived_at": {
"type": [
"string",
"null"
],
"description": "The submission archive date."
},
"created_at": {
@ -65,9 +87,19 @@ Get submission creation, completion, expiration, and archiving notifications usi
]
},
"audit_log_url": {
"type": "string",
"type": [
"string",
"null"
],
"description": "Audit log file URL."
},
"combined_document_url": {
"type": [
"string",
"null"
],
"description": "Combined PDF file URL with documents and Audit Log."
},
"submitters": {
"type": "array",
"description": "The list of submitters for the submission.",
@ -90,26 +122,40 @@ Get submission creation, completion, expiration, and archiving notifications usi
"type": "string",
"description": "The email address of the submitter.",
"format": "email",
"example": "john.doe@example.com"
"examples": [
"john.doe@example.com"
]
},
"slug": {
"type": "string",
"description": "The unique slug of the document template."
},
"sent_at": {
"type": "string",
"type": [
"string",
"null"
],
"description": "The date and time when the signing request was sent to the submitter."
},
"opened_at": {
"type": "string",
"type": [
"string",
"null"
],
"description": "The date and time when the submitter opened the signing form."
},
"completed_at": {
"type": "string",
"type": [
"string",
"null"
],
"description": "The date and time when the submitter completed the signing form."
},
"declined_at": {
"type": "string",
"type": [
"string",
"null"
],
"description": "The date and time when the submitter declined the signing form."
},
"created_at": {
@ -121,27 +167,42 @@ Get submission creation, completion, expiration, and archiving notifications usi
"description": "The date and time when the submitter was last updated."
},
"name": {
"type": "string",
"type": [
"string",
"null"
],
"description": "The name of the submitter."
},
"phone": {
"type": "string",
"type": [
"string",
"null"
],
"description": "The phone number of the submitter, formatted according to the E.164 standard.",
"example": "+1234567890"
"examples": [
"+1234567890"
]
},
"role": {
"type": "string",
"description": "The role name or title of the submitter.",
"example": "First Party"
"examples": [
"First Party"
]
},
"external_id": {
"type": "string",
"type": [
"string",
"null"
],
"description": "Your application-specific unique string key to identify this submitter within your app."
},
"metadata": {
"type": "object",
"description": "Metadata object with additional submitter information.",
"example": "{ 'customField': 'value' }"
"examples": [
"{ 'customField': 'value' }"
]
},
"status": {
"type": "string",
@ -154,10 +215,6 @@ Get submission creation, completion, expiration, and archiving notifications usi
"awaiting"
]
},
"application_key": {
"type": "string",
"description": "Your application-specific unique string key to identify this submitter within your app."
},
"values": {
"type": "object",
"description": "An object with pre-filled values for the submission. Use field names for keys of the object. For more configurations see `fields` param."
@ -222,6 +279,7 @@ Get submission creation, completion, expiration, and archiving notifications usi
},
"created_by_user": {
"type": "object",
"description": "User who created the submission.",
"properties": {
"id": {
"type": "integer",
@ -243,6 +301,7 @@ Get submission creation, completion, expiration, and archiving notifications usi
},
"submission_events": {
"type": "array",
"description": "List of submission events.",
"items": {
"type": "object",
"properties": {

@ -3,7 +3,8 @@
Get template creation and update notifications using these events:
- **'template.created'** is triggered when the template is created.
- **'tempate.updated'** is triggered when the template is updated.
- **'template.updated'** is triggered when the template is updated.
- **'template.archived'** is triggered when the template is archived.
@ -14,13 +15,16 @@ Get template creation and update notifications using these events:
"description": "The event type.",
"enum": [
"template.created",
"template.updated"
"template.updated",
"template.archived"
]
},
"timestamp": {
"type": "string",
"description": "The event timestamp.",
"example": "2023-09-24T11:20:42Z",
"examples": [
"2023-09-24T11:20:42Z"
],
"format": "date-time"
},
"data": {
@ -74,6 +78,31 @@ Get template creation and update notifications using these events:
"type": "string",
"description": "The field name."
},
"type": {
"type": "string",
"description": "The field type.",
"enum": [
"heading",
"text",
"signature",
"initials",
"date",
"number",
"image",
"checkbox",
"multiple",
"file",
"radio",
"select",
"cells",
"stamp",
"payment",
"phone",
"verification",
"kba",
"strikethrough"
]
},
"required": {
"type": "boolean",
"description": "The flag indicating whether the field is required."
@ -120,6 +149,7 @@ Get template creation and update notifications using these events:
},
"submitters": {
"type": "array",
"description": "List of submitter roles defined in the template.",
"items": {
"type": "object",
"properties": {
@ -138,12 +168,11 @@ Get template creation and update notifications using these events:
"type": "integer",
"description": "Unique identifier of the author of the template."
},
"account_id": {
"type": "integer",
"description": "Unique identifier of the account of the template."
},
"archived_at": {
"type": "string",
"type": [
"string",
"null"
],
"description": "Date and time when the template was archived."
},
"created_at": {
@ -164,7 +193,10 @@ Get template creation and update notifications using these events:
]
},
"external_id": {
"type": "string",
"type": [
"string",
"null"
],
"description": "Identifier of the template in the external system."
},
"folder_id": {
@ -175,12 +207,17 @@ Get template creation and update notifications using these events:
"type": "string",
"description": "Folder name where the template is placed."
},
"application_key": {
"type": "string",
"description": "Your application-specific unique string key to identify tempate_id within your app."
"preferences": {
"type": "object",
"description": "Template preferences object."
},
"shared_link": {
"type": "boolean",
"description": "Flag indicating whether the shared link is enabled for the template."
},
"author": {
"type": "object",
"description": "Author of the template.",
"properties": {
"id": {
"type": "integer",

@ -1,13 +1,70 @@
# frozen_string_literal: true
module EmailMessages
MIN_BODY_SIZE = 2.kilobytes
MIN_ASSET_SIZE = 256.bytes
STYLE_REGEXP = %r{<style[^>]*>.*?</style>(?:\s*<style[^>]*>.*?</style>)*}mi
BASE64_REGEXP = %r{(data:[^,]*;base64,)([A-Za-z0-9+/=]+)}
ASSET_REGEXP = Regexp.union(STYLE_REGEXP, BASE64_REGEXP)
ASSET_PREFIX = '[[asset:'
PLACEHOLDER_REGEXP = /\[\[asset:(\h{40})\]\]/
module_function
def find_or_create_for_account_user(account, user, subject, body)
subject = I18n.t(:you_are_invited_to_sign_a_document) if subject.blank?
message = account.email_messages.new(author: user, subject:, body:).tap(&:validate)
body, assets = maybe_extract_assets(account, body)
new_message = account.email_messages.new(author: user, subject:, body:).tap(&:validate)
message = account.email_messages.find_by(sha1: new_message.sha1)
message ||= new_message.tap do |m|
m.save!(validate: false)
save_new_assets!(account, assets)
end
message
end
def save_new_assets!(account, assets)
return if assets.blank?
existing_assets_sha1 = account.email_message_assets.where(sha1: assets.map(&:sha1)).pluck(:sha1)
assets.each do |asset|
asset.save!(validate: false) if existing_assets_sha1.exclude?(asset.sha1)
rescue ActiveRecord::RecordNotUnique
nil
end
end
def maybe_extract_assets(account, body)
return [body, []] if body.blank? || body.bytesize < MIN_BODY_SIZE
assets_index = {}
result = body.gsub(ASSET_REGEXP) do
match = Regexp.last_match
prefix, data = match[1] ? [match[1], match[2]] : ['', match[0]]
next match[0] if data.blank? || data.bytesize < MIN_ASSET_SIZE
asset = account.email_message_assets.new(data:).tap(&:validate)
assets_index[asset.sha1] = asset
"#{prefix}#{ASSET_PREFIX}#{asset.sha1}]]"
end
[result, assets_index.values]
end
def rebuild_body_with_assets(account_id, body)
shas = body.scan(PLACEHOLDER_REGEXP).flatten.uniq
data = EmailMessageAsset.where(account_id:, sha1: shas).pluck(:sha1, :data).to_h
account.email_messages.find_by(sha1: message.sha1) || message.tap { |m| m.save!(validate: false) }
body.gsub(PLACEHOLDER_REGEXP) { data[Regexp.last_match(1)] || Regexp.last_match(0) }
end
end

@ -116,15 +116,6 @@ module Mcp
Submissions.send_signature_requests(submissions)
submissions.each do |submission|
submission.submitters.each do |submitter|
next unless submitter.completed_at?
ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id,
'send_invitation_email' => false)
end
end
SearchEntries.enqueue_reindex(submissions)
submission = submissions.first

@ -5,6 +5,22 @@ module Submissions
module_function
def maybe_update_completed_at(submission)
incomplete_submitter = Submitter.where(submission_id: submission.id, completed_at: nil).select(1)
max_completed_at =
Arel::Nodes::Grouping.new(
Submitter.arel_table.project(Submitter.arel_table[:completed_at].maximum)
.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]))
.ast
)
Submission.where(id: submission.id, completed_at: nil)
.where.not(incomplete_submitter.arel.exists)
.update_all(completed_at: max_completed_at)
.positive?
end
def search(current_user, submissions, keyword, search_values: false, search_template: false)
if Docuseal.fulltext_search?
fulltext_search(current_user, submissions, keyword, search_template:)
@ -21,19 +37,22 @@ module Submissions
arel_table = Submitter.arel_table
arel = arel_table[:email].lower.matches(term)
.or(arel_table[:phone].matches(term))
.or(arel_table[:name].lower.matches(term))
submitter_arel = arel_table[:email].lower.matches(term)
.or(arel_table[:phone].matches(term))
.or(arel_table[:name].lower.matches(term))
submitter_arel = submitter_arel.or(arel_table[:values].matches(term)) if search_values
arel = arel.or(Arel::Table.new(:submitters)[:values].matches(term)) if search_values
arel = Submitter.where(arel_table[:submission_id].eq(Submission.arel_table[:id]))
.where(submitter_arel).select(1).arel.exists
if search_template
submissions = submissions.left_joins(:template)
arel = arel.or(Template.arel_table[:name].lower.matches("%#{sanitized}%"))
arel = arel.or(Template.arel_table[:name].lower.matches(term))
end
submissions.joins(:submitters).where(arel).group(:id)
submissions.where(arel)
end
def fulltext_search(current_user, submissions, keyword, search_template: false)

@ -127,14 +127,18 @@ module Submissions
end
end
submission.template_fields = template.fields.deep_dup
submission.template_fields = template.fields.deep_dup.filter_map do |field|
next field if field['areas'].blank?
submission.template_fields.each do |field|
field['areas'].to_a.each do |area|
field['areas'] = field['areas'].filter_map do |area|
dynamic_area = areas_index[area['uuid']]
area.merge!(dynamic_area) if dynamic_area
next area.merge(dynamic_area) if dynamic_area
area if area.key?('page')
end
field if field['areas'].present?
end
submission

@ -15,7 +15,7 @@ module Submissions
def call(submission)
return nil unless submission
raise NotCompletedYet unless submission.submitters.all?(&:completed_at?)
raise NotCompletedYet unless submission.completed_at?
total_wait_time ||= 0
key = [KEY_PREFIX, submission.id].join(':')

@ -40,7 +40,6 @@ module Submissions
submissions.where(created_by_user_id: user&.id || -1)
end
# rubocop:disable Metrics/MethodLength
def filter_by_status(submissions, filters)
case filters[:status]
when 'pending'
@ -52,33 +51,24 @@ module Submissions
when 'expired'
submissions.expired
when 'sent'
submissions.joins(:submitters)
.where(submitters: { opened_at: nil, completed_at: nil, declined_at: nil })
.where.not(submitters: { sent_at: nil })
.group(:id)
submissions.where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]))
.where(opened_at: nil, completed_at: nil, declined_at: nil)
.where.not(sent_at: nil)
.limit(1).arel.exists)
when 'opened'
submissions.joins(:submitters)
.where(submitters: { completed_at: nil, declined_at: nil })
.where.not(submitters: { opened_at: nil })
.group(:id)
submissions.where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]))
.where(completed_at: nil, declined_at: nil)
.where.not(opened_at: nil)
.limit(1).arel.exists)
when 'partially_completed'
submissions.joins(:submitters)
.group(:id)
.having(Arel::Nodes::NamedFunction.new(
'COUNT', [Arel::Nodes::NamedFunction.new('NULLIF',
[Submitter.arel_table[:completed_at].eq(nil),
Arel::Nodes.build_quoted(false)])]
).gt(0))
.having(Arel::Nodes::NamedFunction.new(
'COUNT', [Arel::Nodes::NamedFunction.new('NULLIF',
[Submitter.arel_table[:completed_at].not_eq(nil),
Arel::Nodes.build_quoted(false)])]
).gt(0))
submissions.where(completed_at: nil)
.where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]))
.where.not(completed_at: nil)
.limit(1).arel.exists)
else
submissions
end
end
# rubocop:enable Metrics/MethodLength
def filter_by_created_at(submissions, filters)
submissions = submissions.where(created_at: filters[:created_at_from]..) if filters[:created_at_from].present?
@ -104,16 +94,15 @@ module Submissions
def filter_by_completed_at(submissions, filters)
return submissions unless filters[:completed_at_from].present? || filters[:completed_at_to].present?
completed_arel = Submitter.arel_table[:completed_at].maximum
submissions = submissions.completed.joins(:submitters).group(:id)
submissions = submissions.completed
if filters[:completed_at_from].present?
submissions = submissions.having(completed_arel.gteq(filters[:completed_at_from]))
submissions = submissions.where(completed_at: filters[:completed_at_from]..)
end
return submissions if filters[:completed_at_to].blank?
submissions.having(completed_arel.lteq(filters[:completed_at_to].end_of_day))
submissions.where(completed_at: ..filters[:completed_at_to].end_of_day)
end
def normalize_filter_params(params, current_user)

@ -37,8 +37,8 @@ module Submissions
json['fields'] = submission.template_fields || submission.template&.fields
end
if submitters.all?(&:completed_at?)
last_submitter = submitters.max_by(&:completed_at)
if submission.completed_at?
last_submitter = submitters.select(&:completed_at?).max_by(&:completed_at)
if with_documents
json['documents'] = serialized_submitters.find { |e| e['id'] == last_submitter.id }['documents']
@ -49,7 +49,7 @@ module Submissions
json['combined_document_url'] ||= maybe_build_combined_url(submitters, submission, params, expires_at:)
json['status'] = 'completed'
json['completed_at'] = last_submitter.completed_at.as_json
json['completed_at'] = submission.completed_at.as_json
else
json['documents'] = [] if with_documents
json['audit_log_url'] = nil
@ -73,12 +73,12 @@ module Submissions
end
def maybe_build_combined_url(submitters, submission, params, expires_at: nil)
return unless submitters.all?(&:completed_at?)
return unless submission.completed_at?
attachment = submission.combined_document_attachment
if !attachment && params[:include].to_s.include?('combined_document_url')
submitter = submitters.max_by(&:completed_at)
submitter = submitters.select(&:completed_at?).max_by(&:completed_at)
attachment = Submissions::EnsureCombinedGenerated.call(submitter)
end

@ -84,7 +84,7 @@ module Submitters
submitter_ids = SearchEntry.where(record_type: 'Submitter')
.where(account_id: current_user.account_id)
.where(*query)
.limit(500)
.limit(keyword.length > 2 ? 500 : 5000)
.pluck(:record_id)
submitters.where(id: submitter_ids.first(100))
@ -108,7 +108,7 @@ module Submitters
if AccountConfig.exists?(account_id: submitter.submission.account_id,
key: AccountConfig::COMBINE_PDF_RESULT_KEY,
value: true) &&
submitter.submission.submitters.all?(&:completed_at?) &&
submitter.submission.completed_at? &&
submitter.submission.template_fields.none? { |f| f['type'] == 'verification' }
return [submitter.submission.combined_document_attachment || Submissions::EnsureCombinedGenerated.call(submitter)]
end
@ -207,7 +207,7 @@ module Submitters
filename = filename.gsub('{document.name}', blob.filename.base)
filename = filename.gsub(' - {submission.status}') do
if submitter.submission.submitters.all?(&:completed_at?)
if submitter.submission.completed_at?
status =
if submitter.submission.template_fields.any? { |f| f['type'] == 'signature' }
I18n.t(:signed)
@ -264,7 +264,7 @@ module Submitters
end
def build_combined_url(submitter, ttl: FILES_TTL)
return if submitter.submission.submitters.exists?(completed_at: nil)
return unless submitter.submission.completed_at?
return if submitter.submission.submitters.order(:completed_at).last != submitter
attachment = submitter.submission.combined_document_attachment

@ -93,11 +93,9 @@ module Submitters
end
def build_submission_status(submission)
submitters = submission.submitters
if submitters.all?(&:completed_at?)
if submission.completed_at?
'completed'
elsif submitters.any?(&:declined_at?)
elsif submission.submitters.any?(&:declined_at?)
'declined'
else
submission.expired? ? 'expired' : 'pending'

@ -32,7 +32,11 @@ module Submitters
submitter.submission.save!
ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id) if submitter.completed_at?
if submitter.completed_at?
is_last = Submissions.maybe_update_completed_at(submitter.submission)
ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id, 'is_last' => is_last)
end
submitter
end

@ -10,6 +10,8 @@ RSpec.describe ProcessSubmitterCompletionJob do
before do
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
value: GenerateCertificate.call.transform_values(&:to_pem))
Submissions.maybe_update_completed_at(submitter.submission)
end
describe '#perform' do

@ -26,6 +26,7 @@ RSpec.describe 'Submission Preview' do
create(:encrypted_config, account:, key: EncryptedConfig::EMAIL_SMTP_KEY, value: '{}')
submission.submitters.each { |s| s.update(completed_at: 1.day.ago) }
Submissions.maybe_update_completed_at(submission)
visit submissions_preview_path(slug: submission.slug)
end
@ -47,6 +48,7 @@ RSpec.describe 'Submission Preview' do
it "doesn't display the email form if SMTP is not configured" do
submission.submitters.each { |s| s.update(completed_at: 1.day.ago) }
Submissions.maybe_update_completed_at(submission)
visit submissions_preview_path(slug: submission.slug)

@ -0,0 +1,26 @@
# frozen_string_literal: true
RSpec.describe 'Submission' do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:template) { create(:template, account:, author: user) }
let(:submission) do
create(:submission, :with_submitters, template:, created_by_user: user,
archived_at: Time.current, completed_at: Time.current)
end
before do
sign_in(user)
submission.submitters.each { |s| s.update!(completed_at: 1.day.ago) }
end
it 'unarchives a completed submission from the download dropdown' do
visit submission_path(submission)
find('label[aria-label="Download"]').click
click_button 'Unarchive'
expect(page).to have_content('Submission has been unarchived.')
expect(submission.reload.archived_at).to be_nil
end
end

@ -207,6 +207,8 @@ RSpec.describe 'Template' do
submitter.update!(completed_at: rand(2..5).days.ago)
end
(last_week_submissions + this_week_submissions).each { |s| Submissions.maybe_update_completed_at(s) }
visit template_path(template)
(last_week_submissions + this_week_submissions).map(&:submitters).flatten.last(10).uniq.each do |submitter|

Loading…
Cancel
Save