Merge branch 'docusealco:master' into master

pull/402/head
Vincent Barrier 1 week ago committed by GitHub
commit 81ebb7083d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -46,6 +46,7 @@ class AccountsController < ApplicationController
def destroy def destroy
authorize!(:manage, current_account) authorize!(:manage, current_account)
true_user.skip_reconfirmation!
true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@')) true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@'))
true_user.account.update!(archived_at: Time.current) true_user.account.update!(archived_at: Time.current)

@ -1,4 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
class InvitationsController < Devise::PasswordsController class InvitationsController < Devise::PasswordsController
def update
super do |resource|
resource.confirmed_at ||= Time.current if resource.errors.empty?
PasswordsController::Current.user = resource
end
end
end end

@ -9,7 +9,14 @@ class ProfileController < ApplicationController
def update_contact def update_contact
if current_user.update(contact_params) if current_user.update(contact_params)
if current_user.try(:pending_reconfirmation?) && current_user.previous_changes.key?(:unconfirmed_email)
SendConfirmationInstructionsJob.perform_async('user_id' => current_user.id)
redirect_to settings_profile_index_path,
notice: I18n.t('a_confirmation_email_has_been_sent_to_the_new_email_address')
else
redirect_to settings_profile_index_path, notice: I18n.t('contact_information_has_been_update') redirect_to settings_profile_index_path, notice: I18n.t('contact_information_has_been_update')
end
else else
render :index, status: :unprocessable_content render :index, status: :unprocessable_content
end end

@ -81,7 +81,7 @@ class StartFormController < ApplicationController
@submitter = Submitter.where(submission: @template.submissions) @submitter = Submitter.where(submission: @template.submissions)
.where.not(completed_at: nil) .where.not(completed_at: nil)
.find_by!(required_params) .find_by!(required_params.except('name'))
end end
private private

@ -65,7 +65,14 @@ class UsersController < ApplicationController
end end
if @user.update(attrs.except(*(current_user == @user ? %i[password otp_required_for_login role] : %i[password]))) if @user.update(attrs.except(*(current_user == @user ? %i[password otp_required_for_login role] : %i[password])))
if @user.try(:pending_reconfirmation?) && @user.previous_changes.key?(:unconfirmed_email)
SendConfirmationInstructionsJob.perform_async('user_id' => @user.id)
redirect_back fallback_location: settings_users_path,
notice: I18n.t('a_confirmation_email_has_been_sent_to_the_new_email_address')
else
redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_updated') redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_updated')
end
else else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_content render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_content
end end

@ -51,6 +51,7 @@ import ToggleClasses from './elements/toggle_classes'
import AutosizeField from './elements/autosize_field' import AutosizeField from './elements/autosize_field'
import GoogleDriveFilePicker from './elements/google_drive_file_picker' import GoogleDriveFilePicker from './elements/google_drive_file_picker'
import OpenModal from './elements/open_modal' import OpenModal from './elements/open_modal'
import BarChart from './elements/bar_chart'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@ -140,6 +141,7 @@ safeRegisterElement('toggle-classes', ToggleClasses)
safeRegisterElement('autosize-field', AutosizeField) safeRegisterElement('autosize-field', AutosizeField)
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker) safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
safeRegisterElement('open-modal', OpenModal) safeRegisterElement('open-modal', OpenModal)
safeRegisterElement('bar-chart', BarChart)
safeRegisterElement('template-builder', class extends HTMLElement { safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {

@ -0,0 +1,50 @@
export default class extends HTMLElement {
connectedCallback () {
this.chartLabels = JSON.parse(this.dataset.labels || '[]')
this.chartDatasets = JSON.parse(this.dataset.datasets || '[]')
this.initChart()
}
disconnectedCallback () {
if (this.chartInstance) {
this.chartInstance.destroy()
this.chartInstance = null
}
}
async initChart () {
const { default: Chart } = await import(/* webpackChunkName: "chartjs" */ 'chart.js/auto')
const canvas = this.querySelector('canvas')
const ctx = canvas.getContext('2d')
this.chartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: this.chartLabels,
datasets: this.chartDatasets
},
options: {
responsive: true,
maintainAspectRatio: true,
animation: false,
scales: {
y: {
beginAtZero: true,
grace: '20%',
ticks: {
precision: 0
}
}
},
plugins: {
legend: {
display: false
}
}
}
})
}
}

@ -47,6 +47,7 @@ class ProcessSubmitterCompletionJob
completed_submitter.assign_attributes( completed_submitter.assign_attributes(
submission_id: submitter.submission_id, submission_id: submitter.submission_id,
account_id: submission.account_id, account_id: submission.account_id,
is_first: !CompletedSubmitter.exists?(submission: submitter.submission_id, is_first: true),
template_id: submission.template_id, template_id: submission.template_id,
source: submission.source, source: submission.source,
sms_count: sms_events.sum { |e| e.data['segments'] || 1 }, sms_count: sms_events.sum { |e| e.data['segments'] || 1 },

@ -0,0 +1,11 @@
# frozen_string_literal: true
class SendConfirmationInstructionsJob
include Sidekiq::Job
def perform(params = {})
user = User.find(params['user_id'])
user.send_confirmation_instructions
end
end

@ -60,6 +60,10 @@ class Account < ApplicationRecord
linked_account_account&.testing? linked_account_account&.testing?
end end
def tz_info
@tz_info ||= TZInfo::Timezone.get(ActiveSupport::TimeZone::MAPPING[timezone] || timezone)
end
def default_template_folder def default_template_folder
super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME, super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME,
author_id: users.minimum(:id)).tap(&:save!) author_id: users.minimum(:id)).tap(&:save!)

@ -6,6 +6,7 @@
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# completed_at :datetime not null # completed_at :datetime not null
# is_first :boolean
# sms_count :integer not null # sms_count :integer not null
# source :string not null # source :string not null
# verification_method :string # verification_method :string
@ -18,7 +19,9 @@
# #
# Indexes # Indexes
# #
# index_completed_submitters_on_account_id (account_id) # index_completed_submitters_account_id_completed_at_is_first (account_id,completed_at) WHERE (is_first = true)
# index_completed_submitters_on_account_id_and_completed_at (account_id,completed_at)
# index_completed_submitters_on_submission_id (submission_id) UNIQUE WHERE (is_first = true)
# index_completed_submitters_on_submitter_id (submitter_id) UNIQUE # index_completed_submitters_on_submitter_id (submitter_id) UNIQUE
# #
class CompletedSubmitter < ApplicationRecord class CompletedSubmitter < ApplicationRecord

