Merge from docusealco/wip

pull/493/head 2.0.3
Pete Matsyburka 5 months ago committed by GitHub
commit 055f7e2bc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -387,7 +387,7 @@ GEM
puma (6.5.0)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.1.14)
rack (3.1.16)
rack-proxy (0.7.7)
rack
rack-session (2.0.0)

@ -10,7 +10,7 @@ module Api
end
def index
submissions = Submissions.search(@submissions, params[:q])
submissions = Submissions.search(current_user, @submissions, params[:q])
submissions = filter_submissions(submissions, params)
submissions = paginate(submissions.preload(:created_by_user, :submitters,
@ -80,6 +80,8 @@ module Api
end
end
SearchEntries.enqueue_reindex(submissions)
render json: build_create_json(submissions)
rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError,
DownloadUtils::UnableToDownload => e
@ -183,14 +185,14 @@ module Api
def submissions_params
permitted_attrs = [
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last,
:expire_at,
:expire_at, :name,
{
message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :reply_to, :go_to_last,
{ metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body],
fields: [:name, :uuid, :default_value, :value, :title, :description,
:readonly, :validation_pattern, :invalid_message,
:readonly, :required, :validation_pattern, :invalid_message,
{ default_value: [], value: [], preferences: {} }] }]]
}
]

@ -5,7 +5,7 @@ module Api
load_and_authorize_resource :submitter
def index
submitters = Submitters.search(@submitters, params[:q])
submitters = Submitters.search(current_user, @submitters, params[:q])
submitters = filter_submitters(submitters, params)
@ -37,14 +37,16 @@ module Api
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_entity
end
role = @submitter.submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
normalized_params, new_attachments =
Submissions::NormalizeParamUtils.normalize_submitter_params!(submitter_params.merge(role:), @submitter.template,
for_submitter: @submitter)
normalized_params, new_attachments = Submissions::NormalizeParamUtils.normalize_submitter_params!(
submitter_params.merge(role:),
@submitter.template || Template.new(submitters: submission.template_submitters, account: @submitter.account),
for_submitter: @submitter
)
Submissions::CreateFromSubmitters.maybe_set_template_fields(@submitter.submission,
[normalized_params],
Submissions::CreateFromSubmitters.maybe_set_template_fields(submission, [normalized_params],
default_submitter_uuid: @submitter.uuid)
assign_submitter_attrs(@submitter, normalized_params)
@ -65,10 +67,10 @@ module Api
Submitters.send_signature_requests([@submitter])
end
render json: Submitters::SerializeForApi.call(@submitter, with_template: false,
with_urls: true,
with_events: false,
params:)
SearchEntries.enqueue_reindex(@submitter)
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true,
with_events: false, params:)
rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar)
@ -82,7 +84,7 @@ module Api
:send_email, :send_sms, :reply_to, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :go_to_last,
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
fields: [[:name, :uuid, :default_value, :value,
fields: [[:name, :uuid, :default_value, :value, :required,
:readonly, :validation_pattern, :invalid_message,
{ default_value: [], value: [], preferences: {} }]] }
)

@ -21,17 +21,20 @@ module Api
)
cloned_template.source = :api
cloned_template.save!
schema_documents = Templates::CloneAttachments.call(template: cloned_template,
original_template: @template,
documents: params[:documents])
cloned_template.save!
WebhookUrls.for_account_id(cloned_template.account_id, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id,
'webhook_url_id' => webhook_url.id)
end
SearchEntries.enqueue_reindex(cloned_template)
render json: Templates::SerializeForApi.call(cloned_template, schema_documents)
end
end

@ -65,6 +65,8 @@ module Api
@template.update!(template_params)
SearchEntries.enqueue_reindex(@template)
WebhookUrls.for_account_id(@template.account_id, 'template.updated').each do |webhook_url|
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id,
'webhook_url_id' => webhook_url.id)
@ -86,7 +88,7 @@ module Api
private
def filter_templates(templates, params)
templates = Templates.search(templates, params[:q])
templates = Templates.search(current_user, templates, params[:q])
templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active
templates = templates.where(external_id: params[:application_key]) if params[:application_key].present?
templates = templates.where(external_id: params[:external_id]) if params[:external_id].present?

@ -53,6 +53,8 @@ class StartFormController < ApplicationController
if is_new_record
enqueue_submission_create_webhooks(@submitter)
SearchEntries.enqueue_reindex(@submitter)
if @submitter.submission.expire_at?
ProcessSubmissionExpiredJob.perform_at(@submitter.submission.expire_at,
'submission_id' => @submitter.submission_id)
@ -142,7 +144,7 @@ class StartFormController < ApplicationController
end
def filter_undefined_submitters(template)
Templates.filter_undefined_submitters(template)
Templates.filter_undefined_submitters(template.submitters)
end
def submitter_params

@ -4,12 +4,12 @@ class SubmissionsArchivedController < ApplicationController
load_and_authorize_resource :submission, parent: false
def index
@submissions = @submissions.joins(:template)
@submissions = @submissions.left_joins(:template)
@submissions = @submissions.where.not(archived_at: nil)
.or(@submissions.where.not(templates: { archived_at: nil }))
.preload(:template_accesses, :created_by_user, template: :author)
@submissions = Submissions.search(@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 = if params[:completed_at_from].present? || params[:completed_at_to].present?

@ -8,6 +8,10 @@ class SubmissionsController < ApplicationController
prepend_before_action :maybe_redirect_com, only: %i[show]
before_action only: :create do
authorize!(:create, Submission)
end
def show
@submission = Submissions.preload_with_pages(@submission)
@ -26,8 +30,6 @@ class SubmissionsController < ApplicationController
end
def create
authorize!(:create, Submission)
save_template_message(@template, params) if params[:save_message] == '1'
if params[:is_custom_message] != '1'
@ -56,6 +58,8 @@ class SubmissionsController < ApplicationController
Submissions.send_signature_requests(submissions)
SearchEntries.enqueue_reindex(submissions)
redirect_to template_path(@template), notice: I18n.t('new_recipients_have_been_added')
rescue Submissions::CreateFromSubmitters::BaseError => e
render turbo_stream: turbo_stream.replace(:submitters_error,
@ -81,7 +85,7 @@ class SubmissionsController < ApplicationController
I18n.t('submission_has_been_archived')
end
redirect_back(fallback_location: template_path(@submission.template), notice:)
redirect_back(fallback_location: @submission.template_id ? template_path(@submission.template) : root_path, notice:)
end
private

@ -4,13 +4,13 @@ class SubmissionsDashboardController < ApplicationController
load_and_authorize_resource :submission, parent: false
def index
@submissions = @submissions.joins(:template)
@submissions = @submissions.left_joins(:template)
@submissions = @submissions.where(archived_at: nil)
.where(templates: { archived_at: nil })
.preload(:template_accesses, :created_by_user, template: :author)
@submissions = Submissions.search(@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 = if params[:completed_at_from].present? || params[:completed_at_to].present?

@ -8,6 +8,7 @@ class SubmitFormController < ApplicationController
skip_authorization_check
before_action :load_submitter, only: %i[show update completed]
before_action :maybe_render_locked_page, only: :show
CONFIG_KEYS = [].freeze
@ -15,19 +16,14 @@ class SubmitFormController < ApplicationController
submission = @submitter.submission
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
return render :archived if submission.template.archived_at? ||
submission.archived_at? ||
@submitter.account.archived_at?
return render :expired if submission.expired?
return render :declined if @submitter.declined_at?
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
return render :awaiting if (@form_configs[:enforce_signing_order] ||
submission.template.preferences['submitters_order'] == 'preserved') &&
submission.template&.preferences&.dig('submitters_order') == 'preserved') &&
!Submitters.current_submitter_order?(@submitter)
Submitters.preload_with_pages(@submitter)
Submissions.preload_with_pages(submission)
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
@ -54,7 +50,7 @@ class SubmitFormController < ApplicationController
return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_entity
end
if @submitter.template.archived_at? || @submitter.submission.archived_at?
if @submitter.template&.archived_at? || @submitter.submission.archived_at?
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_entity
end
@ -84,6 +80,15 @@ class SubmitFormController < ApplicationController
private
def maybe_render_locked_page
return render :archived if @submitter.submission.template&.archived_at? ||
@submitter.submission.archived_at? ||
@submitter.account.archived_at?
return render :expired if @submitter.submission.expired?
render :declined if @submitter.declined_at?
end
def load_submitter
@submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug])
end

@ -11,7 +11,7 @@ class SubmitFormDeclineController < ApplicationController
submitter.completed_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template.archived_at?
submitter.submission.template&.archived_at?
ApplicationRecord.transaction do
submitter.update!(declined_at: Time.current)

@ -14,7 +14,10 @@ class SubmitFormDownloadController < ApplicationController
return head :unprocessable_entity if @submitter.declined_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template.archived_at?
@submitter.submission.template&.archived_at? ||
AccountConfig.exists?(account_id: @submitter.account_id,
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
value: false)
last_completed_submitter = @submitter.submission.submitters
.where.not(id: @submitter.id)
@ -25,7 +28,7 @@ class SubmitFormDownloadController < ApplicationController
if last_completed_submitter
Submitters.select_attachments_for_download(last_completed_submitter)
else
@submitter.submission.template.schema_documents.preload(:blob)
@submitter.submission.schema_documents.preload(:blob)
end
urls = attachments.map do |attachment|

@ -22,13 +22,17 @@ class SubmittersAutocompleteController < ApplicationController
def search_submitters(submitters)
if SELECT_COLUMNS.include?(params[:field])
column = Submitter.arel_table[params[:field].to_sym]
if Docuseal.fulltext_search?(current_user)
Submitters.fulltext_search_field(current_user, submitters, params[:q], params[:field])
else
column = Submitter.arel_table[params[:field].to_sym]
term = "#{params[:q].downcase}%"
term = "#{params[:q].downcase}%"
submitters.where(column.matches(term))
submitters.where(column.matches(term))
end
else
Submitters.search(submitters, params[:q])
Submitters.search(current_user, submitters, params[:q])
end
end
end

@ -38,6 +38,8 @@ class SubmittersController < ApplicationController
if @submitter.save
maybe_resend_email_sms(@submitter, params)
SearchEntries.enqueue_reindex(@submitter)
redirect_back fallback_location: submission_path(submission), notice: I18n.t('changes_have_been_saved')
else
redirect_back fallback_location: submission_path(submission), alert: I18n.t('unable_to_save')

