diff --git a/Gemfile.lock b/Gemfile.lock index 1a097c98..943a7d74 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb index 17d961dd..11e9b3e7 100644 --- a/app/controllers/api/submissions_controller.rb +++ b/app/controllers/api/submissions_controller.rb @@ -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: {} }] }]] } ] diff --git a/app/controllers/api/submitters_controller.rb b/app/controllers/api/submitters_controller.rb index 4d53d3cb..3b9a4680 100644 --- a/app/controllers/api/submitters_controller.rb +++ b/app/controllers/api/submitters_controller.rb @@ -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: {} }]] } ) diff --git a/app/controllers/api/templates_clone_controller.rb b/app/controllers/api/templates_clone_controller.rb index 7879abfd..2619a243 100644 --- a/app/controllers/api/templates_clone_controller.rb +++ b/app/controllers/api/templates_clone_controller.rb @@ -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 diff --git a/app/controllers/api/templates_controller.rb b/app/controllers/api/templates_controller.rb index 25c0c537..a36732a3 100644 --- a/app/controllers/api/templates_controller.rb +++ b/app/controllers/api/templates_controller.rb @@ -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? diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index 415adab4..fb2a9786 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -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 diff --git a/app/controllers/submissions_archived_controller.rb b/app/controllers/submissions_archived_controller.rb index 3ad5d936..793da755 100644 --- a/app/controllers/submissions_archived_controller.rb +++ b/app/controllers/submissions_archived_controller.rb @@ -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? diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 0240b5be..c411b5df 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -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 diff --git a/app/controllers/submissions_dashboard_controller.rb b/app/controllers/submissions_dashboard_controller.rb index 55fb6ae0..f0851741 100644 --- a/app/controllers/submissions_dashboard_controller.rb +++ b/app/controllers/submissions_dashboard_controller.rb @@ -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? diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index a9a489c8..46b28128 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -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 diff --git a/app/controllers/submit_form_decline_controller.rb b/app/controllers/submit_form_decline_controller.rb index 94099b0d..15572da1 100644 --- a/app/controllers/submit_form_decline_controller.rb +++ b/app/controllers/submit_form_decline_controller.rb @@ -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) diff --git a/app/controllers/submit_form_download_controller.rb b/app/controllers/submit_form_download_controller.rb index 07447940..d357019c 100644 --- a/app/controllers/submit_form_download_controller.rb +++ b/app/controllers/submit_form_download_controller.rb @@ -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| diff --git a/app/controllers/submitters_autocomplete_controller.rb b/app/controllers/submitters_autocomplete_controller.rb index e86e129e..70f02747 100644 --- a/app/controllers/submitters_autocomplete_controller.rb +++ b/app/controllers/submitters_autocomplete_controller.rb @@ -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 diff --git a/app/controllers/submitters_controller.rb b/app/controllers/submitters_controller.rb index 3adb3c89..697b3dc1 100644 --- a/app/controllers/submitters_controller.rb +++ b/app/controllers/submitters_controller.rb @@ -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') diff --git a/app/controllers/submitters_resubmit_controller.rb b/app/controllers/submitters_resubmit_controller.rb index fb893d98..6ab28731 100644 --- a/app/controllers/submitters_resubmit_controller.rb +++ b/app/controllers/submitters_resubmit_controller.rb @@ -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 diff --git a/app/controllers/template_folders_controller.rb b/app/controllers/template_folders_controller.rb index c22f2fed..25dad2c1 100644 --- a/app/controllers/template_folders_controller.rb +++ b/app/controllers/template_folders_controller.rb @@ -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) diff --git a/app/controllers/templates_archived_controller.rb b/app/controllers/templates_archived_controller.rb index f52e5af7..34d2b601 100644 --- a/app/controllers/templates_archived_controller.rb +++ b/app/controllers/templates_archived_controller.rb @@ -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 diff --git a/app/controllers/templates_archived_submissions_controller.rb b/app/controllers/templates_archived_submissions_controller.rb index 9c9da083..39aa0877 100644 --- a/app/controllers/templates_archived_submissions_controller.rb +++ b/app/controllers/templates_archived_submissions_controller.rb @@ -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? diff --git a/app/controllers/templates_clone_and_replace_controller.rb b/app/controllers/templates_clone_and_replace_controller.rb index 0d75416b..21989067 100644 --- a/app/controllers/templates_clone_and_replace_controller.rb +++ b/app/controllers/templates_clone_and_replace_controller.rb @@ -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 } } diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index b3d0ccec..40c8dbd1 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -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) diff --git a/app/controllers/templates_dashboard_controller.rb b/app/controllers/templates_dashboard_controller.rb index b4806fbc..af47d179 100644 --- a/app/controllers/templates_dashboard_controller.rb +++ b/app/controllers/templates_dashboard_controller.rb @@ -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) diff --git a/app/controllers/templates_form_preview_controller.rb b/app/controllers/templates_form_preview_controller.rb index f7a1a396..5c4f23c6 100644 --- a/app/controllers/templates_form_preview_controller.rb +++ b/app/controllers/templates_form_preview_controller.rb @@ -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) diff --git a/app/controllers/templates_uploads_controller.rb b/app/controllers/templates_uploads_controller.rb index 4cd0072f..45403a7a 100644 --- a/app/controllers/templates_uploads_controller.rb +++ b/app/controllers/templates_uploads_controller.rb @@ -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) diff --git a/app/javascript/elements/submitter_autocomplete.js b/app/javascript/elements/submitter_autocomplete.js index 5fc539bf..bf24739a 100644 --- a/app/javascript/elements/submitter_autocomplete.js +++ b/app/javascript/elements/submitter_autocomplete.js @@ -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 }) diff --git a/app/javascript/submission_form/form.vue b/app/javascript/submission_form/form.vue index c229ed11..358c0396 100644 --- a/app/javascript/submission_form/form.vue +++ b/app/javascript/submission_form/form.vue @@ -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 diff --git a/app/javascript/submission_form/signature_step.vue b/app/javascript/submission_form/signature_step.vue index 146cf791..2011b77a 100644 --- a/app/javascript/submission_form/signature_step.vue +++ b/app/javascript/submission_form/signature_step.vue @@ -133,7 +133,7 @@ class="mx-auto bg-white border border-base-300 rounded max-h-44" > -
  • +
  • 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 diff --git a/app/models/submitter.rb b/app/models/submitter.rb index 91322f96..b684dfbe 100644 --- a/app/models/submitter.rb +++ b/app/models/submitter.rb @@ -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: -> { {} } diff --git a/app/models/template.rb b/app/models/template.rb index faa9e696..dbfadb79 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -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: -> { {} } diff --git a/app/views/start_form/completed.html.erb b/app/views/start_form/completed.html.erb index f629d6ab..003005d8 100644 --- a/app/views/start_form/completed.html.erb +++ b/app/views/start_form/completed.html.erb @@ -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' %> <% 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? %> <%= 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' %> diff --git a/app/views/submissions/_send_email.html.erb b/app/views/submissions/_send_email.html.erb index 43617218..785eac90 100644 --- a/app/views/submissions/_send_email.html.erb +++ b/app/views/submissions/_send_email.html.erb @@ -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 %>
    <% can_send_emails = Accounts.can_send_emails?(current_account) %>
    @@ -37,18 +38,18 @@ <% config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) %>
    -

    <%= @submitter.submission.template.name %>

    +

    <%= @submitter.submission.name || @submitter.submission.template.name %>

    <%= t(@submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(@submitter.completed_at.to_date, format: :long)) %>

    @@ -23,7 +23,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: { submitter_slug: @submitter.slug }, class: 'white-button w-full' %> - <% if Templates.filter_undefined_submitters(@submitter.submission.template).size != 1 %> + <% if Templates.filter_undefined_submitters(@submitter.submission.template_submitters).size != 1 %>
    <%= t('or') %>
    <% else %>
    @@ -42,8 +42,8 @@ <% end %>
    - <% 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? %>
    <%= t('or') %>
    <%= 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' %> diff --git a/app/views/submit_form/declined.html.erb b/app/views/submit_form/declined.html.erb index f0e06b3f..cc45cc23 100644 --- a/app/views/submit_form/declined.html.erb +++ b/app/views/submit_form/declined.html.erb @@ -10,7 +10,7 @@ <%= svg_icon('writing_sign', class: 'w-10 h-10') %>
    -

    <%= @submitter.submission.template.name %>

    +

    <%= @submitter.submission.name || @submitter.submission.template.name %>

    <%= t('form_has_been_declined_on_html', time: l(@submitter.declined_at, format: :long)) %>

    diff --git a/app/views/submit_form/expired.html.erb b/app/views/submit_form/expired.html.erb index 8a85d5e8..d012185f 100644 --- a/app/views/submit_form/expired.html.erb +++ b/app/views/submit_form/expired.html.erb @@ -10,7 +10,7 @@ <%= svg_icon('writing_sign', class: 'w-10 h-10') %>
    -

    <%= @submitter.submission.template.name %>

    +

    <%= @submitter.submission.name || @submitter.submission.template.name %>

    <%= t('form_expired_at_html', time: l(@submitter.submission.expire_at, format: :long)) %>

    diff --git a/app/views/submit_form/show.html.erb b/app/views/submit_form/show.html.erb index 491256a6..bdd5351f 100644 --- a/app/views/submit_form/show.html.erb +++ b/app/views/submit_form/show.html.erb @@ -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') %>
    - <%= @submitter.submission.template.name %> + <%= @submitter.submission.name || @submitter.submission.template.name %>
    <% if @form_configs[:with_decline] %> @@ -24,16 +24,18 @@ <% end %>
    <% end %> - - - <%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %> - - - - + <% if @form_configs[:with_partial_download] %> + + + <%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %> + + + + + <% end %>
    <% 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'] } %>
    <% 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")) %>
    diff --git a/app/views/submitter_mailer/completed_email.html.erb b/app/views/submitter_mailer/completed_email.html.erb index 7269fb39..382efb16 100644 --- a/app/views/submitter_mailer/completed_email.html.erb +++ b/app/views/submitter_mailer/completed_email.html.erb @@ -2,6 +2,6 @@ <%= render 'custom_content', content: @body, submitter: @submitter %> <% else %>

    <%= t('hi_there') %>,

    -

    <%= 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(', ')) %>

    +

    <%= 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(', ')) %>

    <%= link_to submission_url(@submitter.submission), submission_url(@submitter.submission) %>

    <% end %> diff --git a/app/views/submitter_mailer/declined_email.html.erb b/app/views/submitter_mailer/declined_email.html.erb index 0e6ce535..07b877c9 100644 --- a/app/views/submitter_mailer/declined_email.html.erb +++ b/app/views/submitter_mailer/declined_email.html.erb @@ -1,4 +1,4 @@

    <%= t('hi_there') %>,

    -

    <%= t('name_declined_by_submitter_with_the_following_reason', name: @submitter.submission.template.name, submitter: @submitter.name || @submitter.email || @submitter.phone) %>

    +

    <%= t('name_declined_by_submitter_with_the_following_reason', name: @submitter.submission.name || @submitter.submission.template.name, submitter: @submitter.name || @submitter.email || @submitter.phone) %>

    <%= simple_format(h(@submitter.submission_events.find_by(event_type: :decline_form).data['reason'])) %>

    <%= link_to submission_url(@submitter.submission), submission_url(@submitter.submission) %>

    diff --git a/app/views/submitter_mailer/documents_copy_email.html.erb b/app/views/submitter_mailer/documents_copy_email.html.erb index 0312a0bc..3117481d 100644 --- a/app/views/submitter_mailer/documents_copy_email.html.erb +++ b/app/views/submitter_mailer/documents_copy_email.html.erb @@ -2,10 +2,10 @@ <%= render 'custom_content', content: @body, submitter: @submitter, sig: @sig %> <% else %>

    <%= t('hi_there') %>,

    -

    <%= t('please_check_the_copy_of_your_name_in_the_email_attachments', name: @submitter.submission.template.name) %> +

    <%= t('please_check_the_copy_of_your_name_in_the_email_attachments', name: @submitter.submission.name || @submitter.submission.template.name) %>

    <%= t('alternatively_you_can_review_and_download_your_copy_using_the_link_below') %>

    - <%= 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) %>

    <%= t('thanks') %>,
    <%= @current_account.name %> diff --git a/app/views/submitter_mailer/invitation_email.html.erb b/app/views/submitter_mailer/invitation_email.html.erb index 968ec02b..06eaef30 100644 --- a/app/views/submitter_mailer/invitation_email.html.erb +++ b/app/views/submitter_mailer/invitation_email.html.erb @@ -6,7 +6,7 @@ <% end %> <% else %>

    <%= t('hi_there') %>,

    -

    <%= 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) %>

    +

    <%= 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) %>

    <%= 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])) %>

    <%= t('please_contact_us_by_replying_to_this_email_if_you_didn_t_request_this') %>

    diff --git a/app/views/templates/_submission.html.erb b/app/views/templates/_submission.html.erb index 5d75a047..cfa7f936 100644 --- a/app/views/templates/_submission.html.erb +++ b/app/views/templates/_submission.html.erb @@ -2,12 +2,12 @@

    - <% 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 %> - <% if !submission.archived_at? && !template.archived_at? && can?(:destroy, submission) %> + <% if !submission.archived_at? && !template&.archived_at? && can?(:destroy, submission) %> <%= 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 %> @@ -164,7 +164,7 @@ <%= t('download')[..-2] %>... - <% 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? %>
    <% if current_user.email == submitter.email %> @@ -208,7 +208,7 @@ <%= t('view') %>
    - <% if !submission.archived_at? && !template.archived_at? %> + <% if !submission.archived_at? && !template&.archived_at? %> <%= 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 %> diff --git a/app/views/templates/show.html.erb b/app/views/templates/show.html.erb index 18188523..c749ef1f 100644 --- a/app/views/templates/show.html.erb +++ b/app/views/templates/show.html.erb @@ -113,7 +113,7 @@
    <% 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? %>
    <%= t('view_archived') %>
    diff --git a/app/views/templates_dashboard/index.html.erb b/app/views/templates_dashboard/index.html.erb index 99f4d80e..f1a4c3de 100644 --- a/app/views/templates_dashboard/index.html.erb +++ b/app/views/templates_dashboard/index.html.erb @@ -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 %> diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 0b0632ab..6c8ef943 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -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" } ] } diff --git a/db/migrate/20250527102542_add_name_to_submissions.rb b/db/migrate/20250527102542_add_name_to_submissions.rb new file mode 100644 index 00000000..66166de3 --- /dev/null +++ b/db/migrate/20250527102542_add_name_to_submissions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddNameToSubmissions < ActiveRecord::Migration[8.0] + def change + add_column :submissions, :name, :text + end +end diff --git a/db/migrate/20250527102550_remove_not_null_template_id.rb b/db/migrate/20250527102550_remove_not_null_template_id.rb new file mode 100644 index 00000000..1ad542bb --- /dev/null +++ b/db/migrate/20250527102550_remove_not_null_template_id.rb @@ -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 diff --git a/db/migrate/20250530080846_add_email_events_date_index.rb b/db/migrate/20250530080846_add_email_events_date_index.rb index 25a0d643..7d46bec1 100644 --- a/db/migrate/20250530080846_add_email_events_date_index.rb +++ b/db/migrate/20250530080846_add_email_events_date_index.rb @@ -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 diff --git a/db/migrate/20250531085328_remove_completed_submitter_template_not_null.rb b/db/migrate/20250531085328_remove_completed_submitter_template_not_null.rb new file mode 100644 index 00000000..dc52a162 --- /dev/null +++ b/db/migrate/20250531085328_remove_completed_submitter_template_not_null.rb @@ -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 diff --git a/db/migrate/20250603105556_create_search_enties.rb b/db/migrate/20250603105556_create_search_enties.rb new file mode 100644 index 00000000..2b219f84 --- /dev/null +++ b/db/migrate/20250603105556_create_search_enties.rb @@ -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 diff --git a/db/migrate/20250608163157_add_ngram_to_search_index.rb b/db/migrate/20250608163157_add_ngram_to_search_index.rb new file mode 100644 index 00000000..3af6729d --- /dev/null +++ b/db/migrate/20250608163157_add_ngram_to_search_index.rb @@ -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 diff --git a/db/migrate/20250613160132_add_timezone_to_submitters.rb b/db/migrate/20250613160132_add_timezone_to_submitters.rb new file mode 100644 index 00000000..3b13b82d --- /dev/null +++ b/db/migrate/20250613160132_add_timezone_to_submitters.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddTimezoneToSubmitters < ActiveRecord::Migration[8.0] + def change + add_column :submitters, :timezone, :string + end +end diff --git a/db/migrate/20250615091654_add_submitters_completed_at_index.rb b/db/migrate/20250615091654_add_submitters_completed_at_index.rb new file mode 100644 index 00000000..1ff3431f --- /dev/null +++ b/db/migrate/20250615091654_add_submitters_completed_at_index.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index b024ff66..c9e47280 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/lib/abilities/template_conditions.rb b/lib/abilities/template_conditions.rb index e39019b2..32afd8a3 100644 --- a/lib/abilities/template_conditions.rb +++ b/lib/abilities/template_conditions.rb @@ -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) diff --git a/lib/accounts.rb b/lib/accounts.rb index f3dd639b..898051fc 100644 --- a/lib/accounts.rb +++ b/lib/accounts.rb @@ -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 diff --git a/lib/docuseal.rb b/lib/docuseal.rb index 3c11deda..77d28a5e 100644 --- a/lib/docuseal.rb +++ b/lib/docuseal.rb @@ -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 diff --git a/lib/pdf_utils.rb b/lib/pdf_utils.rb index e4c22400..9dff1828 100644 --- a/lib/pdf_utils.rb +++ b/lib/pdf_utils.rb @@ -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 diff --git a/lib/phone_codes.rb b/lib/phone_codes.rb new file mode 100644 index 00000000..43f8ea14 --- /dev/null +++ b/lib/phone_codes.rb @@ -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 diff --git a/lib/replace_email_variables.rb b/lib/replace_email_variables.rb index 6b3efacd..3cd2d698 100644 --- a/lib/replace_email_variables.rb +++ b/lib/replace_email_variables.rb @@ -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 } diff --git a/lib/search_entries.rb b/lib/search_entries.rb new file mode 100644 index 00000000..8965f73f --- /dev/null +++ b/lib/search_entries.rb @@ -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 diff --git a/lib/submissions.rb b/lib/submissions.rb index 92e715ed..2ba84731 100644 --- a/lib/submissions.rb +++ b/lib/submissions.rb @@ -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 diff --git a/lib/submissions/create_from_submitters.rb b/lib/submissions/create_from_submitters.rb index 44adf95e..91997560 100644 --- a/lib/submissions/create_from_submitters.rb +++ b/lib/submissions/create_from_submitters.rb @@ -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 diff --git a/lib/submissions/generate_audit_trail.rb b/lib/submissions/generate_audit_trail.rb index cdd5945a..d15c242b 100644 --- a/lib/submissions/generate_audit_trail.rb +++ b/lib/submissions/generate_audit_trail.rb @@ -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{(.*?)(.*)}).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]) ] diff --git a/lib/submissions/generate_combined_attachment.rb b/lib/submissions/generate_combined_attachment.rb index 7cda0b46..4caa2104 100644 --- a/lib/submissions/generate_combined_attachment.rb +++ b/lib/submissions/generate_combined_attachment.rb @@ -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 diff --git a/lib/submissions/generate_preview_attachments.rb b/lib/submissions/generate_preview_attachments.rb index 742f96f4..39fb2dd3 100644 --- a/lib/submissions/generate_preview_attachments.rb +++ b/lib/submissions/generate_preview_attachments.rb @@ -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 diff --git a/lib/submissions/generate_result_attachments.rb b/lib/submissions/generate_result_attachments.rb index 7dfdb6d9..2751afde 100644 --- a/lib/submissions/generate_result_attachments.rb +++ b/lib/submissions/generate_result_attachments.rb @@ -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 diff --git a/lib/submissions/serialize_for_api.rb b/lib/submissions/serialize_for_api.rb index e32d7b48..e7271699 100644 --- a/lib/submissions/serialize_for_api.rb +++ b/lib/submissions/serialize_for_api.rb @@ -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] }, diff --git a/lib/submitters.rb b/lib/submitters.rb index 32e97720..6e7d856d 100644 --- a/lib/submitters.rb +++ b/lib/submitters.rb @@ -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]) diff --git a/lib/submitters/form_configs.rb b/lib/submitters/form_configs.rb index ef4077e5..9c1fa06f 100644 --- a/lib/submitters/form_configs.rb +++ b/lib/submitters/form_configs.rb @@ -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 diff --git a/lib/submitters/serialize_for_webhook.rb b/lib/submitters/serialize_for_webhook.rb index 9e897ef3..614cca2c 100644 --- a/lib/submitters/serialize_for_webhook.rb +++ b/lib/submitters/serialize_for_webhook.rb @@ -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 } diff --git a/lib/submitters/submit_values.rb b/lib/submitters/submit_values.rb index efff5b46..8775f26e 100644 --- a/lib/submitters/submit_values.rb +++ b/lib/submitters/submit_values.rb @@ -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 diff --git a/lib/templates.rb b/lib/templates.rb index 35651634..aa476493 100644 --- a/lib/templates.rb +++ b/lib/templates.rb @@ -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 diff --git a/lib/templates/clone_attachments.rb b/lib/templates/clone_attachments.rb index 2cbcb3b3..11484c1f 100644 --- a/lib/templates/clone_attachments.rb +++ b/lib/templates/clone_attachments.rb @@ -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 diff --git a/lib/templates/find_acro_fields.rb b/lib/templates/find_acro_fields.rb index b02bbdfc..af0ee395 100644 --- a/lib/templates/find_acro_fields.rb +++ b/lib/templates/find_acro_fields.rb @@ -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 diff --git a/lib/templates/process_document.rb b/lib/templates/process_document.rb index 1a5e5ae1..77aeaacd 100644 --- a/lib/templates/process_document.rb +++ b/lib/templates/process_document.rb @@ -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 diff --git a/lib/text_utils.rb b/lib/text_utils.rb index f286311f..38be7f92 100644 --- a/lib/text_utils.rb +++ b/lib/text_utils.rb @@ -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? diff --git a/spec/requests/submissions_spec.rb b/spec/requests/submissions_spec.rb index 5f9e98db..7a362ef5 100644 --- a/spec/requests/submissions_spec.rb +++ b/spec/requests/submissions_spec.rb @@ -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',