@ -10,17 +10,21 @@
# event_type :string not null # event_type :string not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :bigint
# submission_id :bigint not null # submission_id :bigint not null
# submitter_id :bigint # submitter_id :bigint
# #
# Indexes # Indexes
# #
# index_submission_events_on_account_id (account_id)
# index_submission_events_on_created_at (created_at) # index_submission_events_on_created_at (created_at)
# index_submission_events_on_submission_id (submission_id) # index_submission_events_on_submission_id (submission_id)
# index_submission_events_on_submitter_id (submitter_id) # index_submission_events_on_submitter_id (submitter_id)
# index_submissions_events_on_sms_event_types (account_id,created_at) WHERE ((event_type)::text = ANY ((ARRAY['send_sms'::character varying, 'send_2fa_sms'::character varying])::text[]))
# #
# Foreign Keys # Foreign Keys
# #
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (submission_id => submissions.id) # fk_rails_... (submission_id => submissions.id)
# fk_rails_... (submitter_id => submitters.id) # fk_rails_... (submitter_id => submitters.id)
# #
@ -35,6 +39,7 @@ class SubmissionEvent < ApplicationRecord
serialize :data, coder: JSON serialize :data, coder: JSON
before_validation :set_submission_id, on: :create before_validation :set_submission_id, on: :create
before_validation :set_account_id, on: :create
enum :event_type, { enum :event_type, {
send_email: 'send_email', send_email: 'send_email',
@ -63,4 +68,8 @@ class SubmissionEvent < ApplicationRecord
def set_submission_id def set_submission_id
self.submission_id = submitter&.submission_id self.submission_id = submitter&.submission_id
end end
def set_account_id
self.account_id = submitter&.account_id
end
end end

@ -6,6 +6,9 @@
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# archived_at :datetime # archived_at :datetime
# confirmation_sent_at :datetime
# confirmation_token :string
# confirmed_at :datetime
# consumed_timestep :integer # consumed_timestep :integer
# current_sign_in_at :datetime # current_sign_in_at :datetime
# current_sign_in_ip :string # current_sign_in_ip :string
@ -24,8 +27,9 @@
# reset_password_token :string # reset_password_token :string
# role :string not null # role :string not null
# sign_in_count :integer default(0), not null # sign_in_count :integer default(0), not null
# unconfirmed_email :string
# unlock_token :string # unlock_token :string
# uuid :text not null # uuid :string not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :bigint not null # account_id :bigint not null

@ -3,7 +3,7 @@
<%= svg_icon('waving_hand', class: 'h-10 w-10') %> <%= svg_icon('waving_hand', class: 'h-10 w-10') %>
<span><%= t('welcome_to_product_name', product_name: Docuseal.product_name) %></span> <span><%= t('welcome_to_product_name', product_name: Docuseal.product_name) %></span>
</h1> </h1>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put, class: 'space-y-6' }) do |f| %> <%= form_for(resource, as: resource_name, url: invitation_path, html: { method: :put, class: 'space-y-6' }) do |f| %>
<div class="space-y-2"> <div class="space-y-2">
<%= render 'devise/shared/error_messages', resource: %> <%= render 'devise/shared/error_messages', resource: %>
<%= f.hidden_field :reset_password_token %> <%= f.hidden_field :reset_password_token %>

@ -18,6 +18,11 @@
<div class="form-control"> <div class="form-control">
<%= f.label :email, t('email'), class: 'label' %> <%= f.label :email, t('email'), class: 'label' %>
<%= f.email_field :email, autocomplete: 'off', class: 'base-input' %> <%= f.email_field :email, autocomplete: 'off', class: 'base-input' %>
<% if current_user.try(:pending_reconfirmation?) %>
<label class="label">
<span class="label-text-alt"><%= t('email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm', email: f.object.unconfirmed_email) %></span>
</label>
<% end %>
</div> </div>
<div class="form-control pt-2"> <div class="form-control pt-2">
<%= f.button button_title(title: t('update'), disabled_with: t('updating')), class: 'base-button' %> <%= f.button button_title(title: t('update'), disabled_with: t('updating')), class: 'base-button' %>

@ -66,7 +66,7 @@
<% end %> <% end %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.linked_account_account) %> <% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.linked_account_account) %>
<li> <li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %> <%= content_for(:pro_link) || link_to(Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'text-base hover:bg-base-300', data: { turbo: false }) do %>
<%= t('plans') %> <%= t('plans') %>
<span class="badge badge-warning"><%= t('pro') %></span> <span class="badge badge-warning"><%= t('pro') %></span>
<% end %> <% end %>
@ -74,13 +74,13 @@
<% end %> <% end %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user == true_user || current_account.testing?) %> <% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user == true_user || current_account.testing?) %>
<li> <li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premises", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %> <%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premises", class: 'text-base hover:bg-base-300', data: { turbo: false } do %>
<% if Docuseal.multitenant? %> API <% else %> <%= t('console') %> <% end %> <% if Docuseal.multitenant? %> API <% else %> <%= t('console') %> <% end %>
<% end %> <% end %>
</li> </li>
<% if Docuseal.multitenant? %> <% if Docuseal.multitenant? %>
<li> <li>
<%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/embedding/form"), class: 'text-base hover:bg-base-300', data: { prefetch: false } do %> <%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/embedding/form"), class: 'text-base hover:bg-base-300', data: { turbo: false } do %>
<%= t('embedding') %> <%= t('embedding') %>
<% end %> <% end %>
</li> </li>

@ -13,6 +13,11 @@
<div class="form-control"> <div class="form-control">
<%= f.label :email, t('email'), class: 'label' %> <%= f.label :email, t('email'), class: 'label' %>
<%= f.email_field :email, required: true, class: 'base-input' %> <%= f.email_field :email, required: true, class: 'base-input' %>
<% if user.try(:pending_reconfirmation?) %>
<label class="label">
<span class="label-text-alt"><%= t('email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm', email: f.object.unconfirmed_email) %></span>
</label>
<% end %>
<% if user.persisted? && Accounts.can_send_emails?(current_account) %> <% if user.persisted? && Accounts.can_send_emails?(current_account) %>
<span class="label-text-alt mt-2 mx-1"> <span class="label-text-alt mt-2 mx-1">
<%= t('click_here_to_send_a_reset_password_email_html') %> <%= t('click_here_to_send_a_reset_password_email_html') %>

@ -52,7 +52,13 @@
<%= user.full_name %> <%= user.full_name %>
</td> </td>
<td> <td>
<% if user.try(:pending_reconfirmation?) %>
<%= user.unconfirmed_email %>
<br>
<span class="label-text-alt">(<%= t('unconfirmed') %>)</span>
<% else %>
<%= user.email %> <%= user.email %>
<% end %>
</td> </td>
<td> <td>
<span class="badge badge-info badge-outline whitespace-nowrap"> <span class="badge badge-info badge-outline whitespace-nowrap">

@ -31,6 +31,7 @@ end
# #
# Use this hook to configure devise mailer, warden hooks and so forth. # Use this hook to configure devise mailer, warden hooks and so forth.
# Many of these configuration options can be set straight in your model. # Many of these configuration options can be set straight in your model.
# rubocop:disable Metrics/BlockLength
Devise.setup do |config| Devise.setup do |config|
config.warden do |manager| config.warden do |manager|
manager.default_strategies(scope: :user).unshift(:two_factor_authenticatable) manager.default_strategies(scope: :user).unshift(:two_factor_authenticatable)
@ -166,7 +167,7 @@ Devise.setup do |config|
# without confirming their account. # without confirming their account.
# Default is 0.days, meaning the user cannot access the website without # Default is 0.days, meaning the user cannot access the website without
# confirming their account. # confirming their account.
# config.allow_unconfirmed_access_for = 2.days config.allow_unconfirmed_access_for = nil
# A period that the user is allowed to confirm their account before their # A period that the user is allowed to confirm their account before their
# token becomes invalid. For example, if set to 3.days, the user can confirm # token becomes invalid. For example, if set to 3.days, the user can confirm
@ -332,3 +333,4 @@ Devise.setup do |config|
ActiveSupport.run_load_hooks(:devise_config, config) ActiveSupport.run_load_hooks(:devise_config, config)
end end
# rubocop:enable Metrics/BlockLength