@ -6,13 +6,15 @@ class SubmittersResubmitController < ApplicationController
def update
return redirect_to submit_form_path(slug: @submitter.slug) if @submitter.email != current_user.email
submission = @submitter.template.submissions.new(created_by_user: current_user,
submitters_order: :preserved,
**@submitter.submission.slice(:template_fields,
:account_id,
:template_schema,
:template_submitters,
:preferences))
submission = @submitter.account.submissions.new(created_by_user: current_user,
submitters_order: :preserved,
**@submitter.submission.slice(:template_fields,
:account_id,
:name,
:template_id,
:template_schema,
:template_submitters,
:preferences))
@submitter.submission.submitters.each do |submitter|
new_submitter = submission.submitters.new(submitter.slice(:uuid, :email, :phone, :name,
@ -27,6 +29,10 @@ class SubmittersResubmitController < ApplicationController
submission.save!
@submitter.submission.documents_attachments.each do |attachment|
submission.documents_attachments.create!(uuid: attachment.uuid, blob_id: attachment.blob_id)
end
redirect_to submit_form_path(slug: @new_submitter.slug)
end

@ -6,7 +6,7 @@ class TemplateFoldersController < ApplicationController
def show
@templates = @template_folder.templates.active.accessible_by(current_ability)
.preload(:author, :template_accesses)
@templates = Templates.search(@templates, params[:q])
@templates = Templates.search(current_user, @templates, params[:q])
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
@pagy, @templates = pagy_auto(@templates, limit: 12)

@ -5,7 +5,7 @@ class TemplatesArchivedController < ApplicationController
def index
@templates = @templates.where.not(archived_at: nil).preload(:author, :folder, :template_accesses).order(id: :desc)
@templates = Templates.search(@templates, params[:q])
@templates = Templates.search(current_user, @templates, params[:q])
@pagy, @templates = pagy_auto(@templates, limit: 12)
end

@ -6,7 +6,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
def index
@submissions = @submissions.where.not(archived_at: nil)
@submissions = Submissions.search(@submissions, params[:q], search_values: true)
@submissions = Submissions.search(current_user, @submissions, params[:q], search_values: true)
@submissions = Submissions::Filter.call(@submissions, current_user, params)
@submissions = if params[:completed_at_from].present? || params[:completed_at_to].present?

@ -22,6 +22,8 @@ class TemplatesCloneAndReplaceController < ApplicationController
Templates::CloneAttachments.call(template: cloned_template, original_template: @template,
excluded_attachment_uuids: documents.map(&:uuid))
SearchEntries.enqueue_reindex(cloned_template)
respond_to do |f|
f.html { redirect_to edit_template_path(cloned_template) }
f.json { render json: { id: cloned_template.id } }

@ -8,7 +8,7 @@ class TemplatesController < ApplicationController
def show
submissions = @template.submissions.accessible_by(current_ability)
submissions = submissions.active if @template.archived_at.blank?
submissions = Submissions.search(submissions, params[:q], search_values: true)
submissions = Submissions.search(current_user, submissions, params[:q], search_values: true)
submissions = Submissions::Filter.call(submissions, current_user, params.except(:status))
@base_submissions = submissions
@ -72,6 +72,8 @@ class TemplatesController < ApplicationController
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
SearchEntries.enqueue_reindex(@template)
enqueue_template_created_webhooks(@template)
maybe_redirect_to_template(@template)
@ -81,7 +83,13 @@ class TemplatesController < ApplicationController
end
def update
@template.update!(template_params)
@template.assign_attributes(template_params)
is_name_changed = @template.name_changed?
@template.save!
SearchEntries.enqueue_reindex(@template) if is_name_changed
enqueue_template_updated_webhooks(@template)

@ -25,7 +25,7 @@ class TemplatesDashboardController < ApplicationController
@templates = @templates.none
else
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
@templates = filter_templates(@templates)
@templates = filter_templates(@templates).preload(:author, :template_accesses)
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
limit =
@ -42,7 +42,7 @@ class TemplatesDashboardController < ApplicationController
private
def filter_templates(templates)
rel = templates.active.preload(:author, :template_accesses)
rel = templates.active
if params[:q].blank?
if Docuseal.multitenant? ? current_account.testing? : current_account.linked_account_account
@ -51,13 +51,20 @@ class TemplatesDashboardController < ApplicationController
shared_template_ids = TemplateSharing.where(account_id: shared_account_ids).select(:template_id)
rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids))
rel = Template.where(
Template.arel_table[:id].in(
Arel::Nodes::Union.new(
rel.where(folder_id: current_account.default_template_folder.id).select(:id).arel,
shared_template_ids.arel
)
)
)
else
rel = rel.where(folder_id: current_account.default_template_folder.id)
end
end
Templates.search(rel, params[:q])
Templates.search(current_user, rel, params[:q])
end
def sort_template_folders(template_folders, current_user, order)

@ -13,7 +13,7 @@ class TemplatesFormPreviewController < ApplicationController
@submitter.submission.submitters = @template.submitters.map { |item| Submitter.new(uuid: item['uuid']) }
Submitters.preload_with_pages(@submitter)
Submissions.preload_with_pages(@submitter.submission)
@attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid)

@ -25,6 +25,8 @@ class TemplatesUploadsController < ApplicationController
enqueue_template_created_webhooks(@template)
SearchEntries.enqueue_reindex(@template)
redirect_to edit_template_path(@template)
rescue Templates::CreateAttachments::PdfEncrypted
render turbo_stream: turbo_stream.append(params[:form_id], html: helpers.tag.prompt_password)

