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 (@submission.schema_documents || @submission.template.schema_documents).size > 1
documents = documents =
if @submission.submitters.all?(&:completed_at?) if @submission.completed_at?
build_completed_documents(@submission, merge: is_merge) build_completed_documents(@submission, merge: is_merge)
else else
build_preview_documents(@submission, merge: is_merge) build_preview_documents(@submission, merge: is_merge)

@ -5,7 +5,7 @@ module Api
load_and_authorize_resource :submission, parent: false load_and_authorize_resource :submission, parent: false
def index 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[: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? params[:before] = Time.zone.at(params[:before].to_i) if params[:before].present?
@ -36,20 +36,5 @@ module Api
} }
} }
end 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
end end

@ -2,7 +2,7 @@
module Api module Api
class SubmissionsController < ApiBaseController 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 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 TEMPLATE_COLUMNS = %i[id name external_id created_at updated_at folder_id submitters].freeze
@ -13,6 +13,8 @@ module Api
authorize!(:create, Submission) authorize!(:create, Submission)
end end
before_action :maybe_return_template_error, only: :create
def index def index
submissions = Submissions.search(current_user, @submissions, params[:q]) submissions = Submissions.search(current_user, @submissions, params[:q])
submissions = filter_submissions(submissions, params) submissions = filter_submissions(submissions, params)
@ -58,7 +60,7 @@ module Api
end end
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) @submission.audit_trail_attachment = Submissions::EnsureAuditGenerated.call(@submission)
end end
@ -68,20 +70,6 @@ module Api
def create def create
Params::SubmissionCreateValidator.call(params) 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_email] = true unless params.key?(:send_email)
params[:send_sms] = false unless params.key?(:send_sms) params[:send_sms] = false unless params.key?(:send_sms)
@ -92,10 +80,16 @@ module Api
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
submissions.each do |submission| 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| submission.submitters.each do |submitter|
next unless submitter.completed_at? 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
end end
@ -123,6 +117,22 @@ module Api
private 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) def filter_submissions(submissions, params)
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present? submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
submissions = submissions.where(slug: params[:slug]) if params[:slug].present? submissions = submissions.where(slug: params[:slug]) if params[:slug].present?

@ -4,6 +4,8 @@ module Api
class SubmittersController < ApiBaseController class SubmittersController < ApiBaseController
load_and_authorize_resource :submitter load_and_authorize_resource :submitter
before_action :maybe_return_submitter_error, only: :update
def index def index
submitters = Submitters.search(current_user, @submitters, params[:q]) submitters = Submitters.search(current_user, @submitters, params[:q])
@ -36,14 +38,6 @@ module Api
# rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/MethodLength
def update 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 submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name'] role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
@ -73,7 +67,9 @@ module Api
end end
if @submitter.completed_at? 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] elsif normalized_params[:send_email] || normalized_params[:send_sms]
Submitters.send_signature_requests([@submitter]) Submitters.send_signature_requests([@submitter])
end end
@ -104,6 +100,16 @@ module Api
private 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) def maybe_filter_by_completed_at(submitters, params)
if params[:completed_after].present? if params[:completed_after].present?
submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..) submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..)

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

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

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

@ -25,7 +25,7 @@ class SubmissionsPreviewController < ApplicationController
raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at? 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)) (!current_user || !current_ability.can?(:read, @submission))
raise ActionController::RoutingError, I18n.t('not_found') raise ActionController::RoutingError, I18n.t('not_found')
end end

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

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

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