@ -23,6 +23,7 @@ en: &en
add_from_google_drive: Add from Google Drive add_from_google_drive: Add from Google Drive
or_add_from: Or add from or_add_from: Or add from
upload_a_new_document: Upload a New Document upload_a_new_document: Upload a New Document
billing: Billing
hi_there: Hi there hi_there: Hi there
pro: Pro pro: Pro
thanks: Thanks thanks: Thanks
@ -70,7 +71,7 @@ en: &en
team_access: Team access team_access: Team access
document_download_filename_format: Document download filename format document_download_filename_format: Document download filename format
docuseal_trusted_signature: DocuSeal Trusted Signature docuseal_trusted_signature: DocuSeal Trusted Signature
hello_name: Hello %{name} hello_name: Hi %{name}
you_are_invited_to_product_name: You are invited to %{product_name} you_are_invited_to_product_name: You are invited to %{product_name}
you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'You have been invited to %{account_name} %{product_name}. Please sign up using the link below:' you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'You have been invited to %{account_name} %{product_name}. Please sign up using the link below:'
sent_using_product_name_in_testing_mode_html: 'Sent using <a href="%{product_url}">%{product_name}</a> in testing mode' sent_using_product_name_in_testing_mode_html: 'Sent using <a href="%{product_url}">%{product_name}</a> in testing mode'
@ -844,6 +845,36 @@ en: &en
tablet: Tablet tablet: Tablet
reset_default: Reset default reset_default: Reset default
send_signature_request_email: Send signature request email send_signature_request_email: Send signature request email
last_3_months: Last 3 months
last_6_months: Last 6 months
last_year: Last year
all_time: All time
everyone: Everyone
daily: Daily
weekly: Weekly
monthly: Monthly
api: API
embed: Embed
bulk: Bulk
invite: Invite
api_and_embed: API and Embed
period: Period
reports: Reports
completed_submissions: Completed submissions
sms: SMS
a_confirmation_email_has_been_sent_to_the_new_email_address: A confirmation email has been sent to the new email address.
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} email address is awaiting confirmation. Follow the link in the email to confirm."
please_confirm_your_email_address_using_the_link_below_: 'Please confirm your email address using the link below:'
confirm_email: Confirm email
unconfirmed: Unconfirmed
devise:
confirmations:
confirmed: Your email address has been successfully confirmed.
failure:
unconfirmed: You have to confirm your email address before continuing.
mailer:
confirmation_instructions:
subject: Confirm your email address
submission_sources: submission_sources:
api: API api: API
bulk: Bulk Send bulk: Bulk Send
@ -950,10 +981,12 @@ en: &en
range_without_total: "%{from}-%{to} events" range_without_total: "%{from}-%{to} events"
es: &es es: &es
billing: Facturación
add_from_google_drive: Agregar desde Google Drive add_from_google_drive: Agregar desde Google Drive
or_add_from: O agregar desde or_add_from: O agregar desde
upload_a_new_document: Subir nuevo documento upload_a_new_document: Subir nuevo documento
use_direct_file_attachment_links_in_the_documents: Usar enlaces directos de archivos adjuntos en los documentos use_direct_file_attachment_links_in_the_documents: Usar enlaces directos de archivos adjuntos en los documentos
click_here_to_send_a_reset_password_email_html: '<label class="link" for="resend_password_button">Haz clic aquí</label> para enviar un correo electrónico de restablecimiento de contraseña.'
enabled: Habilitado enabled: Habilitado
disabled: Deshabilitado disabled: Deshabilitado
expirable_file_download_links: Enlaces de descarga de archivos con vencimiento expirable_file_download_links: Enlaces de descarga de archivos con vencimiento
@ -1596,6 +1629,7 @@ es: &es
image_field: Campo de Imagen image_field: Campo de Imagen
file_field: Campo de Archivo file_field: Campo de Archivo
select_field: Campo de Selección select_field: Campo de Selección
checkbox_field: Campo de Casilla
multiple_field: Campo Múltiple multiple_field: Campo Múltiple
radio_field: Campo de Grupo Radio radio_field: Campo de Grupo Radio
cells_field: Campo de Celdas cells_field: Campo de Celdas
@ -1772,6 +1806,36 @@ es: &es
tablet: Tableta tablet: Tableta
reset_default: Restablecer por defecto reset_default: Restablecer por defecto
send_signature_request_email: Enviar correo de solicitud de firma send_signature_request_email: Enviar correo de solicitud de firma
last_3_months: Últimos 3 meses
last_6_months: Últimos 6 meses
last_year: Último año
all_time: Todo el tiempo
everyone: Todos
daily: Diario
weekly: Semanal
monthly: Mensual
api: API
embed: Integrar
bulk: Masivo
invite: Invitación
api_and_embed: API e Integrar
period: Período
reports: Informes
completed_submissions: Envíos completados
sms: SMS
a_confirmation_email_has_been_sent_to_the_new_email_address: Se ha enviado un correo electrónico de confirmación a la nueva dirección de correo electrónico.
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} está pendiente de confirmación. Sigue el enlace en el correo para confirmarla."
please_confirm_your_email_address_using_the_link_below_: 'Por favor, confirma tu dirección de correo electrónico utilizando el enlace a continuación:'
confirm_email: Confirmar correo
unconfirmed: No confirmado
devise:
confirmations:
confirmed: Tu dirección de correo electrónico ha sido confirmada correctamente.
failure:
unconfirmed: Debes confirmar tu dirección de correo electrónico antes de continuar.
mailer:
confirmation_instructions:
subject: Confirma tu dirección de correo electrónico
submission_sources: submission_sources:
api: API api: API
bulk: Envío masivo bulk: Envío masivo
@ -1878,6 +1942,7 @@ es: &es
range_without_total: "%{from}-%{to} eventos" range_without_total: "%{from}-%{to} eventos"
it: &it it: &it
billing: Fatturazione
add_from_google_drive: Aggiungi da Google Drive add_from_google_drive: Aggiungi da Google Drive
or_add_from: Oppure aggiungi da or_add_from: Oppure aggiungi da
upload_a_new_document: Carica nuovo documento upload_a_new_document: Carica nuovo documento
@ -1911,6 +1976,7 @@ it: &it
edit_per_party: Modifica per partito edit_per_party: Modifica per partito
signed: Firmato signed: Firmato
reply_to: Rispondi a reply_to: Rispondi a
partially_completed: Parzialmente completato
pending_by_me: In sospeso da me pending_by_me: In sospeso da me
add: Aggiungi add: Aggiungi
adding: Aggiungendo adding: Aggiungendo
@ -2357,6 +2423,7 @@ it: &it
upload_signature: Carica firma upload_signature: Carica firma
integration: Integrazione integration: Integrazione
admin: Amministratore admin: Amministratore
tenant_admin: Amministratore tenant
editor: Editor editor: Editor
viewer: Visualizzatore viewer: Visualizzatore
member: Membro member: Membro
@ -2701,6 +2768,36 @@ it: &it
tablet: Tablet tablet: Tablet
reset_default: Reimposta predefinito reset_default: Reimposta predefinito
send_signature_request_email: Invia email di richiesta firma send_signature_request_email: Invia email di richiesta firma
last_3_months: Ultimi 3 mesi
last_6_months: Ultimi 6 mesi
last_year: Ultimo anno
all_time: Tutto il tempo
everyone: Tutti
daily: Giornaliero
weekly: Settimanale
monthly: Mensile
api: API
embed: Incorporare
bulk: Massivo
invite: Invito
api_and_embed: API e Incorporare
period: Periodo
reports: Rapporti
completed_submissions: Invii completati
sms: SMS
a_confirmation_email_has_been_sent_to_the_new_email_address: È stata inviata un'email di conferma al nuovo indirizzo email.
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} è in attesa di conferma. Segui il link nell'email per confermare."
please_confirm_your_email_address_using_the_link_below_: 'Conferma il tuo indirizzo email utilizzando il link qui sotto:'
confirm_email: Conferma email
unconfirmed: Non confermato
devise:
confirmations:
confirmed: Il tuo indirizzo email è stato confermato con successo.
failure:
unconfirmed: Devi confermare il tuo indirizzo email prima di continuare.
mailer:
confirmation_instructions:
subject: Conferma il tuo indirizzo email
submission_sources: submission_sources:
api: API api: API
bulk: Invio massivo bulk: Invio massivo
@ -2807,6 +2904,7 @@ it: &it
range_without_total: "%{from}-%{to} eventi" range_without_total: "%{from}-%{to} eventi"
fr: &fr fr: &fr
billing: Facturation
add_from_google_drive: Ajouter depuis Google Drive add_from_google_drive: Ajouter depuis Google Drive
or_add_from: Ou ajouter depuis or_add_from: Ou ajouter depuis
upload_a_new_document: Téléverser un nouveau document upload_a_new_document: Téléverser un nouveau document
@ -3627,6 +3725,37 @@ fr: &fr
tablet: Tablette tablet: Tablette
reset_default: Réinitialiser par défaut reset_default: Réinitialiser par défaut
send_signature_request_email: Envoyer un e-mail de demande de signature send_signature_request_email: Envoyer un e-mail de demande de signature
last_month: Mois dernier
last_3_months: 3 derniers mois
last_6_months: 6 derniers mois
last_year: Année dernière
all_time: Tout le temps
everyone: Tout le monde
daily: Quotidien
weekly: Hebdomadaire
monthly: Mensuel
api: API
embed: Intégrer
bulk: En masse
invite: Invitation
api_and_embed: API et Intégrer
period: Période
reports: Rapports
completed_submissions: Soumissions terminées
sms: SMS
a_confirmation_email_has_been_sent_to_the_new_email_address: Un e-mail de confirmation a été envoyé à la nouvelle adresse e-mail.
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} est en attente de confirmation. Suivez le lien dans l'e-mail pour la confirmer."
please_confirm_your_email_address_using_the_link_below_: 'Veuillez confirmer votre adresse e-mail en utilisant le lien ci-dessous :'
confirm_email: "Confirmer l'e-mail"
unconfirmed: Non confirmé
devise:
confirmations:
confirmed: Votre adresse e-mail a été confirmée avec succès.
failure:
unconfirmed: Vous devez confirmer votre adresse e-mail avant de continuer.
mailer:
confirmation_instructions:
subject: Confirmez votre adresse e-mail
submission_sources: submission_sources:
api: API api: API
bulk: Envoi en masse bulk: Envoi en masse
@ -3733,6 +3862,7 @@ fr: &fr
range_without_total: "%{from}-%{to} événements" range_without_total: "%{from}-%{to} événements"
pt: &pt pt: &pt
billing: Pagamentos
add_from_google_drive: Adicionar do Google Drive add_from_google_drive: Adicionar do Google Drive
or_add_from: Ou adicionar de or_add_from: Ou adicionar de
upload_a_new_document: Enviar novo documento upload_a_new_document: Enviar novo documento
@ -4557,6 +4687,36 @@ pt: &pt
tablet: Tablet tablet: Tablet
reset_default: Redefinir para padrão reset_default: Redefinir para padrão
send_signature_request_email: Enviar e-mail de solicitação de assinatura send_signature_request_email: Enviar e-mail de solicitação de assinatura
last_3_months: Últimos 3 meses
last_6_months: Últimos 6 meses
last_year: Último ano
all_time: Todo o tempo
everyone: Todos
daily: Diário
weekly: Semanal
monthly: Mensal
api: API
embed: Incorporar
bulk: Em massa
invite: Convite
api_and_embed: API e Incorporar
period: Período
reports: Relatórios
completed_submissions: Envios concluídos
sms: SMS
a_confirmation_email_has_been_sent_to_the_new_email_address: Um e-mail de confirmação foi enviado para o novo endereço de e-mail.
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} está aguardando confirmação. Siga o link enviado para esse endereço de e-mail para confirmar."
please_confirm_your_email_address_using_the_link_below_: 'Por favor, confirme seu endereço de e-mail usando o link abaixo:'
confirm_email: Confirmar e-mail
unconfirmed: Não confirmado
devise:
confirmations:
confirmed: Seu endereço de e-mail foi confirmado com sucesso.
failure:
unconfirmed: Você deve confirmar seu endereço de e-mail antes de continuar.
mailer:
confirmation_instructions:
subject: Confirme seu endereço de e-mail
submission_sources: submission_sources:
api: API api: API
bulk: Envio em massa bulk: Envio em massa
@ -4663,6 +4823,7 @@ pt: &pt
range_without_total: "%{from}-%{to} eventos" range_without_total: "%{from}-%{to} eventos"
de: &de de: &de
billing: Abrechnung
add_from_google_drive: Aus Google Drive hinzufügen add_from_google_drive: Aus Google Drive hinzufügen
or_add_from: Oder hinzufügen aus or_add_from: Oder hinzufügen aus
upload_a_new_document: Neues Dokument hochladen upload_a_new_document: Neues Dokument hochladen
@ -5487,6 +5648,36 @@ de: &de
tablet: Tablet tablet: Tablet
reset_default: Standard zurücksetzen reset_default: Standard zurücksetzen
send_signature_request_email: Signaturanfrage-E-Mail senden send_signature_request_email: Signaturanfrage-E-Mail senden
last_3_months: Letzte 3 Monate
last_6_months: Letzte 6 Monate
last_year: Letztes Jahr
all_time: Gesamte Zeit
everyone: Alle
daily: Täglich
weekly: Wöchentlich
monthly: Monatlich
api: API
embed: Einbetten
bulk: Massenversand
invite: Einladung
api_and_embed: API und Einbetten
period: Zeitraum
reports: Berichte
completed_submissions: Abgeschlossene Übermittlungen
sms: SMS
a_confirmation_email_has_been_sent_to_the_new_email_address: Eine Bestätigungs-E-Mail wurde an die neue E-Mail-Adresse gesendet.
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} wartet auf Bestätigung. Folgen Sie dem Link in der E-Mail, um sie zu bestätigen."
please_confirm_your_email_address_using_the_link_below_: 'Bitte bestätigen Sie Ihre E-Mail-Adresse über den folgenden Link:'
confirm_email: E-Mail bestätigen
unconfirmed: Unbestätigt
devise:
confirmations:
confirmed: Ihre E-Mail-Adresse wurde erfolgreich bestätigt.
failure:
unconfirmed: Sie müssen Ihre E-Mail-Adresse bestätigen, bevor Sie fortfahren.
mailer:
confirmation_instructions:
subject: Bestätigen Sie Ihre E-Mail-Adresse
submission_sources: submission_sources:
api: API api: API
bulk: Massenversand bulk: Massenversand
@ -5957,6 +6148,7 @@ he:
your_email_could_not_be_reached_this_may_happen_if_there_was_a_typo_in_your_address_or_if_your_mailbox_is_not_available_please_contact_support_email_to_log_in: לא ניתן היה לגשת לדוא"ל שלך. ייתכן שזה קרה עקב שגיאת כתיב בכתובת או אם תיבת הדואר אינה זמינה. אנא פנה ל־support@docuseal.com כדי להתחבר. your_email_could_not_be_reached_this_may_happen_if_there_was_a_typo_in_your_address_or_if_your_mailbox_is_not_available_please_contact_support_email_to_log_in: לא ניתן היה לגשת לדוא"ל שלך. ייתכן שזה קרה עקב שגיאת כתיב בכתובת או אם תיבת הדואר אינה זמינה. אנא פנה ל־support@docuseal.com כדי להתחבר.
nl: &nl nl: &nl
billing: Facturatie
add_from_google_drive: Toevoegen vanuit Google Drive add_from_google_drive: Toevoegen vanuit Google Drive
or_add_from: Of toevoegen vanuit or_add_from: Of toevoegen vanuit
upload_a_new_document: Nieuw document uploaden upload_a_new_document: Nieuw document uploaden
@ -6777,6 +6969,36 @@ nl: &nl
tablet: Tablet tablet: Tablet
reset_default: Standaard herstellen reset_default: Standaard herstellen
send_signature_request_email: E-mail met handtekeningaanvraag verzenden send_signature_request_email: E-mail met handtekeningaanvraag verzenden
last_3_months: Afgelopen 3 maanden
last_6_months: Afgelopen 6 maanden
last_year: Afgelopen jaar
all_time: Altijd
everyone: Iedereen
daily: Dagelijks
weekly: Wekelijks
monthly: Maandelijks
api: API
embed: Insluiten
bulk: Bulk
invite: Uitnodiging
api_and_embed: API en Insluiten
period: Periode
reports: Rapporten
completed_submissions: Voltooide inzendingen
sms: SMS
a_confirmation_email_has_been_sent_to_the_new_email_address: Er is een bevestigingsmail verzonden naar het nieuwe e-mailadres.
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} wacht op bevestiging. Volg de link in de e-mail om te bevestigen."
please_confirm_your_email_address_using_the_link_below_: 'Bevestig je e-mailadres via de onderstaande link:'
confirm_email: E-mailadres bevestigen
unconfirmed: Onbevestigd
devise:
confirmations:
confirmed: Je e-mailadres is succesvol bevestigd.
failure:
unconfirmed: Je moet je e-mailadres bevestigen voordat je verdergaat.
mailer:
confirmation_instructions:
subject: Bevestig je e-mailadres
submission_sources: submission_sources:
api: API api: API
bulk: Bulkverzending bulk: Bulkverzending