@ -36,7 +36,9 @@ export default class extends HTMLElement {
}
fetch = (text, resolve) => {
const q = text.split(/[;,\s]+/).pop().trim()
const q = this.dataset.field === 'email'
? text.split(/[;,\s]+/).pop().trim()
: text
if (q) {
const queryParams = new URLSearchParams({ q, field: this.dataset.field })

@ -1390,6 +1390,7 @@ export default {
if (isLastStep && !emptyRequiredField && !this.inviteSubmitters.length && !this.optionalInviteSubmitters.length) {
formData.append('completed', 'true')
formData.append('timezone', Intl.DateTimeFormat().resolvedOptions().timeZone)
}
let saveStepRequest

@ -133,7 +133,7 @@
class="mx-auto bg-white border border-base-300 rounded max-h-44"
>
<FileDropzone
v-if="format === 'upload' && !modelValue"
v-if="format === 'upload' && !modelValue && !computedPreviousValue"
:message="`${t('upload')} ${field.name || t('signature')}`"
:submitter-slug="submitterSlug"
:dry-run="dryRun"

@ -424,7 +424,7 @@
</a>
</li>
</template>
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells'].includes(field.type)">
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells', 'stamp'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"

@ -0,0 +1,21 @@
# frozen_string_literal: true
class GenerateAttachmentPreviewJob
include Sidekiq::Job
InvalidFormat = Class.new(StandardError)
sidekiq_options queue: :images
def perform(params = {})
attachment = ActiveStorage::Attachment.find(params['attachment_id'])
if attachment.content_type == Templates::ProcessDocument::PDF_CONTENT_TYPE
Templates::ProcessDocument.generate_pdf_preview_images(attachment, attachment.download)
elsif attachment.image?
Templates::ProcessDocument.generate_preview_image(attachment, attachment.download)
else
raise InvalidFormat, attachment.id
end
end
end

@ -7,7 +7,7 @@ class ProcessSubmissionExpiredJob
submission = Submission.find(params['submission_id'])
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 unless submission.submitters.exists?(completed_at: nil)

@ -82,7 +82,7 @@ class ProcessSubmitterCompletionJob
user = submission.created_by_user || submitter.template.author
if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false &&
submitter.template.preferences['completed_notification_email_enabled'] != false
submitter.template&.preferences&.dig('completed_notification_email_enabled') != false
if submission.submitters.map(&:email).exclude?(user.email) &&
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false &&
user.role != 'integration'
@ -98,7 +98,7 @@ class ProcessSubmitterCompletionJob
end
def maybe_enqueue_copy_emails(submitter)
return if submitter.template.preferences['documents_copy_email_enabled'] == false
return if submitter.template&.preferences&.dig('documents_copy_email_enabled') == false
configs = AccountConfigs.find_or_initialize_for_key(submitter.account,
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
@ -119,7 +119,7 @@ class ProcessSubmitterCompletionJob
def build_bcc_addresses(submission)
bcc = submission.preferences['bcc_completed'].presence ||
submission.template.preferences['bcc_completed'].presence ||
submission.template&.preferences&.dig('bcc_completed').presence ||
submission.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value

@ -0,0 +1,13 @@
# frozen_string_literal: true
class ReindexSearchEntryJob
include Sidekiq::Job
InvalidFormat = Class.new(StandardError)
def perform(params = {})
entry = SearchEntry.find_or_initialize_by(params.slice('record_type', 'record_id'))
SearchEntries.reindex_record(entry.record)
end
end

@ -23,11 +23,11 @@ class SubmitterMailer < ApplicationMailer
@body = @email_message&.body.presence ||
template_submitters_index.dig(@submitter.uuid, 'request_email_body').presence ||
@submitter.template.preferences['request_email_body'].presence
@submitter.template&.preferences&.dig('request_email_body').presence
@subject = @email_message&.subject.presence ||
template_submitters_index.dig(@submitter.uuid, 'request_email_subject').presence ||
@submitter.template.preferences['request_email_subject'].presence
@submitter.template&.preferences&.dig('request_email_subject').presence
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
@ -53,6 +53,8 @@ class SubmitterMailer < ApplicationMailer
@submission = submitter.submission
@user = user
template_preferences = @submission.template&.preferences || {}
Submissions::EnsureResultGenerated.call(submitter)
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
@ -60,15 +62,15 @@ class SubmitterMailer < ApplicationMailer
add_completed_email_attachments!(
submitter,
with_documents: @email_config&.value&.dig('attach_documents') != false &&
@submitter.template.preferences['completed_notification_email_attach_documents'] != false,
template_preferences['completed_notification_email_attach_documents'] != false,
with_audit_log: @email_config&.value&.dig('attach_audit_log') != false &&
@submitter.template.preferences['completed_notification_email_attach_audit'] != false
template_preferences['completed_notification_email_attach_audit'] != false
)
@subject = @submitter.template.preferences['completed_notification_email_subject'].presence
@subject = template_preferences['completed_notification_email_subject'].presence
@subject ||= @email_config.value['subject'] if @email_config
@body = @submitter.template.preferences['completed_notification_email_body'].presence
@body = template_preferences['completed_notification_email_body'].presence
@body ||= @email_config.value['body'] if @email_config
assign_message_metadata('submitter_completed', @submitter)
@ -97,7 +99,7 @@ class SubmitterMailer < ApplicationMailer
to: user.role == 'integration' ? user.friendly_name.sub(/\+\w+@/, '@') : user.friendly_name,
reply_to: @submitter.friendly_name,
subject: I18n.t(:name_declined_by_submitter,
name: @submission.template.name.truncate(20),
name: (@submission.name || @submission.template.name).truncate(20),
submitter: @submitter.name || @submitter.email || @submitter.phone))
end
end
@ -107,22 +109,24 @@ class SubmitterMailer < ApplicationMailer
@submitter = submitter
@sig = submitter.signed_id(expires_in: SIGN_TTL, purpose: :download_completed) if sig
template_preferences = @submitter.template&.preferences || {}
Submissions::EnsureResultGenerated.call(@submitter)
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
add_completed_email_attachments!(
submitter,
with_documents: @submitter.template.preferences['documents_copy_email_attach_documents'] != false &&
(@email_config.nil? || @email_config.value['attach_documents'] != false),
with_audit_log: @submitter.template.preferences['documents_copy_email_attach_audit'] != false &&
(@email_config.nil? || @email_config.value['attach_audit_log'] != false)
with_documents: template_preferences['documents_copy_email_attach_documents'] != false &&
(@email_config.nil? || @email_config.value['attach_documents'] != false),
with_audit_log: template_preferences['documents_copy_email_attach_audit'] != false &&
(@email_config.nil? || @email_config.value['attach_audit_log'] != false)
)
@subject = @submitter.template.preferences['documents_copy_email_subject'].presence
@subject = template_preferences['documents_copy_email_subject'].presence
@subject ||= @email_config.value['subject'] if @email_config
@body = @submitter.template.preferences['documents_copy_email_body'].presence
@body = template_preferences['documents_copy_email_body'].presence
@body ||= @email_config.value['body'] if @email_config
assign_message_metadata('submitter_documents_copy', @submitter)
@ -130,11 +134,7 @@ class SubmitterMailer < ApplicationMailer
I18n.with_locale(@current_account.locale) do
subject =
if @subject.present?
ReplaceEmailVariables.call(@subject, submitter:)
else
I18n.t(:your_document_copy)
end
@subject.present? ? ReplaceEmailVariables.call(@subject, submitter:) : I18n.t(:your_document_copy)
mail(from: from_address_for_submitter(submitter),
to: to || @submitter.friendly_name,
@ -147,7 +147,7 @@ class SubmitterMailer < ApplicationMailer
def build_submitter_reply_to(submitter, email_config: nil, documents_copy_email: nil)
reply_to = submitter.preferences['reply_to'].presence
reply_to ||= submitter.template.preferences['documents_copy_email_reply_to'].presence if documents_copy_email
reply_to ||= submitter.template&.preferences&.dig('documents_copy_email_reply_to').presence if documents_copy_email
reply_to ||= email_config.value['reply_to'].presence if email_config
if reply_to.blank? && (submitter.submission.created_by_user || submitter.template.author)&.email != submitter.email
@ -212,7 +212,7 @@ class SubmitterMailer < ApplicationMailer
end
def build_submitter_preferences_index(submitter)
submitter.template.preferences['submitters'].to_a.index_by { |e| e['uuid'] }
submitter.template&.preferences&.dig('submitters').to_a.index_by { |e| e['uuid'] }
end
def add_attachments_with_size_limit(submitter, storage_attachments, current_size, filename_format = nil)

@ -29,6 +29,7 @@ class AccountConfig < ApplicationRecord
ALLOW_TYPED_SIGNATURE = 'allow_typed_signature'
ALLOW_TO_RESUBMIT = 'allow_to_resubmit'
ALLOW_TO_DECLINE_KEY = 'allow_to_decline'
ALLOW_TO_PARTIAL_DOWNLOAD_KEY = 'allow_to_partial_download'
SUBMITTER_REMINDERS = 'submitter_reminders'
ENFORCE_SIGNING_ORDER_KEY = 'enforce_signing_order'
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
@ -41,6 +42,7 @@ class AccountConfig < ApplicationRecord
FLATTEN_RESULT_PDF_KEY = 'flatten_result_pdf'
WITH_SIGNATURE_ID = 'with_signature_id'
WITH_AUDIT_VALUES_KEY = 'with_audit_values'
WITH_AUDIT_SUBMITTER_TIMEZONE_KEY = 'with_audit_submitter_timezone'
REQUIRE_SIGNING_REASON_KEY = 'require_signing_reason'
REUSE_SIGNATURE_KEY = 'reuse_signature'
COMBINE_PDF_RESULT_KEY = 'combine_pdf_result_key'

@ -13,7 +13,7 @@
# account_id :bigint not null
# submission_id :bigint not null
# submitter_id :bigint not null
# template_id :bigint not null
# template_id :bigint
#
# Indexes
#
@ -24,7 +24,7 @@ class CompletedSubmitter < ApplicationRecord
belongs_to :submitter
belongs_to :submission
belongs_to :account
belongs_to :template
belongs_to :template, optional: true
has_many :completed_documents, dependent: :destroy,
primary_key: :submitter_id,

@ -0,0 +1,29 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: search_entries
#
# id :bigint not null, primary key
# ngram :tsvector
# record_type :string not null
# tsvector :tsvector not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# record_id :bigint not null
#
# Indexes
#
# index_search_entries_on_account_id_ngram_submission (account_id,ngram) WHERE ((record_type)::text = 'Submission'::text) USING gin
# index_search_entries_on_account_id_ngram_submitter (account_id,ngram) WHERE ((record_type)::text = 'Submitter'::text) USING gin
# index_search_entries_on_account_id_ngram_template (account_id,ngram) WHERE ((record_type)::text = 'Template'::text) USING gin
# index_search_entries_on_account_id_tsvector_submission (account_id,tsvector) WHERE ((record_type)::text = 'Submission'::text) USING gin
# index_search_entries_on_account_id_tsvector_submitter (account_id,tsvector) WHERE ((record_type)::text = 'Submitter'::text) USING gin
# index_search_entries_on_account_id_tsvector_template (account_id,tsvector) WHERE ((record_type)::text = 'Template'::text) USING gin
# index_search_entries_on_record_id_and_record_type (record_id,record_type) UNIQUE
#
class SearchEntry < ApplicationRecord
belongs_to :record, polymorphic: true
belongs_to :account
end

@ -7,6 +7,7 @@
# id :bigint not null, primary key
# archived_at :datetime
# expire_at :datetime
# name :text
# preferences :text not null
# slug :string not null
# source :text not null
@ -18,7 +19,7 @@
# updated_at :datetime not null
# account_id :bigint not null
# created_by_user_id :bigint
# template_id :bigint not null
# template_id :bigint
#
# Indexes
#
@ -33,10 +34,12 @@
# fk_rails_... (template_id => templates.id)
#
class Submission < ApplicationRecord
belongs_to :template
belongs_to :template, optional: true
belongs_to :account
belongs_to :created_by_user, class_name: 'User', optional: true
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy
has_many :submitters, dependent: :destroy
has_many :submission_events, dependent: :destroy
@ -56,6 +59,7 @@ class Submission < ApplicationRecord
has_one_attached :combined_document
has_many_attached :preview_documents
has_many_attached :documents
has_many :template_accesses, primary_key: :template_id, foreign_key: :template_id, dependent: nil, inverse_of: false
@ -96,6 +100,14 @@ class Submission < ApplicationRecord
expire_at && expire_at <= Time.current
end
def schema_documents
if template_id?
template_schema_documents
else
documents_attachments
end
end
def fields_uuid_index
@fields_uuid_index ||= (template_fields || template.fields).index_by { |f| f['uuid'] }
end

@ -16,6 +16,7 @@
# preferences :text not null
# sent_at :datetime
# slug :string not null
# timezone :string
# ua :string
# uuid :string not null
# values :text not null
@ -27,11 +28,12 @@
#
# Indexes
#
# index_submitters_on_account_id_and_id (account_id,id)
# index_submitters_on_email (email)
# index_submitters_on_external_id (external_id)
# index_submitters_on_slug (slug) UNIQUE
# index_submitters_on_submission_id (submission_id)
# index_submitters_on_account_id_and_id (account_id,id)
# index_submitters_on_completed_at_and_account_id (completed_at,account_id)
# index_submitters_on_email (email)
# index_submitters_on_external_id (external_id)
# index_submitters_on_slug (slug) UNIQUE
# index_submitters_on_submission_id (submission_id)
#
# Foreign Keys
#
@ -41,6 +43,7 @@ class Submitter < ApplicationRecord
belongs_to :submission
belongs_to :account
has_one :template, through: :submission
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy
attribute :values, :string, default: -> { {} }
attribute :preferences, :string, default: -> { {} }

@ -42,6 +42,8 @@ class Template < ApplicationRecord
belongs_to :account
belongs_to :folder, class_name: 'TemplateFolder'
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy
before_validation :maybe_set_default_folder, on: :create
attribute :preferences, :string, default: -> { {} }

@ -26,7 +26,7 @@
<%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { template_slug: @template.slug, email: params[:email] }, class: 'base-button w-full' %>
</toggle-submit>
<% end %>
<% if Templates.filter_undefined_submitters(@template).size == 1 && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && @template.shared_link? %>
<% if Templates.filter_undefined_submitters(@template.submitters).size == 1 && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && @template.shared_link? %>
<toggle-submit class="block">
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: { email: params[:email] }, resubmit: true }, method: :put, class: 'white-button w-full' %>
</toggle-submit>

@ -1,4 +1,5 @@
<% submitter_preferences_index = template.preferences['submitters'].to_a.index_by { |e| e['uuid'] } %>
<% submitter_preferences_index = template&.preferences&.dig('submitters').to_a.index_by { |e| e['uuid'] } %>
<% template_submitters = local_assigns[:submitter]&.submission&.template_submitters || template.submitters %>
<div class="form-control">
<% can_send_emails = Accounts.can_send_emails?(current_account) %>
<div class="flex justify-between items-center">
@ -37,18 +38,18 @@
<% config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) %>
<div id="message_field" class="card card-compact bg-base-300/40 hidden">
<div class="card-body">
<%= tag.input id: toggle_uuid = SecureRandom.uuid, value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: local_assigns[:message_per_submitter] != false && template.preferences['submitters'].to_a.size > 1 %>
<%= tag.input id: toggle_uuid = SecureRandom.uuid, value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: local_assigns[:message_per_submitter] != false && template&.preferences&.dig('submitters').to_a.size > 1 %>
<div class="peer-checked:hidden form-control space-y-2">
<div class="form-control">
<div class="flex justify-between">
<%= f.label :subject, t('subject'), class: 'label' %>
<% if template.submitters.size > 1 && template.submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
<% if template_submitters.size > 1 && template_submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
<label for="<%= toggle_uuid %>" class="label underline">
<%= t('edit_per_party') %>
</label>
<% end %>
</div>
<%= f.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_subject').presence || template.preferences['request_email_subject'].presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
<%= f.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
</div>
<div class="form-control">
<div class="flex items-center">
@ -58,7 +59,7 @@
</span>
</div>
<autoresize-textarea>
<%= f.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || template.preferences['request_email_body'].presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
<%= f.text_area :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'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
</autoresize-textarea>
<% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %>
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
@ -69,10 +70,10 @@
</div>
<%= render 'submissions/message_fields' %>
</div>
<% if template.submitters.size > 1 && template.submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
<% if template_submitters.size > 1 && template_submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
<div class="hidden peer-checked:block form-control space-y-2">
<% uuid = SecureRandom.uuid %>
<% options = template.submitters.map { |e| [e['name'], "request_email_#{uuid}_#{e['uuid']}"] } %>
<% options = template_submitters.map { |e| [e['name'], "request_email_#{uuid}_#{e['uuid']}"] } %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="flex relative px-1">
<ul class="tabs w-full flex flex-nowrap">
<% options.each_with_index do |(label, val), index| %>
@ -83,14 +84,14 @@
<% end %>
</ul>
</toggle-visible>
<% template.submitters.each_with_index do |submitter, index| %>
<% template_submitters.each_with_index do |submitter, index| %>
<%= fields_for :submitter_preferences, nil, index: submitter['uuid'] do |ff| %>
<div id="request_email_<%= uuid %>_<%= submitter['uuid'] %>" class="<%= 'hidden' if index != 0 %>">
<div class="form-control">
<div class="flex justify-between">
<%= ff.label :subject, t('subject'), class: 'label' %>
</div>
<%= ff.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_subject').presence || template.preferences['request_email_subject'].presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
<%= ff.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
</div>
<div class="form-control">
<div class="flex items-center">
@ -100,7 +101,7 @@
</span>
</div>
<autoresize-textarea>
<%= ff.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || template.preferences['request_email_body'].presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
<%= ff.text_area :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'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
</autoresize-textarea>
</div>
</div>

@ -4,9 +4,9 @@
<% with_signature_id, is_combined_enabled = AccountConfig.where(account_id: @submission.account_id, key: [AccountConfig::COMBINE_PDF_RESULT_KEY, AccountConfig::WITH_SIGNATURE_ID], value: true).then { |configs| [configs.any? { |e| e.key == AccountConfig::WITH_SIGNATURE_ID }, configs.any? { |e| e.key == AccountConfig::COMBINE_PDF_RESULT_KEY }] } %>
<div style="max-width: 1600px" class="mx-auto pl-4">
<div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100">
<a href="<%= signed_in? && @submission.account_id == current_account&.id ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1">
<a href="<%= signed_in? && @submission.account_id == current_account&.id && @submission.template ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1">
<span><%= render 'submissions/logo' %></span>
<span 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.template.name.split(/(_)/).each do |item| %><%= item %><wbr><% end %></span>
<span 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).split(/(_)/).each do |item| %><%= item %><wbr><% end %></span>
</a>
<div class="space-x-3 flex items-center">
<% last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %>
@ -73,7 +73,7 @@
<% values = @submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
<% schema = Submissions.filtered_conditions_schema(@submission, values:) %>
<% schema.each do |item| %>
<% document = @submission.template_schema_documents.find { |a| item['attachment_uuid'] == a.uuid } %>
<% document = @submission.schema_documents.find { |a| item['attachment_uuid'] == a.uuid } %>
<a href="#<%= "page-#{document.uuid}-0" %>" onclick="[event.preventDefault(), window[event.target.closest('a').href.split('#')[1]].scrollIntoView({ behavior: 'smooth', block: 'start' })]" class="block cursor-pointer">
<img src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, 0].join(':'), expires_in: 10.minutes) { document.preview_images.first.url } %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
<div class="pb-2 pt-1.5 text-center" dir="auto">
@ -89,7 +89,7 @@
<% attachments_index = ActiveStorage::Attachment.where(record: @submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %>
<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %>
<% schema.each do |item| %>
<% document = @submission.template_schema_documents.find { |e| e.uuid == item['attachment_uuid'] } %>
<% document = @submission.schema_documents.find { |e| e.uuid == item['attachment_uuid'] } %>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.first.metadata %>

@ -26,7 +26,7 @@
</div>
</div>
<% view_archived_html = capture do %>
<% if current_account.submissions.where.not(archived_at: nil).exists? %>
<% if can?(:manage, :countless) || current_account.submissions.where.not(archived_at: nil).exists? %>
<div>
<a href="<%= submissions_archived_index_path %>" class="link text-sm"><%= t('view_archived') %></a>
</div>

@ -11,7 +11,7 @@
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div>
<p dir="auto" class="text-lg font-bold mb-1"><%= @submission.template.name %></p>
<p dir="auto" class="text-lg font-bold mb-1"><%= @submission.name || @submission.template.name %></p>
<% last_submitter = @submission.submitters.completed.order(:completed_at).last %>
<% if last_submitter %>
<p dir="auto" class="text-sm">

@ -2,4 +2,4 @@
<% data_fields = Submissions.filtered_conditions_fields(submitter).to_json %>
<% invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %>
<% optional_invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['optional_invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-schema="<%= schema.to_json %>" data-reuse-signature="<%= configs[:reuse_signature] %>" data-require-signing-reason="<%= configs[:require_signing_reason] %>" data-with-signature-id="<%= configs[:with_signature_id] %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'].presence || submitter.submission.template.preferences['completed_redirect_url'] %>" data-completed-message="<%= (configs[:completed_message]&.compact_blank.presence || submitter.submission.template.preferences['completed_message'] || {}).to_json %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.preferences.key?('go_to_last') ? submitter.preferences['go_to_last'] : submitter.opened_at? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(submitter.submission.account) %>" data-optional-invite-submitters="<%= optional_invite_submitters %>" data-invite-submitters="<%= invite_submitters %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>" data-previous-signature-value="<%= local_assigns[:signature_attachment]&.uuid %>" data-remember-signature="<%= configs[:prefill_signature] %>" data-dry-run="<%= local_assigns[:dry_run] %>" data-expand="<%= local_assigns[:expand] %>" data-scroll-padding="<%= local_assigns[:scroll_padding] %>" data-language="<%= I18n.locale.to_s.split('-').first %>"></submission-form>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-schema="<%= schema.to_json %>" data-reuse-signature="<%= configs[:reuse_signature] %>" data-require-signing-reason="<%= configs[:require_signing_reason] %>" data-with-signature-id="<%= configs[:with_signature_id] %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'].presence || submitter.submission.template&.preferences&.dig('completed_redirect_url') %>" data-completed-message="<%= (configs[:completed_message]&.compact_blank.presence || submitter.submission.template&.preferences&.dig('completed_message') || {}).to_json %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.preferences.key?('go_to_last') ? submitter.preferences['go_to_last'] : submitter.opened_at? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(submitter.submission.account) %>" data-optional-invite-submitters="<%= optional_invite_submitters %>" data-invite-submitters="<%= invite_submitters %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>" data-previous-signature-value="<%= local_assigns[:signature_attachment]&.uuid %>" data-remember-signature="<%= configs[:prefill_signature] %>" data-dry-run="<%= local_assigns[:dry_run] %>" data-expand="<%= local_assigns[:expand] %>" data-scroll-padding="<%= local_assigns[:scroll_padding] %>" data-language="<%= I18n.locale.to_s.split('-').first %>"></submission-form>

@ -10,7 +10,7 @@
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div dir="auto">
<p class="text-lg font-bold mb-1"><%= @submitter.submission.template.name %></p>
<p class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template.name %></p>
<p class="text-sm"><%= t('form_has_been_deleted_by_html', name: @submitter.account.name) %></p>
</div>
</div>

@ -10,7 +10,7 @@
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div dir="auto">
<p class="text-lg font-bold mb-1"><%= @submitter.submission.template.name %></p>
<p class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template.name %></p>
<p class="text-sm"><%= t('awaiting_completion_by_the_other_party') %></p>
</div>
</div>

@ -10,7 +10,7 @@
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div>
<p dir="auto" class="text-lg font-bold mb-1"><%= @submitter.submission.template.name %></p>
<p dir="auto" class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template.name %></p>
<p dir="auto" class="text-sm">
<%= t(@submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(@submitter.completed_at.to_date, format: :long)) %>
</p>
@ -23,7 +23,7 @@
<toggle-submit>
<%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, class: 'white-button w-full' %>
</toggle-submit>
<% if Templates.filter_undefined_submitters(@submitter.submission.template).size != 1 %>
<% if Templates.filter_undefined_submitters(@submitter.submission.template_submitters).size != 1 %>
<div class="divider uppercase"><%= t('or') %></div>
<% else %>
<div class="py-2"></div>
@ -42,8 +42,8 @@
</download-button>
<% end %>
</div>
<% undefined_submitters = Templates.filter_undefined_submitters(@submitter.submission.template) %>
<% if undefined_submitters.size == 1 && undefined_submitters.first['uuid'] == @submitter.uuid && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false %>
<% undefined_submitters = Templates.filter_undefined_submitters(@submitter.submission.template_submitters) %>
<% if undefined_submitters.size == 1 && undefined_submitters.first['uuid'] == @submitter.uuid && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && @submitter.template && !@submitter.template.archived_at? %>
<div class="divider uppercase"><%= t('or') %></div>
<toggle-submit class="block">
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), resubmit_form_path, params: { resubmit: @submitter.slug }, method: :put, class: 'white-button w-full' %>

