Compare commits

..

31 Commits

Author SHA1 Message Date
Alex Turchyn 9011e3f4b4 Merge from docusealco/wip 2026-01-12 13:25:54 +02:00
Pete Matsyburka 0b45ce5c0c update model 2026-01-12 12:05:54 +02:00
Pete Matsyburka a8ab93a4cc adjust sort 2026-01-12 11:27:19 +02:00
Pete Matsyburka d1768be636 secure env 2026-01-12 11:27:17 +02:00
Pete Matsyburka b2cd7c82a7 insert new field at page position 2026-01-11 14:45:42 +02:00
Pete Matsyburka b88d99afe0 merge preview docs 2026-01-11 11:09:00 +02:00
Pete Matsyburka 4523d38551 direct links option 2026-01-09 22:05:37 +02:00
Pete Matsyburka c4ba892559 refactor clone 2026-01-08 09:34:45 +02:00
Pete Matsyburka 40f8093e00 adjust onnx 2026-01-07 14:50:27 +02:00
Pete Matsyburka 4375459856 add nmm 2026-01-07 12:19:05 +02:00
Pete Matsyburka b3eb8a70bf fix submitter uuid 2026-01-07 00:01:47 +02:00
Pete Matsyburka 6245a487b6 add kba countdown 2026-01-06 11:24:30 +02:00
Pete Matsyburka a89f4c1b10 adjust trim 2026-01-06 10:22:34 +02:00
Pete Matsyburka 7df729b8b5 containment threshold 2026-01-06 09:04:25 +02:00
Pete Matsyburka f1db7c7e82 adjust trim 2026-01-06 08:54:29 +02:00
Alex Turchyn 00af69cbf2 Merge from docusealco/wip 2026-01-05 21:46:19 +02:00
Pete Matsyburka c6819a19bd adjust inference 2026-01-05 18:36:50 +02:00
Pete Matsyburka f5cbcb3d24 adjust kba 2026-01-05 13:45:41 +02:00
Pete Matsyburka 6ca75c53ef fix condition 2026-01-05 12:46:43 +02:00
Pete Matsyburka e0ec443e10 fix mysql migration 2026-01-05 12:34:50 +02:00
Pete Matsyburka 9eed2fc2ef fix 2026-01-05 12:21:34 +02:00
Pete Matsyburka b9c6d3ff9f model v2 2026-01-05 09:18:38 +02:00
Pete Matsyburka 03c25a89b2 fix save 2026-01-04 13:16:57 +02:00
Pete Matsyburka f5b4a0c5ab inference v2 2026-01-03 16:55:05 +02:00
Pete Matsyburka b10ed46ccc add kba 2026-01-02 17:02:45 +02:00
Pete Matsyburka c55036a1b1 update gem 2025-12-31 23:27:41 +02:00
Pete Matsyburka 962a5dade5 add preview url 2025-12-31 22:32:27 +02:00
Pete Matsyburka 369a09a6df fix upload 2025-12-26 12:13:30 +02:00
Pete Matsyburka e9a39e2571 add s3 endpoint variable 2025-12-25 14:03:57 +02:00
Pete Matsyburka 1cf9cc18f1 do not retry 2025-12-23 10:17:19 +02:00
Pete Matsyburka 1984d4ec8d fix sync 2025-12-22 20:31:00 +02:00
50 changed files with 1359 additions and 224 deletions
+3
View File
@@ -48,6 +48,9 @@ Style/MultipleComparison:
Style/NumericPredicate:
Enabled: false
Style/MinMaxComparison:
Enabled: false
Naming/PredicateMethod:
Enabled: false
+6 -1
View File
@@ -9,7 +9,7 @@ RUN apk --no-cache add fontforge wget && \
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/1.1.0/model_704_int8.onnx" && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.1.0/model_704_int8.onnx" && \
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
@@ -53,6 +53,8 @@ WORKDIR /app
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\
[provider_sect]\n\
@@ -92,7 +94,10 @@ COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
RUN bundle exec bootsnap precompile -j 1 --gemfile app/ lib/
RUN chown -R docuseal:docuseal /app
WORKDIR /data/docuseal
ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal
EXPOSE 3000
+1 -1
View File
@@ -587,7 +587,7 @@ GEM
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uniform_notifier (1.16.0)
uri (1.0.3)
uri (1.1.1)
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
@@ -5,34 +5,17 @@ module Api
load_and_authorize_resource :submission
def index
is_merge = params[:merge] == 'true' &&
(@submission.schema_documents || @submission.template.schema_documents).size > 1
documents =
if @submission.submitters.all?(&:completed_at?)
last_submitter = @submission.submitters.max_by(&:completed_at)
if last_submitter.documents_attachments.blank?
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(last_submitter)
end
last_submitter.documents_attachments
build_completed_documents(@submission, merge: is_merge)
else
values_hash = Submissions::GeneratePreviewAttachments.build_values_hash(@submission)
if @submission.preview_documents.present? &&
@submission.preview_documents.all? { |s| s.metadata['values_hash'] == values_hash }
@submission.preview_documents
else
ApplicationRecord.no_touching do
@submission.preview_documents.each(&:destroy)
end
Submissions::GeneratePreviewAttachments.call(@submission, values_hash:)
end
build_preview_documents(@submission, merge: is_merge)
end
ActiveRecord::Associations::Preloader.new(
records: documents,
associations: [:blob]
).call
ActiveRecord::Associations::Preloader.new(records: documents, associations: [:blob]).call
expires_at = Accounts.link_expires_at(current_account)
@@ -43,5 +26,50 @@ module Api
end
}
end
private
def build_completed_documents(submission, merge: false)
last_submitter = submission.submitters.max_by(&:completed_at)
if merge
if submission.merged_document_attachment.blank?
submission.merged_document_attachment =
Submissions::GenerateCombinedAttachment.call(last_submitter, with_audit: false)
end
[submission.merged_document_attachment]
else
if last_submitter.documents_attachments.blank?
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(last_submitter)
end
last_submitter.documents_attachments
end
end
def build_preview_documents(submission, merge: false)
values_hash = Submissions::GeneratePreviewAttachments.build_values_hash(submission)
if merge
if submission.preview_merged_document_attachment.present? &&
submission.preview_merged_document_attachment.metadata['values_hash'] == values_hash
[submission.preview_merged_document_attachment]
else
ApplicationRecord.no_touching { submission.preview_merged_document_attachment&.destroy }
Submissions::GeneratePreviewAttachments.call(submission, values_hash:, merge: true)
end
elsif submission.preview_documents.present? &&
submission.preview_documents.all? { |s| s.metadata['values_hash'] == values_hash }
submission.preview_documents
else
ApplicationRecord.no_touching do
submission.preview_documents.each(&:destroy)
end
Submissions::GeneratePreviewAttachments.call(submission, values_hash:)
end
end
end
end
@@ -17,6 +17,7 @@ class ApplicationController < ActionController::Base
helper_method :button_title,
:current_account,
:true_ability,
:form_link_host,
:svg_icon
@@ -102,6 +103,10 @@ class ApplicationController < ActionController::Base
current_user&.account
end
def true_ability
@true_ability ||= Ability.new(true_user)
end
def maybe_redirect_to_setup
redirect_to setup_index_path unless User.exists?
end
+1 -1
View File
@@ -17,7 +17,7 @@ class SubmissionsController < ApplicationController
'number' => 'square_number_1', 'image' => 'photo', 'initials' => 'letter_case_upper',
'file' => 'paperclip', 'select' => 'select', 'checkbox' => 'checkbox', 'radio' => 'circle_dot',
'stamp' => 'rubber_stamp', 'cells' => 'columns_3', 'multiple' => 'checks', 'phone' => 'phone_check',
'payment' => 'credit_card', 'verification' => 'id'
'payment' => 'credit_card', 'verification' => 'id', 'kba' => 'user_scan'
}.freeze
def show
@@ -0,0 +1,56 @@
# frozen_string_literal: true
class TemplatesCloneController < ApplicationController
load_and_authorize_resource :template, instance_name: :base_template
def new
authorize!(:create, Template)
@template = Template.new(name: "#{@base_template.name} (#{I18n.t('clone')})")
end
def create
ActiveRecord::Associations::Preloader.new(
records: [@base_template],
associations: [schema_documents: :preview_images_attachments]
).call
@template = Templates::Clone.call(@base_template, author: current_user,
name: params.dig(:template, :name),
folder_name: params[:folder_name])
authorize!(:create, @template)
if params[:account_id].present? && true_ability.authorize!(:manage, Account.find(params[:account_id]))
@template.account_id = params[:account_id]
@template.author = true_user if true_user.account_id == @template.account_id
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
else
@template.account = current_account
end
Templates.maybe_assign_access(@template)
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template)
SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template)
else
render turbo_stream: turbo_stream.replace(:modal, partial: 'templates_clone/form'), status: :unprocessable_content
end
end
private
def maybe_redirect_to_template(template)
if template.account == current_account
redirect_to(edit_template_path(template))
else
redirect_back(fallback_location: root_path, notice: I18n.t('template_has_been_cloned'))
end
end
end
+5 -47
View File
@@ -3,8 +3,6 @@
class TemplatesController < ApplicationController
load_and_authorize_resource :template
before_action :load_base_template, only: %i[new create]
def show
submissions = @template.submissions.accessible_by(current_ability)
submissions = submissions.active if @template.archived_at.blank?
@@ -26,9 +24,7 @@ class TemplatesController < ApplicationController
redirect_to root_path
end
def new
@template.name = "#{@base_template.name} (#{I18n.t('clone')})" if @base_template
end
def new; end
def edit
ActiveRecord::Associations::Preloader.new(
@@ -48,37 +44,18 @@ class TemplatesController < ApplicationController
end
def create
if @base_template
ActiveRecord::Associations::Preloader.new(
records: [@base_template],
associations: [schema_documents: :preview_images_attachments]
).call
@template = Templates::Clone.call(@base_template, author: current_user,
name: params.dig(:template, :name),
folder_name: params[:folder_name])
else
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
end
if params[:account_id].present? && authorized_clone_account_id?(params[:account_id])
@template.account_id = params[:account_id]
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
else
@template.account = current_account
end
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
@template.account = current_account
Templates.maybe_assign_access(@template)
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template)
redirect_to(edit_template_path(@template))
else
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_content
end
@@ -132,23 +109,4 @@ class TemplatesController < ApplicationController
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
)
end
def authorized_clone_account_id?(account_id)
true_user.account_id.to_s == account_id.to_s ||
true_user.account.linked_accounts.accessible_by(current_ability).exists?(id: account_id)
end
def maybe_redirect_to_template(template)
if template.account == current_account
redirect_to(edit_template_path(@template))
else
redirect_back(fallback_location: root_path, notice: I18n.t('template_has_been_cloned'))
end
end
def load_base_template
return if params[:base_template_id].blank?
@base_template = Template.accessible_by(current_ability).find_by(id: params[:base_template_id])
end
end
+1
View File
@@ -159,6 +159,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
locale: this.dataset.locale,
withPhone: this.dataset.withPhone === 'true',
withVerification: ['true', 'false'].includes(this.dataset.withVerification) ? this.dataset.withVerification === 'true' : null,
withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null,
withLogo: this.dataset.withLogo !== 'false',
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
editable: this.dataset.editable !== 'false',
+17 -3
View File
@@ -60,6 +60,11 @@
class="object-contain mx-auto"
:src="stamp.url"
>
<img
v-else-if="field.type === 'kba' && kba"
class="object-contain mx-auto"
:src="kba.url"
>
<div
v-else-if="field.type === 'signature' && signature"
class="flex justify-between h-full gap-1 overflow-hidden w-full"
@@ -272,7 +277,7 @@
<script>
import MarkdownContent from './markdown_content'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconId } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconId, IconUserScan } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
@@ -391,7 +396,8 @@ export default {
stamp: this.t('stamp'),
payment: this.t('payment'),
phone: this.t('phone'),
verification: this.t('verify_id')
verification: this.t('verify_id'),
kba: this.t('kba')
}
},
strikethroughWidth () {
@@ -454,7 +460,8 @@ export default {
multiple: IconChecks,
phone: IconPhoneCheck,
payment: IconCreditCard,
verification: IconId
verification: IconId,
kba: IconUserScan
}
},
image () {
@@ -471,6 +478,13 @@ export default {
return null
}
},
kba () {
if (this.field.type === 'kba') {
return this.attachmentsIndex[this.modelValue]
} else {
return null
}
},
signature () {
if (this.field.type === 'signature') {
return this.attachmentsIndex[this.modelValue]
+16 -3
View File
@@ -465,6 +465,17 @@
@focus="scrollIntoField(currentField)"
@submit="!isSubmitting && submitStep()"
/>
<KbaStep
v-else-if="currentField.type === 'kba'"
ref="currentStep"
:key="currentField.uuid"
:submitter="submitter"
:empty-value-required-step="emptyValueRequiredStep"
:field="currentField"
:submitter-slug="submitterSlug"
:values="values"
@submit="!isSubmitting && submitStep()"
/>
<VerificationStep
v-else-if="currentField.type === 'verification'"
ref="currentStep"
@@ -480,7 +491,7 @@
/>
</div>
<div
v-if="(currentField.type !== 'payment' && currentField.type !== 'verification') || submittedValues[currentField.uuid]"
v-if="(currentField.type !== 'payment' && currentField.type !== 'verification' && currentField.type !== 'kba') || submittedValues[currentField.uuid]"
:class="currentField.type === 'signature' ? 'mt-2' : 'mt-4 md:mt-6'"
>
<button
@@ -569,6 +580,7 @@ import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step'
import PaymentStep from './payment_step'
import VerificationStep from './verification_step'
import KbaStep from './kba_step'
import TextStep from './text_step'
import NumberStep from './number_step'
import DateStep from './date_step'
@@ -613,6 +625,7 @@ export default {
AttachmentStep,
InitialsStep,
VerificationStep,
KbaStep,
InviteForm,
MultiSelectStep,
IconInnerShadowTop,
@@ -1004,7 +1017,7 @@ export default {
const verificationFields = []
const sortedFields = this.fields.reduce((acc, f) => {
if (f.type === 'verification') {
if (f.type === 'verification' || f.type === 'kba') {
verificationFields.push(f)
} else if (!f.readonly) {
acc.push(f)
@@ -1401,7 +1414,7 @@ export default {
const submitStep = this.currentStep
const stepPromise = ['signature', 'phone', 'initials', 'payment', 'verification'].includes(this.currentField.type)
const stepPromise = ['signature', 'phone', 'initials', 'payment', 'verification', 'kba'].includes(this.currentField.type)
? this.$refs.currentStep.submit
: () => Promise.resolve({})
+14
View File
@@ -1,4 +1,5 @@
const en = {
kba: 'KBA',
please_upload_an_image_file: 'Please upload an image file',
must_be_characters_length: 'Must be {number} characters length',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
@@ -100,6 +101,7 @@ const en = {
}
const es = {
kba: 'KBA',
please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
must_be_characters_length: 'Debe tener {number} caracteres de longitud',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
@@ -201,6 +203,7 @@ const es = {
}
const it = {
kba: 'KBA',
please_upload_an_image_file: 'Per favore carica un file immagine',
must_be_characters_length: 'Deve essere lungo {number} caratteri',
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
@@ -302,6 +305,7 @@ const it = {
}
const de = {
kba: 'KBA',
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
must_be_characters_length: 'Muss {number} Zeichen lang sein',
complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
@@ -403,6 +407,7 @@ const de = {
}
const fr = {
kba: 'KBA',
please_upload_an_image_file: 'Veuillez téléverser un fichier image',
must_be_characters_length: 'Doit comporter {number} caractères',
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
@@ -504,6 +509,7 @@ const fr = {
}
const pl = {
kba: 'KBA',
please_upload_an_image_file: 'Proszę przesłać plik obrazu',
must_be_characters_length: 'Musi mieć długość {number} znaków',
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
@@ -605,6 +611,7 @@ const pl = {
}
const uk = {
kba: 'KBA',
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
must_be_characters_length: 'Має містити {number} символів',
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
@@ -706,6 +713,7 @@ const uk = {
}
const cs = {
kba: 'KBA',
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
must_be_characters_length: 'Musí mít délku {number} znaků',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
@@ -807,6 +815,7 @@ const cs = {
}
const pt = {
kba: 'KBA',
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
must_be_characters_length: 'Deve ter {number} caracteres',
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
@@ -908,6 +917,7 @@ const pt = {
}
const he = {
kba: 'KBA',
please_upload_an_image_file: 'אנא העלה קובץ תמונה',
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
@@ -1009,6 +1019,7 @@ const he = {
}
const nl = {
kba: 'KBA',
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
must_be_characters_length: 'Moet {number} tekens lang zijn',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
@@ -1110,6 +1121,7 @@ const nl = {
}
const ar = {
kba: 'KBA',
please_upload_an_image_file: 'يرجى تحميل ملف صورة',
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
@@ -1211,6 +1223,7 @@ const ar = {
}
const ko = {
kba: 'KBA',
please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
must_be_characters_length: '{number}자여야 합니다',
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
@@ -1312,6 +1325,7 @@ const ko = {
}
const ja = {
kba: 'KBA',
please_upload_an_image_file: '画像ファイルをアップロードしてください',
must_be_characters_length: '{number}文字でなければなりません',
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
+576
View File
@@ -0,0 +1,576 @@
<template>
<label
v-if="!error"
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
>
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name || 'Knowledge Based Authentication' }}</template>
<span
v-if="questions"
class="float-right font-normal text-neutral-500 mt-1 whitespace-nowrap text-right text-sm"
>
<span>Question {{ currentQuestionIndex + 1 }} / {{ questions.length }}</span>
<span
v-if="timeLeftSeconds !== null"
class="block"
>
Time left: {{ formattedTimeLeft }}
</span>
</span>
</label>
<div
v-if="field.description"
dir="auto"
class="mb-4 px-1 field-description-text"
>
<MarkdownContent :string="field.description" />
</div>
<div
v-if="isRequiredFieldEmpty"
class="px-1 field-description-text"
>
{{ t('complete_all_required_fields_to_proceed_with_identity_verification') }}
</div>
<div
v-if="error"
class="mb-4 text-center"
>
<div class="text-xl mb-4">
{{ error }}
</div>
<button
class="base-button w-full flex justify-center submit-form-button"
@click="restartKba"
>
{{ questions ? 'Restart KBA' : 'Retry' }}
</button>
</div>
<div
v-if="isLoading"
class="w-full flex space-x-2 justify-center mb-2"
>
<IconInnerShadowTop
width="40"
class="animate-spin h-10"
/>
</div>
<div v-else-if="questions && !error">
<form @submit.prevent="nextQuestion">
<div class="mb-6 px-1">
<p class="font-semibold mb-4 text-lg">
{{ currentQuestion.prompt }}
</p>
<div class="space-y-3.5 mx-auto">
<div
v-for="(answer, index) in currentQuestion.answers"
:key="`${currentQuestion.id}_${index}`"
>
<label
:for="`${currentQuestion.id}_${index}`"
class="flex items-center space-x-3 radio-label"
>
<input
:id="`${currentQuestion.id}_${index}`"
v-model="answers[currentQuestion.id]"
type="radio"
:name="currentQuestion.id"
:value="answer.text"
class="base-radio !h-7 !w-7"
required
>
<span class="text-xl">{{ answer.text }}</span>
</label>
</div>
</div>
</div>
<div class="mt-6">
<button
type="submit"
class="base-button w-full flex justify-center submit-form-button"
:disabled="isSubmitting || !answers[currentQuestion.id]"
>
<span class="flex">
<IconInnerShadowTop
v-if="isSubmitting"
class="mr-1 animate-spin"
/>
<span>
{{ isLastQuestion ? (isSubmitting ? t('submitting_') : t('complete')) : t('next') }}
</span><span
v-if="isSubmitting"
class="w-6 flex justify-start mr-1"
><span>...</span></span>
</span>
</button>
</div>
</form>
</div>
<div v-else-if="!error && !isRequiredFieldEmpty">
<form @submit.prevent="startKba">
<div class="grid grid-cols-6 gap-x-2 md:gap-x-4 md:gap-y-2 mb-4">
<div class="col-span-3">
<label
for="kba_fn"
class="label text-sm md:text-base"
>First Name</label>
<input
id="kba_fn"
v-model="form.fn"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-3">
<label
for="kba_ln"
class="label text-sm md:text-base"
>Last Name</label>
<input
id="kba_ln"
v-model="form.ln"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-6">
<label
for="kba_addr"
class="label text-sm md:text-base"
>Address</label>
<input
id="kba_addr"
v-model="form.addr"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-2">
<label
for="kba_city"
class="label text-sm md:text-base"
>City</label>
<input
id="kba_city"
v-model="form.city"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-2">
<label
for="kba_state"
class="label text-sm md:text-base"
>State</label>
<select
id="kba_state"
v-model="form.state"
class="select select-bordered !h-10 min-h-[2.5rem] w-full bg-white font-normal !text-base"
required
>
<option
value=""
disabled
>
Select State
</option>
<option
v-for="state in states"
:key="state.code"
:value="state.code"
>
{{ state.name }}
</option>
</select>
</div>
<div class="col-span-2">
<label
for="kba_zip"
class="label text-sm md:text-base"
>Zip</label>
<input
id="kba_zip"
v-model="form.zip"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-3">
<label
for="kba_phone"
class="label text-sm md:text-base"
>Phone</label>
<input
id="kba_phone"
v-model="form.phone"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-3">
<label
for="kba_email"
class="label text-sm md:text-base"
>Email</label>
<input
id="kba_email"
v-model="form.email"
type="email"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
<div class="col-span-3">
<label
for="kba_dob"
class="label text-sm md:text-base"
>DOB</label>
<input
id="kba_dob"
v-model="form.dob"
type="date"
class="input input-bordered !h-10 md:w-full bg-white"
required
>
</div>
<div class="col-span-3">
<label
for="kba_ssn"
class="label text-sm md:text-base"
>SSN</label>
<input
id="kba_ssn"
v-model="form.ssn"
type="text"
class="input input-bordered !h-10 w-full bg-white"
required
>
</div>
</div>
<div class="mt-6">
<button
type="submit"
class="base-button w-full flex justify-center submit-form-button"
:disabled="isLoading"
>
<span class="flex">
<IconInnerShadowTop
v-if="isLoading"
class="mr-1 animate-spin"
/>
<span>
{{ isLoading ? 'Loading...' : 'Start Verification' }}
</span><span
v-if="isLoading"
class="w-6 flex justify-start mr-1"
><span>...</span></span>
</span>
</button>
</div>
</form>
</div>
</template>
<script>
import MarkdownContent from './markdown_content'
import { IconInnerShadowTop } from '@tabler/icons-vue'
export default {
name: 'KbaStep',
components: {
MarkdownContent,
IconInnerShadowTop
},
inject: ['baseUrl', 't'],
props: {
field: {
type: Object,
required: true
},
submitter: {
type: Object,
required: true
},
submitterSlug: {
type: String,
required: true
},
emptyValueRequiredStep: {
type: Object,
required: false,
default: null
},
values: {
type: Object,
required: true
}
},
emits: ['submit'],
data () {
return {
isLoading: false,
isSubmitting: false,
questions: null,
currentQuestionIndex: 0,
token: null,
reference: null,
answers: {},
error: null,
timeLeftSeconds: null,
countdownIntervalId: null,
form: {
fn: '',
ln: '',
addr: '',
city: '',
state: '',
zip: '',
dob: '',
ssn: '',
phone: '',
email: ''
}
}
},
computed: {
currentQuestion () {
return this.questions ? this.questions[this.currentQuestionIndex] : null
},
isRequiredFieldEmpty () {
return this.emptyValueRequiredStep && this.emptyValueRequiredStep[0] !== this.field
},
kbaTimeLimitSeconds () {
return 90
},
states () {
return [
{ code: 'AL', name: 'Alabama' },
{ code: 'AK', name: 'Alaska' },
{ code: 'AZ', name: 'Arizona' },
{ code: 'AR', name: 'Arkansas' },
{ code: 'CA', name: 'California' },
{ code: 'CO', name: 'Colorado' },
{ code: 'CT', name: 'Connecticut' },
{ code: 'DE', name: 'Delaware' },
{ code: 'DC', name: 'District of Columbia' },
{ code: 'FL', name: 'Florida' },
{ code: 'GA', name: 'Georgia' },
{ code: 'HI', name: 'Hawaii' },
{ code: 'ID', name: 'Idaho' },
{ code: 'IL', name: 'Illinois' },
{ code: 'IN', name: 'Indiana' },
{ code: 'IA', name: 'Iowa' },
{ code: 'KS', name: 'Kansas' },
{ code: 'KY', name: 'Kentucky' },
{ code: 'LA', name: 'Louisiana' },
{ code: 'ME', name: 'Maine' },
{ code: 'MD', name: 'Maryland' },
{ code: 'MA', name: 'Massachusetts' },
{ code: 'MI', name: 'Michigan' },
{ code: 'MN', name: 'Minnesota' },
{ code: 'MS', name: 'Mississippi' },
{ code: 'MO', name: 'Missouri' },
{ code: 'MT', name: 'Montana' },
{ code: 'NE', name: 'Nebraska' },
{ code: 'NV', name: 'Nevada' },
{ code: 'NH', name: 'New Hampshire' },
{ code: 'NJ', name: 'New Jersey' },
{ code: 'NM', name: 'New Mexico' },
{ code: 'NY', name: 'New York' },
{ code: 'NC', name: 'North Carolina' },
{ code: 'ND', name: 'North Dakota' },
{ code: 'OH', name: 'Ohio' },
{ code: 'OK', name: 'Oklahoma' },
{ code: 'OR', name: 'Oregon' },
{ code: 'PA', name: 'Pennsylvania' },
{ code: 'RI', name: 'Rhode Island' },
{ code: 'SC', name: 'South Carolina' },
{ code: 'SD', name: 'South Dakota' },
{ code: 'TN', name: 'Tennessee' },
{ code: 'TX', name: 'Texas' },
{ code: 'UT', name: 'Utah' },
{ code: 'VT', name: 'Vermont' },
{ code: 'VA', name: 'Virginia' },
{ code: 'WA', name: 'Washington' },
{ code: 'WV', name: 'West Virginia' },
{ code: 'WI', name: 'Wisconsin' },
{ code: 'WY', name: 'Wyoming' }
]
},
isLastQuestion () {
return this.questions && this.currentQuestionIndex === this.questions.length - 1
},
formattedTimeLeft () {
if (this.timeLeftSeconds === null) return ''
const minutes = Math.floor(this.timeLeftSeconds / 60)
const seconds = this.timeLeftSeconds % 60
return `${minutes}:${String(seconds).padStart(2, '0')}`
}
},
beforeUnmount () {
this.clearCountdown()
},
methods: {
clearCountdown () {
if (this.countdownIntervalId) {
clearInterval(this.countdownIntervalId)
this.countdownIntervalId = null
}
this.timeLeftSeconds = null
},
startCountdown () {
this.clearCountdown()
this.timeLeftSeconds = this.kbaTimeLimitSeconds
this.countdownIntervalId = setInterval(() => {
if (this.timeLeftSeconds === null) return
this.timeLeftSeconds -= 1
if (this.timeLeftSeconds <= 0) {
this.handleTimeout()
}
}, 1000)
},
handleTimeout () {
this.clearCountdown()
this.questions = null
this.token = null
this.reference = null
this.answers = {}
this.currentQuestionIndex = 0
this.error = `Knowledge Based Authentication timed out. You only have ${this.kbaTimeLimitSeconds} seconds to complete the verification. Please retry.`
},
nextQuestion () {
if (this.isLastQuestion) {
this.$emit('submit')
} else {
this.currentQuestionIndex++
}
},
restartKba () {
this.clearCountdown()
this.questions = null
this.token = null
this.reference = null
this.answers = {}
this.currentQuestionIndex = 0
this.error = null
},
async startKba () {
this.isLoading = true
this.error = null
this.clearCountdown()
try {
const payload = { ...this.form, submitter_slug: this.submitterSlug }
if (payload.dob) {
payload.dob = payload.dob.replace(/-/g, '')
}
if (payload.ssn) {
payload.ssn = payload.ssn.replace(/\D/g, '')
}
if (payload.phone) {
payload.phone = payload.phone.replace(/^\+1/, '')
}
const resp = await fetch(this.baseUrl + '/api/kba', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
if (!resp.ok) throw new Error('Failed to start KBA')
const data = await resp.json()
if (data.result && data.result.action === 'FAIL') {
if (data.result.detail === 'NO MATCH') {
throw new Error('Unfortunately, we were unable to start Knowledge Based Authentication with the details provided. Please review and confirm that all your personal details are correct.')
}
throw new Error(data.result.detail || 'KBA Start Failed')
}
if (data.output && data.output.questions && data.output.questions.questions) {
this.questions = data.output.questions.questions
this.token = data.continuations.questions.template.token
this.reference = data.meta.reference
this.questions.forEach(q => {
this.answers[q.id] = null
})
this.startCountdown()
} else {
throw new Error('Invalid KBA response')
}
} catch (e) {
this.error = e.message
} finally {
this.isLoading = false
}
},
async submit () {
this.clearCountdown()
this.isSubmitting = true
this.error = null
const formattedAnswers = Object.keys(this.answers).reduce((acc, key) => {
acc[key] = [this.answers[key]]
return acc
}, {})
try {
const resp = await fetch(this.baseUrl + `/api/kba/${this.field.uuid}`, {
method: 'PUT',
body: JSON.stringify({
token: this.token,
answers: formattedAnswers,
reference: this.reference,
submitter_slug: this.submitterSlug
}),
headers: { 'Content-Type': 'application/json' }
})
const data = await resp.json()
if (data.result?.action !== 'PASS') {
this.error = 'Knowledge Based Authentication Failed - make sure you provide correct answers for the Knowledge Based authentication.'
throw new Error('Knowledge Based Authentication Failed')
}
if (!resp.ok) {
this.error = 'Failed to submit answers'
throw new Error('Failed to submit answers')
}
return resp
} finally {
this.isSubmitting = false
}
}
}
}
</script>
+91 -14
View File
@@ -583,6 +583,7 @@ export default {
backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
withVerification: this.withVerification,
withKba: this.withKba,
withPayment: this.withPayment,
isPaymentConnected: this.isPaymentConnected,
withFormula: this.withFormula,
@@ -794,6 +795,11 @@ export default {
required: false,
default: null
},
withKba: {
type: Boolean,
required: false,
default: null
},
withPayment: {
type: Boolean,
required: false,
@@ -1110,14 +1116,17 @@ export default {
const sortArea = (aArea, bArea) => {
if (aArea.attachment_uuid === bArea.attachment_uuid) {
if (aArea.page === bArea.page) {
if (Math.abs(aArea.y - bArea.y) < 0.01) {
const aY = aArea.y + aArea.h
const bY = bArea.y + bArea.h
if (Math.abs(aY - bY) < 0.01 || (aArea.h < bArea.h ? (aArea.y >= bArea.y && aY <= bY) : (bArea.y >= aArea.y && bY <= aY))) {
if (aArea.x === bArea.x) {
return 0
} else {
return aArea.x - bArea.x
}
} else {
return aArea.y - bArea.y
return (aArea.y + aArea.h) - (bArea.y + bArea.h)
}
} else {
return aArea.page - bArea.page
@@ -1167,6 +1176,72 @@ export default {
this.save()
}
},
findFieldInsertIndex (field) {
if (!field.areas?.length) return -1
const area = field.areas[0]
const attachmentUuidsIndex = this.template.schema.reduce((acc, e, index) => {
acc[e.attachment_uuid] = index
return acc
}, {})
const compareAreas = (a, b) => {
const aAttIdx = attachmentUuidsIndex[a.attachment_uuid]
const bAttIdx = attachmentUuidsIndex[b.attachment_uuid]
if (aAttIdx !== bAttIdx) return aAttIdx - bAttIdx
if (a.page !== b.page) return a.page - b.page
const aY = a.y + a.h
const bY = b.y + b.h
if (Math.abs(aY - bY) < 0.01) return a.x - b.x
if (a.h < b.h ? a.y >= b.y && aY <= bY : b.y >= a.y && bY <= aY) return a.x - b.x
return aY - bY
}
let closestBeforeIndex = -1
let closestBeforeArea = null
let closestAfterIndex = -1
let closestAfterArea = null
this.template.fields.forEach((f, index) => {
if (f.submitter_uuid === field.submitter_uuid) {
(f.areas || []).forEach((a) => {
const cmp = compareAreas(a, area)
if (cmp < 0) {
if (!closestBeforeArea || (compareAreas(a, closestBeforeArea) > 0 && closestBeforeIndex < index)) {
closestBeforeIndex = index
closestBeforeArea = a
}
} else {
if (!closestAfterArea || (compareAreas(a, closestAfterArea) < 0 && closestAfterIndex > index)) {
closestAfterIndex = index
closestAfterArea = a
}
}
})
}
})
if (closestBeforeIndex !== -1) return closestBeforeIndex + 1
if (closestAfterIndex !== -1) return closestAfterIndex
return -1
},
insertField (field) {
const insertIndex = this.findFieldInsertIndex(field)
if (insertIndex !== -1) {
this.template.fields.splice(insertIndex, 0, field)
} else {
this.template.fields.push(field)
}
},
closeDropdown () {
document.activeElement.blur()
},
@@ -1220,7 +1295,7 @@ export default {
field.preferences.with_signature_id = this.withSignatureId
}
this.template.fields.push(field)
this.insertField(field)
this.save()
},
@@ -1449,11 +1524,13 @@ export default {
field.areas.push(area)
} else {
this.template.fields.push({
const newField = {
...JSON.parse(JSON.stringify(field)),
uuid: v4(),
areas: [area]
})
}
this.insertField(newField)
}
this.selectedAreaRef.value = area
@@ -1476,13 +1553,13 @@ export default {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const pageMask = documentRef.pageRefs[area.page].$refs.mask
if (type === 'checkbox') {
if (type === 'checkbox' || type === 'radio' || type === 'multiple') {
area.w = pageMask.clientWidth / 30 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 30 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (type === 'image') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (type === 'signature' || type === 'stamp' || type === 'verification') {
} else if (type === 'signature' || type === 'stamp' || type === 'verification' || type === 'kba') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight) / 2
} else if (type === 'initials') {
@@ -1538,7 +1615,7 @@ export default {
}
if (this.template.fields.indexOf(this.drawField) === -1) {
this.template.fields.push(this.drawField)
this.insertField(this.drawField)
}
this.drawField = null
@@ -1566,8 +1643,8 @@ export default {
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
if (previousArea || area.w) {
const areaW = previousArea?.w || (30 / pageMask.clientWidth)
const areaH = previousArea?.h || (30 / pageMask.clientHeight)
const areaW = previousArea?.w || area.w || (30 / pageMask.clientWidth)
const areaH = previousArea?.h || area.h || (30 / pageMask.clientHeight)
if ((pageMask.clientWidth * area.w) < 5) {
area.x = area.x - (areaW / 2)
@@ -1677,7 +1754,7 @@ export default {
this.selectedAreaRef.value = fieldArea
if (this.template.fields.indexOf(field) === -1) {
this.template.fields.push(field)
this.insertField(field)
}
this.save()
@@ -1707,7 +1784,7 @@ export default {
} else if (previousField?.areas?.length) {
baseArea = previousField.areas[previousField.areas.length - 1]
} else {
if (['checkbox'].includes(fieldType)) {
if (['checkbox', 'radio', 'multiple'].includes(fieldType)) {
baseArea = {
w: area.maskW / 30 / area.maskW,
h: area.maskW / 30 / area.maskW * (area.maskW / area.maskH)
@@ -1717,7 +1794,7 @@ export default {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH)
}
} else if (fieldType === 'signature' || fieldType === 'stamp' || fieldType === 'verification') {
} else if (fieldType === 'signature' || fieldType === 'stamp' || fieldType === 'verification' || fieldType === 'kba') {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
@@ -1839,7 +1916,7 @@ export default {
attachment.metadata.pdf.fields.forEach((field) => {
field.submitter_uuid = this.selectedSubmitter.uuid
this.template.fields.push(field)
this.insertField(field)
})
}
})
@@ -30,7 +30,7 @@
v-for="(icon, type) in fieldIconsSorted"
:key="type"
>
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))">
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification') && (withKba || type != 'kba'))">
<a
href="#"
class="text-sm py-1 px-2"
@@ -51,11 +51,11 @@
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconHeading, IconId, IconCalendarCheck, IconStrikethrough } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconHeading, IconId, IconCalendarCheck, IconStrikethrough, IconUserScan } from '@tabler/icons-vue'
export default {
name: 'FiledTypeDropdown',
inject: ['withPhone', 'withPayment', 'withVerification', 't', 'fieldTypes'],
inject: ['withPhone', 'withPayment', 'withVerification', 'withKba', 't', 'fieldTypes'],
props: {
modelValue: {
type: String,
@@ -114,7 +114,8 @@ export default {
stamp: this.t('stamp'),
payment: this.t('payment'),
phone: this.t('phone'),
verification: this.t('verify_id')
verification: this.t('verify_id'),
kba: this.t('kba')
}
},
fieldLabels () {
@@ -134,7 +135,8 @@ export default {
stamp: this.t('stamp_field'),
payment: this.t('payment_field'),
phone: this.t('phone_field'),
verification: this.t('verify_id')
verification: this.t('verify_id'),
kba: this.t('kba')
}
},
fieldIcons () {
@@ -157,7 +159,8 @@ export default {
stamp: IconRubberStamp,
payment: IconCreditCard,
phone: IconPhoneCheck,
verification: IconId
verification: IconId,
kba: IconUserScan
}
},
skipTypes () {
+8 -6
View File
@@ -114,7 +114,7 @@
:key="type"
>
<button
v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))"
v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification') && (withKba || type != 'kba'))"
:id="`${type}_type_field_button`"
draggable="true"
class="field-type-button group flex items-center justify-center border border-dashed w-full rounded relative fields-grid-item"
@@ -122,7 +122,7 @@
:class="drawFieldType === type ? 'border-base-content/40' : 'border-base-300 hover:border-base-content/20'"
@dragstart="onDragstart($event, { type: type })"
@dragend="$emit('drag-end')"
@click="['file', 'payment', 'verification'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)"
@click="['file', 'payment', 'verification', 'kba'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)"
>
<div
class="flex items-console transition-all cursor-grab h-full absolute left-0"
@@ -283,7 +283,7 @@ export default {
IconDrag,
IconLock
},
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withPayment', 't', 'fieldsDragFieldRef', 'baseFetch'],
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'baseFetch'],
props: {
fields: {
type: Array,
@@ -579,7 +579,7 @@ export default {
removeSubmitter (submitter) {
[...this.fields].forEach((field) => {
if (field.submitter_uuid === submitter.uuid) {
this.removeField(field)
this.removeField(field, false)
}
})
@@ -591,7 +591,7 @@ export default {
this.save()
},
removeField (field) {
removeField (field, save = true) {
this.fields.splice(this.fields.indexOf(field), 1)
this.fields.forEach((f) => {
@@ -610,7 +610,9 @@ export default {
})
})
this.save()
if (save) {
this.save()
}
}
}
}
@@ -27,7 +27,7 @@
</li>
<li>
<button
form="sync_form"
:form="`sync_form_${inputId}`"
type="submit"
:disabled="isLoading"
>
@@ -40,7 +40,7 @@
</li>
</ul>
<form
id="sync_form"
:id="`sync_form_${inputId}`"
ref="form"
class="hidden"
@submit.prevent="upload({ path: uploadUrl })"
+7
View File
@@ -1,4 +1,5 @@
const en = {
kba: 'KBA',
analyzing_: 'Analyzing...',
download: 'Download',
downloading_: 'Downloading...',
@@ -188,6 +189,7 @@ const en = {
}
const es = {
kba: 'KBA',
autodetect_fields: 'Autodetectar campos',
analyzing_: 'Analizando...',
download: 'Descargar',
@@ -377,6 +379,7 @@ const es = {
}
const it = {
kba: 'KBA',
autodetect_fields: 'Rileva campi',
analyzing_: 'Analisi...',
download: 'Scarica',
@@ -566,6 +569,7 @@ const it = {
}
const pt = {
kba: 'KBA',
autodetect_fields: 'Detectar campos',
analyzing_: 'Analisando...',
download: 'Baixar',
@@ -755,6 +759,7 @@ const pt = {
}
const fr = {
kba: 'KBA',
autodetect_fields: 'Détecter les champs',
analyzing_: 'Analyse...',
download: 'Télécharger',
@@ -944,6 +949,7 @@ const fr = {
}
const de = {
kba: 'KBA',
autodetect_fields: 'Felder erkennen',
analyzing_: 'Analysiere...',
download: 'Download',
@@ -1133,6 +1139,7 @@ const de = {
}
const nl = {
kba: 'KBA',
autodetect_fields: 'Velden detecteren',
analyzing_: 'Analyseren...',
download: 'Downloaden',
@@ -77,7 +77,7 @@ export default {
removeSubmitter (submitter) {
[...this.fields].forEach((field) => {
if (field.submitter_uuid === submitter.uuid) {
this.removeField(field)
this.removeField(field, false)
}
})
@@ -89,10 +89,12 @@ export default {
this.save()
},
removeField (field) {
removeField (field, save = true) {
this.fields.splice(this.fields.indexOf(field), 1)
this.save()
if (save) {
this.save()
}
}
}
}
@@ -59,7 +59,7 @@
v-for="(icon, type) in fieldIconsSorted"
:key="type"
>
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))">
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification') && (withKba || type != 'kba'))">
<a
href="#"
class="text-sm py-1 px-2"
@@ -89,7 +89,7 @@ export default {
IconPlus,
IconX
},
inject: ['withPhone', 'withPayment', 'withVerification', 'backgroundColor', 't'],
inject: ['withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor', 't'],
props: {
modelValue: {
type: String,
+4 -1
View File
@@ -216,6 +216,9 @@ export default {
queryParams () {
return new URLSearchParams(window.location.search)
},
uploadUrl () {
return `/templates/${this.templateId}/documents`
},
googleDriveOauthPath () {
const params = {
access_type: 'offline',
@@ -279,7 +282,7 @@ export default {
async upload ({ path } = {}) {
this.isLoading = true
return this.baseFetch(path || `/templates/${this.templateId}/documents`, {
return this.baseFetch(path || this.uploadUrl, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(this.$refs.form)
+10 -3
View File
@@ -39,11 +39,18 @@ class ProcessSubmitterCompletionJob
submission = submitter.submission
complete_verification_events, sms_events =
submitter.submission_events.where(event_type: %i[send_sms send_2fa_sms complete_verification])
.partition { |e| e.event_type == 'complete_verification' }
submitter.submission_events.where(event_type: %i[send_sms send_2fa_sms complete_verification complete_kba])
.partition { |e| e.event_type == 'complete_verification' || e.event_type == 'complete_kba' }
complete_verification_event = complete_verification_events.first
verification_method =
if complete_verification_event&.event_type == 'complete_kba'
'kba'
elsif complete_verification_event
complete_verification_event.data['method']
end
completed_submitter.assign_attributes(
submission_id: submitter.submission_id,
account_id: submission.account_id,
@@ -51,7 +58,7 @@ class ProcessSubmitterCompletionJob
template_id: submission.template_id,
source: submission.source,
sms_count: sms_events.sum { |e| e.data['segments'] || 1 },
verification_method: complete_verification_event&.data&.dig('method'),
verification_method:,
completed_at: submitter.completed_at
)
+2
View File
@@ -63,6 +63,8 @@ class Submission < ApplicationRecord
has_one_attached :audit_trail
has_one_attached :combined_document
has_one_attached :merged_document
has_one_attached :preview_merged_document
has_many_attached :preview_documents
has_many_attached :documents
+3
View File
@@ -57,6 +57,9 @@ class SubmissionEvent < ApplicationRecord
start_form: 'start_form',
start_verification: 'start_verification',
complete_verification: 'complete_verification',
start_kba: 'start_kba',
complete_kba: 'complete_kba',
fail_kba: 'fail_kba',
view_form: 'view_form',
invite_party: 'invite_party',
complete_form: 'complete_form',
+1 -1
View File
@@ -235,7 +235,7 @@
<% end %>
<% end %>
<% end %>
<% if can?(:manage, :personalization_advanced) %>
<% if !Docuseal.multitenant? || can?(:manage, :personalization_advanced) %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::WITH_FILE_LINKS_KEY) %>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M10 9a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M4 8v-2a2 2 0 0 1 2 -2h2" /><path d="M4 16v2a2 2 0 0 0 2 2h2" /><path d="M16 4h2a2 2 0 0 1 2 2v2" /><path d="M16 20h2a2 2 0 0 0 2 -2v-2" /><path d="M8 16a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2" />
</svg>

After

Width:  |  Height:  |  Size: 525 B

+2 -2
View File
@@ -13,10 +13,10 @@
<meta property="og:image" content="">
<meta name="twitter:image" content="">
<% else %>
<meta property="og:image" content="<%= root_url %>preview.png">
<meta property="og:image" content="<%= content_for(:preview_image_url).presence || "#{root_url}preview.png" %>">
<meta property="og:image:width" content="800">
<meta property="og:image:height" content="800">
<meta name="twitter:image" content="<%= root_url %>preview.png">
<meta name="twitter:image" content="<%= content_for(:preview_image_url).presence || "#{root_url}preview.png" %>">
<% end %>
<meta name="twitter:card" content="summary">
<meta name="twitter:creator" content="@docusealco">
+1 -1
View File
@@ -33,7 +33,7 @@
</div>
<% end %>
</div>
<% elsif field['type'].in?(['image', 'initials', 'stamp']) && attachments_index[value].image? %>
<% elsif field['type'].in?(['image', 'initials', 'stamp', 'kba']) && attachments_index[value].image? %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'].in?(['file', 'payment', 'image']) %>
<autosize-field></autosize-field>
+1 -1
View File
@@ -262,7 +262,7 @@
<div class="w-full bg-base-300 py-1">
<img class="object-contain mx-auto" style="max-height: <%= field['type'] == 'signature' ? 100 : 50 %>px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
</div>
<% elsif field['type'].in?(['image', 'stamp']) && attachments_index[value].image? %>
<% elsif field['type'].in?(['image', 'stamp', 'kba']) && attachments_index[value].image? %>
<img class="object-contain mx-auto max-h-28" style="max-height: 200px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'].in?(['file', 'payment', 'image']) %>
<div class="flex flex-col justify-center">
+2 -10
View File
@@ -1,12 +1,4 @@
<%= form_for @template, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %>
<% if @base_template %>
<%= hidden_field_tag :base_template_id, @base_template.id %>
<% end %>
<% if @base_template && (can?(:manage, :tenants) || true_user != current_user) && true_user.account.linked_accounts.active.accessible_by(current_ability).exists? %>
<div class="form-control -mb-2 mt-2">
<%= select_tag :account_id, options_for_select([true_user.account, *true_user.account.linked_accounts.active.accessible_by(current_ability)].uniq.map { |e| [e.name, e.id] }, current_account.id), required: true, class: 'base-select' %>
</div>
<% end %>
<div class="form-control mt-4">
<%= f.text_field :name, required: true, placeholder: t('document_name'), class: 'base-input', dir: 'auto' %>
</div>
@@ -16,7 +8,7 @@
</label>
<folder-autocomplete class="flex justify-between w-full">
<set-value data-on="blur" data-value="<%= TemplateFolder::DEFAULT_NAME %>" data-empty-only="true" class="peer w-full whitespace-nowrap">
<input id="folder_name" placeholder="<%= t('folder_name') %>" type="text" class="w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1" name="folder_name" value="<%= params[:folder_name].presence || @base_template&.folder&.full_name || TemplateFolder::DEFAULT_NAME %>" autocomplete="off">
<input id="folder_name" placeholder="<%= t('folder_name') %>" type="text" class="w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1" name="folder_name" value="<%= params[:folder_name].presence || TemplateFolder::DEFAULT_NAME %>" autocomplete="off">
</set-value>
<set-value data-on="click" data-value="" data-input-id="folder_name" class="peer-focus-within:hidden whitespace-nowrap">
<label for="folder_name" data-clear-on-focus="true" class="shrink-0 link mr-1.5 cursor-pointer">
@@ -26,6 +18,6 @@
</folder-autocomplete>
</div>
<div class="form-control">
<%= f.button button_title(title: @base_template ? t('submit') : t('create'), disabled_with: t('creating')), class: 'base-button' %>
<%= f.button button_title(title: t('create'), disabled_with: t('creating')), class: 'base-button' %>
</div>
<% end %>
+1 -1
View File
@@ -53,7 +53,7 @@
<% end %>
<% if can?(:create, template) %>
<span class="tooltip tooltip-left" data-tip="<%= t('clone') %>">
<a href="<%= new_template_path(base_template_id: template.id) %>" data-turbo-frame="modal" class="btn btn-xs hover:btn-outline bg-base-200 btn-circle">
<a href="<%= new_template_clone_path(template) %>" data-turbo-frame="modal" class="btn btn-xs hover:btn-outline bg-base-200 btn-circle">
<%= svg_icon('copy', class: 'w-4 h-4') %>
</a>
</span>
+1 -1
View File
@@ -63,7 +63,7 @@
<%= button_to button_title(title: t('archive'), disabled_with: t('archiving'), title_class: 'inline', icon: svg_icon('archive', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm w-full', form_class: 'flex-1', method: :delete, data: { turbo_confirm: t('are_you_sure_') } %>
<% end %>
<% if can?(:create, current_account.templates.new(author: current_user)) %>
<%= link_to new_template_path(base_template_id: template.id), class: 'btn btn-outline btn-sm flex-1', data: { turbo_frame: :modal } do %>
<%= link_to new_template_clone_path(template), class: 'btn btn-outline btn-sm flex-1', data: { turbo_frame: :modal } do %>
<span class="flex items-center justify-center space-x-2">
<%= svg_icon('copy', class: 'w-6 h-6') %>
<span class="inline"><%= t('clone') %></span>
+29
View File
@@ -0,0 +1,29 @@
<%= form_for @template, url: template_clone_index_path(@base_template), data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %>
<% accounts = Account.accessible_by(true_ability).where.not(id: true_user.account.testing_accounts).where(User.where(User.arel_table[:account_id].eq(Account.arel_table[:id])).arel.exists).active %>
<% if (can?(:manage, :tenants) || true_user != current_user) && accounts.where.not(id: current_account.id).exists? %>
<div class="form-control -mb-2 mt-2">
<%= select_tag :account_id, options_for_select([current_account, *accounts.order(:name)].uniq.map { |e| [e.name, e.id] }, current_account.id), required: true, class: 'base-select' %>
</div>
<% end %>
<div class="form-control mt-4">
<%= f.text_field :name, required: true, placeholder: t('document_name'), class: 'base-input', dir: 'auto' %>
</div>
<div class="mt-3 mb-4 flex items-center justify-between">
<label for="folder_name" class="cursor-pointer">
<%= svg_icon('folder', class: 'w-6 h-6') %>
</label>
<folder-autocomplete class="flex justify-between w-full">
<set-value data-on="blur" data-value="<%= TemplateFolder::DEFAULT_NAME %>" data-empty-only="true" class="peer w-full whitespace-nowrap">
<input id="folder_name" placeholder="<%= t('folder_name') %>" type="text" class="w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1" name="folder_name" value="<%= params[:folder_name].presence || @base_template&.folder&.full_name || TemplateFolder::DEFAULT_NAME %>" autocomplete="off">
</set-value>
<set-value data-on="click" data-value="" data-input-id="folder_name" class="peer-focus-within:hidden whitespace-nowrap">
<label for="folder_name" data-clear-on-focus="true" class="shrink-0 link mr-1.5 cursor-pointer">
<%= t('change_folder') %>
</label>
</set-value>
</folder-autocomplete>
</div>
<div class="form-control">
<%= f.button button_title(title: t('submit'), disabled_with: t('creating')), class: 'base-button' %>
</div>
<% end %>
+3
View File
@@ -0,0 +1,3 @@
<%= render 'shared/turbo_modal', title: t('clone_template') do %>
<%= render 'templates_clone/form' %>
<% end %>
+32
View File
@@ -1,6 +1,20 @@
# frozen_string_literal: true
if ENV['RAILS_ENV'] == 'production'
if Process.uid.zero?
begin
workdir = ENV.fetch('WORKDIR', '.')
if File.exist?(workdir) && File.stat(workdir).uid != 2000
puts 'Changing the owner of the docuseal directory...' unless Dir.empty?(workdir)
FileUtils.chown_R(2000, 2000, workdir)
end
rescue StandardError
puts 'Unable to change docuseal directory owner'
end
end
if !ENV['AWS_SECRET_MANAGER_ID'].to_s.empty?
require 'aws-sdk-secretsmanager'
@@ -30,12 +44,30 @@ if ENV['RAILS_ENV'] == 'production'
File.write(dotenv_path, default_env)
end
if Process.uid.zero?
begin
File.chown(0, 0, dotenv_path)
File.chmod(0o600, dotenv_path)
rescue StandardError
puts 'Unable to set dotenv mod'
end
end
database_url = ENV.fetch('DATABASE_URL', nil)
Dotenv.load(dotenv_path)
ENV['DATABASE_URL'] = ENV['DATABASE_URL'].to_s.empty? ? database_url : ENV.fetch('DATABASE_URL', nil)
end
unless Process.uid == 2000
begin
Process::Sys.setgid(2000)
Process::Sys.setuid(2000)
rescue StandardError
puts 'Unable to run as 2000:2000'
end
end
end
if ENV['DATABASE_URL'].to_s.split('@').last.to_s.split('/').first.to_s.include?('_')
+42
View File
@@ -677,6 +677,9 @@ en: &en
payment_field: Payment Field
phone_field: Phone Field
verification_field: Identity Verification
knowledge_based_authentication: Knowledge Based Authentication
kba_field: KBA
passed: Passed
identity_verification: Identity verification
paid_price: 'Paid %{price}'
verified: Verified
@@ -910,6 +913,9 @@ en: &en
complete_form_by_html: '<b>Submission completed</b> by %{submitter_name}'
start_verification_by_html: '<b>Identity verification started</b> by %{submitter_name}'
complete_verification_by_html: '<b>Identity verification completed</b> by %{submitter_name} with %{provider}'
start_kba_by_html: '<b>KBA started</b> by %{submitter_name}'
complete_kba_by_html: '<b>KBA completed</b> by %{submitter_name}'
fail_kba_by_html: '<b>KBA failed</b> by %{submitter_name}'
api_complete_form_by_html: '<b>Submission completed via API</b> by %{submitter_name}'
import_list:
select_worksheet: Select Worksheet
@@ -992,6 +998,9 @@ en: &en
range_without_total: "%{from}-%{to} events"
es: &es
knowledge_based_authentication: Autenticación basada en el conocimiento
kba_field: KBA
passed: Aprobado
templates_that_require_email_or_phone_2fa_cannot_be_used_via_a_shared_link: Las plantillas que requieren autenticación en dos pasos por correo electrónico o teléfono no se pueden usar mediante un enlace compartido.
make_owner: Hacer propietario
billing: Facturación
@@ -1883,6 +1892,9 @@ es: &es
api_complete_form_by_html: '<b>Envío completado vía API</b> por %{submitter_name}'
start_verification_by_html: '<b>Verificación de identidad iniciada</b> por %{submitter_name}'
complete_verification_by_html: '<b>Verificación de identidad completada</b> por %{submitter_name} con %{provider}'
start_kba_by_html: '<b>KBA iniciado</b> por %{submitter_name}'
complete_kba_by_html: '<b>KBA completado</b> por %{submitter_name}'
fail_kba_by_html: '<b>KBA fallido</b> por %{submitter_name}'
import_list:
select_worksheet: Seleccionar hoja de cálculo
open: Abrir
@@ -1964,6 +1976,9 @@ es: &es
range_without_total: "%{from}-%{to} eventos"
it: &it
knowledge_based_authentication: Autenticazione basata sulla conoscenza
kba_field: KBA
passed: Superato
templates_that_require_email_or_phone_2fa_cannot_be_used_via_a_shared_link: I modelli che richiedono 2FA via email o telefono non possono essere utilizzati tramite un link condiviso.
make_owner: Rendi proprietario
billing: Fatturazione
@@ -2856,6 +2871,9 @@ it: &it
api_complete_form_by_html: '<b>Invio completato tramite API</b> da %{submitter_name}'
start_verification_by_html: "<b>Verifica dell'identità iniziata</b> da %{submitter_name}"
complete_verification_by_html: "<b>Verifica dell'identità completata</b> da %{submitter_name} con %{provider}"
start_kba_by_html: "<b>KBA avviata</b> da %{submitter_name}"
complete_kba_by_html: "<b>KBA completata</b> da %{submitter_name}"
fail_kba_by_html: "<b>KBA non riuscita</b> da %{submitter_name}"
import_list:
select_worksheet: Seleziona il foglio di lavoro
open: Apri
@@ -2937,6 +2955,9 @@ it: &it
range_without_total: "%{from}-%{to} eventi"
fr: &fr
knowledge_based_authentication: Authentification basée sur la connaissance
kba_field: KBA
passed: Réussi
templates_that_require_email_or_phone_2fa_cannot_be_used_via_a_shared_link: Les modèles nécessitant une authentification à deux facteurs par e-mail ou téléphone ne peuvent pas être utilisés via un lien partagé.
make_owner: Rendre propriétaire
billing: Facturation
@@ -3825,6 +3846,9 @@ fr: &fr
start_verification_by_html: "<b>Vérification didentité démarrée</b> par %{submitter_name}"
complete_verification_by_html: "<b>Vérification didentité terminée</b> par %{submitter_name} avec %{provider}"
api_complete_form_by_html: "<b>Soumission terminée via API</b> par %{submitter_name}"
start_kba_by_html: "<b>KBA démarré</b> par %{submitter_name}"
complete_kba_by_html: "<b>KBA terminé</b> par %{submitter_name}"
fail_kba_by_html: "<b>KBA échoué</b> par %{submitter_name}"
import_list:
select_worksheet: Sélectionner une feuille
open: Ouvrir
@@ -3906,6 +3930,9 @@ fr: &fr
range_without_total: "%{from}-%{to} événements"
pt: &pt
knowledge_based_authentication: Autenticação baseada em conhecimento
kba_field: KBA
passed: Aprovado
templates_that_require_email_or_phone_2fa_cannot_be_used_via_a_shared_link: Modelos que exigem 2FA por e-mail ou telefone não podem ser usados por meio de um link compartilhado.
make_owner: Tornar proprietário
billing: Pagamentos
@@ -4797,6 +4824,9 @@ pt: &pt
start_verification_by_html: '<b>Verificação de identidade iniciada</b> por %{submitter_name}'
complete_verification_by_html: '<b>Verificação de identidade concluída</b> por %{submitter_name} com %{provider}'
api_complete_form_by_html: '<b>Submissão concluída via API</b> por %{submitter_name}'
start_kba_by_html: '<b>KBA iniciada</b> por %{submitter_name}'
complete_kba_by_html: '<b>KBA concluída</b> por %{submitter_name}'
fail_kba_by_html: '<b>KBA reprovada</b> por %{submitter_name}'
import_list:
select_worksheet: Selecionar planilha
open: Abrir
@@ -4878,6 +4908,9 @@ pt: &pt
range_without_total: "%{from}-%{to} eventos"
de: &de
knowledge_based_authentication: Wissensbasierte Authentifizierung
kba_field: KBA
passed: Bestanden
templates_that_require_email_or_phone_2fa_cannot_be_used_via_a_shared_link: Vorlagen, die eine Zwei-Faktor-Authentifizierung per E-Mail oder Telefon erfordern, können nicht über einen geteilten Link verwendet werden.
make_owner: Eigentümer machen
billing: Abrechnung
@@ -5769,6 +5802,9 @@ de: &de
start_verification_by_html: '<b>Identitätsüberprüfung gestartet</b> von %{submitter_name}'
complete_verification_by_html: '<b>Identitätsüberprüfung abgeschlossen</b> von %{submitter_name} mit %{provider}'
api_complete_form_by_html: '<b>Einreichung über API abgeschlossen</b> von %{submitter_name}'
start_kba_by_html: '<b>KBA gestartet</b> von %{submitter_name}'
complete_kba_by_html: '<b>KBA abgeschlossen</b> von %{submitter_name}'
fail_kba_by_html: '<b>KBA fehlgeschlagen</b> von %{submitter_name}'
import_list:
select_worksheet: Arbeitsblatt auswählen
open: Öffnen
@@ -6238,6 +6274,9 @@ he:
too_many_requests_try_again_later: יותר מדי בקשות. נסה שוב מאוחר יותר.
nl: &nl
knowledge_based_authentication: Kennisgebaseerde authenticatie
kba_field: KBA
passed: Geslaagd
templates_that_require_email_or_phone_2fa_cannot_be_used_via_a_shared_link: Sjablonen waarvoor e-mail- of telefoon-2FA vereist is, kunnen niet via een gedeelde link worden gebruikt.
make_owner: Eigenaar maken
billing: Facturatie
@@ -7126,6 +7165,9 @@ nl: &nl
start_verification_by_html: "<b>Identiteitsverificatie gestart</b> door %{submitter_name}"
complete_verification_by_html: "<b>Identiteitsverificatie voltooid</b> door %{submitter_name} met %{provider}"
api_complete_form_by_html: "<b>Inzending via API voltooid</b> door %{submitter_name}"
start_kba_by_html: "<b>KBA gestart</b> door %{submitter_name}"
complete_kba_by_html: "<b>KBA voltooid</b> door %{submitter_name}"
fail_kba_by_html: "<b>KBA mislukt</b> door %{submitter_name}"
import_list:
select_worksheet: Werkblad selecteren
open: Openen
+1
View File
@@ -92,6 +92,7 @@ Rails.application.routes.draw do
resources :templates, only: %i[index], controller: 'templates_dashboard'
resources :submissions_filters, only: %i[show], param: 'name'
resources :templates, only: %i[new create edit update show destroy] do
resources :clone, only: %i[new create], controller: 'templates_clone'
resource :debug, only: %i[show], controller: 'templates_debug' if Rails.env.development?
resources :documents, only: %i[index create], controller: 'template_documents'
resources :clone_and_replace, only: %i[create], controller: 'templates_clone_and_replace'
+4
View File
@@ -9,6 +9,10 @@ aws_s3:
secret_access_key: <%= ENV['AWS_SECRET_ACCESS_KEY'] %>
region: <%= ENV['AWS_REGION'] || 'us-east-1' %>
bucket: <%= ENV['S3_ATTACHMENTS_BUCKET'] %>
<% if !ENV['S3_ENDPOINT'].to_s.empty? %>
endpoint: <%= ENV['S3_ENDPOINT'] %>
force_path_style: true
<% end %>
public: <%= ENV['ACTIVE_STORAGE_PUBLIC'] == 'true' %>
upload:
cache_control: 'public, max-age=31536000'
@@ -6,7 +6,7 @@ class CreateLockEvents < ActiveRecord::Migration[8.0]
t.string :key, index: true, null: false
t.string :event_name, null: false
t.index %i[event_name key], unique: true, where: "event_name IN ('start', 'complete')"
t.index %i[event_name key], unique: adapter_name != 'Mysql2', where: "event_name IN ('start', 'complete')"
t.timestamps
end
+4 -1
View File
@@ -17,6 +17,7 @@ module Submissions
raise NotCompletedYet unless submission.submitters.all?(&:completed_at?)
total_wait_time ||= 0
key = [KEY_PREFIX, submission.id].join(':')
if ApplicationRecord.uncached { LockEvent.exists?(key:, event_name: :complete) }
@@ -39,7 +40,9 @@ module Submissions
rescue ActiveRecord::RecordNotUnique
sleep WAIT_FOR_RETRY
retry
total_wait_time += WAIT_FOR_RETRY
total_wait_time > CHECK_COMPLETE_TIMEOUT ? raise : retry
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
Rails.logger.error(e)
+3 -1
View File
@@ -17,6 +17,7 @@ module Submissions
raise NotCompletedYet unless submitter.completed_at?
total_wait_time ||= 0
key = [KEY_PREFIX, submitter.id].join(':')
if ApplicationRecord.uncached { LockEvent.exists?(key:, event_name: :complete) }
@@ -38,8 +39,9 @@ module Submissions
end
rescue ActiveRecord::RecordNotUnique
sleep WAIT_FOR_RETRY
total_wait_time += WAIT_FOR_RETRY
retry
total_wait_time > CHECK_COMPLETE_TIMEOUT ? raise : retry
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
Rails.logger.error(e)
+3 -1
View File
@@ -16,6 +16,7 @@ module Submissions
raise NotCompletedYet unless submitter.completed_at?
total_wait_time ||= 0
key = ['result_attachments', submitter.id].join(':')
return submitter.documents if ApplicationRecord.uncached { LockEvent.exists?(key:, event_name: :complete) }
@@ -35,8 +36,9 @@ module Submissions
end
rescue ActiveRecord::RecordNotUnique
sleep WAIT_FOR_RETRY
total_wait_time += WAIT_FOR_RETRY
retry
total_wait_time > CHECK_COMPLETE_TIMEOUT ? raise : retry
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
Rails.logger.error(e)
+9 -1
View File
@@ -265,6 +265,11 @@ module Submissions
e['type'] == 'verification' && e['submitter_uuid'] == submitter.uuid && submitter.values[e['uuid']].present?
end
is_kba_passed =
submission.template_fields.any? do |e|
e['type'] == 'kba' && e['submitter_uuid'] == submitter.uuid && submitter.values[e['uuid']].present?
end
info_rows = [
[
composer.document.layout.formatted_text_box(
@@ -288,6 +293,9 @@ module Submissions
is_id_verified && {
text: "#{I18n.t('identity_verification')}: #{I18n.t('verified')}\n"
},
is_kba_passed && {
text: "#{I18n.t('knowledge_based_authentication')}: #{I18n.t('passed')}\n"
},
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" },
@@ -353,7 +361,7 @@ module Submissions
text_align: field_name.to_s.match?(RTL_REGEXP) ? :right : :left,
line_spacing: 1.3, padding: [0, 0, 2, 0]
),
if field['type'].in?(%w[image signature initials stamp]) &&
if field['type'].in?(%w[image signature initials stamp kba]) &&
(attachment = submitter.attachments.find { |a| a.uuid == value }) &&
attachment.image?
@@ -4,8 +4,8 @@ module Submissions
module GenerateCombinedAttachment
module_function
def call(submitter)
pdf = build_combined_pdf(submitter)
def call(submitter, with_audit: true)
pdf = build_combined_pdf(submitter, with_audit:)
submission = submitter.submission
account = submission.account
@@ -39,7 +39,7 @@ module Submissions
blob: ActiveStorage::Blob.create_and_upload!(
io: io.tap(&:rewind), filename: "#{submission.name || submission.template.name}.pdf"
),
name: 'combined_document',
name: with_audit ? 'combined_document' : 'merged_document',
record: submission
)
end
@@ -58,14 +58,16 @@ module Submissions
pdf.sign(io, write_options: { validate: false }, **sign_params)
end
def build_combined_pdf(submitter)
def build_combined_pdf(submitter, with_audit:)
pdfs_index = Submissions::GenerateResultAttachments.generate_pdfs(submitter)
audit_trail = I18n.with_locale(submitter.account.locale) do
Submissions::GenerateAuditTrail.build_audit_trail(submitter.submission)
end
if with_audit
audit_trail = I18n.with_locale(submitter.account.locale) do
Submissions::GenerateAuditTrail.build_audit_trail(submitter.submission)
end
audit_trail.dispatch_message(:complete_objects)
audit_trail.dispatch_message(:complete_objects)
end
result = HexaPDF::Document.new
@@ -79,7 +81,7 @@ module Submissions
pdf.pages.each { |page| result.pages << result.import(page) }
end
audit_trail.pages.each { |page| result.pages << result.import(page) }
audit_trail&.pages&.each { |page| result.pages << result.import(page) }
result
end
+63 -36
View File
@@ -5,7 +5,7 @@ module Submissions
module_function
# rubocop:disable Metrics
def call(submission, values_hash: nil, submitter: nil)
def call(submission, values_hash: nil, submitter: nil, merge: false)
values_hash ||= if submitter
build_submitter_values_hash(submitter)
else
@@ -42,48 +42,74 @@ module Submissions
template = submission.template
image_pdfs = []
original_documents = submission.schema_documents.preload(:blob)
if merge
result = HexaPDF::Document.new
result_attachments =
(submission.template_schema || template.schema).filter_map do |item|
(submission.template_schema || template.schema).each do |item|
pdf = pdfs_index[item['attachment_uuid']]
next if pdf.nil?
next unless pdf
if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
pdf = GenerateResultAttachments.normalize_image_pdf(pdf)
pdf.dispatch_message(:complete_objects)
image_pdfs << pdf
end
build_pdf_attachment(pdf:, submission:, submitter:,
uuid: item['attachment_uuid'],
values_hash:,
name: item['name'])
pdf.pages.each { |page| result.pages << result.import(page) }
end
return ApplicationRecord.no_touching { result_attachments.map { |e| e.tap(&:save!) } } if image_pdfs.size < 2
images_pdf =
image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc|
pdf.pages.each { |page| doc.pages << doc.import(page) }
end
images_pdf = GenerateResultAttachments.normalize_image_pdf(images_pdf)
images_pdf_attachment =
build_pdf_attachment(
pdf: images_pdf,
attachment = build_pdf_attachment(
pdf: result,
submission:,
submitter:,
uuid: GenerateResultAttachments.images_pdf_uuid(original_documents.select(&:image?)),
values_hash:,
name: submission.name || template.name
name: 'preview_merged_document',
filename: "#{submission.name || template.name}.pdf"
)
ApplicationRecord.no_touching do
(result_attachments + [images_pdf_attachment]).map { |e| e.tap(&:save!) }
ApplicationRecord.no_touching { attachment.save! }
[attachment]
else
image_pdfs = []
original_documents = submission.schema_documents.preload(:blob)
result_attachments =
(submission.template_schema || template.schema).filter_map do |item|
pdf = pdfs_index[item['attachment_uuid']]
next if pdf.nil?
if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
pdf = GenerateResultAttachments.normalize_image_pdf(pdf)
image_pdfs << pdf
end
build_pdf_attachment(pdf:, submission:, submitter:,
uuid: item['attachment_uuid'],
values_hash:,
filename: "#{item['name']}.pdf")
end
return ApplicationRecord.no_touching { result_attachments.map { |e| e.tap(&:save!) } } if image_pdfs.size < 2
images_pdf =
image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc|
pdf.pages.each { |page| doc.pages << doc.import(page) }
end
images_pdf = GenerateResultAttachments.normalize_image_pdf(images_pdf)
images_pdf_attachment =
build_pdf_attachment(
pdf: images_pdf,
submission:,
submitter:,
uuid: GenerateResultAttachments.images_pdf_uuid(original_documents.select(&:image?)),
values_hash:,
filename: "#{submission.name || template.name}.pdf"
)
ApplicationRecord.no_touching do
(result_attachments + [images_pdf_attachment]).map { |e| e.tap(&:save!) }
end
end
end
@@ -102,7 +128,8 @@ module Submissions
)
end
def build_pdf_attachment(pdf:, submission:, submitter:, uuid:, name:, values_hash:)
def build_pdf_attachment(pdf:, submission:, filename:, values_hash:, submitter: nil, uuid: nil,
name: 'preview_documents')
io = StringIO.new
begin
@@ -114,13 +141,13 @@ module Submissions
end
ActiveStorage::Attachment.new(
blob: ActiveStorage::Blob.create_and_upload!(io: io.tap(&:rewind), filename: "#{name}.pdf"),
blob: ActiveStorage::Blob.create_and_upload!(io: io.tap(&:rewind), filename:),
io_data: io.string,
metadata: { original_uuid: uuid,
values_hash:,
analyzed: true,
sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) },
name: 'preview_documents',
sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) }.compact,
name: name,
record: submitter || submission
)
end
@@ -434,7 +434,7 @@ module Submissions
height: image.height * scale
)
end
when 'image', 'signature', 'initials', 'stamp'
when 'image', 'signature', 'initials', 'stamp', 'kba'
attachment = submitter.attachments.find { |a| a.uuid == value }
image =
+12 -8
View File
@@ -33,14 +33,18 @@ module Submissions
return submitter_params if default_values.blank?
values, new_attachments, new_fields =
Submitters::NormalizeValues.call(template,
default_values,
submitter_name: submitter_params[:role] ||
template.submitters.dig(index, 'name'),
role_names: submitter_params[:roles],
for_submitter:,
add_fields:,
throw_errors: !with_values)
Submitters::NormalizeValues.call(
template,
default_values,
submitter_name: submitter_params[:role] ||
(submitter_params[:uuid] &&
template.submitters.find { |s| s['uuid'] == submitter_params[:uuid] }&.dig('name')) ||
template.submitters.dig(index, 'name'),
role_names: submitter_params[:roles],
for_submitter:,
add_fields:,
throw_errors: !with_values
)
submitter_params[:values] = values
+1 -1
View File
@@ -111,7 +111,7 @@ module Submitters
end
def fetch_field_value(field, value, attachments_index, expires_at: nil)
if field['type'].in?(%w[image signature initials stamp payment])
if field['type'].in?(%w[image signature initials stamp payment kba])
rails_storage_proxy_url(attachments_index[value], expires_at:)
elsif field['type'] == 'file'
Array.wrap(value).compact_blank.filter_map { |e| rails_storage_proxy_url(attachments_index[e], expires_at:) }
+16 -12
View File
@@ -59,25 +59,25 @@ module Templates
CHECKBOXES = ['☐', '□'].freeze
# rubocop:disable Metrics, Style
def call(io, attachment: nil, confidence: 0.3, temperature: 1, inference: Templates::ImageToFields,
nms: 0.1, split_page: false, aspect_ratio: true, padding: 20, regexp_type: true, &)
def call(io, attachment: nil, confidence: 0.3, temperature: 1, inference: Templates::ImageToFields, nms: 0.1,
nmm: 0.5, split_page: false, aspect_ratio: true, padding: 20, regexp_type: true, &)
fields, head_node =
if attachment&.image?
process_image_attachment(io, attachment:, confidence:, nms:, split_page:, inference:,
process_image_attachment(io, attachment:, confidence:, nms:, nmm:, split_page:, inference:,
temperature:, aspect_ratio:, padding:, &)
else
process_pdf_attachment(io, attachment:, confidence:, nms:, split_page:, inference:,
process_pdf_attachment(io, attachment:, confidence:, nms:, nmm:, split_page:, inference:,
temperature:, aspect_ratio:, regexp_type:, padding:, &)
end
[fields, head_node]
end
def process_image_attachment(io, attachment:, confidence:, nms:, temperature:, inference:,
def process_image_attachment(io, attachment:, confidence:, nms:, nmm:, temperature:, inference:,
split_page: false, aspect_ratio: false, padding: nil)
image = Vips::Image.new_from_buffer(io.read, '')
fields = inference.call(image, confidence:, nms:, split_page:,
fields = inference.call(image, confidence:, nms:, nmm:, split_page:,
temperature:, aspect_ratio:, padding:)
fields = sort_fields(fields, y_threshold: 10.0 / image.height)
@@ -104,7 +104,7 @@ module Templates
[fields, nil]
end
def process_pdf_attachment(io, attachment:, confidence:, nms:, temperature:, inference:,
def process_pdf_attachment(io, attachment:, confidence:, nms:, nmm:, temperature:, inference:,
split_page: false, aspect_ratio: false, padding: nil, regexp_type: false)
doc = Pdfium::Document.open_bytes(io.read)
@@ -114,11 +114,14 @@ module Templates
fields = doc.page_count.times.flat_map do |page_number|
page = doc.get_page(page_number)
data, width, height = page.render_to_bitmap(width: inference::RESOLUTION * 1.5)
size_key = page.width > page.height ? :width : :height
size = padding ? inference.resolution * 1.5 : inference.resolution
data, width, height = page.render_to_bitmap(size_key => size)
image = Vips::Image.new_from_memory(data, width, height, 4, :uchar)
fields = inference.call(image, confidence: confidence / 4.0, nms:, split_page:,
fields = inference.call(image, confidence: confidence / 3.0, nms:, nmm:, split_page:,
temperature:, aspect_ratio:, padding:)
text_fields = extract_text_fields_from_page(page)
@@ -126,8 +129,8 @@ module Templates
fields = sort_fields(fields, y_threshold: 10.0 / image.height)
fields = increase_confidence_for_overlapping_fields(fields, text_fields)
fields = increase_confidence_for_overlapping_fields(fields, line_fields)
fields = increase_confidence_for_overlapping_fields(fields, text_fields, confidence:)
fields = increase_confidence_for_overlapping_fields(fields, line_fields, confidence:)
fields = fields.reject { |f| f.confidence < confidence }
@@ -477,10 +480,11 @@ module Templates
!(box1.endx < box2.x || box2.endx < box1.x || box1.endy < box2.y || box2.endy < box1.y)
end
def increase_confidence_for_overlapping_fields(image_fields, text_fields, by: 1.0)
def increase_confidence_for_overlapping_fields(image_fields, text_fields, confidence: 1, by: 1.0)
return image_fields if text_fields.blank?
image_fields.map do |image_field|
next if image_field.confidence >= confidence
next if image_field.type != 'text'
text_fields.each do |text_field|
+217 -19
View File
@@ -16,7 +16,7 @@ module Templates
MODEL_PATH = Rails.root.join('tmp/model.onnx')
RESOLUTION = 704
INPUT_NAMES = %w[images input].freeze
ID_TO_CLASS = %w[text checkbox].freeze
@@ -26,13 +26,15 @@ module Templates
CPU_THREADS = Etc.nprocessors
# rubocop:disable Metrics
def call(image, confidence: 0.3, nms: 0.1, temperature: 1,
split_page: false, aspect_ratio: true, padding: nil, resolution: RESOLUTION)
base_image = image.extract_band(0, n: 3)
def call(image, confidence: 0.3, nms: 0.1, nmm: 0.9, temperature: 1,
split_page: false, aspect_ratio: true, padding: nil, resolution: self.resolution)
image = image.extract_band(0, n: 3) if image.bands > 3
trimmed_base, base_offset_x, base_offset_y = trim_image_with_padding(base_image, padding)
trimmed_base, base_offset_x, base_offset_y = trim_image_with_padding(image, padding)
if split_page && image.height > image.width
if model_v2?
detections = call_v2(trimmed_base, base_offset_x, base_offset_y, split_page, confidence:, resolution:)
elsif split_page && image.height > image.width
regions = build_split_image_regions(trimmed_base)
detections = { xyxy: Numo::SFloat[], confidence: Numo::SFloat[], class_id: Numo::Int32[] }
@@ -66,11 +68,132 @@ module Templates
detections = postprocess_outputs(boxes, logits, transform_info, confidence:, temperature:, resolution:)
end
detections = apply_nms(detections, nms)
detections = apply_nms_nmm(detections, nms_threshold: nms, nmm_threshold: nmm)
build_fields_from_detections(detections, image)
end
def call_v2(image, offset_x, offset_y, split_page, confidence:, resolution:)
if split_page && image.height > image.width
regions = build_split_image_regions(image)
detections = { xyxy: Numo::SFloat[], confidence: Numo::SFloat[], class_id: Numo::Int32[] }
regions.reduce(detections) do |acc, r|
next acc if r[:img].height <= 0 || r[:img].width <= 0
input_tensor, orig_size_tensor, transform_info = preprocess_image_v2(r[:img], resolution)
outputs = model.predict({ 'images' => input_tensor, 'orig_target_sizes' => orig_size_tensor },
output_type: :numo)
boxes = outputs['boxes'][0, true, true]
labels = outputs['labels'][0, true]
scores = outputs['scores'][0, true]
postprocess_outputs_v2(boxes, labels, scores, acc,
offset_x:, offset_y: offset_y + r[:offset_y],
confidence:, transform_info:)
end
else
input_tensor, orig_size_tensor, transform_info = preprocess_image_v2(image, resolution)
outputs = model.predict({ 'images' => input_tensor, 'orig_target_sizes' => orig_size_tensor },
output_type: :numo)
boxes = outputs['boxes'][0, true, true]
labels = outputs['labels'][0, true]
scores = outputs['scores'][0, true]
postprocess_outputs_v2(boxes, labels, scores, offset_x:, offset_y:,
confidence:, transform_info:)
end
end
def preprocess_image_v2(image, resolution)
image = image.extract_band(0, n: 3) if image.bands > 3
ratio = [resolution.to_f / image.width, resolution.to_f / image.height].min
new_width = (image.width * ratio).to_i
new_height = (image.height * ratio).to_i
image = image.resize(ratio, vscale: ratio, kernel: :linear) if ratio != 1
pad_w = (resolution - new_width) / 2
pad_h = (resolution - new_height) / 2
padded = image.embed(pad_w, pad_h, resolution, resolution, background: [255, 255, 255])
padded /= 255.0
img_array = Numo::SFloat.from_binary(padded.write_to_memory, [resolution, resolution, 3])
img_array = img_array.transpose(2, 0, 1)
input_tensor = img_array.reshape(1, 3, resolution, resolution)
orig_size_tensor = Numo::Int64[[resolution, resolution]]
transform_info = { ratio: ratio, pad_w: pad_w, pad_h: pad_h }
[input_tensor, orig_size_tensor, transform_info]
end
def postprocess_outputs_v2(boxes, labels, scores, detections = nil, offset_x:, offset_y:, confidence:,
transform_info:)
keep_mask = scores.gt(confidence)
keep_indices = keep_mask.where
if keep_indices.empty?
detections || {
xyxy: Numo::SFloat[],
confidence: Numo::SFloat[],
class_id: Numo::Int32[]
}
else
scores = scores[keep_indices]
labels = labels[keep_indices]
boxes_xyxy = boxes[keep_indices, true]
ratio = transform_info[:ratio]
pad_w = transform_info[:pad_w]
pad_h = transform_info[:pad_h]
boxes_xyxy[true, 0] = ((boxes_xyxy[true, 0] - pad_w) / ratio) + offset_x
boxes_xyxy[true, 1] = ((boxes_xyxy[true, 1] - pad_h) / ratio) + offset_y
boxes_xyxy[true, 2] = ((boxes_xyxy[true, 2] - pad_w) / ratio) + offset_x
boxes_xyxy[true, 3] = ((boxes_xyxy[true, 3] - pad_h) / ratio) + offset_y
if detections
existing_n = detections[:xyxy].shape[0]
new_n = boxes_xyxy.shape[0]
total = existing_n + new_n
xyxy = Numo::SFloat.zeros(total, 4)
conf = Numo::SFloat.zeros(total)
cls = Numo::Int32.zeros(total)
if existing_n.positive?
xyxy[0...existing_n, true] = detections[:xyxy]
conf[0...existing_n] = detections[:confidence]
cls[0...existing_n] = detections[:class_id]
end
xyxy[existing_n...(existing_n + new_n), true] = boxes_xyxy
conf[existing_n...(existing_n + new_n)] = scores
cls[existing_n...(existing_n + new_n)] = labels
{ xyxy: xyxy, confidence: conf, class_id: cls }
else
{
xyxy: boxes_xyxy,
confidence: scores,
class_id: labels
}
end
end
end
def build_split_image_regions(image)
half_h = image.height / 2
top_h = half_h
@@ -122,6 +245,8 @@ module Templates
left, top, trim_width, trim_height = image.find_trim(threshold: 10, background: [255, 255, 255])
trim_width = [trim_width, image.width - (left * 1.9)].max
padded_left = [left - padding, 0].max
padded_top = [top - padding, 0].max
padded_right = [left + trim_width + padding, image.width].min
@@ -172,7 +297,10 @@ module Templates
[img_array.reshape(1, 3, resolution, resolution), transform_info]
end
def nms(boxes, scores, iou_threshold = 0.5)
def nms(detections, iou_threshold = 0.5)
boxes = detections[:xyxy]
scores = detections[:confidence]
return Numo::Int32[] if boxes.shape[0].zero?
x1 = boxes[true, 0]
@@ -208,11 +336,77 @@ module Templates
order = order[inds + 1]
end
Numo::Int32.cast(keep)
{
xyxy: detections[:xyxy][keep, true],
confidence: detections[:confidence][keep],
class_id: detections[:class_id][keep]
}
end
def nmm(detections, overlap_threshold = 0.9, confidence: 0.3)
boxes = detections[:xyxy]
scores = detections[:confidence]
classes = detections[:class_id]
return detections if boxes.shape[0].zero?
x1 = boxes[true, 0]
y1 = boxes[true, 1]
x2 = boxes[true, 2]
y2 = boxes[true, 3]
areas = (x2 - x1) * (y2 - y1)
order = areas.sort_index.reverse
keep = []
while order.size.positive?
i = order[0]
keep << i
break if order.size == 1
xx1 = Numo::SFloat.maximum(x1[i], x1[order[1..]])
yy1 = Numo::SFloat.maximum(y1[i], y1[order[1..]])
xx2 = Numo::SFloat.minimum(x2[i], x2[order[1..]])
yy2 = Numo::SFloat.minimum(y2[i], y2[order[1..]])
w = Numo::SFloat.maximum(0.0, xx2 - xx1)
h = Numo::SFloat.maximum(0.0, yy2 - yy1)
intersection = w * h
overlap = intersection / areas[order[1..]]
merge_mask = scores[i] > confidence ? (overlap.gt(overlap_threshold) & classes[order[1..]].eq(classes[i])) : nil
if merge_mask && (merge_inds = merge_mask.where).size.positive?
candidates = order[merge_inds + 1]
scores[i] = [scores[i], scores[candidates].max].max
x1[i] = [x1[i], x1[candidates].min].min
y1[i] = [y1[i], y1[candidates].min].min
x2[i] = [x2[i], x2[candidates].max].max
y2[i] = [y2[i], y2[candidates].max].max
end
if merge_mask
inds = (~merge_mask).where
order = order[inds + 1]
else
order = order[1..]
end
end
{
xyxy: detections[:xyxy][keep, true],
confidence: detections[:confidence][keep],
class_id: detections[:class_id][keep]
}
end
def postprocess_outputs(boxes, logits, transform_info, detections = nil, confidence: 0.3, temperature: 1,
resolution: RESOLUTION)
resolution: self.resolution)
scaled_logits = logits / temperature
probs = 1.0 / (1.0 + Numo::NMath.exp(-scaled_logits))
@@ -304,28 +498,32 @@ module Templates
end
end
def apply_nms(detections, threshold = 0.5)
def apply_nms_nmm(detections, nms_threshold: 0.5, nmm_threshold: 0.7, confidence: 0.3)
return detections if detections[:xyxy].shape[0].zero?
keep_indices = nms(detections[:xyxy], detections[:confidence], threshold)
nms_result = nms(detections, nms_threshold)
{
xyxy: detections[:xyxy][keep_indices, true],
confidence: detections[:confidence][keep_indices],
class_id: detections[:class_id][keep_indices]
}
nmm(nms_result, nmm_threshold, confidence:)
end
def model
@model ||= OnnxRuntime::Model.new(
MODEL_PATH.to_s,
inter_op_num_threads: CPU_THREADS,
inter_op_num_threads: 1,
intra_op_num_threads: CPU_THREADS,
enable_mem_pattern: false,
enable_cpu_mem_arena: false,
enable_cpu_mem_arena: Docuseal.multitenant? || Rails.env.development?,
providers: ['CPUExecutionProvider']
)
end
def resolution
@resolution ||= model.inputs.find { |i| INPUT_NAMES.include?(i[:name]) }.dig(:shape, 2)
end
def model_v2?
@model_v2 ||= model.inputs.pluck(:name).include?('orig_target_sizes')
end
# rubocop:enable Metrics
end
end