@ -14,13 +14,8 @@ Rails.application.routes.draw do
get 'up' => 'rails/health#show' get 'up' => 'rails/health#show'
get 'manifest' => 'pwa#manifest' get 'manifest' => 'pwa#manifest'
devise_for :users, devise_for :users, path: '/', only: %i[sessions passwords],
path: '/', only: %i[sessions passwords omniauth_callbacks], controllers: { sessions: 'sessions', passwords: 'passwords' }
controllers: begin
options = { sessions: 'sessions', passwords: 'passwords' }
options[:omniauth_callbacks] = 'omniauth_callbacks' if User.devise_modules.include?(:omniauthable)
options
end
devise_scope :user do devise_scope :user do
resource :invitation, only: %i[update] do resource :invitation, only: %i[update] do

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddAccountIdToSubmissionEvents < ActiveRecord::Migration[8.0]
def change
add_reference :submission_events, :account, null: true, foreign_key: true, index: true
end
end

@ -0,0 +1,14 @@
# frozen_string_literal: true
class AddIsFirstToCompletedSubmitters < ActiveRecord::Migration[8.0]
def change
# rubocop:disable Rails/ThreeStateBooleanColumn
add_column :completed_submitters, :is_first, :boolean
# rubocop:enable Rails/ThreeStateBooleanColumn
add_index :completed_submitters, %i[account_id completed_at],
where: 'is_first = TRUE',
name: 'index_completed_submitters_account_id_completed_at_is_first'
add_index :completed_submitters, :submission_id, unique: true, where: 'is_first = TRUE'
end
end

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddCompletedAtIndexToCompletedSubmitters < ActiveRecord::Migration[8.0]
def change
add_index :completed_submitters, %i[account_id completed_at],
name: 'index_completed_submitters_on_account_id_and_completed_at'
remove_index :completed_submitters, :account_id
end
end

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddSendSmsSend2faSmsIndex < ActiveRecord::Migration[8.0]
def change
add_index :submission_events, %i[account_id created_at],
where: "event_type IN ('send_sms', 'send_2fa_sms')",
name: 'index_submissions_events_on_sms_event_types'
end
end