@ -10,7 +10,7 @@
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div dir="auto">
<p class="text-lg font-bold mb-1"><%= @submitter.submission.template.name %></p>
<p class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template.name %></p>
<p class="text-sm"><%= t('form_has_been_declined_on_html', time: l(@submitter.declined_at, format: :long)) %></p>
</div>
</div>

@ -10,7 +10,7 @@
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div dir="auto">
<p class="text-lg font-bold mb-1"><%= @submitter.submission.template.name %></p>
<p class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template.name %></p>
<p class="text-sm"><%= t('form_expired_at_html', time: l(@submitter.submission.expire_at, format: :long)) %></p>
</div>
</div>

@ -1,4 +1,4 @@
<% content_for(:html_title, "#{@submitter.submission.template.name} | DocuSeal") %>
<% content_for(:html_title, "#{@submitter.submission.name || @submitter.submission.template.name} | DocuSeal") %>
<% content_for(:html_description, "#{@submitter.account.name} has invited you to fill and sign documents online effortlessly with a secure, fast, and user-friendly digital document signing solution.") %>
<% fields_index = Templates.build_field_areas_index(@submitter.submission.template_fields || @submitter.submission.template.fields) %>
<% values = @submitter.submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
@ -13,7 +13,7 @@
<%= render('submit_form/banner') %>
<div id="signing_form_header" class="sticky min-[1230px]:static top-0 z-50 bg-base-100 py-2 px-2 flex items-center md:-mx-[8px]" style="margin-bottom: -16px">
<div class="text-xl md:text-2xl font-medium focus:text-clip" style="width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<%= @submitter.submission.template.name %>
<%= @submitter.submission.name || @submitter.submission.template.name %>
</div>
<div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0">
<% if @form_configs[:with_decline] %>
@ -24,16 +24,18 @@
<% end %>
</div>
<% end %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4">
<span class="flex items-center justify-center" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %>
<span class="hidden md:inline"><%= t('download') %></span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span class="hidden md:inline"><%= t('downloading') %></span>
</span>
</download-button>
<% if @form_configs[:with_partial_download] %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4">
<span class="flex items-center justify-center" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %>
<span class="hidden md:inline"><%= t('download') %></span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span class="hidden md:inline"><%= t('downloading') %></span>
</span>
</download-button>
<% end %>
</div>
</div>
<scroll-buttons class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10">
@ -58,11 +60,11 @@
</scroll-buttons>
<% end %>
<% schema.each do |item| %>
<% document = @submitter.submission.template_schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<% document = @submitter.submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<div id="document-<%= document.uuid %>">
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.last.metadata %>
<% lazyload_metadata = document.preview_images.last&.metadata || {} %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
<div class="relative my-4 shadow-md">

@ -2,6 +2,6 @@
<%= render 'custom_content', content: @body, submitter: @submitter %>
<% else %>
<p><%= t('hi_there') %>,</p>
<p><%= I18n.t(:name_has_been_completed_by_submitters, name: @submitter.submission.template.name, submitters: @submitter.submission.submitters.order(:completed_at).map { |e| e.name || e.email || e.phone }.uniq.join(', ')) %></p>
<p><%= I18n.t(:name_has_been_completed_by_submitters, name: @submitter.submission.name || @submitter.submission.template.name, submitters: @submitter.submission.submitters.order(:completed_at).map { |e| e.name || e.email || e.phone }.uniq.join(', ')) %></p>
<p><%= link_to submission_url(@submitter.submission), submission_url(@submitter.submission) %></p>
<% end %>

@ -1,4 +1,4 @@
<p><%= t('hi_there') %>,</p>
<p><%= t('name_declined_by_submitter_with_the_following_reason', name: @submitter.submission.template.name, submitter: @submitter.name || @submitter.email || @submitter.phone) %></p>
<p><%= t('name_declined_by_submitter_with_the_following_reason', name: @submitter.submission.name || @submitter.submission.template.name, submitter: @submitter.name || @submitter.email || @submitter.phone) %></p>
<%= simple_format(h(@submitter.submission_events.find_by(event_type: :decline_form).data['reason'])) %>
<p><%= link_to submission_url(@submitter.submission), submission_url(@submitter.submission) %></p>

@ -2,10 +2,10 @@
<%= render 'custom_content', content: @body, submitter: @submitter, sig: @sig %>
<% else %>
<p><%= t('hi_there') %>,</p>
<p><%= t('please_check_the_copy_of_your_name_in_the_email_attachments', name: @submitter.submission.template.name) %>
<p><%= t('please_check_the_copy_of_your_name_in_the_email_attachments', name: @submitter.submission.name || @submitter.submission.template.name) %>
<p><%= t('alternatively_you_can_review_and_download_your_copy_using_the_link_below') %></p>
<p>
<%= link_to @submitter.template.name, submissions_preview_url(@submitter.submission.slug, { sig: @sig }.compact) %>
<%= link_to @submitter.submission.name || @submitter.submission.template.name, submissions_preview_url(@submitter.submission.slug, { sig: @sig }.compact) %>
</p>
<p>
<%= t('thanks') %>,<br><%= @current_account.name %>

@ -6,7 +6,7 @@
<% end %>
<% else %>
<p><%= t('hi_there') %>,</p>
<p><%= I18n.t(@submitter.with_signature_fields? ? :you_have_been_invited_to_sign_the_name : :you_have_been_invited_to_submit_the_name_form, name: @submitter.submission.template.name) %></p>
<p><%= I18n.t(@submitter.with_signature_fields? ? :you_have_been_invited_to_sign_the_name : :you_have_been_invited_to_submit_the_name_form, name: @submitter.submission.name || @submitter.submission.template.name) %></p>
<p><%= link_to I18n.t(@submitter.with_signature_fields? ? :review_and_sign : :review_and_submit), submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email'), host: ENV.fetch('EMAIL_HOST', Docuseal.default_url_options[:host])) %></p>
<p><%= t('please_contact_us_by_replying_to_this_email_if_you_didn_t_request_this') %></p>
<p>

