Merge from docusealco/wip

This commit is contained in:
Alex Turchyn authored and GitHub committed 2026-10-26 12:27:46 +02:00
commit 13a1835c00
44 files changed
+291 -180

No files matched your search

+4 -4
View File
@@ -12,7 +12,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.5 ruby-version: 4.0.9
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -37,7 +37,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.5 ruby-version: 4.0.9
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -89,7 +89,7 @@ jobs:
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 4.0.5 ruby-version: 4.0.9
- name: Cache gems - name: Cache gems
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -113,7 +113,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
container: container:
image: ruby:4.0.5-alpine image: ruby:4.0.9-alpine
services: services:
postgres: postgres:
+5 -4
View File
@@ -1,4 +1,4 @@
FROM ruby:4.0.5-alpine AS download FROM ruby:4.0.9-alpine AS download
WORKDIR /fonts WORKDIR /fonts
@@ -17,7 +17,7 @@ RUN apk --no-cache add wget unzip && \
mkdir -p /pdfium-linux && \ mkdir -p /pdfium-linux && \
unzip -q pdfium-linux.zip -d /pdfium-linux unzip -q pdfium-linux.zip -d /pdfium-linux
FROM ruby:4.0.5-alpine AS webpack FROM ruby:4.0.9-alpine AS webpack
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -44,7 +44,7 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:4.0.5-alpine AS app FROM ruby:4.0.9-alpine AS app
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test" ENV BUNDLE_WITHOUT="development:test"
@@ -52,7 +52,8 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app WORKDIR /app
RUN apk add --no-cache libpq vips redis onnxruntime leptonica jemalloc font-liberation font-carlito && \ RUN apk upgrade --no-cache ca-certificates-bundle && \
apk add --no-cache libpq vips redis onnxruntime leptonica jemalloc font-liberation font-carlito && \
rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
+1 -1
View File
@@ -2,7 +2,7 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '4.0.5' ruby '4.0.9'
gem 'addressable' gem 'addressable'
gem 'arabic-letter-connector', require: false gem 'arabic-letter-connector', require: false
+6 -6
View File
@@ -572,15 +572,15 @@ GEM
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
wordcraft (0.8.0-aarch64-linux-gnu) wordcraft (0.11.0-aarch64-linux-gnu)
ffi (>= 1.15) ffi (>= 1.15)
wordcraft (0.8.0-aarch64-linux-musl) wordcraft (0.11.0-aarch64-linux-musl)
ffi (>= 1.15) ffi (>= 1.15)
wordcraft (0.8.0-arm64-darwin) wordcraft (0.11.0-arm64-darwin)
ffi (>= 1.15) ffi (>= 1.15)
wordcraft (0.8.0-x86_64-linux-gnu) wordcraft (0.11.0-x86_64-linux-gnu)
ffi (>= 1.15) ffi (>= 1.15)
wordcraft (0.8.0-x86_64-linux-musl) wordcraft (0.11.0-x86_64-linux-musl)
ffi (>= 1.15) ffi (>= 1.15)
xlsxtream (3.1.0) xlsxtream (3.1.0)
zip_kit (>= 6.2, < 7) zip_kit (>= 6.2, < 7)
@@ -664,7 +664,7 @@ DEPENDENCIES
xlsxtream xlsxtream
RUBY VERSION RUBY VERSION
ruby 4.0.5 ruby 4.0.9
BUNDLED WITH BUNDLED WITH
4.0.3 4.0.3
@@ -1,74 +0,0 @@
# frozen_string_literal: true
module Api
class AttachmentsController < ActionController::API
include ActionController::Cookies
include ActiveStorage::SetCurrent
COOKIE_STORE_LIMIT = 10
def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
unless can_upload?(@submitter)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
file = params[:file]
if params[:type].in?(%w[initials signature])
image = ImageUtils.load_vips(file.read, content_type: file.content_type)
if ImageUtils.blank?(image)
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content
end
if ImageUtils.error?(image)
Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} error, try to sign on another device" },
status: :unprocessable_content
end
metadata = { analyzed: true, identified: true, width: image.width, height: image.height }
end
attachment = Submitters.create_attachment!(@submitter, file, metadata:)
if params[:remember_signature] == 'true' && @submitter.email.present?
cookies.permanent.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
end
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
rescue Submitters::MaliciousFileExtension => e
Rollbar.error(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content
end
def can_upload?(submitter)
!submitter.declined_at? &&
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at?
end
def build_new_cookie_signatures_json(submitter, attachment)
values =
begin
JSON.parse(cookies.encrypted[:signature_uuids].presence || '{}')
rescue JSON::ParserError
{}
end
values[submitter.email] = attachment.uuid
values = values.to_a.last(COOKIE_STORE_LIMIT).to_h if values.size > COOKIE_STORE_LIMIT
values.to_json
end
end
end
@@ -1,15 +0,0 @@
# frozen_string_literal: true
module Api
class SubmitterEmailClicksController < ActionController::API
def create
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
if params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email')
SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request)
end
render json: {}
end
end
end
+1 -4
View File
@@ -179,10 +179,7 @@ module Api
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present? submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
submitters = submitters.where(slug: params[:slug]) if params[:slug].present? submitters = submitters.where(slug: params[:slug]) if params[:slug].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present? submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
submitters = submitters.where(template_id: params[:template_id]) if params[:template_id].present?
if params[:template_id].present?
submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] })
end
maybe_filter_by_completed_at(submitters, params) maybe_filter_by_completed_at(submitters, params)
end end
+1
View File
@@ -228,6 +228,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
template, template,
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')), customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
dateFormats: JSON.parse(this.dataset.dateFormats || '[]'), dateFormats: JSON.parse(this.dataset.dateFormats || '[]'),
autosave: this.dataset.autosave !== 'false',
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')), dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
backgroundColor: '#faf7f5', backgroundColor: '#faf7f5',
locale: this.dataset.locale, locale: this.dataset.locale,
+14 -2
View File
@@ -12,7 +12,14 @@ export default class extends HTMLElement {
toggle = (event) => { toggle = (event) => {
if (event?.type === 'input' && event.target.matches(this.fieldSelector('name'))) return if (event?.type === 'input' && event.target.matches(this.fieldSelector('name'))) return
const isNameOnly = (field) => this.isFilled('name') && !this.isFilled(field) if (event?.type === 'focusin' && !this.isKeepSendUsed && this.isFocusFromName(event)) {
this.isKeepSendUsed = true
this.keepSendInput = event.target
} else if (event?.type === 'focusout' && event.target === this.keepSendInput) {
this.keepSendInput = null
}
const isNameOnly = (field) => this.isFilled('name') && !this.isFilled(field) && !this.keepSendInput?.matches(this.fieldSelector(field))
const isSend = (this.isChecked('send_email') && !isNameOnly('email')) || const isSend = (this.isChecked('send_email') && !isNameOnly('email')) ||
(this.isChecked('send_sms') && !isNameOnly('phone')) (this.isChecked('send_sms') && !isNameOnly('phone'))
@@ -29,12 +36,17 @@ export default class extends HTMLElement {
return [...this.form.querySelectorAll(this.fieldSelector(field))].some((input) => input.value.trim()) return [...this.form.querySelectorAll(this.fieldSelector(field))].some((input) => input.value.trim())
} }
isFocusFromName ({ target, relatedTarget }) {
return !!relatedTarget?.matches(this.fieldSelector('name')) && !!relatedTarget.value.trim() &&
['email', 'phone'].some((field) => target.matches(this.fieldSelector(field)))
}
fieldSelector (field) { fieldSelector (field) {
return `[name$="[][${field}]"]` return `[name$="[][${field}]"]`
} }
get events () { get events () {
return ['change', 'input', 'linked-input.update'] return ['change', 'input', 'linked-input.update', 'focusin', 'focusout']
} }
get form () { get form () {
+49 -2
View File
@@ -415,9 +415,9 @@
<a <a
:href="`/templates/${template.id}/form`" :href="`/templates/${template.id}/form`"
:data-native-actions="template.submitters.length > 1 ? 'users' : null" :data-native-actions="template.submitters.length > 1 ? 'users' : null"
data-turbo="false" data-turbo-prefetch="false"
class="flex items-center justify-center space-x-2" class="flex items-center justify-center space-x-2"
@click.exact="isPreviewLoading = true" @click.exact="onSaveAndPreviewClick"
> >
<IconInnerShadowTop <IconInnerShadowTop
v-if="isPreviewLoading" v-if="isPreviewLoading"
@@ -640,6 +640,7 @@
:draw-field-type="drawFieldType" :draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField" :draw-custom-field="drawCustomField"
:draw-option="drawOption" :draw-option="drawOption"
:autosave="autosave"
@update="onDynamicDocumentUpdate" @update="onDynamicDocumentUpdate"
@draw="clearDrawField" @draw="clearDrawField"
@add-custom-field="addCustomField" @add-custom-field="addCustomField"
@@ -1379,6 +1380,7 @@ export default {
isPreviewLoading: false, isPreviewLoading: false,
isLoadingBlankPage: false, isLoadingBlankPage: false,
isSaving: false, isSaving: false,
hasUnsavedChanges: false,
isDetectingPageFields: false, isDetectingPageFields: false,
detectFieldsQueue: [], detectFieldsQueue: [],
detectingAnalyzingProgress: null, detectingAnalyzingProgress: null,
@@ -1662,6 +1664,11 @@ export default {
window.addEventListener('dragleave', this.onWindowDragLeave) window.addEventListener('dragleave', this.onWindowDragLeave)
if (!this.autosave) {
window.addEventListener('beforeunload', this.onBeforeUnload)
document.addEventListener('turbo:before-visit', this.onBeforeVisit)
}
this.$nextTick(() => { this.$nextTick(() => {
if (document.location.search?.includes('stripe_connect_success')) { if (document.location.search?.includes('stripe_connect_success')) {
document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus() document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
@@ -1685,6 +1692,11 @@ export default {
this.resizeObserver.disconnect() this.resizeObserver.disconnect()
window.removeEventListener('dragleave', this.onWindowDragLeave) window.removeEventListener('dragleave', this.onWindowDragLeave)
if (!this.autosave) {
window.removeEventListener('beforeunload', this.onBeforeUnload)
document.removeEventListener('turbo:before-visit', this.onBeforeVisit)
}
}, },
beforeUpdate () { beforeUpdate () {
this.documentRefs = [] this.documentRefs = []
@@ -2241,6 +2253,37 @@ export default {
this.insertField(field) this.insertField(field)
} }
}, },
onSaveAndPreviewClick (event) {
this.isPreviewLoading = true
if (!this.autosave) {
event.preventDefault()
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
dynamicDocumentRefs.forEach((ref) => ref.update())
this.rebuildVariablesSchema({ disable: false })
Promise.all([this.save({ force: true }), ...dynamicDocumentRefs.map((ref) => ref.saveBody())]).then(() => {
window.Turbo.visit(`/templates/${this.template.id}/form`)
})
}
},
onBeforeUnload (event) {
if (this.hasUnsavedChanges) {
event.preventDefault()
event.returnValue = ''
return ''
}
},
onBeforeVisit (event) {
if (this.hasUnsavedChanges && !window.confirm(this.t('close_without_saving_changes'))) {
event.preventDefault()
}
},
closeDropdown () { closeDropdown () {
document.activeElement.blur() document.activeElement.blur()
}, },
@@ -4036,6 +4079,8 @@ export default {
} }
if (!this.autosave && !force) { if (!this.autosave && !force) {
this.hasUnsavedChanges = true
return Promise.resolve({}) return Promise.resolve({})
} }
@@ -4058,6 +4103,8 @@ export default {
}), }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}).then(() => { }).then(() => {
this.hasUnsavedChanges = false
if (this.onSave) { if (this.onSave) {
this.onSave(this.template) this.onSave(this.template)
} }
@@ -84,6 +84,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
autosave: {
type: Boolean,
required: false,
default: true
},
selectedSubmitter: { selectedSubmitter: {
type: Object, type: Object,
required: false, required: false,
@@ -279,7 +284,7 @@ export default {
this.updatedSectionEditors.set(section.id, editor) this.updatedSectionEditors.set(section.id, editor)
this.saveTimer = setTimeout(() => this.updateSectionsAndSave(), 1000) this.saveTimer = setTimeout(() => this.autosave ? this.updateSectionsAndSave() : this.updateSections(), 1000)
}, },
loadListsStyle () { loadListsStyle () {
this.listsStyle = this.bodyDom.querySelector('body > style[data-lists]')?.textContent || null this.listsStyle = this.bodyDom.querySelector('body > style[data-lists]')?.textContent || null
@@ -299,7 +304,9 @@ export default {
updateVariablesSchema () { updateVariablesSchema () {
this.document.variables_schema = buildVariablesSchema(this.bodyDom.body) this.document.variables_schema = buildVariablesSchema(this.bodyDom.body)
}, },
updateSectionsAndSave () { updateSections () {
delete this.saveTimer
this.updatedSectionEditors.forEach((editor, sectionId) => { this.updatedSectionEditors.forEach((editor, sectionId) => {
const target = this.bodyDom.getElementById(sectionId) const target = this.bodyDom.getElementById(sectionId)
@@ -315,6 +322,9 @@ export default {
this.updateVariablesSchema() this.updateVariablesSchema()
this.$emit('update', this.document) this.$emit('update', this.document)
},
updateSectionsAndSave () {
this.updateSections()
return this.saveBody() return this.saveBody()
}, },
+7
View File
@@ -70,6 +70,7 @@ const en = {
remove_condition: 'Remove condition', remove_condition: 'Remove condition',
add_condition: 'Add condition', add_condition: 'Add condition',
are_you_sure_: 'Are you sure?', are_you_sure_: 'Are you sure?',
close_without_saving_changes: 'Close without saving changes?',
sign_yourself: 'Sign Yourself', sign_yourself: 'Sign Yourself',
set_signing_date: 'Set signing date', set_signing_date: 'Set signing date',
signing_date: 'Signing Date', signing_date: 'Signing Date',
@@ -371,6 +372,7 @@ const es = {
down: 'Abajo', down: 'Abajo',
set_signing_date: 'Establecer fecha de firma', set_signing_date: 'Establecer fecha de firma',
are_you_sure_: '¿Estás seguro?', are_you_sure_: '¿Estás seguro?',
close_without_saving_changes: '¿Cerrar sin guardar los cambios?',
sign_yourself: 'Firma tú mismo', sign_yourself: 'Firma tú mismo',
checked: 'Marcado', checked: 'Marcado',
current_date: 'Fecha actual', current_date: 'Fecha actual',
@@ -652,6 +654,7 @@ const it = {
remove_condition: 'Rimuovi condizione', remove_condition: 'Rimuovi condizione',
add_condition: 'Aggiungi condizione', add_condition: 'Aggiungi condizione',
are_you_sure_: 'Sei sicuro?', are_you_sure_: 'Sei sicuro?',
close_without_saving_changes: 'Chiudere senza salvare le modifiche?',
sign_yourself: 'Firma te stesso', sign_yourself: 'Firma te stesso',
set_signing_date: 'Imposta data di firma', set_signing_date: 'Imposta data di firma',
signing_date: 'Data di firma', signing_date: 'Data di firma',
@@ -953,6 +956,7 @@ const pt = {
down: 'Para baixo', down: 'Para baixo',
set_signing_date: 'Definir data de assinatura', set_signing_date: 'Definir data de assinatura',
are_you_sure_: 'Tem certeza?', are_you_sure_: 'Tem certeza?',
close_without_saving_changes: 'Fechar sem salvar as alterações?',
sign_yourself: 'Assine você mesmo', sign_yourself: 'Assine você mesmo',
checked: 'Marcado', checked: 'Marcado',
current_date: 'Data atual', current_date: 'Data atual',
@@ -1234,6 +1238,7 @@ const fr = {
remove_condition: 'Supprimer la condition', remove_condition: 'Supprimer la condition',
add_condition: 'Ajouter une condition', add_condition: 'Ajouter une condition',
are_you_sure_: 'Êtes-vous sûr ?', are_you_sure_: 'Êtes-vous sûr ?',
close_without_saving_changes: 'Fermer sans enregistrer les modifications ?',
sign_yourself: 'Signer vous-même', sign_yourself: 'Signer vous-même',
set_signing_date: 'Définir la date de signature', set_signing_date: 'Définir la date de signature',
signing_date: 'Date de signature', signing_date: 'Date de signature',
@@ -1525,6 +1530,7 @@ const de = {
remove_condition: 'Bedingung entfernen', remove_condition: 'Bedingung entfernen',
add_condition: 'Bedingung hinzufügen', add_condition: 'Bedingung hinzufügen',
are_you_sure_: 'Sind Sie sicher?', are_you_sure_: 'Sind Sie sicher?',
close_without_saving_changes: 'Ohne Speichern der Änderungen schließen?',
sign_yourself: 'Selbst unterschreiben', sign_yourself: 'Selbst unterschreiben',
set_signing_date: 'Unterzeichnungsdatum festlegen', set_signing_date: 'Unterzeichnungsdatum festlegen',
signing_date: 'Unterzeichnungsdatum', signing_date: 'Unterzeichnungsdatum',
@@ -1816,6 +1822,7 @@ const nl = {
remove_condition: 'Voorwaarde verwijderen', remove_condition: 'Voorwaarde verwijderen',
add_condition: 'Voorwaarde toevoegen', add_condition: 'Voorwaarde toevoegen',
are_you_sure_: 'Weet u het zeker?', are_you_sure_: 'Weet u het zeker?',
close_without_saving_changes: 'Sluiten zonder wijzigingen op te slaan?',
sign_yourself: 'Zelf ondertekenen', sign_yourself: 'Zelf ondertekenen',
set_signing_date: 'Ondertekeningsdatum instellen', set_signing_date: 'Ondertekeningsdatum instellen',
signing_date: 'Ondertekeningsdatum', signing_date: 'Ondertekeningsdatum',
+8 -6
View File
@@ -30,14 +30,16 @@ class SendTestWebhookRequestJob
raise LocalhostError, "Can't send to localhost." if uri.host.in?(SendWebhookRequest::LOCALHOSTS) raise LocalhostError, "Can't send to localhost." if uri.host.in?(SendWebhookRequest::LOCALHOSTS)
end end
Faraday.post(webhook_url.url, body_json = {
{ event_type: 'form.completed',
event_type: 'form.completed', timestamp: Time.current.iso8601,
timestamp: Time.current.iso8601, data: Submitters::SerializeForWebhook.call(submitter)
data: Submitters::SerializeForWebhook.call(submitter) }.to_json
}.to_json,
Faraday.post(webhook_url.url, body_json,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT, 'User-Agent' => USER_AGENT,
'X-Docuseal-Signature' => WebhookUrls::Signatures.sign(webhook_url.hmac_secret, body: body_json),
**webhook_url.secret.to_h) **webhook_url.secret.to_h)
end end
end end
+1
View File
@@ -61,6 +61,7 @@ class AccountConfig < ApplicationRecord
DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format' DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format'
TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields' TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields'
TEMPLATE_DATE_FORMATS_KEY = 'template_date_formats' TEMPLATE_DATE_FORMATS_KEY = 'template_date_formats'
TEMPLATE_BUILDER_AUTOSAVE_KEY = 'template_builder_autosave'
POLICY_LINKS_KEY = 'policy_links' POLICY_LINKS_KEY = 'policy_links'
ENABLE_MCP_KEY = 'enable_mcp' ENABLE_MCP_KEY = 'enable_mcp'
+9 -1
View File
@@ -25,15 +25,17 @@
# account_id :bigint not null # account_id :bigint not null
# external_id :string # external_id :string
# submission_id :bigint not null # submission_id :bigint not null
# template_id :bigint
# #
# Indexes # Indexes
# #
# index_submitters_on_account_id_and_completed_at (account_id,completed_at) WHERE (completed_at IS NOT NULL) # index_submitters_on_account_id_and_completed_at (account_id,completed_at) WHERE (completed_at IS NOT NULL)
# index_submitters_on_account_id_and_id (account_id,id) # index_submitters_on_account_id_and_id (account_id,id)
# index_submitters_on_email (email) # index_submitters_on_email_and_template_id (email,template_id) WHERE (email IS NOT NULL)
# index_submitters_on_external_id (external_id) # index_submitters_on_external_id (external_id)
# index_submitters_on_slug (slug) UNIQUE # index_submitters_on_slug (slug) UNIQUE
# index_submitters_on_submission_id (submission_id) # index_submitters_on_submission_id (submission_id)
# index_submitters_on_template_id_and_id (template_id,id)
# #
# Foreign Keys # Foreign Keys
# #
@@ -68,6 +70,8 @@ class Submitter < ApplicationRecord
scope :completed, -> { where.not(completed_at: nil) } scope :completed, -> { where.not(completed_at: nil) }
before_validation :set_template_id, on: :create
after_destroy :anonymize_email_events, if: -> { Docuseal.multitenant? } after_destroy :anonymize_email_events, if: -> { Docuseal.multitenant? }
def status def status
@@ -124,6 +128,10 @@ class Submitter < ApplicationRecord
private private
def set_template_id
self.template_id = submission&.template_id
end
def anonymize_email_events def anonymize_email_events
email_events.each do |event| email_events.each do |event|
event.update!(email: Digest::MD5.base64digest(event.email)) event.update!(email: Digest::MD5.base64digest(event.email))
+3 -3
View File
@@ -33,10 +33,10 @@
<% end %> <% end %>
<turbo-frame id="drawer"></turbo-frame> <turbo-frame id="drawer"></turbo-frame>
<%= render turbo_native_app? ? 'shared/native_navbar' : 'shared/navbar' %> <%= render turbo_native_app? ? 'shared/native_navbar' : 'shared/navbar' %>
<% if flash.present? %><%= render 'shared/flash' %><% end %> <main class="max-w-6xl mx-auto px-4 md:px-2 mb-8">
<div class="max-w-6xl mx-auto px-4 md:px-2 mb-8">
<%= yield %> <%= yield %>
</div> </main>
<% if flash.present? %><%= render 'shared/flash' %><% end %>
<%= render 'shared/body_scripts' %> <%= render 'shared/body_scripts' %>
</body> </body>
</html> </html>
+3 -1
View File
@@ -13,6 +13,8 @@
<%= stylesheet_pack_tag 'form', media: 'all', data: { turbo_track: 'dynamic' } %> <%= stylesheet_pack_tag 'form', media: 'all', data: { turbo_track: 'dynamic' } %>
</head> </head>
<body> <body>
<%= yield %> <main>
<%= yield %>
</main>
</body> </body>
</html> </html>
+4 -2
View File
@@ -13,8 +13,10 @@
<%= stylesheet_pack_tag 'application', media: 'all', data: { turbo_track: 'dynamic' } %> <%= stylesheet_pack_tag 'application', media: 'all', data: { turbo_track: 'dynamic' } %>
</head> </head>
<body> <body>
<% if flash.present? %><%= render 'shared/flash' %><% end %> <main>
<%= yield %> <%= yield %>
</main>
<turbo-frame id="modal"></turbo-frame> <turbo-frame id="modal"></turbo-frame>
<% if flash.present? %><%= render 'shared/flash' %><% end %>
</body> </body>
</html> </html>
+2 -2
View File
@@ -1,5 +1,5 @@
<%= render 'shared/navbar_warning' %> <%= render 'shared/navbar_warning' %>
<div class="max-w-6xl mb-4 mx-auto px-4 md:px-2 py-3 flex items-center justify-between"> <nav class="max-w-6xl mb-4 mx-auto px-4 md:px-2 py-3 flex items-center justify-between">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2"> <a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2">
<%= render 'shared/title' %> <%= render 'shared/title' %>
@@ -106,4 +106,4 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
</div> </nav>
+2 -2
View File
@@ -1,4 +1,4 @@
<main class="max-w-md mx-auto px-2 mt-12 mb-4"> <div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto"> <div class="space-y-6 mx-auto">
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@@ -32,5 +32,5 @@
</toggle-submit> </toggle-submit>
<% end %> <% end %>
</div> </div>
</main> </div>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %> <%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
@@ -2,7 +2,7 @@
<% I18n.with_locale(@template.account.locale) do %> <% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %> <% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %>
<% end %> <% end %>
<main class="max-w-md mx-auto px-2 mt-12 mb-4"> <div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto"> <div class="space-y-6 mx-auto">
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@@ -55,4 +55,4 @@
<%= button_to t(:re_send_email), start_form_email_2fa_send_index_path, params: { slug: @template.slug, resend: true, submitter: { name: params[:name] || params.dig(:submitter, :name), email: params[:email] || params.dig(:submitter, :email), phone: params[:phone] || params.dig(:submitter, :phone) } }.compact, method: :post, id: 'resend_code', class: 'hidden' %> <%= button_to t(:re_send_email), start_form_email_2fa_send_index_path, params: { slug: @template.slug, resend: true, submitter: { name: params[:name] || params.dig(:submitter, :name), email: params[:email] || params.dig(:submitter, :email), phone: params[:phone] || params.dig(:submitter, :phone) } }.compact, method: :post, id: 'resend_code', class: 'hidden' %>
<% end %> <% end %>
</div> </div>
</main> </div>
+2 -2
View File
@@ -2,7 +2,7 @@
<% I18n.with_locale(@template.account.locale) do %> <% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %> <% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %>
<% end %> <% end %>
<main class="max-w-md mx-auto px-2 mt-12 mb-4"> <div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto"> <div class="space-y-6 mx-auto">
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@@ -28,4 +28,4 @@
<%= link_to t('back'), @resubmit_submitter ? submit_form_path(@resubmit_submitter.slug) : start_form_path(@template.slug, @submitter.slice(:name, :email, :phone).compact_blank), class: 'base-button' %> <%= link_to t('back'), @resubmit_submitter ? submit_form_path(@resubmit_submitter.slug) : start_form_path(@template.slug, @submitter.slice(:name, :email, :phone).compact_blank), class: 'base-button' %>
</div> </div>
</div> </div>
</main> </div>
+2 -2
View File
@@ -2,7 +2,7 @@
<% I18n.with_locale(@template.account.locale) do %> <% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('share_link_is_currently_disabled')) %> <% content_for(:html_description, t('share_link_is_currently_disabled')) %>
<% end %> <% end %>
<main class="max-w-md space-y-6 mx-auto px-2 mt-12 mb-4"> <div class="max-w-md space-y-6 mx-auto px-2 mt-12 mb-4">
<div class="text-center w-full space-y-6"> <div class="text-center w-full space-y-6">
<%= render 'start_form/banner' %> <%= render 'start_form/banner' %>
<p class="text-xl font-semibold text-center"> <p class="text-xl font-semibold text-center">
@@ -27,5 +27,5 @@
<%= button_to button_title(title: t('enable_shared_link'), icon: svg_icon('lock_open', class: 'w-6 h-6')), template_share_link_path(@template), params: { template: { shared_link: true }, redir: start_form_path(slug: @template.slug) }, method: :post, class: 'white-button w-full' %> <%= button_to button_title(title: t('enable_shared_link'), icon: svg_icon('lock_open', class: 'w-6 h-6')), template_share_link_path(@template), params: { template: { shared_link: true }, redir: start_form_path(slug: @template.slug) }, method: :post, class: 'white-button w-full' %>
</toggle-submit> </toggle-submit>
<% end %> <% end %>
</main> </div>
<%= render 'shared/attribution', link_path: '/start', account: @template.account %> <%= render 'shared/attribution', link_path: '/start', account: @template.account %>
+2 -2
View File
@@ -2,7 +2,7 @@
<% I18n.with_locale(@template.account.locale) do %> <% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %> <% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %>
<% end %> <% end %>
<main class="max-w-md mx-auto px-2 mt-12 mb-4"> <div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto"> <div class="space-y-6 mx-auto">
<div class="space-y-6"> <div class="space-y-6">
<div class="text-center w-full space-y-6"> <div class="text-center w-full space-y-6">
@@ -66,4 +66,4 @@
<%= render 'shared/attribution', link_path: '/start', account: @template.account %> <%= render 'shared/attribution', link_path: '/start', account: @template.account %>
<%= render 'start_form/policy', account: @template.account %> <%= render 'start_form/policy', account: @template.account %>
</div> </div>
</main> </div>
+2 -2
View File
@@ -11,7 +11,7 @@
<% with_timestamp_seconds = configs.find { |e| e.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true %> <% with_timestamp_seconds = configs.find { |e| e.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true %>
<% with_signature_id_reason = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false %> <% with_signature_id_reason = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false %>
<% with_signature_id_completed_at = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID_COMPLETED_AT_KEY }&.value == true %> <% with_signature_id_completed_at = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID_COMPLETED_AT_KEY }&.value == true %>
<main style="max-width: 1600px" class="mx-auto pl-4"> <div style="max-width: 1600px" class="mx-auto pl-4">
<% unless native_tab == 'recipients' %> <% unless native_tab == 'recipients' %>
<div class="flex justify-between items-center pr-4 sticky top-0 md:relative z-10 bg-base-100 <%= 'py-1.5' unless turbo_native_app? %>"> <div class="flex justify-between items-center pr-4 sticky top-0 md:relative z-10 bg-base-100 <%= 'py-1.5' unless turbo_native_app? %>">
<% unless turbo_native_app? %> <% unless turbo_native_app? %>
@@ -394,7 +394,7 @@
</label> </label>
</toggle-visible> </toggle-visible>
<% end %> <% end %>
</main> </div>
<% unless request.headers['HTTP_X_TURBO_REQUEST_ID'] %> <% unless request.headers['HTTP_X_TURBO_REQUEST_ID'] %>
<%= render 'scripts/autosize_field' %> <%= render 'scripts/autosize_field' %>
<% end %> <% end %>
+2 -2
View File
@@ -1,5 +1,5 @@
<% completed_at = @submitter.completed_at || @submitter.submission.completed_at %> <% completed_at = @submitter.completed_at || @submitter.submission.completed_at %>
<main class="max-w-md mx-auto px-2 mt-12 mb-4"> <div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto"> <div class="space-y-6 mx-auto">
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@@ -51,5 +51,5 @@
</toggle-submit> </toggle-submit>
<% end %> <% end %>
</div> </div>
</main> </div>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %> <%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
+2 -2
View File
@@ -3,7 +3,7 @@
<% I18n.with_locale(@submitter.account.locale) do %> <% I18n.with_locale(@submitter.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @submitter.account.name)) %> <% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @submitter.account.name)) %>
<% end %> <% end %>
<main class="max-w-md mx-auto px-2 mt-12 mb-4"> <div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto"> <div class="space-y-6 mx-auto">
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@@ -70,4 +70,4 @@
<div><%= t('please_contact_the_requester_to_specify_your_email_for_two_factor_authentication') %></div> <div><%= t('please_contact_the_requester_to_specify_your_email_for_two_factor_authentication') %></div>
<% end %> <% end %>
</div> </div>
</main> </div>
+2 -2
View File
@@ -14,7 +14,7 @@
<% delegate_modal_id = nil %> <% delegate_modal_id = nil %>
<% banner_html = turbo_native_app? ? '' : local_assigns[:banner_html] %> <% banner_html = turbo_native_app? ? '' : local_assigns[:banner_html] %>
<div style="max-height: -webkit-fill-available;"> <div style="max-height: -webkit-fill-available;">
<main id="scrollbox"> <div id="scrollbox">
<div id="sr_only_content"></div> <div id="sr_only_content"></div>
<div class="mx-auto block pb-72" style="max-width: 1000px"> <div class="mx-auto block pb-72" style="max-width: 1000px">
<%# flex block w-full sticky top-0 z-50 space-x-2 items-center bg-yellow-100 p-2 border-y border-yellow-200 transition-transform duration-300 %> <%# flex block w-full sticky top-0 z-50 space-x-2 items-center bg-yellow-100 p-2 border-y border-yellow-200 transition-transform duration-300 %>
@@ -172,7 +172,7 @@
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
</main> </div>
</div> </div>
<div class="fixed bottom-0 w-full h-0 z-50"> <div class="fixed bottom-0 w-full h-0 z-50">
<div class="mx-auto" style="max-width: 1000px"> <div class="mx-auto" style="max-width: 1000px">
+3 -2
View File
@@ -7,7 +7,8 @@
<%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %> <%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %>
<% end %> <% end %>
<% end %> <% end %>
<% account_configs = current_account.account_configs.where(key: [AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY, AccountConfig::TEMPLATE_DATE_FORMATS_KEY]).to_a %> <% account_configs = current_account.account_configs.where(key: [AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY, AccountConfig::TEMPLATE_DATE_FORMATS_KEY, AccountConfig::TEMPLATE_BUILDER_AUTOSAVE_KEY]).to_a %>
<% custom_fields = account_configs.find { |e| e.key == AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY }&.value || [] %> <% custom_fields = account_configs.find { |e| e.key == AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY }&.value || [] %>
<% date_formats = account_configs.find { |e| e.key == AccountConfig::TEMPLATE_DATE_FORMATS_KEY }&.value || [] %> <% date_formats = account_configs.find { |e| e.key == AccountConfig::TEMPLATE_DATE_FORMATS_KEY }&.value || [] %>
<template-builder data-native-platform="<%= turbo_native_ios? ? 'ios' : (turbo_native_android? ? 'android' : '') %>" data-accept-file-types="image/*, application/pdf, application/zip, application/json, <%= Templates::CreateAttachments.document_extensions.join(', ') %>" class="grid" data-template="<%= @template_data.to_json %>" data-custom-fields="<%= custom_fields.to_json %>" data-date-formats="<%= date_formats.to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-with-revisions-menu="<%= @template.template_versions.exists? %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder> <% autosave = account_configs.find { |e| e.key == AccountConfig::TEMPLATE_BUILDER_AUTOSAVE_KEY }&.value != false %>
<template-builder data-native-platform="<%= turbo_native_ios? ? 'ios' : (turbo_native_android? ? 'android' : '') %>" data-accept-file-types="image/*, application/pdf, application/zip, application/json, <%= Templates::CreateAttachments.document_extensions.join(', ') %>" class="grid" data-template="<%= @template_data.to_json %>" data-custom-fields="<%= custom_fields.to_json %>" data-date-formats="<%= date_formats.to_json %>" data-autosave="<%= autosave %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-with-revisions-menu="<%= @template.template_versions.exists? %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
@@ -28,7 +28,7 @@
</submit-form> </submit-form>
</form> </form>
<% end %> <% end %>
<a href="<%= edit_template_path(@template) %>" class="base-button" data-turbo="false" style="flex-shrink: 0; padding: 0px 24px;"> <a href="<%= edit_template_path(@template) %>" class="base-button" data-turbo-prefetch="false" style="flex-shrink: 0; padding: 0px 24px;">
<%= t('exit_preview') %> <%= t('exit_preview') %>
</a> </a>
</div> </div>
+2 -2
View File
@@ -5,11 +5,11 @@
"note": "Permitted parameters are necessary for creating submitters via API" "note": "Permitted parameters are necessary for creating submitters via API"
}, },
{ {
"fingerprint": "645f2f352a813105b8b2eeadc68101d2e7ea04b8e839aa1336616be56f1108ca", "fingerprint": "58619647d807d5bedb9499d6b1432f7f9cee385167aa97b0cb5e1b31a3db3f40",
"note": "Safe SQL" "note": "Safe SQL"
}, },
{ {
"fingerprint": "5bcf4e6426d17b0efd98e47fde7c012f09500b9595af9cff226cf09341c023be", "fingerprint": "8538c7cca701df19fdc3c1c6c5b7b0a1a3acacc19c840d63147553014d60ffa0",
"note": "Safe SQL" "note": "Safe SQL"
}, },
{ {
+1 -9
View File
@@ -25,8 +25,6 @@ Rails.application.routes.draw do
namespace :api, defaults: { format: :json } do namespace :api, defaults: { format: :json } do
resource :user, only: %i[show] resource :user, only: %i[show]
resources :attachments, only: %i[create]
resources :submitter_email_clicks, only: %i[create]
resources :submitter_form_views, only: %i[create] resources :submitter_form_views, only: %i[create]
resources :submitters, only: %i[index show update] resources :submitters, only: %i[index show update]
resources :submissions, only: %i[index show create update destroy] do resources :submissions, only: %i[index show create update destroy] do
@@ -128,13 +126,7 @@ Rails.application.routes.draw do
controller: 'api/active_storage_blobs_proxy' controller: 'api/active_storage_blobs_proxy'
if Docuseal.multitenant? if Docuseal.multitenant?
resource :blobs_proxy_legacy, only: %i[show], get '/disk/:encoded_key/*filename' => 'active_storage/disk#show', as: :rails_disk_service if Rails.env.local?
path: '/blobs/proxy/:signed_id/*filename',
controller: 'api/active_storage_blobs_proxy_legacy',
as: :rails_blob
get '/disk/:encoded_key/*filename' => 'active_storage/disk#show', as: :rails_disk_service
put '/disk/:encoded_token' => 'active_storage/disk#update', as: :update_rails_disk_service
post '/direct_uploads' => 'active_storage/direct_uploads#create', as: :rails_direct_uploads
ActiveSupport.run_load_hooks(:multitenant_routes, self) ActiveSupport.run_load_hooks(:multitenant_routes, self)
end end
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddTemplateIdToSubmitters < ActiveRecord::Migration[8.1]
def change
add_column :submitters, :template_id, :bigint, if_not_exists: true
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class PopulateSubmitterTemplateId < ActiveRecord::Migration[8.1]
disable_ddl_transaction!
class MigrationSubmitter < ApplicationRecord
self.table_name = 'submitters'
end
class MigrationSubmission < ApplicationRecord
self.table_name = 'submissions'
end
def up
return if ENV['MULTITENANT'] == 'true'
max_id = MigrationSubmitter.maximum(:id)
return unless max_id
submission_template_id =
Arel::Nodes::Grouping.new(
MigrationSubmission.arel_table.project(MigrationSubmission.arel_table[:template_id])
.where(MigrationSubmission.arel_table[:id].eq(MigrationSubmitter.arel_table[:submission_id]))
.ast
)
(1..max_id).step(10_000) do |start_id|
MigrationSubmitter.where(template_id: nil, id: start_id...(start_id + 10_000))
.update_all(template_id: submission_template_id)
end
end
def down
nil
end
end
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class AddSubmittersTemplateIdIndexes < ActiveRecord::Migration[8.1]
def up
add_index :submitters, %i[email template_id], where: 'email IS NOT NULL', if_not_exists: true
add_index :submitters, %i[template_id id], if_not_exists: true
remove_index :submitters, :email, if_exists: true
end
def down
add_index :submitters, :email, if_not_exists: true
remove_index :submitters, %i[template_id id], if_exists: true
remove_index :submitters, %i[email template_id], if_exists: true
end
end
+4 -2
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_09_30_061927) do ActiveRecord::Schema[8.1].define(version: 2026_10_25_120002) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "btree_gin" enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@@ -424,6 +424,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_30_061927) do
t.datetime "sent_at" t.datetime "sent_at"
t.string "slug", null: false t.string "slug", null: false
t.bigint "submission_id", null: false t.bigint "submission_id", null: false
t.bigint "template_id"
t.string "timezone" t.string "timezone"
t.string "ua" t.string "ua"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
@@ -431,10 +432,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_30_061927) do
t.text "values", null: false t.text "values", null: false
t.index ["account_id", "completed_at"], name: "index_submitters_on_account_id_and_completed_at", where: "(completed_at IS NOT NULL)" t.index ["account_id", "completed_at"], name: "index_submitters_on_account_id_and_completed_at", where: "(completed_at IS NOT NULL)"
t.index ["account_id", "id"], name: "index_submitters_on_account_id_and_id" t.index ["account_id", "id"], name: "index_submitters_on_account_id_and_id"
t.index ["email"], name: "index_submitters_on_email" t.index ["email", "template_id"], name: "index_submitters_on_email_and_template_id", where: "(email IS NOT NULL)"
t.index ["external_id"], name: "index_submitters_on_external_id" t.index ["external_id"], name: "index_submitters_on_external_id"
t.index ["slug"], name: "index_submitters_on_slug", unique: true t.index ["slug"], name: "index_submitters_on_slug", unique: true
t.index ["submission_id"], name: "index_submitters_on_submission_id" t.index ["submission_id"], name: "index_submitters_on_submission_id"
t.index ["template_id", "id"], name: "index_submitters_on_template_id_and_id"
end end
create_table "template_accesses", force: :cascade do |t| create_table "template_accesses", force: :cascade do |t|
+1 -4
View File
@@ -7,10 +7,7 @@ class ApiPathConsiderJsonMiddleware
def call(env) def call(env)
if env['PATH_INFO'].starts_with?('/api') && if env['PATH_INFO'].starts_with?('/api') &&
(!env['PATH_INFO'].ends_with?('/documents') || env['REQUEST_METHOD'] != 'POST') && !env['PATH_INFO'].ends_with?('/submitter_sms_clicks')
!env['PATH_INFO'].ends_with?('/attachments') &&
!env['PATH_INFO'].ends_with?('/submitter_sms_clicks') &&
!env['PATH_INFO'].ends_with?('/submitter_email_clicks')
env['CONTENT_TYPE'] = 'application/json' env['CONTENT_TYPE'] = 'application/json'
end end
+13 -3
View File
@@ -26,6 +26,8 @@ module ReplaceEmailVariables
DOCUMENTS_LINKS = /\{+documents\.links\}+/i DOCUMENTS_LINKS = /\{+documents\.links\}+/i
DOCUMENTS_LINK = /\{+documents\.link\}+/i DOCUMENTS_LINK = /\{+documents\.link\}+/i
ESCAPED_BRACES = "\u{E000}\u{E001}"
EMAIL_HOST = ENV.fetch('EMAIL_HOST', nil) EMAIL_HOST = ENV.fetch('EMAIL_HOST', nil)
module_function module_function
@@ -86,7 +88,11 @@ module ReplaceEmailVariables
build_submitters_n_field(submitter.submission, index, :values, match[:field_name].to_s.strip) build_submitters_n_field(submitter.submission, index, :values, match[:field_name].to_s.strip)
end end
replace(text, SENDER_EMAIL, html_escape:) { submitter.submission.created_by_user&.email.to_s.sub(/\+\w+@/, '@') } text = replace(text, SENDER_EMAIL, html_escape:) do
submitter.submission.created_by_user&.email.to_s.sub(/\+\w+@/, '@')
end
text.tr(ESCAPED_BRACES, '{}')
end end
# rubocop:enable Metrics # rubocop:enable Metrics
@@ -124,6 +130,8 @@ module ReplaceEmailVariables
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:) ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:)
end end
elsif (mask = field.dig('preferences', 'mask').presence)
TextUtils.mask_value(value[field['uuid']].to_s, mask)
else else
value[field&.dig('uuid')] value[field&.dig('uuid')]
end end
@@ -134,10 +142,12 @@ module ReplaceEmailVariables
def replace(text, var, html_escape: false) def replace(text, var, html_escape: false)
text.gsub(var) do text.gsub(var) do
value = yield(Regexp.last_match).to_s.tr('{}', ESCAPED_BRACES)
if html_escape if html_escape
ERB::Util.html_escape(yield(Regexp.last_match)) ERB::Util.html_escape(value)
else else
yield(Regexp.last_match) value
end end
end end
end end
+20 -7
View File
@@ -3,6 +3,8 @@
module SearchEntries module SearchEntries
MAX_VALUE_LENGTH = 100 MAX_VALUE_LENGTH = 100
MUTEX = Mutex.new
UUID_REGEXP = /\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i UUID_REGEXP = /\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
module_function module_function
@@ -70,10 +72,10 @@ module SearchEntries
arel arel
else else
"tsvector @@ (quote_literal(coalesce((ts_lexize('english_stem', :keyword))[1], :keyword)) || ':*')::tsquery" "tsvector @@ (quote_literal(coalesce((ts_lexize(:dictionary, :keyword))[1], :keyword)) || ':*')::tsquery"
end end
[sql, { keyword: }] [sql, { keyword:, dictionary: default_dictionary }]
end end
end end
@@ -83,7 +85,7 @@ module SearchEntries
Arel.sql("ngram @@ (quote_literal(:term#{terms.size - 1}) || ':' || :weight)::tsquery") Arel.sql("ngram @@ (quote_literal(:term#{terms.size - 1}) || ':' || :weight)::tsquery")
else else
Arel.sql(<<~SQL.squish) Arel.sql(<<~SQL.squish)
(quote_literal(coalesce((ts_lexize('english_stem', :term#{terms.size - 1}))[1], :term#{terms.size - 1})) || ':*' || :weight)::tsquery (quote_literal(coalesce((ts_lexize(:dictionary, :term#{terms.size - 1}))[1], :term#{terms.size - 1})) || ':*' || :weight)::tsquery
SQL SQL
end end
@@ -91,7 +93,7 @@ module SearchEntries
index = terms.index(term) index = terms.index(term)
arel = Arel.sql(<<~SQL.squish) arel = Arel.sql(<<~SQL.squish)
(quote_literal(coalesce((ts_lexize('english_stem', :term#{index}))[1], :term#{index})) || ':' || :weight)::tsquery (quote_literal(coalesce((ts_lexize(:dictionary, :term#{index}))[1], :term#{index})) || ':' || :weight)::tsquery
SQL SQL
acc ? Arel::Nodes::InfixOperation.new('&&', arel, acc) : arel acc ? Arel::Nodes::InfixOperation.new('&&', arel, acc) : arel
@@ -109,7 +111,8 @@ module SearchEntries
) )
end end
[query.to_sql, terms.index_by.with_index { |_, index| :"term#{index}" }.merge(weight:)] [query.to_sql,
terms.index_by.with_index { |_, index| :"term#{index}" }.merge(weight:, dictionary: default_dictionary)]
end end
def build_weights_wildcard_tsquery(keyword, weight) def build_weights_wildcard_tsquery(keyword, weight)
@@ -122,11 +125,11 @@ module SearchEntries
SQL SQL
else else
<<~SQL.squish <<~SQL.squish
tsvector @@ (quote_literal(coalesce((ts_lexize('english_stem', :keyword))[1], :keyword)) || ':*' || :weight)::tsquery tsvector @@ (quote_literal(coalesce((ts_lexize(:dictionary, :keyword))[1], :keyword)) || ':*' || :weight)::tsquery
SQL SQL
end end
[sql, { keyword:, weight: }] [sql, { keyword:, weight:, dictionary: default_dictionary }]
end end
def index_submitter(submitter) def index_submitter(submitter)
@@ -256,4 +259,14 @@ module SearchEntries
ngrams.uniq { |e| e.sub(/':[\d,]/, "':1") }.join(' ') ngrams.uniq { |e| e.sub(/':[\d,]/, "':1") }.join(' ')
end end
def default_dictionary
@default_dictionary || MUTEX.synchronize do
@default_dictionary ||= SearchEntry.connection.select_value(<<~SQL.squish)
SELECT mapdict::regdictionary::text FROM pg_ts_config_map
WHERE mapcfg = get_current_ts_config() AND maptokentype = 1
ORDER BY mapseqno LIMIT 1
SQL
end
end
end end
@@ -884,6 +884,8 @@ module Submissions
end end
def maybe_flatten_pdf(pdf) def maybe_flatten_pdf(pdf)
pdf.catalog.delete(:AcroForm) if pdf.catalog[:AcroForm].is_a?(Integer)
pdf.acro_form.create_appearances(force: true) if pdf.acro_form && pdf.acro_form[:NeedAppearances] pdf.acro_form.create_appearances(force: true) if pdf.acro_form && pdf.acro_form[:NeedAppearances]
pdf.acro_form&.flatten pdf.acro_form&.flatten
rescue HexaPDF::MissingGlyphError rescue HexaPDF::MissingGlyphError
+1
View File
@@ -65,6 +65,7 @@ module Submitters
submissions = submissions.where(source:) if source submissions = submissions.where(source:) if source
Submitter Submitter
.where(template_id: template.id)
.where(submission: submissions) .where(submission: submissions)
.order(id: :desc) .order(id: :desc)
.where(declined_at: nil) .where(declined_at: nil)
+31 -1
View File
@@ -9,6 +9,8 @@ module Submitters
PHONE_REGEXP = /[+\d()\s-]+/ PHONE_REGEXP = /[+\d()\s-]+/
NONEDITABLE_FIELD_TYPES = %w[stamp heading strikethrough].freeze NONEDITABLE_FIELD_TYPES = %w[stamp heading strikethrough].freeze
REQUIRED_FIELD_TYPES = %w[payment kba verification].freeze REQUIRED_FIELD_TYPES = %w[payment kba verification].freeze
CHOICE_FIELD_TYPES = %w[radio multiple select].freeze
BOOLEAN_VALUES = [true, false].freeze
STRFTIME_MAP = { STRFTIME_MAP = {
'hour' => '%-k', 'hour' => '%-k',
@@ -55,6 +57,7 @@ module Submitters
validate_values!(reason_field ? values.except(reason_field['uuid']) : values, submitter, params, request) validate_values!(reason_field ? values.except(reason_field['uuid']) : values, submitter, params, request)
if params[:completed] == 'true' if params[:completed] == 'true'
validate_invite_submitters!(submitter)
maybe_invite_via_field(submitter, request) maybe_invite_via_field(submitter, request)
SubmissionEvents.create_with_tracking_data(submitter, 'complete_form', request) SubmissionEvents.create_with_tracking_data(submitter, 'complete_form', request)
@@ -504,7 +507,7 @@ module Submitters
submitter submitter
end end
def validate_value!(_value, field, _params, submitter, _request) def validate_value!(value, field, _params, submitter, _request)
raise ValidationError, 'Missing field' unless field raise ValidationError, 'Missing field' unless field
raise ValidationError, 'Invalid field' if field['submitter_uuid'] != submitter.uuid raise ValidationError, 'Invalid field' if field['submitter_uuid'] != submitter.uuid
@@ -514,7 +517,34 @@ module Submitters
raise ValidationError, 'Read-only field' raise ValidationError, 'Read-only field'
end end
raise ValidationError, 'Value is invalid' if field['type'] == 'checkbox' && !value.in?(BOOLEAN_VALUES)
validate_choice_value!(value, field, submitter) if field['type'].in?(CHOICE_FIELD_TYPES)
true true
end end
def validate_choice_value!(value, field, submitter)
return if value.blank?
raise ValidationError, 'Value is invalid' unless value.is_a?(field['type'] == 'multiple' ? Array : String)
option_names = field['options'].to_a.map.with_index do |option, index|
option['value'].presence ||
"#{I18n.t('option', locale: submitter.metadata.fetch('lang', submitter.account.locale))} #{index + 1}"
end
raise ValidationError, 'Value is invalid' unless Array.wrap(value).all? { |item| option_names.include?(item) }
end
def validate_invite_submitters!(submitter)
submission = submitter.submission
is_pending_invite = (submission.template_submitters || submission.template.submitters).any? do |s|
s['invite_by_uuid'] == submitter.uuid && submission.submitters.none? { |e| e.uuid == s['uuid'] }
end
raise ValidationError, 'Invite required' if is_pending_invite
end
end end
end end
+1 -1
View File
@@ -6,7 +6,7 @@ module Templates
ZIP_CONTENT_TYPE = 'application/zip' ZIP_CONTENT_TYPE = 'application/zip'
X_ZIP_CONTENT_TYPE = 'application/x-zip-compressed' X_ZIP_CONTENT_TYPE = 'application/x-zip-compressed'
JSON_CONTENT_TYPE = 'application/json' JSON_CONTENT_TYPE = 'application/json'
WORD_EXTENSIONS = %w[.docx .doc .rtf].freeze WORD_EXTENSIONS = %w[.docx .doc .odt .rtf].freeze
DOCUMENT_EXTENSIONS = %w[.docx .doc .xlsx .xls .odt .rtf].freeze DOCUMENT_EXTENSIONS = %w[.docx .doc .xlsx .xls .odt .rtf].freeze
DOCUMENT_CONTENT_TYPES = %w[ DOCUMENT_CONTENT_TYPES = %w[
+4 -1
View File
@@ -180,8 +180,11 @@ module VerifyPdfSignature
return false if byte_range.size != 4 || byte_range.any?(&:negative?) || byte_range[0].positive? return false if byte_range.size != 4 || byte_range.any?(&:negative?) || byte_range[0].positive?
return false if signature.signed_end > io.size return false if signature.signed_end > io.size
return false if byte_range[2] != byte_range[1] + (signature.contents.bytesize * 2) + 2
byte_range[2] == byte_range[1] + (signature.contents.bytesize * 2) + 2 io.seek(byte_range[1])
io.read(byte_range[2] - byte_range[1]).casecmp?("<#{signature.contents.unpack1('H*')}>")
end end
def unsigned_changes?(document, io, signed_end) def unsigned_changes?(document, io, signed_end)