@ -0,0 +1,10 @@
# frozen_string_literal: true
class AddConfirmableToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :confirmation_token, :string
add_column :users, :confirmed_at, :datetime
add_column :users, :confirmation_sent_at, :datetime
add_column :users, :unconfirmed_email, :string
end
end

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do ActiveRecord::Schema[8.0].define(version: 2025_11_25_194305) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "btree_gin" enable_extension "btree_gin"
enable_extension "plpgsql" enable_extension "plpgsql"
@ -118,7 +118,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "verification_method" t.string "verification_method"
t.index ["account_id"], name: "index_completed_submitters_on_account_id" t.boolean "is_first"
t.index ["account_id", "completed_at"], name: "index_completed_submitters_account_id_completed_at_is_first", where: "(is_first = true)"
t.index ["account_id", "completed_at"], name: "index_completed_submitters_on_account_id_and_completed_at"
t.index ["submission_id"], name: "index_completed_submitters_on_submission_id", unique: true, where: "(is_first = true)"
t.index ["submitter_id"], name: "index_completed_submitters_on_submitter_id", unique: true t.index ["submitter_id"], name: "index_completed_submitters_on_submitter_id", unique: true
end end
@ -293,6 +296,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do
t.datetime "event_timestamp", null: false t.datetime "event_timestamp", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.bigint "account_id"
t.index ["account_id", "created_at"], name: "index_submissions_events_on_sms_event_types", where: "((event_type)::text = ANY ((ARRAY['send_sms'::character varying, 'send_2fa_sms'::character varying])::text[]))"
t.index ["account_id"], name: "index_submission_events_on_account_id"
t.index ["created_at"], name: "index_submission_events_on_created_at" t.index ["created_at"], name: "index_submission_events_on_created_at"
t.index ["submission_id"], name: "index_submission_events_on_submission_id" t.index ["submission_id"], name: "index_submission_events_on_submission_id"
t.index ["submitter_id"], name: "index_submission_events_on_submitter_id" t.index ["submitter_id"], name: "index_submission_events_on_submitter_id"
@ -445,6 +451,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do
t.string "otp_secret" t.string "otp_secret"
t.integer "consumed_timestep" t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false, null: false t.boolean "otp_required_for_login", default: false, null: false
t.string "confirmation_token"
t.datetime "confirmed_at"
t.datetime "confirmation_sent_at"
t.string "unconfirmed_email"
t.index ["account_id"], name: "index_users_on_account_id" t.index ["account_id"], name: "index_users_on_account_id"
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
@ -506,6 +516,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_22_053744) do
add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id" add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id"
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id" add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id"
add_foreign_key "submission_events", "accounts"
add_foreign_key "submission_events", "submissions" add_foreign_key "submission_events", "submissions"
add_foreign_key "submission_events", "submitters" add_foreign_key "submission_events", "submitters"
add_foreign_key "submissions", "templates" add_foreign_key "submissions", "templates"