@ -2,12 +2,12 @@
<div class="bg-base-200 rounded-2xl flex flex-col sm:flex-row items-strech">
<% if local_assigns[:with_template] %>
<% template = submission.template %>
<a href="<%= template_path(template) %>" class="px-5 sm:pr-3 py-3 group sm:rounded-l-2xl sm:rounded-tr-none rounded-t-2xl flex sm:flex-col justify-between sm:w-52 w-full flex-shrink-0 bg-base-300/60 space-x-2 sm:space-x-0">
<a href="<%= template ? template_path(template) : submission_path(submission) %>" class="px-5 sm:pr-3 py-3 group sm:rounded-l-2xl sm:rounded-tr-none rounded-t-2xl flex sm:flex-col justify-between sm:w-52 w-full flex-shrink-0 bg-base-300/60 space-x-2 sm:space-x-0">
<div>
<div class="font-medium items-start w-full group-hover:link text-sm flex space-x-1">
<%= svg_icon('file_text', class: 'w-4 h-4 mt-0.5 flex-shrink-0') %>
<span style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;">
<% template.name.split(/(_)/).each do |item| %><%= item %><wbr><% end %>
<% (submission.name || template.name).split(/(_)/).each do |item| %><%= item %><wbr><% end %>
<%= svg_icon('arrow_right', class: 'w-4 h-4 sm:inline group-hover:visible invisible hidden') %>
</span>
</div>
@ -78,7 +78,7 @@
</download-button>
</div>
</div>
<% elsif !submission.archived_at? && !template.archived_at? && !submission.expired? && !submitter.declined_at? %>
<% elsif !submission.archived_at? && !template&.archived_at? && !submission.expired? && !submitter.declined_at? %>
<% if current_user.email == submitter.email %>
<div class="flex-1 md:flex-none md:w-36 flex">
<a href="<%= submit_form_url(slug: submitter.slug) %>" data-turbo="false" target="_blank" id="sign_yourself_button" class="btn btn-sm btn-neutral btn-outline bg-white w-full md:w-36 flex z-[1]">
@ -101,7 +101,7 @@
<%= t('view') %>
</a>
</div>
<% if !submission.archived_at? && !template.archived_at? && can?(:destroy, submission) %>
<% if !submission.archived_at? && !template&.archived_at? && can?(:destroy, submission) %>
<span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %>
</span>
@ -164,7 +164,7 @@
<span class="inline"><%= t('download')[..-2] %>...</span>
</span>
</download-button>
<% elsif !template.archived_at? && !submission.archived_at? && !is_submission_completed && !submission.expired? && !submitter.declined_at? %>
<% elsif !template&.archived_at? && !submission.archived_at? && !is_submission_completed && !submission.expired? && !submitter.declined_at? %>
<div class="relative flex items-center space-x-3">
<% if current_user.email == submitter.email %>
<a href="<%= submit_form_url(slug: submitter.slug) %>" data-turbo="false" target="_blank" id="sign_yourself_button" class="absolute md:relative top-0 right-0 btn btn-xs btn-outline btn-neutral bg-white w-28 md:w-36 z-[1]">
@ -208,7 +208,7 @@
<%= t('view') %>
</a>
</div>
<% if !submission.archived_at? && !template.archived_at? %>
<% if !submission.archived_at? && !template&.archived_at? %>
<span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %>
</span>

@ -113,7 +113,7 @@
</div>
<% end %>
<% view_archived_html = capture do %>
<% if @template.submissions.where.not(archived_at: nil).exists? && !@template.archived_at? %>
<% if (can?(:manage, :countless) || @template.submissions.where.not(archived_at: nil).exists?) && !@template.archived_at? %>
<div>
<a href="<%= template_archived_index_path(@template) %>" class="link text-sm"><%= t('view_archived') %></a>
</div>

@ -1,4 +1,4 @@
<% has_archived = current_account.templates.where.not(archived_at: nil).exists? %>
<% has_archived = can?(:manage, :countless) || current_account.templates.where.not(archived_at: nil).exists? %>
<% show_dropzone = params[:q].blank? && @pagy.pages == 1 && ((@template_folders.size < 10 && @templates.size.zero?) || (@template_folders.size < 7 && @templates.size < 4) || (@template_folders.size < 4 && @templates.size < 7)) %>
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
<dashboard-dropzone>

@ -3,6 +3,22 @@
{
"fingerprint": "25f4ce5fee1e1180fa1919dc4ee78db3ab3457a956e4679503aa745771a43836",
"note": "Permitted parameters are necessary for creating submitters via API"
},
{
"fingerprint": "645f2f352a813105b8b2eeadc68101d2e7ea04b8e839aa1336616be56f1108ca",
"note": "Safe SQL"
},
{
"fingerprint": "5bcf4e6426d17b0efd98e47fde7c012f09500b9595af9cff226cf09341c023be",
"note": "Safe SQL"
},
{
"fingerprint": "8bf010d01d5cfabdc2124db1378ca14a24a675431047291488abc186d10ba314",
"note": "Safe SQL"
},
{
"fingerprint": "5f52190d03ee922bba9792012d8fcbeb7d4736006bb899b3be9cc10d679e0af1",
"note": "Safe Param"
}
]
}

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddNameToSubmissions < ActiveRecord::Migration[8.0]
def change
add_column :submissions, :name, :text
end
end

@ -0,0 +1,7 @@
# frozen_string_literal: true
class RemoveNotNullTemplateId < ActiveRecord::Migration[8.0]
def change
change_column_null :submissions, :template_id, true
end
end

@ -2,7 +2,7 @@
class AddEmailEventsDateIndex < ActiveRecord::Migration[8.0]
def change
remove_index :email_events, :account_id
add_index :email_events, %i[account_id event_datetime]
remove_index :email_events, :account_id
end
end

@ -0,0 +1,7 @@
# frozen_string_literal: true
class RemoveCompletedSubmitterTemplateNotNull < ActiveRecord::Migration[8.0]
def change
change_column_null :completed_submitters, :template_id, true
end
end

@ -0,0 +1,33 @@
# frozen_string_literal: true
class CreateSearchEnties < ActiveRecord::Migration[8.0]
def up
return unless adapter_name == 'PostgreSQL'
enable_extension 'btree_gin'
create_table :search_entries do |t|
t.references :record, null: false, polymorphic: true, index: false
t.bigint :account_id, null: false
t.tsvector :tsvector, null: false
t.timestamps
end
add_index :search_entries, %i[account_id tsvector], using: :gin, where: "record_type = 'Submitter'",
name: 'index_search_entries_on_account_id_tsvector_submitter'
add_index :search_entries, %i[account_id tsvector], using: :gin, where: "record_type = 'Submission'",
name: 'index_search_entries_on_account_id_tsvector_submission'
add_index :search_entries, %i[account_id tsvector], using: :gin, where: "record_type = 'Template'",
name: 'index_search_entries_on_account_id_tsvector_template'
add_index :search_entries, %i[record_id record_type], unique: true
end
def down
return unless adapter_name == 'PostgreSQL'
drop_table :search_entries
disable_extension 'btree_gin'
end
end

@ -0,0 +1,16 @@
# frozen_string_literal: true
class AddNgramToSearchIndex < ActiveRecord::Migration[8.0]
def change
return unless adapter_name == 'PostgreSQL'
add_column :search_entries, :ngram, :tsvector
add_index :search_entries, %i[account_id ngram], using: :gin, where: "record_type = 'Submitter'",
name: 'index_search_entries_on_account_id_ngram_submitter'
add_index :search_entries, %i[account_id ngram], using: :gin, where: "record_type = 'Submission'",
name: 'index_search_entries_on_account_id_ngram_submission'
add_index :search_entries, %i[account_id ngram], using: :gin, where: "record_type = 'Template'",
name: 'index_search_entries_on_account_id_ngram_template'
end
end

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddTimezoneToSubmitters < ActiveRecord::Migration[8.0]
def change
add_column :submitters, :timezone, :string
end
end

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddSubmittersCompletedAtIndex < ActiveRecord::Migration[8.0]
def change
add_index :submitters, %i[completed_at account_id]
end
end

@ -10,8 +10,9 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_05_30_080846) do
ActiveRecord::Schema[8.0].define(version: 2025_06_15_091654) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "plpgsql"
create_table "access_tokens", force: :cascade do |t|
@ -110,7 +111,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_30_080846) do
t.bigint "submitter_id", null: false
t.bigint "submission_id", null: false
t.bigint "account_id", null: false
t.bigint "template_id", null: false
t.bigint "template_id"
t.string "source", null: false
t.integer "sms_count", null: false
t.datetime "completed_at", null: false
@ -256,6 +257,23 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_30_080846) do
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
end
create_table "search_entries", force: :cascade do |t|
t.string "record_type", null: false
t.bigint "record_id", null: false
t.bigint "account_id", null: false
t.tsvector "tsvector", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.tsvector "ngram"
t.index ["account_id", "ngram"], name: "index_search_entries_on_account_id_ngram_submission", where: "((record_type)::text = 'Submission'::text)", using: :gin
t.index ["account_id", "ngram"], name: "index_search_entries_on_account_id_ngram_submitter", where: "((record_type)::text = 'Submitter'::text)", using: :gin
t.index ["account_id", "ngram"], name: "index_search_entries_on_account_id_ngram_template", where: "((record_type)::text = 'Template'::text)", using: :gin
t.index ["account_id", "tsvector"], name: "index_search_entries_on_account_id_tsvector_submission", where: "((record_type)::text = 'Submission'::text)", using: :gin
t.index ["account_id", "tsvector"], name: "index_search_entries_on_account_id_tsvector_submitter", where: "((record_type)::text = 'Submitter'::text)", using: :gin
t.index ["account_id", "tsvector"], name: "index_search_entries_on_account_id_tsvector_template", where: "((record_type)::text = 'Template'::text)", using: :gin
t.index ["record_id", "record_type"], name: "index_search_entries_on_record_id_and_record_type", unique: true
end
create_table "submission_events", force: :cascade do |t|
t.bigint "submission_id", null: false
t.bigint "submitter_id"
@ -270,7 +288,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_30_080846) do
end
create_table "submissions", force: :cascade do |t|
t.bigint "template_id", null: false
t.bigint "template_id"
t.bigint "created_by_user_id"
t.datetime "archived_at"
t.datetime "created_at", null: false
@ -278,12 +296,13 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_30_080846) do
t.text "template_fields"
t.text "template_schema"
t.text "template_submitters"
t.text "source", null: false
t.string "source", null: false
t.string "submitters_order", null: false
t.string "slug", null: false
t.text "preferences", null: false
t.bigint "account_id", null: false
t.datetime "expire_at"
t.text "name"
t.index ["account_id", "id"], name: "index_submissions_on_account_id_and_id"
t.index ["created_by_user_id"], name: "index_submissions_on_created_by_user_id"
t.index ["slug"], name: "index_submissions_on_slug", unique: true
@ -310,7 +329,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_30_080846) do
t.text "metadata", null: false
t.bigint "account_id", null: false
t.datetime "declined_at"
t.string "timezone"
t.index ["account_id", "id"], name: "index_submitters_on_account_id_and_id"
t.index ["completed_at", "account_id"], name: "index_submitters_on_completed_at_and_account_id"
t.index ["email"], name: "index_submitters_on_email"
t.index ["external_id"], name: "index_submitters_on_external_id"
t.index ["slug"], name: "index_submitters_on_slug", unique: true
@ -400,7 +421,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_30_080846) do
t.datetime "archived_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "uuid", null: false
t.string "uuid", null: false
t.string "otp_secret"
t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false, null: false

@ -13,12 +13,7 @@ module Abilities
TemplateSharing.where({ ability:, account_id: [user.account_id, TemplateSharing::ALL_ID] }.compact)
.select(:template_id)
join_query =
Template.arel_table
.join(Arel::Nodes::TableAlias.new(templates.select(:id).arel.union(shared_ids.arel), 'union_ids'))
.on(Template.arel_table[:id].eq(Arel::Table.new(:union_ids)[:id]))
Template.joins(join_query.join_sources.first)
Template.where(Template.arel_table[:id].in(Arel::Nodes::Union.new(templates.select(:id).arel, shared_ids.arel)))
end
def entity(template, user:, ability: nil)