@ -600,7 +600,8 @@ export default {
} }
}, },
showFont () { 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 () { showDescription () {
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type) return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)

@ -452,7 +452,7 @@
class="pb-0.5 mt-0.5" class="pb-0.5 mt-0.5"
> >
<li <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" class="field-settings-font"
> >
<label <label

@ -11,7 +11,7 @@ class ProcessSubmissionExpiredJob
return if submission.archived_at? return if submission.archived_at?
return if submission.template&.archived_at? return if submission.template&.archived_at?
return if submission.submitters.where.not(declined_at: nil).exists? 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') WebhookUrls.enqueue_events(submission, 'submission.expired')
end end

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

@ -16,7 +16,7 @@ class SubmitterMailer < ApplicationMailer
template_submitters_index = @email_message.blank? ? build_submitter_preferences_index(@submitter) : {} 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 || template_submitters_index.dig(@submitter.uuid, 'request_email_body').presence ||
@submitter.template&.preferences&.dig('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 :encrypted_configs, dependent: :destroy
has_many :account_configs, dependent: :destroy has_many :account_configs, dependent: :destroy
has_many :email_messages, dependent: :destroy has_many :email_messages, dependent: :destroy
has_many :email_message_assets, dependent: :destroy
has_many :templates, dependent: :destroy has_many :templates, dependent: :destroy
has_many :template_folders, dependent: :destroy has_many :template_folders, dependent: :destroy
has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) }, has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) },

@ -33,6 +33,15 @@ class EmailMessage < ApplicationRecord
before_validation :set_sha1, on: :create 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 def set_sha1
self.sha1 = Digest::SHA1.hexdigest({ subject:, body: }.to_json) self.sha1 = Digest::SHA1.hexdigest({ subject:, body: }.to_json)
end 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 # id :bigint not null, primary key
# archived_at :datetime # archived_at :datetime
# completed_at :datetime
# expire_at :datetime # expire_at :datetime
# name :text # name :text
# preferences :text not null # preferences :text not null
@ -25,7 +26,9 @@
# #
# Indexes # 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 (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 (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_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) # 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 :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) } scope :archived, -> { where.not(archived_at: nil) }
scope :pending, lambda { scope :pending, lambda {
where(expire_at: nil).or(where(expire_at: Time.current..)) where(expire_at: nil).or(where(expire_at: Time.current..)).where(completed_at: nil)
.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)
} }
scope :completed, -> { where.not(completed_at: nil) }
scope :declined, lambda { scope :declined, lambda {
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]) 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) .where.not(declined_at: nil).limit(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)
} }
scope :expired, -> { where(expire_at: ..Time.current).where(completed_at: nil) }
scope :select_for_list, lambda { 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) :created_at, :archived_at, :expire_at, :template_id, :template_submitters)
} }

@ -4,7 +4,9 @@
<p> <p>
<% if @current_account&.testing? %> <% 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) %> <%= 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) %> <%= 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 %> <% end %>
</p> </p>

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

@ -56,7 +56,7 @@
<div class="form-control"> <div class="form-control">
<%= f.label :message, t('body'), class: 'label' %> <%= f.label :message, t('body'), class: 'label' %>
<% body_variables = AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY] %> <% 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) %> <% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %>
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer"> <label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
<%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %> <%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %>
@ -91,7 +91,7 @@
</div> </div>
<div class="form-control"> <div class="form-control">
<%= ff.label :message, t('body'), class: 'label' %> <%= 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>
</div> </div>
<% end %> <% 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> <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> </a>
<div class="space-x-3 flex items-center"> <div class="space-x-3 flex items-center">
<% is_all_completed = @submission.submitters.to_a.all?(&:completed_at?) %> <% if signed_in? && can?(:destroy, @submission) && @submission.archived_at? && !@submission.completed_at? %>
<% if signed_in? && can?(:create, @submission) && @submission.archived_at? && !is_all_completed %>
<%= 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' %> <%= 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 %> <% end %>
<% if @submission.audit_trail.present? %> <% if @submission.audit_trail.present? %>
@ -30,16 +29,19 @@
<span class="hidden md:block"><%= t('event_log') %></span> <span class="hidden md:block"><%= t('event_log') %></span>
<% end %> <% end %>
<% 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? } %> <% pending_submitters_count = @submission.submitters.to_a.count { |s| !s.completed_at? && s.email.present? && !s.declined_at? } %>
<% if pending_submitters_count.positive? %> <% 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) } %> <%= 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 %>
<% end %> <% end %>
<% if @submission.submitters.to_a.any?(&:completed_at?) %> <% 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"> <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"> <span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %> <%= svg_icon('download', class: 'w-6 h-6') %>
<span class="hidden md:inline"><%= t('download') %></span> <span class="hidden md:inline"><%= t('download') %></span>
@ -49,14 +51,15 @@
<span class="hidden md:inline"><%= t('downloading') %></span> <span class="hidden md:inline"><%= t('downloading') %></span>
</span> </span>
</download-button> </download-button>
<% if is_all_completed && !is_combined_enabled %> <% if show_download_dropdown %>
<div class="dropdown dropdown-end"> <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"> <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"> <span class="text-sm align-text-top">
<%= svg_icon('chevron_down', class: 'w-6 h-6 flex-shrink-0 stroke-2') %> <%= svg_icon('chevron_down', class: 'w-6 h-6 flex-shrink-0 stroke-2') %>
</span> </span>
</label> </label>
<ul class="z-10 dropdown-content p-2 mt-2 shadow menu text-base bg-base-100 rounded-box text-right"> <ul class="z-10 dropdown-content p-2 mt-2 shadow menu text-base bg-base-100 rounded-box text-right">
<% if show_combined_download %>
<li> <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"> <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"> <span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
@ -69,6 +72,15 @@
</span> </span>
</download-button> </download-button>
</li> </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> </ul>
</div> </div>
<% end %> <% end %>