@ -39,8 +39,25 @@ class Pdfium
FPDF_RENDER_FORCEHALFTONE = 0x400 FPDF_RENDER_FORCEHALFTONE = 0x400
FPDF_PRINTING = 0x800 FPDF_PRINTING = 0x800
TextNode = Struct.new(:content, :x, :y, :w, :h, keyword_init: true) TextNode = Struct.new(:content, :x, :y, :w, :h) do
LineNode = Struct.new(:x, :y, :w, :h, :tilt, keyword_init: true) def endx
@endx ||= x + w
end
def endy
@endy ||= y + h
end
end
LineNode = Struct.new(:x, :y, :w, :h, :tilt) do
def endy
@endy ||= y + h
end
def endx
@endx ||= x + w
end
end
# rubocop:disable Naming/ClassAndModuleCamelCase # rubocop:disable Naming/ClassAndModuleCamelCase
class FPDF_LIBRARY_CONFIG < FFI::Struct class FPDF_LIBRARY_CONFIG < FFI::Struct
@ -433,31 +450,47 @@ class Pdfium
return @text_nodes if char_count.zero? return @text_nodes if char_count.zero?
char_count.times do |i|
unicode = Pdfium.FPDFText_GetUnicode(text_page, i)
char = [unicode].pack('U*')
left_ptr = FFI::MemoryPointer.new(:double) left_ptr = FFI::MemoryPointer.new(:double)
right_ptr = FFI::MemoryPointer.new(:double) right_ptr = FFI::MemoryPointer.new(:double)
bottom_ptr = FFI::MemoryPointer.new(:double) bottom_ptr = FFI::MemoryPointer.new(:double)
top_ptr = FFI::MemoryPointer.new(:double) top_ptr = FFI::MemoryPointer.new(:double)
origin_x_ptr = FFI::MemoryPointer.new(:double)
origin_y_ptr = FFI::MemoryPointer.new(:double)
i = 0
loop do
break unless i < char_count
result = Pdfium.FPDFText_GetCharBox(text_page, i, left_ptr, right_ptr, bottom_ptr, top_ptr) box_index = i
codepoint = Pdfium.FPDFText_GetUnicode(text_page, i)
if codepoint.between?(0xD800, 0xDBFF) && (i + 1 < char_count)
codepoint2 = Pdfium.FPDFText_GetUnicode(text_page, i + 1)
if codepoint2.between?(0xDC00, 0xDFFF)
codepoint = 0x10000 + ((codepoint - 0xD800) << 10) + (codepoint2 - 0xDC00)
i += 1
end
end
char = codepoint.chr(Encoding::UTF_8)
result = Pdfium.FPDFText_GetCharBox(text_page, box_index, left_ptr, right_ptr, bottom_ptr, top_ptr)
next if result.zero? next if result.zero?
left = left_ptr.read_double left = left_ptr.read_double
right = right_ptr.read_double right = right_ptr.read_double
origin_x_ptr = FFI::MemoryPointer.new(:double) Pdfium.FPDFText_GetCharOrigin(text_page, box_index, origin_x_ptr, origin_y_ptr)
origin_y_ptr = FFI::MemoryPointer.new(:double)
Pdfium.FPDFText_GetCharOrigin(text_page, i, origin_x_ptr, origin_y_ptr)
origin_y = origin_y_ptr.read_double origin_y = origin_y_ptr.read_double
origin_x = origin_x_ptr.read_double
font_size = Pdfium.FPDFText_GetFontSize(text_page, i) font_size = Pdfium.FPDFText_GetFontSize(text_page, box_index)
font_size = 8 if font_size == 1 font_size = 8 if font_size == 1
abs_x = left abs_x = left
@ -465,15 +498,21 @@ class Pdfium
abs_width = right - left abs_width = right - left
abs_height = font_size abs_height = font_size
x = abs_x / width x = origin_x / width
y = abs_y / height y = abs_y / height
node_width = abs_width / width node_width = (abs_width + ((abs_x - origin_x).abs * 2)) / width
node_height = abs_height / height node_height = abs_height / height
@text_nodes << TextNode.new(content: char, x: x, y: y, w: node_width, h: node_height) @text_nodes << TextNode.new(char, x, y, node_width, node_height)
ensure
i += 1
end end
@text_nodes = @text_nodes.sort { |a, b| a.y == b.y ? a.x <=> b.x : a.y <=> b.y } y_threshold = 4.0 / width
@text_nodes = @text_nodes.sort do |a, b|
(a.endy - b.endy).abs < y_threshold ? a.x <=> b.x : a.endy <=> b.endy
end
ensure ensure
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null? Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
end end
@ -539,10 +578,10 @@ class Pdfium
norm_w = w / width norm_w = w / width
norm_h = h / height norm_h = h / height
@line_nodes << LineNode.new(x: norm_x, y: norm_y, w: norm_w, h: norm_h, tilt: tilt) @line_nodes << LineNode.new(norm_x, norm_y, norm_w, norm_h, tilt)
end end
@line_nodes = @line_nodes.sort { |a, b| a.y == b.y ? a.x <=> b.x : a.y <=> b.y } @line_nodes = @line_nodes.sort { |a, b| a.endy == b.endy ? a.x <=> b.x : a.endy <=> b.endy }
end end
def close def close

@ -20,4 +20,14 @@ module SubmissionEvents
**data **data
}.compact_blank) }.compact_blank)
end end
def populate_account_id
Account.find_each do |account|
ids = account.submissions.pluck(:id)
ids.each_slice(10_000).each do |batch|
SubmissionEvent.where(submission_id: batch).update_all(account_id: account.id)
end
end
end
end end

@ -3,6 +3,7 @@
module Submissions module Submissions
class TimestampHandler class TimestampHandler
HASH_ALGORITHM = 'SHA256' HASH_ALGORITHM = 'SHA256'
TIMEOUT = 10
TimestampError = Class.new(StandardError) TimestampError = Class.new(StandardError)
@ -32,6 +33,8 @@ module Submissions
uri = Addressable::URI.parse(tsa_url) uri = Addressable::URI.parse(tsa_url)
conn = Faraday.new(uri.origin) do |c| conn = Faraday.new(uri.origin) do |c|
c.options.read_timeout = TIMEOUT
c.options.open_timeout = TIMEOUT
c.basic_auth(uri.user, uri.password) if uri.password.present? c.basic_auth(uri.user, uri.password) if uri.password.present?
end end

@ -251,4 +251,20 @@ module Submitters
true true
end end
def populate_completed_is_first
Account.find_each do |account|
submissions_index = {}
CompletedSubmitter.where(account_id: account.id).order(:account_id, :completed_at).each do |cs|
submissions_index[cs.submission_id] ||= cs.submitter_id
cs.update_columns(is_first: submissions_index[cs.submission_id] == cs.submitter_id)
rescue ActiveRecord::RecordNotUnique
CompletedSubmitter.where(submission_id: cs.submission_id).update_all(is_first: false)
cs.update_columns(is_first: submissions_index[cs.submission_id] == cs.submitter_id)
end
end
end
end end