@ -73,6 +73,8 @@ module Accounts
new_template.save!
SearchEntries.enqueue_reindex(new_template)
Templates::CloneAttachments.call(template: new_template, original_template: template)
new_template

@ -73,6 +73,14 @@ module Docuseal
@default_pkcs ||= GenerateCertificate.load_pkcs(Docuseal::CERTS)
end
def fulltext_search?(_user)
return false unless SearchEntry.table_exists?
return true if Docuseal.multitenant?
return true if Rails.env.local?
false
end
def enable_pwa?
true
end

@ -36,7 +36,8 @@ module PdfUtils
end
merged_content = StringIO.new
merged_pdf.write(merged_content)
merged_pdf.validate(auto_correct: true)
merged_pdf.write(merged_content, validate: false)
merged_content.rewind
merged_content

@ -0,0 +1,228 @@
# frozen_string_literal: true
module PhoneCodes
ALL = [
'+1',
'+93',
'+358',
'+355',
'+213',
'+1684',
'+376',
'+244',
'+1264',
'+1268',
'+54',
'+374',
'+297',
'+61',
'+43',
'+994',
'+1242',
'+973',
'+880',
'+1246',
'+32',
'+501',
'+229',
'+1441',
'+975',
'+591',
'+387',
'+267',
'+55',
'+246',
'+673',
'+359',
'+226',
'+257',
'+855',
'+237',
'+1',
'+238',
'+1345',
'+235',
'+56',
'+86',
'+61',
'+61',
'+57',
'+269',
'+243',
'+682',
'+506',
'+225',
'+385',
'+357',
'+420',
'+45',
'+253',
'+1767',
'+1849',
'+593',
'+20',
'+503',
'+240',
'+291',
'+372',
'+251',
'+500',
'+298',
'+679',
'+358',
'+33',
'+594',
'+689',
'+241',
'+220',
'+995',
'+49',
'+233',
'+350',
'+30',
'+299',
'+1473',
'+590',
'+1671',
'+502',
'+224',
'+245',
'+592',
'+509',
'+504',
'+852',
'+36',
'+354',
'+91',
'+62',
'+964',
'+353',
'+44',
'+972',
'+39',
'+1876',
'+81',
'+44',
'+962',
'+7',
'+254',
'+686',
'+82',
'+965',
'+996',
'+856',
'+371',
'+961',
'+266',
'+231',
'+423',
'+370',
'+352',
'+853',
'+389',
'+261',
'+265',
'+60',
'+960',
'+223',
'+356',
'+692',
'+596',
'+222',
'+230',
'+262',
'+52',
'+691',
'+373',
'+377',
'+976',
'+382',
'+1664',
'+212',
'+258',
'+264',
'+674',
'+977',
'+31',
'+687',
'+64',
'+227',
'+234',
'+683',
'+672',
'+1670',
'+47',
'+968',
'+92',
'+680',
'+507',
'+675',
'+595',
'+51',
'+63',
'+872',
'+48',
'+351',
'+1939',
'+974',
'+40',
'+250',
'+262',
'+590',
'+290',
'+1869',
'+1758',
'+590',
'+508',
'+1784',
'+685',
'+378',
'+239',
'+966',
'+221',
'+381',
'+248',
'+232',
'+65',
'+421',
'+386',
'+677',
'+27',
'+34',
'+94',
'+597',
'+47',
'+268',
'+46',
'+41',
'+886',
'+992',
'+255',
'+66',
'+670',
'+228',
'+690',
'+676',
'+1868',
'+216',
'+90',
'+993',
'+1649',
'+688',
'+256',
'+380',
'+971',
'+44',
'+598',
'+998',
'+678',
'+84',
'+1284',
'+1340',
'+681',
'+967',
'+260'
].freeze
REGEXP = /\A#{Regexp.union(ALL).source}/i
end

@ -2,6 +2,7 @@
module ReplaceEmailVariables
TEMPLATE_NAME = /\{+template\.name\}+/i
SUBMISSION_NAME = /\{+submission\.name\}+/i
TEMPLATE_ID = /\{+template\.id\}+/i
SUBMITTER_LINK = /\{+submitter\.link\}+/i
ACCOUNT_NAME = /\{+account\.name\}+/i
@ -31,7 +32,10 @@ module ReplaceEmailVariables
# rubocop:disable Metrics
def call(text, submitter:, tracking_event_type: 'click_email', html_escape: false, sig: nil)
text = replace(text, TEMPLATE_NAME, html_escape:) { (submitter.template || submitter.submission.template).name }
text = replace(text, TEMPLATE_NAME, html_escape:) do
(submitter.template || submitter.submission.template || submitter.submission).name
end
text = replace(text, SUBMISSION_NAME, html_escape:) { submitter.submission.name }
text = replace(text, TEMPLATE_ID, html_escape:) { submitter.template.id }
text = replace(text, SUBMITTER_ID, html_escape:) { submitter.id }
text = replace(text, SUBMITTER_SLUG, html_escape:) { submitter.slug }