@ -28,7 +28,7 @@
<% end %> <% 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"> <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'] } } %> <% 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 %> <% if submitters.size == 1 %>
<div> <div>
<% submitter = submitters.first %> <% submitter = submitters.first %>

@ -37,7 +37,7 @@
</div> </div>
<% unless can?(:manage, :countless) %> <% unless can?(:manage, :countless) %>
<div class="badge badge-neutral badge-outline font-medium"> <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> </div>
<% end %> <% end %>
</a> </a>
@ -48,7 +48,7 @@
</div> </div>
<% unless can?(:manage, :countless) %> <% unless can?(:manage, :countless) %>
<div class="badge badge-neutral badge-outline font-medium"> <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> </div>
<% end %> <% end %>
</a> </a>
@ -59,7 +59,7 @@
</div> </div>
<% unless can?(:manage, :countless) %> <% unless can?(:manage, :countless) %>
<div class="badge badge-neutral badge-outline font-medium"> <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> </div>
<% end %> <% end %>
</a> </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:' 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_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_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>' 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 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. 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:' 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_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_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>' 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 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. 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:' 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_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_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>' 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 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." 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 :' 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_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_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> 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 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. 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:' 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_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_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>' 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 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. 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:' 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_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_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> 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' 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. 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:' 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_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_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> 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 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 ? 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. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "btree_gin" enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql" 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" t.index ["message_id"], name: "index_email_events_on_message_id"
end 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| create_table "email_messages", force: :cascade do |t|
t.bigint "account_id", null: false t.bigint "account_id", null: false
t.bigint "author_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| create_table "submissions", force: :cascade do |t|
t.bigint "account_id", null: false t.bigint "account_id", null: false
t.datetime "archived_at" t.datetime "archived_at"
t.datetime "completed_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.datetime "expire_at" 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.datetime "updated_at", null: false
t.text "variables" t.text "variables"
t.text "variables_schema" 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"
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", 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 ["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" 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_document_versions", "dynamic_documents"
add_foreign_key "dynamic_documents", "templates" add_foreign_key "dynamic_documents", "templates"
add_foreign_key "email_events", "accounts" add_foreign_key "email_events", "accounts"
add_foreign_key "email_message_assets", "accounts"
add_foreign_key "email_messages", "accounts" add_foreign_key "email_messages", "accounts"
add_foreign_key "email_messages", "users", column: "author_id" add_foreign_key "email_messages", "users", column: "author_id"
add_foreign_key "encrypted_configs", "accounts" 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", "payment",
"phone", "phone",
"verification", "verification",
"kba",
"strikethrough" "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": { "drawFieldType": {
"type": "string", "type": "string",
"required": false, "required": false,
@ -196,6 +203,7 @@ const token = jwt.sign({
"payment", "payment",
"phone", "phone",
"verification", "verification",
"kba",
"strikethrough" "strikethrough"
] ]
}, },
@ -324,6 +332,7 @@ const token = jwt.sign({
"payment", "payment",
"phone", "phone",
"verification", "verification",
"kba",
"strikethrough" "strikethrough"
] ]
}, },
@ -439,13 +448,19 @@ const token = jwt.sign({
"type": "boolean", "type": "boolean",
"required": false, "required": false,
"default": true, "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": { "withFieldsList": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,
"default": true, "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": { "withFieldsDetection": {
"type": "boolean", "type": "boolean",
@ -453,6 +468,12 @@ const token = jwt.sign({
"default": false, "default": false,
"description": "Display a button to automatically detect and add fields to the document with AI." "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": { "withFieldPlaceholder": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,
@ -464,6 +485,12 @@ const token = jwt.sign({
"required": false, "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." "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": { "onlyDefinedFields": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,

@ -119,6 +119,7 @@
"payment", "payment",
"phone", "phone",
"verification", "verification",
"kba",
"strikethrough" "strikethrough"
] ]
}, },
@ -251,6 +252,7 @@
"payment", "payment",
"phone", "phone",
"verification", "verification",
"kba",
"strikethrough" "strikethrough"
] ]
}, },
@ -339,9 +341,16 @@
"payment", "payment",
"phone", "phone",
"verification", "verification",
"kba",
"strikethrough" "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": { "data-draw-field-type": {
"type": "string", "type": "string",
"required": false, "required": false,
@ -403,13 +412,19 @@
"type": "boolean", "type": "boolean",
"required": false, "required": false,
"default": true, "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": { "data-with-fields-list": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,
"default": true, "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": { "data-with-fields-detection": {
"type": "boolean", "type": "boolean",
@ -417,6 +432,12 @@
"default": false, "default": false,
"description": "Display a button to automatically detect and add fields to the document with AI." "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": { "data-with-field-placeholder": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,
@ -428,6 +449,12 @@
"required": false, "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." "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": { "data-preview": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,

@ -143,9 +143,16 @@ const token = jwt.sign({
"payment", "payment",
"phone", "phone",
"verification", "verification",
"kba",
"strikethrough" "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": { "drawFieldType": {
"type": "string", "type": "string",
"required": false, "required": false,
@ -187,6 +194,7 @@ const token = jwt.sign({
"payment", "payment",
"phone", "phone",
"verification", "verification",
"kba",
"strikethrough" "strikethrough"
] ]
}, },
@ -315,6 +323,7 @@ const token = jwt.sign({
"payment", "payment",
"phone", "phone",
"verification", "verification",
"kba",
"strikethrough" "strikethrough"
] ]
}, },
@ -430,13 +439,19 @@ const token = jwt.sign({
"type": "boolean", "type": "boolean",
"required": false, "required": false,
"default": true, "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": { "withFieldsList": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,
"default": true, "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": { "withFieldsDetection": {
"type": "boolean", "type": "boolean",
@ -444,6 +459,12 @@ const token = jwt.sign({
"default": false, "default": false,
"description": "Display a button to automatically detect and add fields to the document with AI." "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": { "withFieldPlaceholder": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,
@ -455,6 +476,12 @@ const token = jwt.sign({
"required": false, "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." "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": { "onlyDefinedFields": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,

@ -164,9 +164,16 @@ const token = jwt.sign({
"payment", "payment",
"phone", "phone",
"verification", "verification",
"kba",
"strikethrough" "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": { "draw-field-type": {
"type": "string", "type": "string",
"required": false, "required": false,
@ -208,6 +215,7 @@ const token = jwt.sign({
"payment", "payment",
"phone", "phone",
"verification", "verification",
"kba",
"strikethrough" "strikethrough"
] ]
}, },
@ -336,6 +344,7 @@ const token = jwt.sign({
"payment", "payment",
"phone", "phone",
"verification", "verification",
"kba",
"strikethrough" "strikethrough"
] ]
}, },
@ -445,13 +454,19 @@ const token = jwt.sign({
"type": "boolean", "type": "boolean",
"required": false, "required": false,
"default": true, "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": { "with-fields-list": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,
"default": true, "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": { "with-fields-detection": {
"type": "boolean", "type": "boolean",
@ -459,6 +474,12 @@ const token = jwt.sign({
"default": false, "default": false,
"description": "Display a button to automatically detect and add fields to the document with AI." "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": { "with-field-placeholder": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,
@ -470,6 +491,12 @@ const token = jwt.sign({
"required": false, "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." "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": { "autosave": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,

@ -51,7 +51,7 @@ export class AppComponent {}
"token": { "token": {
"type": "string", "type": "string",
"doc_type": "object", "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, "required": false,
"properties": { "properties": {
"slug": { "slug": {
@ -109,7 +109,7 @@ export class AppComponent {}
"language": { "language": {
"type": "string", "type": "string",
"required": false, "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": { "i18n": {
"type": "object", "type": "object",
@ -127,7 +127,7 @@ export class AppComponent {}
"type": "boolean", "type": "boolean",
"required": false, "required": false,
"default": true, "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": { "withFieldPlaceholder": {
"type": "boolean", "type": "boolean",
@ -232,6 +232,12 @@ export class AppComponent {}
"default": false, "default": false,
"description": "Set `true` to display the complete button in the form header." "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": { "allowToResubmit": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,
@ -241,7 +247,7 @@ export class AppComponent {}
"signature": { "signature": {
"type": "string", "type": "string",
"required": false, "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": { "rememberSignature": {
"type": "boolean", "type": "boolean",
@ -311,7 +317,7 @@ export class AppComponent {}
"onComplete": { "onComplete": {
"type": "event emitter", "type": "event emitter",
"required": false, "required": false,
"description": "Event emitted the form completion.", "description": "Event emitted on form completion.",
"example": "handleComplete($event)" "example": "handleComplete($event)"
}, },
"onDecline": { "onDecline": {

@ -47,7 +47,7 @@
"data-token": { "data-token": {
"type": "string", "type": "string",
"doc_type": "object", "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, "required": false,
"properties": { "properties": {
"slug": { "slug": {
@ -105,7 +105,7 @@
"data-language": { "data-language": {
"type": "string", "type": "string",
"required": false, "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": { "data-i18n": {
"type": "string", "type": "string",
@ -153,7 +153,7 @@
"type": "boolean", "type": "boolean",
"required": false, "required": false,
"default": true, "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": { "data-with-field-placeholder": {
"type": "boolean", "type": "boolean",
@ -179,6 +179,12 @@
"default": false, "default": false,
"description": "Set `true` to display the complete button in the form header." "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": { "data-allow-to-resubmit": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,
@ -194,7 +200,7 @@
"data-signature": { "data-signature": {
"type": "string", "type": "string",
"required": false, "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": { "data-remember-signature": {
"type": "boolean", "type": "boolean",

@ -48,7 +48,7 @@ export function App() {
"token": { "token": {
"type": "string", "type": "string",
"doc_type": "object", "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, "required": false,
"properties": { "properties": {
"slug": { "slug": {
@ -106,7 +106,7 @@ export function App() {
"language": { "language": {
"type": "string", "type": "string",
"required": false, "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": { "i18n": {
"type": "object", "type": "object",
@ -124,7 +124,7 @@ export function App() {
"type": "boolean", "type": "boolean",
"required": false, "required": false,
"default": true, "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": { "withFieldPlaceholder": {
"type": "boolean", "type": "boolean",
@ -229,6 +229,12 @@ export function App() {
"default": false, "default": false,
"description": "Set `true` to display the complete button in the form header." "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": { "allowToResubmit": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,
@ -244,7 +250,7 @@ export function App() {
"signature": { "signature": {
"type": "string", "type": "string",
"required": false, "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": { "rememberSignature": {
"type": "boolean", "type": "boolean",

@ -57,7 +57,7 @@ export default {
"token": { "token": {
"type": "string", "type": "string",
"doc_type": "object", "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, "required": false,
"properties": { "properties": {
"slug": { "slug": {
@ -115,7 +115,7 @@ export default {
"language": { "language": {
"type": "string", "type": "string",
"required": false, "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": { "i18n": {
"type": "object", "type": "object",
@ -145,7 +145,7 @@ export default {
"type": "boolean", "type": "boolean",
"required": false, "required": false,
"default": true, "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": { "with-field-placeholder": {
"type": "boolean", "type": "boolean",
@ -195,6 +195,12 @@ export default {
"default": false, "default": false,
"description": "Set `true` to display the complete button in the form header." "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": { "allow-to-resubmit": {
"type": "boolean", "type": "boolean",
"required": false, "required": false,
@ -204,7 +210,7 @@ export default {
"signature": { "signature": {
"type": "string", "type": "string",
"required": false, "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": { "remember-signature": {
"type": "boolean", "type": "boolean",

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
# Form Webhook # 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.viewed'** event is triggered when the submitter first opens the form.
- **'form.started'** event is triggered when the submitter initiates filling out 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": [ "enum": [
"form.viewed", "form.viewed",
"form.started", "form.started",
"form.completed" "form.completed",
"form.declined"
] ]
}, },
"timestamp": { "timestamp": {
"type": "string", "type": "string",
"description": "The event timestamp.", "description": "The event timestamp.",
"example": "2023-09-24T11:20:42Z", "examples": [
"2023-09-24T11:20:42Z"
],
"format": "date-time" "format": "date-time"
}, },
"data": { "data": {
@ -36,20 +39,20 @@ During the form filling and signing process, 3 types of events may occur and are
"type": "number", "type": "number",
"description": "The submitter's unique identifier." "description": "The submitter's unique identifier."
}, },
"submission_id": {
"type": "number",
"description": "The unique submission identifier."
},
"email": { "email": {
"type": "string", "type": "string",
"description": "The submitter's email address", "description": "The submitter's email address",
"format": "email", "format": "email",
"example": "john.doe@example.com" "examples": [
"john.doe@example.com"
]
}, },
"ua": { "ua": {
"type": "string", "type": "string",
"description": "The user agent string that provides information about the submitter's web browser.", "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": { "ip": {
"type": "string", "type": "string",
@ -62,27 +65,28 @@ During the form filling and signing process, 3 types of events may occur and are
"phone": { "phone": {
"type": "string", "type": "string",
"description": "The submitter's phone number, formatted according to the E.164 standard.", "description": "The submitter's phone number, formatted according to the E.164 standard.",
"example": "+1234567890" "examples": [
"+1234567890"
]
}, },
"role": { "role": {
"type": "string", "type": "string",
"description": "The submitter's role name or title.", "description": "The submitter's role name or title.",
"example": "First Party" "examples": [
"First Party"
]
}, },
"external_id": { "external_id": {
"type": "string", "type": "string",
"description": "Your application-specific unique string key to identify submitter within your app." "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": { "decline_reason": {
"type": "string", "type": "string",
"description": "Submitter provided decline message." "description": "Submitter provided decline message."
}, },
"sent_at": { "sent_at": {
"type": "string", "type": "string",
"description": "The date and time when the signing request was sent to the submitter.",
"format": "date-time" "format": "date-time"
}, },
"status": { "status": {
@ -98,22 +102,27 @@ During the form filling and signing process, 3 types of events may occur and are
}, },
"opened_at": { "opened_at": {
"type": "string", "type": "string",
"description": "The date and time when the submitter opened the signing form.",
"format": "date-time" "format": "date-time"
}, },
"completed_at": { "completed_at": {
"type": "string", "type": "string",
"description": "The date and time when the submitter completed the signing form.",
"format": "date-time" "format": "date-time"
}, },
"declined_at": { "declined_at": {
"type": "string", "type": "string",
"description": "The date and time when the submitter declined the signing form.",
"format": "date-time" "format": "date-time"
}, },
"created_at": { "created_at": {
"type": "string", "type": "string",
"description": "The date and time when the submitter was created.",
"format": "date-time" "format": "date-time"
}, },
"updated_at": { "updated_at": {
"type": "string", "type": "string",
"description": "The date and time when the submitter was last updated.",
"format": "date-time" "format": "date-time"
}, },
"submission": { "submission": {
@ -189,6 +198,7 @@ During the form filling and signing process, 3 types of events may occur and are
}, },
"preferences": { "preferences": {
"type": "object", "type": "object",
"description": "Submitter preferences for notifications.",
"properties": { "properties": {
"send_email": { "send_email": {
"type": "boolean", "type": "boolean",
@ -210,7 +220,7 @@ During the form filling and signing process, 3 types of events may occur and are
"type": "string", "type": "string",
"description": "The field name." "description": "The field name."
}, },
"values": { "value": {
"type": "string", "type": "string",
"description": "The field value." "description": "The field value."
} }
@ -231,6 +241,7 @@ During the form filling and signing process, 3 types of events may occur and are
}, },
"documents": { "documents": {
"type": "array", "type": "array",
"description": "List of completed documents signed by the submitter.",
"items": { "items": {
"type": "object", "type": "object",
"properties": { "properties": {

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

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

@ -1,13 +1,70 @@
# frozen_string_literal: true # frozen_string_literal: true
module EmailMessages 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 module_function
def find_or_create_for_account_user(account, user, subject, body) def find_or_create_for_account_user(account, user, subject, body)
subject = I18n.t(:you_are_invited_to_sign_a_document) if subject.blank? 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
end end

@ -116,15 +116,6 @@ module Mcp
Submissions.send_signature_requests(submissions) 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) SearchEntries.enqueue_reindex(submissions)
submission = submissions.first submission = submissions.first

@ -5,6 +5,22 @@ module Submissions
module_function 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) def search(current_user, submissions, keyword, search_values: false, search_template: false)
if Docuseal.fulltext_search? if Docuseal.fulltext_search?
fulltext_search(current_user, submissions, keyword, search_template:) fulltext_search(current_user, submissions, keyword, search_template:)
@ -21,19 +37,22 @@ module Submissions
arel_table = Submitter.arel_table arel_table = Submitter.arel_table
arel = arel_table[:email].lower.matches(term) submitter_arel = arel_table[:email].lower.matches(term)
.or(arel_table[:phone].matches(term)) .or(arel_table[:phone].matches(term))
.or(arel_table[:name].lower.matches(term)) .or(arel_table[:name].lower.matches(term))
arel = arel.or(Arel::Table.new(:submitters)[:values].matches(term)) if search_values submitter_arel = submitter_arel.or(arel_table[: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 if search_template
submissions = submissions.left_joins(: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 end
submissions.joins(:submitters).where(arel).group(:id) submissions.where(arel)
end end
def fulltext_search(current_user, submissions, keyword, search_template: false) def fulltext_search(current_user, submissions, keyword, search_template: false)

@ -127,14 +127,18 @@ module Submissions
end end
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'] = field['areas'].filter_map do |area|
field['areas'].to_a.each do |area|
dynamic_area = areas_index[area['uuid']] 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 end
field if field['areas'].present?
end end
submission submission

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

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

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

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

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

@ -32,7 +32,11 @@ module Submitters
submitter.submission.save! 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 submitter
end end

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

@ -26,6 +26,7 @@ RSpec.describe 'Submission Preview' do
create(:encrypted_config, account:, key: EncryptedConfig::EMAIL_SMTP_KEY, value: '{}') create(:encrypted_config, account:, key: EncryptedConfig::EMAIL_SMTP_KEY, value: '{}')
submission.submitters.each { |s| s.update(completed_at: 1.day.ago) } submission.submitters.each { |s| s.update(completed_at: 1.day.ago) }
Submissions.maybe_update_completed_at(submission)
visit submissions_preview_path(slug: submission.slug) visit submissions_preview_path(slug: submission.slug)
end end
@ -47,6 +48,7 @@ RSpec.describe 'Submission Preview' do
it "doesn't display the email form if SMTP is not configured" 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) } submission.submitters.each { |s| s.update(completed_at: 1.day.ago) }
Submissions.maybe_update_completed_at(submission)
visit submissions_preview_path(slug: submission.slug) 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) submitter.update!(completed_at: rand(2..5).days.ago)
end end
(last_week_submissions + this_week_submissions).each { |s| Submissions.maybe_update_completed_at(s) }
visit template_path(template) visit template_path(template)
(last_week_submissions + this_week_submissions).map(&:submitters).flatten.last(10).uniq.each do |submitter| (last_week_submissions + this_week_submissions).map(&:submitters).flatten.last(10).uniq.each do |submitter|

Loading…
Cancel
Save