@ -4,7 +4,16 @@ module Templates
module DetectFields module DetectFields
module_function module_function
TextFieldBox = Struct.new(:x, :y, :w, :h, keyword_init: true) TextFieldBox = Struct.new(:x, :y, :w, :h, keyword_init: true) do
def endy
@endy ||= y + h
end
def endx
@endx ||= x + w
end
end
PageNode = Struct.new(:prev, :next, :elem, :page, :attachment_uuid, keyword_init: true) PageNode = Struct.new(:prev, :next, :elem, :page, :attachment_uuid, keyword_init: true)
DATE_REGEXP = / DATE_REGEXP = /
@ -12,8 +21,7 @@ module Templates
date date
| signed\sat | signed\sat
| datum | datum
) )[:_\s-]*\z
\s*[:-]?\s*\z
/ix /ix
NUMBER_REGEXP = / NUMBER_REGEXP = /
@ -31,8 +39,7 @@ module Templates
| menge | menge
| anzahl | anzahl
| stückzahl | stückzahl
) )[:_\s-]*\z
\s*[:-]?\s*\z
/ix /ix
SIGNATURE_REGEXP = / SIGNATURE_REGEXP = /
@ -45,10 +52,12 @@ module Templates
| unterschrift | unterschrift
| unterschreiben | unterschreiben
| unterzeichnen | unterzeichnen
) )[:_\s-]*\z
\s*[:-]?\s*\z
/ix /ix
LINEBREAK = ["\n", "\r"].freeze
CHECKBOXES = ['☐', '□'].freeze
# rubocop:disable Metrics, Style # rubocop:disable Metrics, Style
def call(io, attachment: nil, confidence: 0.3, temperature: 1, inference: Templates::ImageToFields, 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, &) nms: 0.1, split_page: false, aspect_ratio: true, padding: 20, regexp_type: true, &)
@ -71,11 +80,13 @@ module Templates
fields = inference.call(image, confidence:, nms:, split_page:, fields = inference.call(image, confidence:, nms:, split_page:,
temperature:, aspect_ratio:, padding:) temperature:, aspect_ratio:, padding:)
fields = sort_fields(fields, y_threshold: 10.0 / image.height)
fields = fields.map do |f| fields = fields.map do |f|
{ {
uuid: SecureRandom.uuid, uuid: SecureRandom.uuid,
type: f.type, type: f.type,
required: f.type != 'checkbox', required: f.type == 'signature',
preferences: {}, preferences: {},
areas: [{ areas: [{
x: f.x, x: f.x,
@ -113,6 +124,8 @@ module Templates
text_fields = extract_text_fields_from_page(page) text_fields = extract_text_fields_from_page(page)
line_fields = extract_line_fields_from_page(page) line_fields = extract_line_fields_from_page(page)
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, text_fields)
fields = increase_confidence_for_overlapping_fields(fields, line_fields) fields = increase_confidence_for_overlapping_fields(fields, line_fields)
@ -128,7 +141,7 @@ module Templates
{ {
uuid: SecureRandom.uuid, uuid: SecureRandom.uuid,
type:, type:,
required: type != 'checkbox', required: type == 'signature',
preferences: {}, preferences: {},
areas: [{ areas: [{
x: field.x, y: field.y, x: field.x, y: field.y,
@ -153,6 +166,12 @@ module Templates
doc.close doc.close
end end
def sort_fields(fields, y_threshold: 0.01)
fields.sort do |a, b|
(a.endy - b.endy).abs < y_threshold ? a.x <=> b.x : a.endy <=> b.endy
end
end
def print_debug(head_node) def print_debug(head_node)
current_node = head_node current_node = head_node
index = 0 index = 0
@ -189,121 +208,120 @@ module Templates
def build_page_nodes(page, fields, tail_node, attachment_uuid: nil) def build_page_nodes(page, fields, tail_node, attachment_uuid: nil)
field_nodes = [] field_nodes = []
current_text = ''.b
y_threshold = 4.0 / page.height
x_threshold = 30.0 / page.width
text_nodes = page.text_nodes text_nodes = page.text_nodes
text_idx = 0 current_field = fields.shift
field_idx = 0
while text_idx < text_nodes.length || field_idx < fields.length index = 0
text_node = text_nodes[text_idx]
field = fields[field_idx]
process_text_node = false prev_node = nil
process_field_node = false
if text_node && field loop do
text_y_center = text_node.y + (text_node.h / 2.0) node = text_nodes[index]
field_y_center = field.y + (field.h / 2.0)
y_threshold = text_node.h / 2.0
vertical_distance = (text_y_center - field_y_center).abs
if vertical_distance < y_threshold break unless node
is_underscore = text_node.content == '_'
is_left_of_field = text_node.x < field.x
if is_underscore && is_left_of_field if node.content.in?(LINEBREAK)
text_x_end = text_node.x + text_node.w next_node = text_nodes[index]
distance = field.x - text_x_end if next_node && (next_node.endy - node.endy) < y_threshold
proximity_threshold = text_node.w * 3.0 index += 1
if distance < proximity_threshold next
process_field_node = true
else
process_text_node = true
end end
elsif is_left_of_field
process_text_node = true
else
process_field_node = true
end end
elsif text_node.y < field.y loop do
process_text_node = true break unless current_field
if ((current_field.endy - node.endy).abs < y_threshold &&
(current_field.x <= node.x || node.content.in?(LINEBREAK))) ||
current_field.endy < node.y
if tail_node.elem.is_a?(Templates::ImageToFields::Field)
divider =
if (tail_node.elem.endy - current_field.endy).abs > y_threshold
"\n".b
elsif tail_node.elem.endx - current_field.x > x_threshold
"\t".b
else else
process_field_node = true ' '.b
end end
elsif text_node text_node = PageNode.new(prev: tail_node, elem: divider, page: page.page_index, attachment_uuid:)
process_text_node = true tail_node.next = text_node
elsif field
process_field_node = true tail_node = text_node
end elsif prev_node && (prev_node.endy - current_field.endy).abs > y_threshold
text_node = PageNode.new(prev: tail_node, elem: "\n".b, page: page.page_index, attachment_uuid:)
tail_node.next = text_node
if process_field_node tail_node = text_node
unless current_text.empty?
new_text_node = PageNode.new(prev: tail_node, elem: current_text, page: page.page_index, attachment_uuid:)
tail_node.next = new_text_node
tail_node = new_text_node
current_text = ''.b
end end
new_field_node = PageNode.new(prev: tail_node, elem: field, page: page.page_index, attachment_uuid:) field_node = PageNode.new(prev: tail_node, elem: current_field, page: page.page_index, attachment_uuid:)
tail_node.next = new_field_node
tail_node = new_field_node
tail_node.next = field_node
tail_node = field_node
field_nodes << tail_node field_nodes << tail_node
while text_idx < text_nodes.length current_field = fields.shift
text_node_to_check = text_nodes[text_idx] else
break
end
end
is_part_of_field = false if tail_node.elem.is_a?(Templates::ImageToFields::Field)
prev_field = tail_node.elem
if text_node_to_check.content == '_' text_node = PageNode.new(prev: tail_node, elem: ''.b, page: page.page_index, attachment_uuid:)
check_y_center = text_node_to_check.y + (text_node_to_check.h / 2.0) tail_node.next = text_node
check_y_dist = (check_y_center - field_y_center).abs
check_y_thresh = text_node_to_check.h / 2.0
if check_y_dist < check_y_thresh tail_node = text_node
padding = text_node_to_check.w * 3.0
field_x_start = field.x - padding
field_x_end = field.x + field.w + padding
text_x_start = text_node_to_check.x
text_x_end = text_node_to_check.x + text_node_to_check.w
is_part_of_field = true if text_x_start <= field_x_end && field_x_start <= text_x_end if (node.endy - prev_field.endy).abs > y_threshold
tail_node.elem << "\n"
elsif (node.x - prev_field.endx) > x_threshold
tail_node.elem << "\t"
end
elsif prev_node
if (node.endy - prev_node.endy) > y_threshold && LINEBREAK.exclude?(prev_node.content)
tail_node.elem << "\n"
elsif (node.x - prev_node.endx) > x_threshold && !tail_node.elem.ends_with?("\t")
tail_node.elem << "\t"
end end
end end
break unless is_part_of_field if node.content != '_' || !tail_node.elem.ends_with?('___')
tail_node.elem << node.content unless CHECKBOXES.include?(node.content)
text_idx += 1
end end
field_idx += 1 prev_node = node
elsif process_text_node
if text_idx > 0
prev_text_node = text_nodes[text_idx - 1]
x_gap = text_node.x - (prev_text_node.x + prev_text_node.w) index += 1
end
gap_w = text_node.w > prev_text_node.w ? text_node.w : prev_text_node.w loop do
break unless current_field
current_text << ' ' if x_gap > gap_w * 2 field_node = PageNode.new(prev: tail_node, elem: current_field, page: page.page_index, attachment_uuid:)
end tail_node.next = field_node
tail_node = field_node
field_nodes << tail_node
current_text << text_node.content current_field = fields.shift
text_idx += 1
end
end end
unless current_text.empty? if tail_node.elem.is_a?(Templates::ImageToFields::Field)
new_text_node = PageNode.new(prev: tail_node, elem: current_text, page: page.page_index, attachment_uuid:) text_node = PageNode.new(prev: tail_node, elem: "\n".b, page: page.page_index, attachment_uuid:)
tail_node.next = new_text_node tail_node.next = text_node
tail_node = new_text_node
tail_node = text_node
else
tail_node.elem << "\n"
end end
[field_nodes, tail_node] [field_nodes, tail_node]
@ -399,8 +417,8 @@ module Templates
x1 = node.x x1 = node.x
y1 = node.y y1 = node.y
x2 = node.x + node.w x2 = node.endx
y2 = node.y + node.h y2 = node.endy
underscore_count = 1 underscore_count = 1
@ -417,8 +435,9 @@ module Templates
break if distance > 0.02 || height_diff > node.h * 0.5 break if distance > 0.02 || height_diff > node.h * 0.5
underscore_count += 1 underscore_count += 1
next_x2 = next_node.x + next_node.w
next_y2 = next_node.y + next_node.h next_x2 = next_node.endx
next_y2 = next_node.endy
x2 = next_x2 x2 = next_x2
y2 = [y2, next_y2].max y2 = [y2, next_y2].max
@ -438,8 +457,8 @@ module Templates
def calculate_iou(box1, box2) def calculate_iou(box1, box2)
x1 = [box1.x, box2.x].max x1 = [box1.x, box2.x].max
y1 = [box1.y, box2.y].max y1 = [box1.y, box2.y].max
x2 = [box1.x + box1.w, box2.x + box2.w].min x2 = [box1.endx, box2.endx].min
y2 = [box1.y + box1.h, box2.y + box2.h].min y2 = [box1.endy, box2.endy].min
intersection_width = [0, x2 - x1].max intersection_width = [0, x2 - x1].max
intersection_height = [0, y2 - y1].max intersection_height = [0, y2 - y1].max
@ -455,8 +474,7 @@ module Templates
end end
def boxes_overlap?(box1, box2) def boxes_overlap?(box1, box2)
!(box1.x + box1.w < box2.x || box2.x + box2.w < box1.x || !(box1.endx < box2.x || box2.endx < box1.x || box1.endy < box2.y || box2.endy < box1.y)
box1.y + box1.h < box2.y || box2.y + box2.h < box1.y)
end end
def increase_confidence_for_overlapping_fields(image_fields, text_fields, by: 1.0) def increase_confidence_for_overlapping_fields(image_fields, text_fields, by: 1.0)
@ -465,14 +483,13 @@ module Templates
image_fields.map do |image_field| image_fields.map do |image_field|
next if image_field.type != 'text' next if image_field.type != 'text'
field_bottom = image_field.y + image_field.h
text_fields.each do |text_field| text_fields.each do |text_field|
break if text_field.y > field_bottom break if text_field.y > image_field.endy
next if text_field.y + text_field.h < image_field.y next if text_field.endy < image_field.y
next unless boxes_overlap?(image_field, text_field) && calculate_iou(image_field, text_field) > 0.5 next unless boxes_overlap?(image_field, text_field)
next if calculate_iou(image_field, text_field) < 0.4
break image_field.confidence += by break image_field.confidence += by
end end

@ -4,7 +4,15 @@ module Templates
module ImageToFields module ImageToFields
module_function module_function
Field = Struct.new(:type, :x, :y, :w, :h, :confidence, keyword_init: true) Field = Struct.new(:type, :x, :y, :w, :h, :confidence, keyword_init: true) do
def endy
@endy ||= y + h
end
def endx
@endx ||= x + w
end
end
MODEL_PATH = Rails.root.join('tmp/model.onnx') MODEL_PATH = Rails.root.join('tmp/model.onnx')
@ -60,9 +68,7 @@ module Templates
detections = apply_nms(detections, nms) detections = apply_nms(detections, nms)
fields = build_fields_from_detections(detections, image) build_fields_from_detections(detections, image)
sort_fields(fields, y_threshold: 10.0 / image.height)
end end
def build_split_image_regions(image) def build_split_image_regions(image)
@ -298,27 +304,6 @@ module Templates
end end
end end
def sort_fields(fields, y_threshold: 0.01)
sorted_fields = fields.sort { |a, b| a.y == b.y ? a.x <=> b.x : a.y <=> b.y }
lines = []
current_line = []
sorted_fields.each do |field|
if current_line.blank? || (field.y - current_line.first.y).abs < y_threshold
current_line << field
else
lines << current_line.sort_by(&:x)
current_line = [field]
end
end
lines << current_line.sort_by(&:x) if current_line.present?
lines.flatten
end
def apply_nms(detections, threshold = 0.5) def apply_nms(detections, threshold = 0.5)
return detections if detections[:xyxy].shape[0].zero? return detections if detections[:xyxy].shape[0].zero?

@ -22,7 +22,7 @@ module TimeUtils
DEFAULT_DATE_FORMAT_US = 'MM/DD/YYYY' DEFAULT_DATE_FORMAT_US = 'MM/DD/YYYY'
DEFAULT_DATE_FORMAT = 'DD/MM/YYYY' DEFAULT_DATE_FORMAT = 'DD/MM/YYYY'
US_TIMEZONES = %w[EST CST MST PST HST AKDT].freeze US_TIMEZONES = %w[EST EDT CST CDT MST MDT PST PDT HST HDT AKST AKDT].freeze
module_function module_function

@ -20,6 +20,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3", "babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-macros": "^3.1.0", "babel-plugin-macros": "^3.1.0",
"canvas-confetti": "^1.6.0", "canvas-confetti": "^1.6.0",
"chart.js": "^4.5.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"compression-webpack-plugin": "10.0.0", "compression-webpack-plugin": "10.0.0",
"css-loader": "^6.7.3", "css-loader": "^6.7.3",

@ -1355,6 +1355,11 @@
"@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14" "@jridgewell/sourcemap-codec" "^1.4.14"
"@kurkle/color@^0.3.0":
version "0.3.4"
resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf"
integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==
"@leichtgewicht/ip-codec@^2.0.1": "@leichtgewicht/ip-codec@^2.0.1":
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b"
@ -2341,6 +2346,13 @@ chalk@^4.0, chalk@^4.0.0, chalk@^4.1.0:
ansi-styles "^4.1.0" ansi-styles "^4.1.0"
supports-color "^7.1.0" supports-color "^7.1.0"
chart.js@^4.5.1:
version "4.5.1"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.5.1.tgz#19dd1a9a386a3f6397691672231cb5fc9c052c35"
integrity sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==
dependencies:
"@kurkle/color" "^0.3.0"
"chokidar@>=3.0.0 <4.0.0", chokidar@^3.5.3: "chokidar@>=3.0.0 <4.0.0", chokidar@^3.5.3:
version "3.5.3" version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"

Loading…
Cancel
Save