@ -0,0 +1,239 @@
# frozen_string_literal: true
module SearchEntries
MAX_VALUE_LENGTH = 100
UUID_REGEXP = /\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
module_function
def reindex_all
Submitter.find_each { |submitter| index_submitter(submitter) }
Submission.find_each { |submission| index_submission(submission) }
Template.find_each { |template| index_template(template) }
end
def enqueue_reindex(records)
return unless SearchEntry.table_exists?
args = Array.wrap(records).map { |e| [{ 'record_type' => e.class.name, 'record_id' => e.id }] }
ReindexSearchEntryJob.perform_bulk(args)
end
def reindex_record(record)
case record
when Submitter
index_submitter(record)
when Template
index_template(record)
when Submission
index_submission(record)
record.submitters.each do |submitter|
index_submitter(submitter)
end
else
raise ArgumentError, 'Invalid Record'
end
end
def build_tsquery(keyword, with_or_vector: false)
keyword = keyword.delete("\0")
if keyword.match?(/\d/) && !keyword.match?(/\p{L}/)
number = keyword.gsub(/\D/, '')
sql =
if number.length <= 2
<<~SQL.squish
ngram @@ (quote_literal(?)::tsquery || quote_literal(?)::tsquery) OR tsvector @@ plainto_tsquery(?)
SQL
else
<<~SQL.squish
tsvector @@ ((quote_literal(?) || ':*')::tsquery || (quote_literal(?) || ':*')::tsquery || plainto_tsquery(?))
SQL
end
[sql, number, number.length > 1 ? number.delete_prefix('0') : number, keyword]
elsif keyword.match?(/[^\p{L}\d&@._\-+]/) || keyword.match?(/\A['"].*['"]\z/)
['tsvector @@ plainto_tsquery(?)', TextUtils.transliterate(keyword.downcase)]
else
keyword = TextUtils.transliterate(keyword.downcase).squish
sql =
if keyword.length <= 2
arel = Arel.sql('ngram @@ quote_literal(:keyword)::tsquery')
arel = Arel::Nodes::Or.new([arel, Arel.sql('tsvector @@ plainto_tsquery(:keyword)')]).to_sql if with_or_vector
arel
else
"tsvector @@ (quote_literal(coalesce((ts_lexize('english_stem', :keyword))[1], :keyword)) || ':*')::tsquery"
end
[sql, { keyword: }]
end
end
def build_weights_tsquery(terms, weight)
last_query =
if terms.last.length <= 2
Arel.sql("ngram @@ (quote_literal(:term#{terms.size - 1}) || ':' || :weight)::tsquery")
else
Arel.sql(<<~SQL.squish)
(quote_literal(coalesce((ts_lexize('english_stem', :term#{terms.size - 1}))[1], :term#{terms.size - 1})) || ':*' || :weight)::tsquery
SQL
end
query = terms[..-2].reduce(nil) do |acc, term|
index = terms.index(term)
arel = Arel.sql(<<~SQL.squish)
(quote_literal(coalesce((ts_lexize('english_stem', :term#{index}))[1], :term#{index})) || ':' || :weight)::tsquery
SQL
acc ? Arel::Nodes::InfixOperation.new('&&', arel, acc) : arel
end
query =
if terms.last.length <= 2
query = Arel::Nodes::InfixOperation.new('@@', Arel.sql('tsvector'), Arel::Nodes::Grouping.new(query))
Arel::Nodes::And.new([query, last_query])
else
Arel::Nodes::InfixOperation.new(
'@@', Arel.sql('tsvector'),
Arel::Nodes::Grouping.new(Arel::Nodes::InfixOperation.new('&&', query, last_query))
)
end
[query.to_sql, terms.index_by.with_index { |_, index| :"term#{index}" }.merge(weight:)]
end
def build_weights_wildcard_tsquery(keyword, weight)
keyword = TextUtils.transliterate(keyword.downcase).squish
sql =
if keyword.length <= 2
<<~SQL.squish
ngram @@ (quote_literal(:keyword) || ':' || :weight)::tsquery
SQL
else
<<~SQL.squish
tsvector @@ (quote_literal(coalesce((ts_lexize('english_stem', :keyword))[1], :keyword)) || ':*' || :weight)::tsquery
SQL
end
[sql, { keyword:, weight: }]
end
def index_submitter(submitter)
return if submitter.email.blank? && submitter.phone.blank? && submitter.name.blank?
email_phone_name = [
[submitter.email.to_s, submitter.email.to_s.split('@').last].join(' ').delete("\0"),
[submitter.phone.to_s.gsub(/\D/, ''),
submitter.phone.to_s.gsub(PhoneCodes::REGEXP, '').gsub(/\D/, '')].uniq.join(' ').delete("\0"),
TextUtils.transliterate(submitter.name).delete("\0")
]
sql = SearchEntry.sanitize_sql_array(
[
"SELECT setweight(to_tsvector(?), 'A') || setweight(to_tsvector(?), 'B') ||
setweight(to_tsvector(?), 'C') || setweight(to_tsvector(?), 'D') as tsvector,
setweight(to_tsvector('simple', ?), 'A') ||
setweight(to_tsvector('simple', ?), 'B') ||
setweight(to_tsvector('simple', ?), 'C') as ngram".squish,
*email_phone_name,
build_submitter_values_string(submitter),
*email_phone_name
]
)
entry = submitter.search_entry || submitter.build_search_entry
entry.account_id = submitter.account_id
entry.tsvector, ngram = SearchEntry.connection.select_rows(sql).first
entry.ngram = build_ngram(ngram)
return if entry.tsvector.blank?
entry.save!
entry
rescue ActiveRecord::RecordNotUnique
submitter.reload
retry
end
def build_submitter_values_string(submitter)
values =
submitter.values.values.flatten.filter_map do |v|
next if !v.is_a?(String) || v.length > MAX_VALUE_LENGTH || UUID_REGEXP.match?(v)
TextUtils.transliterate(v)
end
values.uniq.join(' ').downcase.delete("\0")
end
def index_template(template)
sql = SearchEntry.sanitize_sql_array(
["SELECT to_tsvector(:text), to_tsvector('simple', :text)",
{ text: TextUtils.transliterate(template.name.to_s.downcase).delete("\0") }]
)
entry = template.search_entry || template.build_search_entry
entry.account_id = template.account_id
entry.tsvector, ngram = SearchEntry.connection.select_rows(sql).first
entry.ngram = build_ngram(ngram)
return if entry.tsvector.blank?
entry.save!
entry
rescue ActiveRecord::RecordNotUnique
template.reload
retry
end
def index_submission(submission)
return if submission.name.blank?
sql = SearchEntry.sanitize_sql_array(
["SELECT to_tsvector(:text), to_tsvector('simple', :text)",
{ text: TextUtils.transliterate(submission.name.to_s.downcase).delete("\0") }]
)
entry = submission.search_entry || submission.build_search_entry
entry.account_id = submission.account_id
entry.tsvector, ngram = SearchEntry.connection.select_rows(sql).first
entry.ngram = build_ngram(ngram)
return if entry.tsvector.blank?
entry.save!
entry
rescue ActiveRecord::RecordNotUnique
submission.reload
retry
end
def build_ngram(ngram)
ngrams =
ngram.split(/\s(?=')/).each_with_object([]) do |item, acc|
acc << item.sub(/'(.*?)':/) { "'#{Regexp.last_match(1).first(2)}':" }
acc << item.sub(/'(.*?)':/) { "'#{Regexp.last_match(1).first(1)}':" }
end
ngrams.uniq { |e| e.sub(/':[\d,]/, "':1") }.join(' ')
end
end

@ -7,7 +7,15 @@ module Submissions
module_function
def search(submissions, keyword, search_values: false, search_template: false)
def search(current_user, submissions, keyword, search_values: false, search_template: false)
if Docuseal.fulltext_search?(current_user)
fulltext_search(current_user, submissions, keyword, search_template:)
else
plain_search(submissions, keyword, search_values:, search_template:)
end
end
def plain_search(submissions, keyword, search_values: false, search_template: false)
return submissions if keyword.blank?
term = "%#{keyword.downcase}%"
@ -21,7 +29,7 @@ module Submissions
arel = arel.or(Arel::Table.new(:submitters)[:values].matches(term)) if search_values
if search_template
submissions = submissions.joins(:template)
submissions = submissions.left_joins(:template)
arel = arel.or(Template.arel_table[:name].lower.matches("%#{keyword.downcase}%"))
end
@ -29,6 +37,36 @@ module Submissions
submissions.joins(:submitters).where(arel).group(:id)
end
def fulltext_search(current_user, submissions, keyword, search_template: false)
return submissions if keyword.blank?
arel = SearchEntry.where(record_type: 'Submission')
.where(account_id: current_user.account_id)
.where(*SearchEntries.build_tsquery(keyword))
.select(:record_id).arel
if search_template
arel = Arel::Nodes::Union.new(
arel,
Submission.where(
template_id: SearchEntry.where(record_type: 'Template')
.where(account_id: current_user.account_id)
.where(*SearchEntries.build_tsquery(keyword))
.select(:record_id)
).select(:id).arel
)
end
arel = Arel::Nodes::Union.new(
arel, Submitter.joins(:search_entry)
.where(search_entry: { account_id: current_user.account_id })
.where(*SearchEntries.build_tsquery(keyword, with_or_vector: true))
.select(:submission_id).arel
)
submissions.where(Submission.arel_table[:id].in(arel))
end
def update_template_fields!(submission)
submission.template_fields = submission.template.fields
submission.template_schema = submission.template.schema
@ -40,15 +78,17 @@ module Submissions
def preload_with_pages(submission)
ActiveRecord::Associations::Preloader.new(
records: [submission],
associations: [:template, { template_schema_documents: :blob }]
associations: [
submission.template_id? ? { template_schema_documents: :blob } : { documents_attachments: :blob }
]
).call
total_pages =
submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
submission.schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: submission.template_schema_documents,
records: submission.schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
@ -90,10 +130,10 @@ module Submissions
emails
end
def create_from_submitters(template:, user:, submissions_attrs:, source:,
def create_from_submitters(template:, user:, submissions_attrs:, source:, with_template: true,
submitters_order: DEFAULT_SUBMITTERS_ORDER, params: {})
Submissions::CreateFromSubmitters.call(
template:, user:, submissions_attrs:, source:, submitters_order:, params:
template:, user:, submissions_attrs:, source:, submitters_order:, params:, with_template:
)
end

@ -7,7 +7,7 @@ module Submissions
module_function
# rubocop:disable Metrics
def call(template:, user:, submissions_attrs:, source:, submitters_order:, params: {})
def call(template:, user:, submissions_attrs:, source:, submitters_order:, params: {}, with_template: true)
preferences = Submitters.normalize_preferences(user.account, user, params)
submissions = Array.wrap(submissions_attrs).filter_map do |attrs|
@ -21,6 +21,7 @@ module Submissions
submission = template.submissions.new(created_by_user: user, source:,
account_id: user.account_id,
preferences: set_submission_preferences,
name: with_template ? attrs[:name] : (attrs[:name] || template.name),
expire_at:,
template_submitters: [], submitters_order:)
@ -60,7 +61,7 @@ module Submissions
preferences: preferences.merge(submission_preferences))
end
maybe_set_template_fields(submission, attrs[:submitters])
maybe_set_template_fields(submission, attrs[:submitters], with_template:)
if submission.submitters.size > template.submitters.size
raise BaseError, 'Defined more signing parties than in template'
@ -76,6 +77,8 @@ module Submissions
maybe_add_invite_submitters(submission, template)
submission.template = nil unless with_template
submission.tap(&:save!)
end
@ -118,7 +121,7 @@ module Submissions
}.compact_blank
end
def maybe_set_template_fields(submission, submitters_attrs, default_submitter_uuid: nil)
def maybe_set_template_fields(submission, submitters_attrs, default_submitter_uuid: nil, with_template: true)
template_fields = (submission.template_fields || submission.template.fields).deep_dup
submitters = submission.template_submitters || submission.template.submitters
@ -133,7 +136,7 @@ module Submissions
end
if template_fields != (submission.template_fields || submission.template.fields) ||
submitters_attrs.any? { |e| e[:completed].present? }
submitters_attrs.any? { |e| e[:completed].present? } || !with_template
submission.template_fields = template_fields
submission.template_schema = submission.template.schema if submission.template_schema.blank?
end

@ -58,7 +58,8 @@ module Submissions
ActiveStorage::Attachment.create!(
blob: ActiveStorage::Blob.create_and_upload!(
io: io.tap(&:rewind), filename: "#{I18n.t('audit_log')} - #{submission.template.name}.pdf"
io: io.tap(&:rewind), filename: "#{I18n.t('audit_log')} - " \
"#{submission.name || submission.template.name}.pdf"
),
name: 'audit_trail',
record: submission
@ -106,10 +107,18 @@ module Submissions
)
configs = submission.account.account_configs.where(key: [AccountConfig::WITH_AUDIT_VALUES_KEY,
AccountConfig::WITH_SIGNATURE_ID])
AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::WITH_AUDIT_SUBMITTER_TIMEZONE_KEY])
last_submitter = submission.submitters.select(&:completed_at).max_by(&:completed_at)
with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true
with_audit_values = configs.find { |c| c.key == AccountConfig::WITH_AUDIT_VALUES_KEY }&.value != false
with_audit_submitter_timezone =
configs.find { |c| c.key == AccountConfig::WITH_AUDIT_SUBMITTER_TIMEZONE_KEY }&.value == true
timezone = account.timezone
timezone = last_submitter.timezone || account.timezone if with_audit_submitter_timezone
composer.page_style(:default, page_size:) do |canvas, style|
box = canvas.context.box(:media)
@ -183,10 +192,8 @@ module Submissions
composer.draw_box(divider)
last_submitter = submission.submitters.select(&:completed_at).max_by(&:completed_at)
documents_data = Submitters.select_attachments_for_download(last_submitter).map do |document|
original_documents = submission.template.documents.select do |e|
original_documents = submission.schema_documents.select do |e|
e.uuid == (document.metadata['original_uuid'] || document.uuid)
end.presence
@ -209,8 +216,8 @@ module Submissions
document.metadata['sha256'] || document.checksum,
"\n",
{ text: "#{I18n.t('generated_at')}: ", font: [FONT_NAME, { variant: :bold }] },
"#{I18n.l(document.created_at.in_time_zone(account.timezone), format: :long, locale: account.locale)} " \
"#{TimeUtils.timezone_abbr(account.timezone, document.created_at)}"
"#{I18n.l(document.created_at.in_time_zone(timezone), format: :long, locale: account.locale)} " \
"#{TimeUtils.timezone_abbr(timezone, document.created_at)}"
], line_spacing: 1.3
)
]
@ -271,6 +278,7 @@ module Submissions
completed_event.data['ip'] && { text: "IP: #{completed_event.data['ip']}\n" },
completed_event.data['sid'] && { text: "#{I18n.t('session_id')}: #{completed_event.data['sid']}\n" },
completed_event.data['ua'] && { text: "User agent: #{completed_event.data['ua']}\n" },
submitter.timezone && { text: "Time zone: #{submitter.timezone.to_s.sub('Kiev', 'Kyiv')}\n" },
"\n"
].compact_blank, line_spacing: 1.3, padding: [10, 20, 20, 0]
)
@ -410,8 +418,8 @@ module Submissions
bold_text, normal_text = text.match(%r{<b>(.*?)</b>(.*)}).captures
[
"#{I18n.l(event.event_timestamp.in_time_zone(account.timezone), format: :long, locale: account.locale)} " \
"#{TimeUtils.timezone_abbr(account.timezone, event.event_timestamp)}",
"#{I18n.l(event.event_timestamp.in_time_zone(timezone), format: :long, locale: account.locale)} " \
"#{TimeUtils.timezone_abbr(timezone, event.event_timestamp)}",
composer.document.layout.formatted_text_box([{ text: bold_text, font: [FONT_NAME, { variant: :bold }] },
normal_text])
]

@ -32,7 +32,7 @@ module Submissions
ActiveStorage::Attachment.create!(
blob: ActiveStorage::Blob.create_and_upload!(
io: io.tap(&:rewind), filename: "#{submission.template.name}.pdf"
io: io.tap(&:rewind), filename: "#{submission.name || submission.template.name}.pdf"
),
name: 'combined_document',
record: submission

@ -34,7 +34,7 @@ module Submissions
template = submission.template
image_pdfs = []
original_documents = template.documents.preload(:blob)
original_documents = submission.schema_documents.preload(:blob)
result_attachments =
(submission.template_schema || template.schema).filter_map do |item|
@ -70,7 +70,7 @@ module Submissions
submitter:,
uuid: GenerateResultAttachments.images_pdf_uuid(original_documents.select(&:image?)),
values_hash:,
name: template.name
name: submission.name || template.name
)
ApplicationRecord.no_touching do

@ -72,17 +72,17 @@ module Submissions
pdfs_index = generate_pdfs(submitter)
template = submitter.submission.template
account = submitter.account
submission = submitter.submission
pkcs = Accounts.load_signing_pkcs(account)
tsa_url = Accounts.load_timeserver_url(account)
image_pdfs = []
original_documents = template.documents.preload(:blob)
original_documents = submission.schema_documents.preload(:blob)
result_attachments =
submitter.submission.template_schema.filter_map do |item|
submission.template_schema.filter_map do |item|
pdf = pdfs_index[item['attachment_uuid']]
next if pdf.nil?
@ -114,7 +114,7 @@ module Submissions
tsa_url:,
pkcs:,
uuid: images_pdf_uuid(original_documents.select(&:image?)),
name: template.name
name: submission.name || submission.template.name
)
ApplicationRecord.no_touching do
@ -656,14 +656,14 @@ module Submissions
Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter
documents = latest_submitter&.documents&.preload(:blob).to_a.presence
documents ||= submission.template_schema_documents.preload(:blob)
documents ||= submission.schema_documents.preload(:blob)
attachment_uuids = Submissions.filtered_conditions_schema(submission).pluck('attachment_uuid')
attachments_index = documents.index_by { |a| a.metadata['original_uuid'] || a.uuid }
attachment_uuids.each_with_object({}) do |uuid, acc|
attachment = attachments_index[uuid]
attachment ||= submission.template_schema_documents.preload(:blob).find { |a| a.uuid == uuid }
attachment ||= submission.schema_documents.preload(:blob).find { |a| a.uuid == uuid }
next unless attachment

@ -3,7 +3,7 @@
module Submissions
module SerializeForApi
SERIALIZE_PARAMS = {
only: %i[id slug source submitters_order expire_at created_at updated_at archived_at],
only: %i[id name slug source submitters_order expire_at created_at updated_at archived_at],
methods: %i[audit_log_url combined_document_url],
include: {
submitters: { only: %i[id] },

@ -4,9 +4,76 @@ module Submitters
TRUE_VALUES = ['1', 'true', true].freeze
PRELOAD_ALL_PAGES_AMOUNT = 200
FIELD_NAME_WEIGHTS = {
'email' => 'A',
'phone' => 'B',
'name' => 'C',
'values' => 'D'
}.freeze
module_function
def search(submitters, keyword)
def search(current_user, submitters, keyword)
if Docuseal.fulltext_search?(current_user)
fulltext_search(current_user, submitters, keyword)
else
plain_search(submitters, keyword)
end
end
def fulltext_search(current_user, submitters, keyword)
return submitters if keyword.blank?
submitters.where(
id: SearchEntry.where(record_type: 'Submitter')
.where(account_id: current_user.account_id)
.where(*SearchEntries.build_tsquery(keyword))
.select(:record_id)
)
end
def fulltext_search_field(current_user, submitters, keyword, field_name)
keyword = keyword.delete("\0")
return submitters if keyword.blank?
weight = FIELD_NAME_WEIGHTS[field_name]
return submitters if weight.blank?
query =
if keyword.match?(/\d/) && !keyword.match?(/\p{L}/)
number = keyword.gsub(/\D/, '')
sql =
if number.length <= 2
"ngram @@ ((quote_literal(?) || ':' || ?)::tsquery || (quote_literal(?) || ':' || ?)::tsquery)"
else
"tsvector @@ ((quote_literal(?) || ':*' || ?)::tsquery || (quote_literal(?) || ':*' || ?)::tsquery)"
end
[sql, number, weight, number.length > 1 ? number.delete_prefix('0') : number, weight]
elsif keyword.match?(/[^\p{L}\d&@._\-+]/)
terms = TextUtils.transliterate(keyword.downcase).split(/\b/).map(&:squish).compact_blank.uniq
if terms.size > 1
SearchEntries.build_weights_tsquery(terms, weight)
else
SearchEntries.build_weights_wildcard_tsquery(keyword, weight)
end
else
SearchEntries.build_weights_wildcard_tsquery(keyword, weight)
end
submitters.where(
id: SearchEntry.where(record_type: 'Submitter')
.where(account_id: current_user.account_id)
.where(*query)
.select(:record_id)
)
end
def plain_search(submitters, keyword)
return submitters if keyword.blank?
term = "%#{keyword.downcase}%"
@ -27,7 +94,7 @@ module Submitters
return [submitter.submission.combined_document_attachment]
end
original_documents = submitter.submission.template_schema_documents.preload(:blob)
original_documents = submitter.submission.schema_documents.preload(:blob)
is_more_than_two_images = original_documents.count(&:image?) > 1
submitter.documents.preload(:blob).reject do |attachment|
@ -36,25 +103,6 @@ module Submitters
end
end
def preload_with_pages(submitter)
ActiveRecord::Associations::Preloader.new(
records: [submitter],
associations: [submission: [:template, { template_schema_documents: :blob }]]
).call
total_pages =
submitter.submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: submitter.submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
submitter
end
def create_attachment!(submitter, params)
blob =
if (file = params[:file])

@ -11,6 +11,7 @@ module Submitters
AccountConfig::ENFORCE_SIGNING_ORDER_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY,
AccountConfig::REUSE_SIGNATURE_KEY,
AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
AccountConfig::ALLOW_TYPED_SIGNATURE,
*(Docuseal.multitenant? ? [] : [AccountConfig::POLICY_LINKS_KEY])].freeze
@ -26,24 +27,24 @@ module Submitters
prefill_signature = find_safe_value(configs, AccountConfig::FORM_PREFILL_SIGNATURE_KEY) != false
reuse_signature = find_safe_value(configs, AccountConfig::REUSE_SIGNATURE_KEY) != false
with_decline = find_safe_value(configs, AccountConfig::ALLOW_TO_DECLINE_KEY) != false
with_partial_download = find_safe_value(configs, AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY) != false
with_signature_id = find_safe_value(configs, AccountConfig::WITH_SIGNATURE_ID) == true
require_signing_reason = find_safe_value(configs, AccountConfig::REQUIRE_SIGNING_REASON_KEY) == true
enforce_signing_order = find_safe_value(configs, AccountConfig::ENFORCE_SIGNING_ORDER_KEY) == true
policy_links = find_safe_value(configs, AccountConfig::POLICY_LINKS_KEY)
attrs = {
completed_button:,
with_typed_signature:,
with_confetti:,
reuse_signature:,
with_decline:,
policy_links:,
enforce_signing_order:,
completed_message:,
require_signing_reason:,
prefill_signature:,
with_signature_id:
}
attrs = { completed_button:,
with_typed_signature:,
with_confetti:,
reuse_signature:,
with_decline:,
with_partial_download:,
policy_links:,
enforce_signing_order:,
completed_message:,
require_signing_reason:,
prefill_signature:,
with_signature_id: }
keys.each do |key|
attrs[key.to_sym] = configs.find { |e| e.key == key.to_s }&.value

@ -46,7 +46,7 @@ module Submitters
end
def build_values_array(submitter)
fields = submitter.submission.template_fields.presence || submitter.submission.template.fields
fields = submitter.submission.template_fields.presence || submitter.submission&.template&.fields || []
attachments_index = submitter.attachments.index_by(&:uuid)
submitter_field_counters = Hash.new { 0 }
@ -68,7 +68,7 @@ module Submitters
end
def build_fields_array(submitter)
fields = submitter.submission.template_fields.presence || submitter.submission.template.fields
fields = submitter.submission.template_fields.presence || submitter.submission&.template&.fields || []
attachments_index = submitter.attachments.index_by(&:uuid)
submitter_field_counters = Hash.new { 0 }

@ -48,6 +48,8 @@ module Submitters
submitter.save!
end
SearchEntries.enqueue_reindex(submitter) if submitter.completed_at?
submitter
end
@ -55,6 +57,7 @@ module Submitters
submitter.completed_at = Time.current
submitter.ip = request.remote_ip
submitter.ua = request.user_agent
submitter.timezone = request.params[:timezone]
submitter.values = merge_default_values(submitter)
@ -297,13 +300,13 @@ module Submitters
option = field['options'].find { |o| o['uuid'] == condition['value'] }
values = Array.wrap(submitter_values[condition['field_uuid']])
values.include?(option['value'].presence || "#{I18n.t('option')} #{field['options'].index(option)}")
values.include?(option['value'].presence || "#{I18n.t('option')} #{field['options'].index(option) + 1}")
when 'not_equal', 'does_not_contain'
field = fields_uuid_index[condition['field_uuid']]
option = field['options'].find { |o| o['uuid'] == condition['value'] }
values = Array.wrap(submitter_values[condition['field_uuid']])
values.exclude?(option['value'].presence || "#{I18n.t('option')} #{field['options'].index(option)}")
values.exclude?(option['value'].presence || "#{I18n.t('option')} #{field['options'].index(option) + 1}")
else
true
end

@ -37,14 +37,33 @@ module Templates
hash
end
def search(templates, keyword)
def search(current_user, templates, keyword)
if Docuseal.fulltext_search?(current_user)
fulltext_search(current_user, templates, keyword)
else
plain_search(templates, keyword)
end
end
def plain_search(templates, keyword)
return templates if keyword.blank?
templates.where(Template.arel_table[:name].lower.matches("%#{keyword.downcase}%"))
end
def filter_undefined_submitters(template)
template.submitters.to_a.select do |item|
def fulltext_search(current_user, templates, keyword)
return templates if keyword.blank?
templates.where(
id: SearchEntry.where(record_type: 'Template')
.where(account_id: current_user.account_id)
.where(*SearchEntries.build_tsquery(keyword))
.select(:record_id)
)
end
def filter_undefined_submitters(template_submitters)
template_submitters.to_a.select do |item|
item['invite_by_uuid'].blank? && item['optional_invite_by_uuid'].blank? &&
item['linked_to_uuid'].blank? && item['is_requester'].blank? && item['email'].blank?
end

@ -4,7 +4,7 @@ module Templates
module CloneAttachments
module_function
def call(template:, original_template:, documents: [], excluded_attachment_uuids: [])
def call(template:, original_template:, documents: [], excluded_attachment_uuids: [], save: true)
schema_uuids_replacements = {}
template.schema.each_with_index do |schema_item, index|
@ -29,32 +29,31 @@ module Templates
end
end
template.save!
attachments =
original_template.schema_documents.filter_map do |document|
new_attachment_uuid = schema_uuids_replacements[document.uuid]
original_template.schema_documents.filter_map do |document|
new_attachment_uuid = schema_uuids_replacements[document.uuid]
next unless new_attachment_uuid
next unless new_attachment_uuid
new_document =
ApplicationRecord.no_touching do
template.documents_attachments.create!(
new_document =
template.documents_attachments.new(
uuid: new_attachment_uuid,
blob_id: document.blob_id
)
end
clone_document_preview_images_attachments(document:, new_document:)
clone_document_preview_images_attachments(document:, new_document:)
new_document
end
new_document
end
template.save! if save
attachments
end
def clone_document_preview_images_attachments(document:, new_document:)
ApplicationRecord.no_touching do
document.preview_images_attachments.each do |preview_image|
new_document.preview_images_attachments.create!(blob_id: preview_image.blob_id)
end
document.preview_images_attachments.each do |preview_image|
new_document.preview_images_attachments.new(blob_id: preview_image.blob_id)
end
end
end

@ -78,7 +78,7 @@ module Templates
next if attrs[:w].zero? || attrs[:h].zero?
if child_field[:MaxLen] && child_field.try(:concrete_field_type) == :comb_text_field
attrs[:cell_w] = w / page_width / child_field[:MaxLen].to_f
attrs[:cell_w] = w / page_width.to_f / child_field[:MaxLen].to_f
end
attrs

@ -36,6 +36,24 @@ module Templates
attachment
end
def process(attachment, data, extract_fields: false)
if attachment.content_type == PDF_CONTENT_TYPE && extract_fields && data.size < MAX_FLATTEN_FILE_SIZE
pdf = HexaPDF::Document.new(io: StringIO.new(data))
fields = Templates::FindAcroFields.call(pdf, attachment, data)
end
pdf ||= HexaPDF::Document.new(io: StringIO.new(data))
number_of_pages = pdf.pages.size
attachment.metadata['pdf'] ||= {}
attachment.metadata['pdf']['number_of_pages'] = number_of_pages
attachment.metadata['pdf']['fields'] = fields if fields
attachment
end
def generate_preview_image(attachment, data)
ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all

@ -5,6 +5,11 @@ module TextUtils
MASK_REGEXP = /[^\s\-_\[\]\(\)\+\?\.\,]/
MASK_SYMBOL = 'X'
TRANSLITERATIONS =
I18n::Backend::Transliterator::HashTransliterator::DEFAULT_APPROXIMATIONS.reject { |_, v| v.length > 1 }
TRANSLITERATION_REGEXP = Regexp.union(TRANSLITERATIONS.keys)
module_function
def rtl?(text)
@ -15,6 +20,10 @@ module TextUtils
false
end
def transliterate(text)
text.to_s.gsub(TRANSLITERATION_REGEXP) { |e| TRANSLITERATIONS[e] }
end
def mask_value(text, unmask_size = 0)
if unmask_size.is_a?(Numeric) && !unmask_size.zero? && unmask_size.abs < text.length
if unmask_size.negative?

@ -288,6 +288,7 @@ describe 'Submission API' do
{
id: submission.id,
name: submission.name,
source: 'link',
submitters_order: 'random',
slug: submission.slug,
@ -346,6 +347,7 @@ describe 'Submission API' do
{
id: submission.id,
name: submission.name,
source: 'link',
status: 'pending',
submitters_order: 'random',

Loading…
Cancel
Save