Compare commits

..
33 Commits
Author SHA1 Message Date
Alex Turchyn 9d9e134bb4 use default date format in pdf result 2023-09-14 10:35:43 +03:00
Alex Turchyn ec4f399fcc add template upload button 2023-09-14 10:35:43 +03:00
Alex Turchyn effa7b0fc7 add audit log button 2023-09-14 10:35:43 +03:00
Oleksandr Turchyn 016d9de87a Merge pull request #105 from imchairmanm/add_koyeb_target
add Koyeb to README as deployment target
2023-09-14 01:25:46 +03:00
Justin Ellingwood 71ff064356 add Koyeb to README as deployment target 2023-09-13 13:52:31 +01:00
Alex Turchyn 9100d48cc3 fix audit trail generation 2023-09-12 22:36:04 +03:00
Alex Turchyn 21b207cb1a fix submissions api 2023-09-11 09:49:59 +03:00
Alex Turchyn 3aa703032f adjust audit trail 2023-09-11 01:24:43 +03:00
Alex Turchyn fcfd2e13bd track form open after link click 2023-09-10 20:46:45 +03:00
Alex Turchyn cf6cacdc92 add audit trail 2023-09-10 20:46:44 +03:00
Alex Turchyn 8cabe31827 add submission tracking events 2023-09-10 17:49:10 +03:00
Alex Turchyn 62cea05084 add preserve submitters order option 2023-09-10 11:24:28 +03:00
Alex Turchyn 07e49f9f07 adjust default email 2023-09-09 23:17:41 +03:00
Alex Turchyn baad784818 send docs copy only if email present 2023-09-09 14:00:14 +03:00
Alex Turchyn 6eecd3afa4 fix email template submitters list 2023-09-09 11:44:04 +03:00
Alex Turchyn 8e90100871 fix submission download 2023-09-09 02:00:11 +03:00
Alex Turchyn 12c5138364 adjust add recipients view 2023-09-09 01:25:44 +03:00
Alex Turchyn 80ad166fd9 automatically send emails when all parties completed the form 2023-09-09 00:43:32 +03:00
Alex Turchyn 2fab6984ea fix form button loading indicator 2023-09-06 22:21:15 +03:00
Alex Turchyn 422639114b add bcc emails config 2023-09-06 21:41:23 +03:00
Alex Turchyn fca441878a send completed notification emails to a single user 2023-09-06 20:10:55 +03:00
Alex Turchyn 76578077e8 fix pdf rendering with rotate prop 2023-09-06 17:27:54 +03:00
Alex Turchyn bbd3618f0e add js alert translation 2023-09-05 22:28:50 +03:00
Alex Turchyn 47ed8db955 fix typo 2023-09-05 21:53:17 +03:00
Alex Turchyn fec6e2e35d add portuguese form translations 2023-09-05 21:49:12 +03:00
Alex Turchyn 7c5c8779ec fix form translations 2023-09-05 21:39:04 +03:00
Alex Turchyn 37ed110ad4 better submitter select icon 2023-09-05 20:50:25 +03:00
Alex Turchyn 2ad5f8a316 handle unsupported file types 2023-09-05 20:40:52 +03:00
Alex Turchyn e7594d81b6 fix signature step next button disabled 2023-09-05 01:35:48 +03:00
Alex Turchyn e7c5f14860 fix webhook serializer 2023-09-05 01:09:46 +03:00
Alex Turchyn c38830dd1c add phone type placeholder 2023-09-05 01:00:21 +03:00
Alex Turchyn 2cbf429293 better if readability 2023-09-04 19:06:16 +03:00
Alex Turchyn 517c292c17 add submitter name email variable 2023-09-04 10:08:32 +03:00
65 changed files with 1292 additions and 218 deletions

No files matched your search

+3
View File
@@ -26,6 +26,9 @@ Style/Documentation:
Lint/MissingSuper: Lint/MissingSuper:
Enabled: false Enabled: false
Metrics/ParameterLists:
Max: 10
Metrics/MethodLength: Metrics/MethodLength:
Max: 25 Max: 25
Exclude: Exclude:
+1 -1
View File
@@ -31,7 +31,7 @@ ENV BUNDLE_WITHOUT="development:test"
WORKDIR /app WORKDIR /app
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /LiberationSans-Regular.ttf && apk del ttf-liberation RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf / && apk del ttf-liberation
COPY ./Gemfile ./Gemfile.lock ./ COPY ./Gemfile ./Gemfile.lock ./
+2
View File
@@ -52,6 +52,8 @@ DocuSeal is an open source platform that provides secure and efficient digital d
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)| | [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|**DigitalOcean**|**Render**| |**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render) | [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|**Koyeb**| |
| [<img alt="Deploy on Koyeb" src="https://www.koyeb.com/static/images/deploy/button.svg" height="40">](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | |
#### Docker #### Docker
+12 -11
View File
@@ -9,27 +9,28 @@ module Api
template = current_account.templates.find(params[:template_id]) template = current_account.templates.find(params[:template_id])
submissions = submissions =
if (params[:emails] || params[:email]).present? if (emails = (params[:emails] || params[:email]).presence)
Submissions.create_from_emails(template:, Submissions.create_from_emails(template:,
user: current_user, user: current_user,
source: :api, source: :api,
mark_as_sent: params[:send_email] != 'false', mark_as_sent: params[:send_email] != 'false',
emails: params[:emails] || params[:email]) emails:)
else else
submissions_attrs = normalize_submissions_params!(submissions_params[:submission], template) submissions_attrs = normalize_submissions_params!(submissions_params[:submission], template)
Submissions.create_from_submitters(template:, Submissions.create_from_submitters(
user: current_user, template:,
source: :api, user: current_user,
mark_as_sent: params[:send_email] != 'false', source: :api,
submissions_attrs:) mark_as_sent: params[:send_email] != 'false',
submitters_order: params[:submitters_order] || 'preserved',
submissions_attrs:
)
end end
submitters = submissions.flat_map(&:submitters) Submissions.send_signature_requests(submissions, send_email: params[:send_email] != 'false')
Submitters.send_signature_requests(submitters, send_email: params[:send_email] != 'false') render json: submissions.flat_map(&:submitters)
render json: submitters
rescue UnknownFieldName, UnknownSubmitterName => e rescue UnknownFieldName, UnknownSubmitterName => e
render json: { error: e.message }, status: :unprocessable_entity render json: { error: e.message }, status: :unprocessable_entity
end end
@@ -0,0 +1,17 @@
# frozen_string_literal: true
module Api
class SubmitterEmailClicksController < ApiBaseController
skip_before_action :authenticate_user!
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
@@ -0,0 +1,15 @@
# frozen_string_literal: true
module Api
class SubmitterFormViewsController < ApiBaseController
skip_before_action :authenticate_user!
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
render json: {}
end
end
end
+3 -5
View File
@@ -26,16 +26,14 @@ class SubmissionsController < ApplicationController
Submissions.create_from_submitters(template: @template, Submissions.create_from_submitters(template: @template,
user: current_user, user: current_user,
source: :invite, source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
mark_as_sent: params[:send_email] == '1', mark_as_sent: params[:send_email] == '1',
submissions_attrs: submissions_params[:submission].to_h.values) submissions_attrs: submissions_params[:submission].to_h.values)
end end
submitters = submissions.flat_map(&:submitters) Submissions.send_signature_requests(submissions, params)
Submitters.send_signature_requests(submitters, params) redirect_to template_path(@template), notice: 'New recipients have been added'
redirect_to template_path(@template),
notice: "#{submitters.size} #{'recipient'.pluralize(submitters.size)} added"
end end
def destroy def destroy
@@ -15,9 +15,13 @@ class SubmissionsDebugController < ApplicationController
render 'submit_form/show' render 'submit_form/show'
end end
f.pdf do f.pdf do
Submissions::GenerateResultAttachments.call(@submitter) if params[:audit]
Submissions::GenerateAuditTrail.call(@submitter.submission)
else
Submissions::GenerateResultAttachments.call(@submitter)
end
send_data ActiveStorage::Attachment.where(name: :documents).last.download, send_data ActiveStorage::Attachment.where(name: params[:audit] ? :audit_trail : :documents).last.download,
filename: 'debug.pdf', filename: 'debug.pdf',
disposition: 'inline', disposition: 'inline',
type: 'application/pdf' type: 'application/pdf'
@@ -8,8 +8,12 @@ class SubmissionsDownloadController < ApplicationController
Submissions::EnsureResultGenerated.call(submitter) Submissions::EnsureResultGenerated.call(submitter)
last_submitter = submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
Submissions::EnsureResultGenerated.call(last_submitter)
urls = urls =
Submitters.select_attachments_for_download(submitter).map do |attachment| Submitters.select_attachments_for_download(last_submitter).map do |attachment|
helpers.rails_blob_url(attachment) helpers.rails_blob_url(attachment)
end end
@@ -23,6 +23,8 @@ class SubmitFormController < ApplicationController
Submitters::SubmitValues.call(submitter, params, request) Submitters::SubmitValues.call(submitter, params, request)
head :ok head :ok
rescue Submitters::SubmitValues::ValidationError => e
render json: { error: e.message }, status: :unprocessable_entity
end end
def completed def completed
@@ -8,6 +8,8 @@ class SubmittersSendEmailController < ApplicationController
SubmitterMailer.invitation_email(submitter).deliver_later! SubmitterMailer.invitation_email(submitter).deliver_later!
SubmissionEvent.create!(submitter:, event_type: 'send_email')
submitter.sent_at ||= Time.current submitter.sent_at ||= Time.current
submitter.save! submitter.save!
@@ -0,0 +1,18 @@
# frozen_string_literal: true
class TemplatesUploadsController < ApplicationController
def create
template = current_account.templates.new(author: current_user)
template.name = File.basename(params[:files].first.original_filename, '.*')
template.save!
documents = Templates::CreateAttachments.call(template, params)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
template.update!(schema:)
redirect_to edit_template_path(template)
end
end
+2
View File
@@ -75,6 +75,8 @@ window.customElements.define('template-builder', class extends HTMLElement {
this.app = createApp(TemplateBuilder, { this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)), template: reactive(JSON.parse(this.dataset.template)),
backgroundColor: '#faf7f5', backgroundColor: '#faf7f5',
withPhone: this.dataset.withPhone === 'true',
acceptFileTypes: this.dataset.acceptFileTypes,
isDirectUpload: this.dataset.isDirectUpload === 'true' isDirectUpload: this.dataset.isDirectUpload === 'true'
}) })
+5 -3
View File
@@ -113,7 +113,7 @@
</template> </template>
<script> <script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3 } from '@tabler/icons-vue' import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck } from '@tabler/icons-vue'
export default { export default {
name: 'FieldArea', name: 'FieldArea',
@@ -179,7 +179,8 @@ export default {
select: 'Select', select: 'Select',
checkbox: 'Checkbox', checkbox: 'Checkbox',
radio: 'Radio', radio: 'Radio',
multiple: 'Multiple Select' multiple: 'Multiple Select',
phone: 'Phone'
} }
}, },
fieldIcons () { fieldIcons () {
@@ -193,7 +194,8 @@ export default {
checkbox: IconCheckbox, checkbox: IconCheckbox,
radio: IconCircleDot, radio: IconCircleDot,
cells: IconColumns3, cells: IconColumns3,
multiple: IconChecks multiple: IconChecks,
phone: IconPhoneCheck
} }
}, },
image () { image () {
+1 -1
View File
@@ -140,7 +140,7 @@ export default {
fetch(this.baseUrl + `/send_submission_email.json?submitter_slug=${this.submitterSlug}`, { fetch(this.baseUrl + `/send_submission_email.json?submitter_slug=${this.submitterSlug}`, {
method: 'POST' method: 'POST'
}).then(() => { }).then(() => {
alert('Email has been sent') alert(this.t('email_has_been_sent'))
}).finally(() => { }).finally(() => {
this.isSendingCopy = false this.isSendingCopy = false
}) })
+93 -19
View File
@@ -265,6 +265,16 @@
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]" @attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
/> />
<PhoneStep
v-else-if="currentField.type === 'phone'"
ref="currentStep"
v-model="values[currentField.uuid]"
:field="currentField"
:default-value="submitter.phone"
:submitter-slug="submitterSlug"
@focus="$refs.areas.scrollIntoField(currentField)"
@submit="submitStep"
/>
</div> </div>
<div class="mt-6 md:mt-8"> <div class="mt-6 md:mt-8">
<button <button
@@ -295,7 +305,7 @@
:is-demo="isDemo" :is-demo="isDemo"
:attribution="attribution" :attribution="attribution"
:with-confetti="withConfetti" :with-confetti="withConfetti"
:can-send-email="canSendEmail && submitter.email" :can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
/> />
<div class="flex justify-center"> <div class="flex justify-center">
@@ -320,6 +330,7 @@ import ImageStep from './image_step'
import SignatureStep from './signature_step' import SignatureStep from './signature_step'
import AttachmentStep from './attachment_step' import AttachmentStep from './attachment_step'
import MultiSelectStep from './multi_select_step' import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step'
import FormCompleted from './completed' import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { t } from './i18n' import { t } from './i18n'
@@ -334,6 +345,7 @@ export default {
MultiSelectStep, MultiSelectStep,
IconInnerShadowTop, IconInnerShadowTop,
IconArrowsDiagonal, IconArrowsDiagonal,
PhoneStep,
IconArrowsDiagonalMinimize2, IconArrowsDiagonalMinimize2,
FormCompleted FormCompleted
}, },
@@ -409,7 +421,8 @@ export default {
isCompleted: false, isCompleted: false,
isFormVisible: true, isFormVisible: true,
currentStep: 0, currentStep: 0,
isSubmitting: false isSubmitting: false,
recalculateButtonDisabledKey: ''
} }
}, },
computed: { computed: {
@@ -423,9 +436,13 @@ export default {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4 return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4
}, },
isButtonDisabled () { isButtonDisabled () {
return this.isSubmitting || if (this.recalculateButtonDisabledKey) {
return this.isSubmitting ||
(this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) || (this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) ||
(this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted) (this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted)
} else {
return false
}
}, },
currentField () { currentField () {
return this.currentStepFields[0] return this.currentStepFields[0]
@@ -473,13 +490,58 @@ export default {
scrollbox.parentNode.style.maxHeight = '-webkit-fill-available' scrollbox.parentNode.style.maxHeight = '-webkit-fill-available'
}) })
} }
this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random()
this.maybeTrackEmailClick().finally(() => {
this.trackViewForm()
})
})
}, },
methods: { methods: {
t, t,
maybeTrackEmailClick () {
const queryParams = new URLSearchParams(window.location.search)
if (queryParams.has('t')) {
const t = queryParams.get('t')
queryParams.delete('t')
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
window.history.replaceState({}, document.title, newUrl)
return fetch(this.baseUrl + '/api/submitter_email_clicks', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
t,
submitter_slug: this.submitterSlug
})
})
} else {
return Promise.resolve({})
}
},
trackViewForm () {
fetch(this.baseUrl + '/api/submitter_form_views', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
submitter_slug: this.submitterSlug
})
})
},
goToStep (step, scrollToArea = false, clickUpload = false) { goToStep (step, scrollToArea = false, clickUpload = false) {
this.currentStep = this.stepFields.indexOf(step) this.currentStep = this.stepFields.indexOf(step)
this.$nextTick(() => { this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random()
if (scrollToArea) { if (scrollToArea) {
this.$refs.areas.scrollIntoField(step[0]) this.$refs.areas.scrollIntoField(step[0])
} }
@@ -504,28 +566,40 @@ export default {
async submitStep () { async submitStep () {
this.isSubmitting = true this.isSubmitting = true
const stepPromise = this.currentField.type === 'signature' const stepPromise = ['signature', 'phone'].includes(this.currentField.type)
? this.$refs.currentStep.submit ? this.$refs.currentStep.submit
: () => Promise.resolve({}) : () => Promise.resolve({})
await stepPromise() stepPromise().then(async () => {
const formData = new FormData(this.$refs.form)
const formData = new FormData(this.$refs.form) if (this.currentStep === this.stepFields.length - 1) {
formData.append('completed', 'true')
if (this.currentStep === this.stepFields.length - 1) {
formData.append('completed', 'true')
}
this.saveStep(formData).then(response => {
const nextStep = this.stepFields[this.currentStep + 1]
if (nextStep) {
this.goToStep(this.stepFields[this.currentStep + 1], true)
} else {
this.isCompleted = true
} }
await this.saveStep(formData).then(async (response) => {
if (response.status === 422) {
const data = await response.json()
alert(data.error || 'Value is invalid')
return Promise.reject(new Error(data.error))
}
const nextStep = this.stepFields[this.currentStep + 1]
if (nextStep) {
this.goToStep(this.stepFields[this.currentStep + 1], true)
} else {
this.isCompleted = true
}
}).catch(error => {
console.error('Error submitting form:', error)
}).finally(() => {
this.isSubmitting = false
})
}).catch(error => { }).catch(error => {
console.error('Error submitting form:', error) console.log(error)
}).finally(() => { }).finally(() => {
this.isSubmitting = false this.isSubmitting = false
}) })
+100 -10
View File
@@ -13,7 +13,15 @@ const en = {
form_has_been_completed: 'Form has been completed!', form_has_been_completed: 'Form has been completed!',
create_a_free_account: 'Create a Free Account', create_a_free_account: 'Create a Free Account',
signed_with: 'Signed with', signed_with: 'Signed with',
open_source_documents_software: 'open source documents software' open_source_documents_software: 'open source documents software',
verified_phone_number: 'Verify Phone Number',
use_international_format: 'Use internatioanl format: +1xxx',
six_digits_code: '6-digit code',
change_phone_number: 'Change phone number',
sending: 'Sending...',
resend_code: 'Re-send code',
verification_code_has_been_resent: 'Verification code has been re-sent via SMS',
email_has_been_sent: 'Email has been sent'
} }
const es = { const es = {
@@ -31,7 +39,15 @@ const es = {
form_has_been_completed: '¡El formulario ha sido completado!', form_has_been_completed: '¡El formulario ha sido completado!',
create_a_free_account: 'Crear una Cuenta Gratuita', create_a_free_account: 'Crear una Cuenta Gratuita',
signed_with: 'Firmado con', signed_with: 'Firmado con',
open_source_documents_software: 'software de documentos de código abierto' open_source_documents_software: 'software de documentos de código abierto',
verified_phone_number: 'Verificar número de teléfono',
use_international_format: 'Usar formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Cambiar número de teléfono',
sending: 'Enviando...',
resend_code: 'Reenviar código',
verification_code_has_been_resent: 'El código de verificación ha sido reenviado por SMS',
email_has_been_sent: 'El correo electrónico ha sido enviado'
} }
const it = { const it = {
@@ -49,7 +65,15 @@ const it = {
form_has_been_completed: 'Il modulo è stato completato!', form_has_been_completed: 'Il modulo è stato completato!',
create_a_free_account: 'Crea un Account Gratuito', create_a_free_account: 'Crea un Account Gratuito',
signed_with: 'Firmato con', signed_with: 'Firmato con',
open_source_documents_software: 'software di documenti open source' open_source_documents_software: 'software di documenti open source',
verified_phone_number: 'Verifica numero di telefono',
use_international_format: 'Usa formato internazionale: +1xxx',
six_digits_code: 'Codice a 6 cifre',
change_phone_number: 'Cambia numero di telefono',
sending: 'Invio in corso...',
resend_code: 'Rinvia codice',
verification_code_has_been_resent: 'Il codice di verifica è stato rinviato tramite SMS',
email_has_been_sent: "L'email è stata inviata"
} }
const de = { const de = {
@@ -67,7 +91,15 @@ const de = {
form_has_been_completed: 'Formular wurde ausgefüllt!', form_has_been_completed: 'Formular wurde ausgefüllt!',
create_a_free_account: 'Kostenloses Konto erstellen', create_a_free_account: 'Kostenloses Konto erstellen',
signed_with: 'Unterschrieben mit', signed_with: 'Unterschrieben mit',
open_source_documents_software: 'Open-Source-Dokumentensoftware' open_source_documents_software: 'Open-Source-Dokumentensoftware',
verified_phone_number: 'Telefonnummer überprüfen',
use_international_format: 'Internationales Format verwenden: +1xxx',
six_digits_code: '6-stelliger Code',
change_phone_number: 'Telefonnummer ändern',
sending: 'Senden...',
resend_code: 'Code erneut senden',
verification_code_has_been_resent: 'Die Verifizierungscode wurde erneut per SMS gesendet',
email_has_been_sent: 'Die E-Mail wurde gesendet'
} }
const fr = { const fr = {
@@ -85,7 +117,15 @@ const fr = {
form_has_been_completed: 'Le formulaire a été complété !', form_has_been_completed: 'Le formulaire a été complété !',
create_a_free_account: 'Créer un Compte Gratuit', create_a_free_account: 'Créer un Compte Gratuit',
signed_with: 'Signé avec', signed_with: 'Signé avec',
open_source_documents_software: 'logiciel de documents open source' open_source_documents_software: 'logiciel de documents open source',
verified_phone_number: 'Vérifier le numéro de téléphone',
use_international_format: 'Utiliser le format international : +1xxx',
six_digits_code: 'Code à 6 chiffres',
change_phone_number: 'Changer le numéro de téléphone',
sending: 'Envoi en cours...',
resend_code: 'Renvoyer le code',
verification_code_has_been_resent: 'Le code de vérification a été renvoyé par SMS',
email_has_been_sent: "L'email a été envoyé"
} }
const pl = { const pl = {
@@ -103,7 +143,15 @@ const pl = {
form_has_been_completed: 'Formularz został wypełniony!', form_has_been_completed: 'Formularz został wypełniony!',
create_a_free_account: 'Utwórz darmowe konto', create_a_free_account: 'Utwórz darmowe konto',
signed_with: 'Podpisane za pomocą', signed_with: 'Podpisane za pomocą',
open_source_documents_software: 'oprogramowanie do dokumentów open source' open_source_documents_software: 'oprogramowanie do dokumentów open source',
verified_phone_number: 'Zweryfikuj numer telefonu',
use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
six_digits_code: '6-cyfrowy kod',
change_phone_number: 'Zmień numer telefonu',
sending: 'Wysyłanie...',
resend_code: 'Ponownie wyślij kod',
verification_code_has_been_resent: 'Kod weryfikacyjny został ponownie wysłany',
email_has_been_sent: 'E-mail został wysłany'
} }
const uk = { const uk = {
@@ -121,7 +169,15 @@ const uk = {
form_has_been_completed: 'Форму заповнено!', form_has_been_completed: 'Форму заповнено!',
create_a_free_account: 'Створити безкоштовний обліковий запис', create_a_free_account: 'Створити безкоштовний обліковий запис',
signed_with: 'Підписано за допомогою', signed_with: 'Підписано за допомогою',
open_source_documents_software: 'відкритий програмний засіб для документів' open_source_documents_software: 'відкритий програмний засіб для документів',
verified_phone_number: 'Підтвердіть номер телефону',
use_international_format: 'Використовуйте міжнародний формат: +1xxx',
six_digits_code: '6-значний код',
change_phone_number: 'Змінити номер телефону',
sending: 'Надсилаю...',
resend_code: 'Повторно відправити код',
verification_code_has_been_resent: 'Код підтвердження був повторно надісланий',
email_has_been_sent: 'Електронний лист був відправлений'
} }
const cs = { const cs = {
@@ -139,14 +195,48 @@ const cs = {
form_has_been_completed: 'Formulář byl dokončen!', form_has_been_completed: 'Formulář byl dokončen!',
create_a_free_account: 'Vytvořit bezplatný účet', create_a_free_account: 'Vytvořit bezplatný účet',
signed_with: 'Podepsáno pomocí', signed_with: 'Podepsáno pomocí',
open_source_documents_software: 'open source software pro dokumenty' open_source_documents_software: 'open source software pro dokumenty',
verified_phone_number: 'Ověřte telefonní číslo',
use_international_format: 'Použijte mezinárodní formát: +1xxx',
six_digits_code: '6-místný kód',
change_phone_number: 'Změnit telefonní číslo',
sending: 'Odesílání...',
resend_code: 'Znovu odeslat kód',
verification_code_has_been_resent: 'Ověřovací kód byl znovu odeslán',
email_has_been_sent: 'E-mail byl odeslán'
} }
const i18n = { en, es, it, de, fr, pl, uk, cs } const pt = {
submit_form: 'Enviar Formulário',
type_here: 'Digite aqui',
optional: 'opcional',
select_your_option: 'Selecione sua opção',
complete_hightlighted_checkboxes_and_click: 'Complete as caixas de seleção destacadas e clique',
submit: 'enviar',
next: 'próximo',
click_to_upload: 'Clique para fazer o upload',
or_drag_and_drop_files: 'ou arraste e solte arquivos',
send_copy_via_email: 'Enviar cópia por e-mail',
download: 'Baixar',
form_has_been_completed: 'O formulário foi concluído!',
create_a_free_account: 'Criar uma Conta Gratuita',
signed_with: 'Assinado com',
open_source_documents_software: 'software de documentos de código aberto',
verified_phone_number: 'Verificar Número de Telefone',
use_international_format: 'Use formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Alterar número de telefone',
sending: 'Enviando...',
resend_code: 'Reenviar código',
verification_code_has_been_resent: 'O código de verificação foi reenviado via SMS',
email_has_been_sent: 'Email enviado'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt }
const browserLanguage = (navigator.language || navigator.userLanguage || 'en').split('-')[0] const browserLanguage = (navigator.language || navigator.userLanguage || 'en').split('-')[0]
const t = (key) => i18n[browserLanguage][key] || i18n.en[key] || key const t = (key) => i18n[browserLanguage]?.[key] || i18n.en[key] || key
export default i18n export default i18n
export { t } export { t }
@@ -0,0 +1,155 @@
<template>
<div>
<label
:for="isCodeSent ? 'one_time_code' : field.uuid"
class="label text-2xl mb-2"
>{{ field.name || t('verified_phone_number') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
<div>
<input
type="hidden"
name="normalize_phone"
value="true"
>
<div v-if="isCodeSent">
<input
id="one_time_code"
class="base-input !text-2xl w-full text-center"
name="one_time_code"
type="text"
autocomplete="one-time-code"
:placeholder="t('six_digits_code')"
required
maxlength="6"
autofocus
inputmode="decimal"
@input="onInputCode"
>
<div class="flex justify-between mt-2 -mb-2 md:-mb-4">
<a
href="#"
class="link"
@click.prevent="isCodeSent = false"
>
{{ t('change_phone_number') }}
</a>
<a
href="#"
class="link"
@click.prevent="resendCode"
>
{{ isResendLoading ? t('sending') : t('resend_code') }}
</a>
</div>
</div>
<input
v-show="!isCodeSent"
:id="field.uuid"
ref="phone"
:value="modelValue || defaultValue"
class="base-input !text-2xl w-full"
autocomplete="tel"
pattern="^\+[0-9\s\-]+$"
:oninvalid="`this.value ? this.setCustomValidity('${t('use_international_format')}...') : ''`"
oninput="this.setCustomValidity('')"
type="tel"
inputmode="tel"
:required="field.required"
placeholder="+1 234 567-8900"
:name="`values[${field.uuid}]`"
@input="$emit('update:model-value', $event.target.value)"
@focus="$emit('focus')"
>
</div>
</div>
</template>
<script>
function throttle (func, delay) {
let lastCallTime = 0
return function (...args) {
const now = Date.now()
if (now - lastCallTime >= delay) {
func.apply(this, args)
lastCallTime = now
}
}
}
export default {
name: 'PhoneStep',
inject: ['t', 'baseUrl'],
props: {
field: {
type: Object,
required: true
},
submitterSlug: {
type: String,
required: true
},
modelValue: {
type: String,
required: false,
default: ''
},
defaultValue: {
type: String,
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus', 'submit'],
data () {
return {
isCodeSent: false,
isResendLoading: false
}
},
methods: {
emitSubmit: throttle(function (e) {
this.$emit('submit')
}, 1000),
onInputCode (e) {
if (e.target.value.length === 6) {
this.emitSubmit()
}
},
resendCode () {
this.isResendLoading = true
this.sendVerificationCode().finally(() => {
alert(this.t('verification_code_has_been_resent'))
this.isResendLoading = false
})
},
sendVerificationCode () {
return fetch(this.baseUrl + '/api/send_phone_verification_code', {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
phone: this.$refs.phone.value
}),
headers: { 'Content-Type': 'application/json' }
})
},
async submit () {
if (!this.isCodeSent) {
this.sendVerificationCode()
this.$emit('update:model-value', this.$refs.phone.value)
this.isCodeSent = true
return Promise.reject(new Error('verify with code'))
} else {
return Promise.resolve({})
}
}
}
}
</script>
+15 -1
View File
@@ -75,6 +75,7 @@
:with-arrows="template.schema.length > 1" :with-arrows="template.schema.length > 1"
:item="item" :item="item"
:document="sortedDocuments[index]" :document="sortedDocuments[index]"
:accept-file-types="acceptFileTypes"
:template="template" :template="template"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
@scroll-to="scrollIntoDocument(item)" @scroll-to="scrollIntoDocument(item)"
@@ -90,6 +91,7 @@
> >
<Upload <Upload
v-if="sortedDocuments.length" v-if="sortedDocuments.length"
:accept-file-types="acceptFileTypes"
:template-id="template.id" :template-id="template.id"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
@success="updateFromUpload" @success="updateFromUpload"
@@ -104,6 +106,7 @@
<Dropzone <Dropzone
v-if="!sortedDocuments.length" v-if="!sortedDocuments.length"
:template-id="template.id" :template-id="template.id"
:accept-file-types="acceptFileTypes"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
@success="updateFromUpload" @success="updateFromUpload"
/> />
@@ -237,6 +240,7 @@ export default {
save: this.save, save: this.save,
baseFetch: this.baseFetch, baseFetch: this.baseFetch,
backgroundColor: this.backgroundColor, backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
selectedAreaRef: computed(() => this.selectedAreaRef) selectedAreaRef: computed(() => this.selectedAreaRef)
} }
}, },
@@ -255,6 +259,11 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
baseUrl: { baseUrl: {
type: String, type: String,
required: false, required: false,
@@ -270,6 +279,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
withPhone: {
type: Boolean,
required: false,
default: false
},
fetchOptions: { fetchOptions: {
type: Object, type: Object,
required: false, required: false,
@@ -336,7 +350,7 @@ export default {
onWindowResize (e) { onWindowResize (e) {
const breakpointLg = 1024 const breakpointLg = 1024
this.isBreakpointLg = this.$el.getRootNode().querySelector('div').offsetWidth < breakpointLg this.isBreakpointLg = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth < breakpointLg
}, },
setDocumentRefs (el) { setDocumentRefs (el) {
if (el) { if (el) {
+10 -3
View File
@@ -42,7 +42,7 @@
ref="input" ref="input"
type="file" type="file"
name="files[]" name="files[]"
accept="image/*, application/pdf" :accept="acceptFileTypes"
multiple multiple
@change="upload" @change="upload"
> >
@@ -67,6 +67,11 @@ export default {
type: [Number, String], type: [Number, String],
required: true required: true
}, },
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: { isDirectUpload: {
type: Boolean, type: Boolean,
required: true, required: true,
@@ -89,8 +94,10 @@ export default {
return 'Uploading...' return 'Uploading...'
} else if (this.isProcessing) { } else if (this.isProcessing) {
return 'Processing...' return 'Processing...'
} else { } else if (this.acceptFileTypes === 'image/*, application/pdf') {
return 'Add PDF documents or images' return 'Add PDF documents or images'
} else {
return 'Add documents or images'
} }
} }
}, },
@@ -102,7 +109,7 @@ export default {
methods: { methods: {
upload: Upload.methods.upload, upload: Upload.methods.upload,
onDropFiles (e) { onDropFiles (e) {
if ([...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)/))) { if (this.acceptFileTypes !== 'image/*, application/pdf' || [...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)/))) {
this.$refs.input.files = e.dataTransfer.files this.$refs.input.files = e.dataTransfer.files
this.upload() this.upload()
@@ -29,8 +29,11 @@
@update:model-value="$emit('name-change', selectedSubmitter)" @update:model-value="$emit('name-change', selectedSubmitter)"
/> />
</div> </div>
<span> <span class="flex items-center">
▼ <IconPlus
width="18"
height="18"
/>
</span> </span>
</label> </label>
<ul <ul
@@ -86,7 +89,7 @@
</template> </template>
<script> <script>
import { IconUserPlus, IconTrashX } from '@tabler/icons-vue' import { IconUserPlus, IconTrashX, IconPlus } from '@tabler/icons-vue'
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
import { v4 } from 'uuid' import { v4 } from 'uuid'
@@ -95,6 +98,7 @@ export default {
components: { components: {
IconUserPlus, IconUserPlus,
Contenteditable, Contenteditable,
IconPlus,
IconTrashX IconTrashX
}, },
props: { props: {
+23 -18
View File
@@ -18,33 +18,36 @@
:class="menuClasses" :class="menuClasses"
@click="closeDropdown" @click="closeDropdown"
> >
<li <template
v-for="(icon, type) in fieldIcons" v-for="(icon, type) in fieldIcons"
:key="type" :key="type"
> >
<a <li v-if="withPhone || type !== 'phone'">
href="#" <a
class="text-sm py-1 px-2" href="#"
:class="{ 'active': type === modelValue }" class="text-sm py-1 px-2"
@click.prevent="$emit('update:model-value', type)" :class="{ 'active': type === modelValue }"
> @click.prevent="$emit('update:model-value', type)"
<component >
:is="icon" <component
:stroke-width="1.6" :is="icon"
:width="20" :stroke-width="1.6"
/> :width="20"
{{ fieldNames[type] }} />
</a> {{ fieldNames[type] }}
</li> </a>
</li>
</template>
</ul> </ul>
</span> </span>
</template> </template>
<script> <script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3 } from '@tabler/icons-vue' import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck } from '@tabler/icons-vue'
export default { export default {
name: 'FiledTypeDropdown', name: 'FiledTypeDropdown',
inject: ['withPhone'],
props: { props: {
modelValue: { modelValue: {
type: String, type: String,
@@ -79,7 +82,8 @@ export default {
checkbox: 'Checkbox', checkbox: 'Checkbox',
multiple: 'Multiple', multiple: 'Multiple',
radio: 'Radio', radio: 'Radio',
cells: 'Cells' cells: 'Cells',
phone: 'Phone'
} }
}, },
fieldIcons () { fieldIcons () {
@@ -93,7 +97,8 @@ export default {
checkbox: IconCheckbox, checkbox: IconCheckbox,
cells: IconColumns3, cells: IconColumns3,
multiple: IconChecks, multiple: IconChecks,
radio: IconCircleDot radio: IconCircleDot,
phone: IconPhoneCheck
} }
} }
}, },
+72 -40
View File
@@ -25,49 +25,79 @@
/> />
</div> </div>
<div class="grid grid-cols-3 gap-1 pb-2"> <div class="grid grid-cols-3 gap-1 pb-2">
<button <template
v-for="(icon, type) in fieldIcons" v-for="(icon, type) in fieldIcons"
:key="type" :key="type"
draggable="true"
class="flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
@dragstart="onDragstart(type)"
@dragend="$emit('drag-end')"
@click="addField(type)"
> >
<div class="w-0 absolute left-0"> <button
<svg v-if="withPhone || type != 'phone'"
xmlns="http://www.w3.org/2000/svg" draggable="true"
class="cursor-grab" class="flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
width="18" :style="{ backgroundColor: backgroundColor }"
height="18" @dragstart="onDragstart(type)"
viewBox="0 0 24 24" @dragend="$emit('drag-end')"
stroke-width="1.5" @click="addField(type)"
stroke="currentColor" >
fill="none" <div class="w-0 absolute left-0">
stroke-linecap="round" <svg
stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"
> class="cursor-grab"
<path width="18"
stroke="none" height="18"
d="M0 0h24v24H0z" viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none" fill="none"
/> stroke-linecap="round"
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /> stroke-linejoin="round"
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /> >
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /> <path
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /> stroke="none"
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /> d="M0 0h24v24H0z"
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /> fill="none"
</svg> />
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
</div>
</button>
<div
v-else
class="tooltip flex"
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
>
<a
href="https://www.docuseal.co/pricing"
target="_blank"
class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
>
<div class="w-0 absolute left-0">
<IconLock
width="18"
height="18"
stroke-width="1.5"
/>
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
</div>
</a>
</div> </div>
<div class="flex items-center flex-col px-2 py-2"> </template>
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
</div>
</button>
</div> </div>
<div <div
v-if="fields.length < 4" v-if="fields.length < 4"
@@ -95,14 +125,16 @@ import Field from './field'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import FieldType from './field_type' import FieldType from './field_type'
import FieldSubmitter from './field_submitter' import FieldSubmitter from './field_submitter'
import { IconLock } from '@tabler/icons-vue'
export default { export default {
name: 'TemplateFields', name: 'TemplateFields',
components: { components: {
Field, Field,
FieldSubmitter FieldSubmitter,
IconLock
}, },
inject: ['save', 'backgroundColor'], inject: ['save', 'backgroundColor', 'withPhone'],
props: { props: {
fields: { fields: {
type: Array, type: Array,
@@ -18,6 +18,7 @@
<ReplaceButton <ReplaceButton
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
:template-id="template.id" :template-id="template.id"
:accept-file-types="acceptFileTypes"
class="opacity-0 group-hover:opacity-100" class="opacity-0 group-hover:opacity-100"
@click.stop @click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })" @success="$emit('replace', { replaceSchemaItem: item, ...$event })"
@@ -94,6 +95,11 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: { isDirectUpload: {
type: Boolean, type: Boolean,
required: true, required: true,
+6 -1
View File
@@ -14,7 +14,7 @@
ref="input" ref="input"
name="files[]" name="files[]"
type="file" type="file"
accept="image/*, application/pdf" :accept="acceptFileTypes"
@change="upload" @change="upload"
> >
</form> </form>
@@ -32,6 +32,11 @@ export default {
type: [Number, String], type: [Number, String],
required: true required: true
}, },
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: { isDirectUpload: {
type: Boolean, type: Boolean,
required: true, required: true,
+6 -1
View File
@@ -33,7 +33,7 @@
ref="input" ref="input"
name="files[]" name="files[]"
type="file" type="file"
accept="image/*, application/pdf" :accept="acceptFileTypes"
multiple multiple
@change="upload" @change="upload"
> >
@@ -56,6 +56,11 @@ export default {
type: [Number, String], type: [Number, String],
required: true required: true
}, },
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: { isDirectUpload: {
type: Boolean, type: Boolean,
required: true, required: true,
@@ -1,7 +0,0 @@
# frozen_string_literal: true
class GenerateSubmitterResultAttachmentsJob < ApplicationJob
def perform(submitter)
Submissions::EnsureResultGenerated.call(submitter)
end
end
@@ -0,0 +1,53 @@
# frozen_string_literal: true
class ProcessSubmitterCompletionJob < ApplicationJob
def perform(submitter)
is_all_completed = !submitter.submission.submitters.exists?(completed_at: nil)
if !is_all_completed && submitter.submission.submitters_order_preserved?
enqueue_next_submitter_request_notification(submitter)
end
Submissions::EnsureResultGenerated.call(submitter)
if submitter.account.encrypted_configs.exists?(key: EncryptedConfig::WEBHOOK_URL_KEY)
SendWebhookRequestJob.perform_later(submitter)
end
return unless is_all_completed
return if submitter.completed_at != submitter.submission.submitters.maximum(:completed_at)
Submissions::GenerateAuditTrail.call(submitter.submission)
enqueue_emails(submitter)
end
def enqueue_emails(submitter)
user = submitter.submission.created_by_user || submitter.template.author
if submitter.template.account.users.exists?(id: user.id)
bcc = submitter.submission.template.account.account_configs.find_by(key: 'bcc_emails')&.value
SubmitterMailer.completed_email(submitter, user, bcc:).deliver_later!
end
to = submitter.submission.submitters.sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ')
SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! if to.present?
end
def enqueue_next_submitter_request_notification(submitter)
next_submitter_item =
submitter.submission.template_submitters.find do |e|
sub = submitter.submission.submitters.find { |s| s.uuid == e['uuid'] }
sub.completed_at.blank? && sub.sent_at.blank?
end
return unless next_submitter_item
next_submitter = submitter.submission.submitters.find { |s| s.uuid == next_submitter_item['uuid'] }
Submitters.send_signature_requests([next_submitter], send_email: true)
end
end
@@ -0,0 +1,12 @@
# frozen_string_literal: true
class SendSubmitterInvitationEmailJob < ApplicationJob
def perform(submitter)
SubmitterMailer.invitation_email(submitter).deliver_now!
SubmissionEvent.create!(submitter:, event_type: 'send_email')
submitter.sent_at ||= Time.current
submitter.save
end
end
+30 -12
View File
@@ -17,42 +17,46 @@ class SubmitterMailer < ApplicationMailer
'You have been invited to submit a form' 'You have been invited to submit a form'
end end
mail(to: @submitter.email, mail(to: @submitter.friendly_name,
from: from_address_for_submitter(submitter), from: from_address_for_submitter(submitter),
subject:, subject:,
reply_to: submitter.submission.created_by_user&.friendly_name) reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name)
end end
def completed_email(submitter, user) def completed_email(submitter, user, bcc: nil)
@current_account = submitter.submission.template.account @current_account = submitter.submission.template.account
@submitter = submitter @submitter = submitter
@submission = submitter.submission
@user = user @user = user
Submissions::EnsureResultGenerated.call(submitter)
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY) @email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
add_completed_email_attachments!(submitter)
subject = subject =
if @email_config if @email_config
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:) ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
else else
%(#{submitter.email} has completed the "#{submitter.submission.template.name}" form) submitters = submitter.submission.submitters.order(:completed_at)
.map { |e| e.name || e.email || e.phone }.join(', ')
%(#{submitter.submission.template.name} has been completed by #{submitters})
end end
mail(from: from_address_for_submitter(submitter), mail(from: from_address_for_submitter(submitter),
to: user.email, to: user.friendly_name,
bcc:,
subject:) subject:)
end end
def documents_copy_email(submitter) def documents_copy_email(submitter, to: nil)
@current_account = submitter.submission.template.account @current_account = submitter.submission.template.account
@submitter = submitter @submitter = submitter
Submissions::EnsureResultGenerated.call(@submitter) Submissions::EnsureResultGenerated.call(@submitter)
@documents = Submitters.select_attachments_for_download(submitter) @documents = add_completed_email_attachments!(submitter)
@documents.each do |attachment|
attachments[attachment.filename.to_s] = attachment.download
end
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY) @email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
@@ -64,12 +68,26 @@ class SubmitterMailer < ApplicationMailer
end end
mail(from: from_address_for_submitter(submitter), mail(from: from_address_for_submitter(submitter),
to: submitter.email, to: to || @submitter.friendly_name,
subject:) subject:)
end end
private private
def add_completed_email_attachments!(submitter)
documents = Submitters.select_attachments_for_download(submitter)
documents.each do |attachment|
attachments[attachment.filename.to_s] = attachment.download
end
if submitter.submission.audit_trail.present?
attachments[submitter.submission.audit_trail.filename.to_s] = submitter.submission.audit_trail.download
end
documents
end
def from_address_for_submitter(submitter) def from_address_for_submitter(submitter)
submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name
end end
+3 -5
View File
@@ -36,12 +36,10 @@ class AccountConfig < ApplicationRecord
'{{account.name}}' '{{account.name}}'
}, },
SUBMITTER_COMPLETED_EMAIL_KEY => { SUBMITTER_COMPLETED_EMAIL_KEY => {
'subject' => '{{submitter.email}} has completed the "{{template.name}}" form', 'subject' => '{{template.name}} has been completed by {{submission.submitters}}',
'body' => "Hi,\n\n" \ 'body' => "Hi,\n\n" \
"{{submitter.email}} has completed the \"{{template.name}}\" form:\n\n" \ "\"{{template.name}}\" form has been completed by {{submission.submitters}}\n\n" \
"{{submission.link}}\n\n" \ '{{submission.link}}'
"Thanks,\n" \
'{{account.name}}'
}, },
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => { SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => {
'subject' => 'Your copy of documents', 'subject' => 'Your copy of documents',
+10
View File
@@ -7,6 +7,7 @@
# id :bigint not null, primary key # id :bigint not null, primary key
# deleted_at :datetime # deleted_at :datetime
# source :text not null # source :text not null
# submitters_order :string not null
# template_fields :text # template_fields :text
# template_schema :text # template_schema :text
# template_submitters :text # template_submitters :text
@@ -31,12 +32,16 @@ class Submission < ApplicationRecord
belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :created_by_user, class_name: 'User', optional: true
has_many :submitters, dependent: :destroy has_many :submitters, dependent: :destroy
has_many :submission_events, dependent: :destroy
serialize :template_fields, JSON serialize :template_fields, JSON
serialize :template_schema, JSON serialize :template_schema, JSON
serialize :template_submitters, JSON serialize :template_submitters, JSON
attribute :source, :string, default: 'link' attribute :source, :string, default: 'link'
attribute :submitters_order, :string, default: 'random'
has_one_attached :audit_trail
has_many :template_schema_documents, has_many :template_schema_documents,
->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) }, ->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) },
@@ -50,4 +55,9 @@ class Submission < ApplicationRecord
embed: 'embed', embed: 'embed',
link: 'link' link: 'link'
}, scope: false, prefix: true }, scope: false, prefix: true
enum :submitters_order, {
random: 'random',
preserved: 'preserved'
}, scope: false, prefix: true
end end
+53
View File
@@ -0,0 +1,53 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: submission_events
#
# id :bigint not null, primary key
# data :text not null
# event_timestamp :datetime not null
# event_type :string not null
# created_at :datetime not null
# updated_at :datetime not null
# submission_id :bigint not null
# submitter_id :bigint
#
# Indexes
#
# index_submission_events_on_submission_id (submission_id)
# index_submission_events_on_submitter_id (submitter_id)
#
# Foreign Keys
#
# fk_rails_... (submission_id => submissions.id)
# fk_rails_... (submitter_id => submitters.id)
#
class SubmissionEvent < ApplicationRecord
belongs_to :submission
belongs_to :submitter, optional: true
attribute :data, :string, default: -> { {} }
attribute :event_timestamp, :datetime, default: -> { Time.current }
serialize :data, JSON
before_validation :set_submission_id, on: :create
enum :event_type, {
send_email: 'send_email',
send_sms: 'send_sms',
open_email: 'open_email',
click_email: 'click_email',
click_sms: 'click_sms',
start_form: 'start_form',
view_form: 'view_form',
complete_form: 'complete_form'
}, scope: false
private
def set_submission_id
self.submission_id = submitter&.submission_id
end
end
+9
View File
@@ -44,6 +44,7 @@ class Submitter < ApplicationRecord
has_many_attached :attachments has_many_attached :attachments
has_many :document_generation_events, dependent: :destroy has_many :document_generation_events, dependent: :destroy
has_many :submission_events, dependent: :destroy
def status def status
if completed_at? if completed_at?
@@ -57,6 +58,14 @@ class Submitter < ApplicationRecord
end end
end end
def friendly_name
if name.present?
"#{name} <#{email}>"
else
email
end
end
def status_event_at def status_event_at
completed_at || opened_at || sent_at || created_at completed_at || opened_at || sent_at || created_at
end end
+8 -5
View File
@@ -1,11 +1,14 @@
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %> <% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
<% if @templates.any? %> <% if @templates.any? %>
<div class="flex justify-between mb-4 items-center"> <div class="flex justify-between mb-4 items-center">
<h1 class="text-4xl font-bold">Templates</h1> <h1 class="text-4xl font-bold"><span class="hidden md:inline">Document</span> Templates</h1>
<%= link_to new_template_path, class: 'btn btn-primary text-base btn-md gap-2', data: { turbo_frame: :modal } do %> <div class="flex space-x-2">
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %> <%= render 'templates/upload_button' %>
<span class="hidden md:block">Create</span> <%= link_to new_template_path, class: 'btn btn-primary text-base btn-md gap-2', data: { turbo_frame: :modal } do %>
<% end %> <%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="hidden md:block">Create</span>
<% end %>
</div>
</div> </div>
<div class="grid gap-4 md:grid-cols-3"> <div class="grid gap-4 md:grid-cols-3">
<% @templates.each do |template| %> <% @templates.each do |template| %>
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6"></path>
<path d="M11 13l9 -9"></path>
<path d="M15 4h5v5"></path>
</svg>

After

Width:  |  Height:  |  Size: 432 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"></path>
<path d="M7 9l5 -5l5 5"></path>
<path d="M12 4l0 12"></path>
</svg>

After

Width:  |  Height:  |  Size: 416 B

+1 -1
View File
@@ -17,7 +17,7 @@
</div> </div>
</div> </div>
<div class="text-center text-2xl font-semibold"> <div class="text-center text-2xl font-semibold">
Form has been submitter already Form has been submitted already
</div> </div>
<% if Accounts.can_send_emails?(@submitter.submission.template.account) %> <% if Accounts.can_send_emails?(@submitter.submission.template.account) %>
<div> <div>
@@ -30,10 +30,11 @@
</div> </div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem"> <a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %> <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add Recipient</span> <span>Add New</span>
</a> </a>
</dynamic-list> </dynamic-list>
<div> <div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<%= render 'send_email', f:, template: %> <%= render 'send_email', f:, template: %>
<%= render 'send_sms', f: %> <%= render 'send_sms', f: %>
</div> </div>
+5 -2
View File
@@ -31,11 +31,14 @@
</div> </div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem"> <a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %> <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add Recipient</span> <span>Add New</span>
</a> </a>
</dynamic-list> </dynamic-list>
<% end %> <% end %>
<%= render 'send_email', f:, template: %> <div>
<%= render 'send_email', f:, template: %>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
</div>
<div class="form-control"> <div class="form-control">
<%= f.button button_title(title: 'Add Recipients'), class: 'base-button' %> <%= f.button button_title(title: 'Add Recipients'), class: 'base-button' %>
</div> </div>
+2 -1
View File
@@ -34,10 +34,11 @@
</div> </div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem"> <a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %> <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add Recipient</span> <span>Add New</span>
</a> </a>
</dynamic-list> </dynamic-list>
<%= render 'send_sms', f: %> <%= render 'send_sms', f: %>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<div class="form-control"> <div class="form-control">
<%= f.button button_title(title: 'Add Recipients'), class: 'base-button' %> <%= f.button button_title(title: 'Add Recipients'), class: 'base-button' %>
</div> </div>
+2 -19
View File
@@ -1,8 +1,8 @@
<div class="form-control"> <div class="form-control">
<% is_smtp_configured = Accounts.can_send_emails?(current_account) %> <% is_smtp_configured = Accounts.can_send_emails?(current_account) %>
<%= f.label :send_email, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %> <%= f.label :send_email, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %>
<%= f.check_box :send_email, id: uuid, class: 'base-checkbox', disabled: !is_smtp_configured, onchange: "window.message_field && message_field.classList.toggle('hidden', !event.currentTarget.checked)" %> <%= f.check_box :send_email, id: uuid, class: 'base-checkbox', disabled: !is_smtp_configured, checked: is_smtp_configured %>
<span class="label">Send Email</span> <span class="label">Send emails</span>
<% end %> <% end %>
<% unless is_smtp_configured %> <% unless is_smtp_configured %>
<div class="alert my-4"> <div class="alert my-4">
@@ -18,20 +18,3 @@
</div> </div>
<% end %> <% end %>
</div> </div>
<% unless AccountConfig.exists?(key: AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) %>
<div id="message_field" class="card card-compact bg-base-200 hidden">
<div class="card-body">
<div class="form-control space-y-2">
<span class="label-text">Hi there,</span>
<autoresize-textarea>
<%= f.text_area :message, value: format(SubmitterMailer::DEFAULT_MESSAGE, name: template.name), required: true, class: 'base-textarea !rounded-lg w-full' %>
</autoresize-textarea>
<span class="label-text">
Thanks,
<br>
<%= current_account.name %>
</span>
</div>
</div>
</div>
<% end %>
@@ -0,0 +1,11 @@
<% if template.submitters.size > 1 %>
<div class="form-control">
<%= f.label :preserve_order, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %>
<%= f.check_box :preserve_order, id: uuid, class: 'base-checkbox', checked: template.submissions.last&.submitters_order.in?(['preserved', nil]) %>
<span class="label">Preserve submitters order</span>
<span class="tooltip" data-tip="When checked, notifications will be sent to the second submitter once the form is completed by the previous submitter. Uncheck this option to send notifications to all submitters simultaneously right away.">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
<% end %>
</div>
<% end %>
+6
View File
@@ -5,6 +5,12 @@
<span class="text-3xl font-semibold focus:text-clip"><%= @submission.template.name %></span> <span class="text-3xl font-semibold focus:text-clip"><%= @submission.template.name %></span>
</a> </a>
<div class="space-x-3 flex items-center"> <div class="space-x-3 flex items-center">
<% if @submission.audit_trail.present? %>
<a href="<%= rails_blob_path(@submission.audit_trail) %>" class="white-button" target="_blank">
<%= svg_icon('external_link', class: 'w-6 h-6') %>
Audit Log
</a>
<% end %>
<% if last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %> <% if last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %>
<download-button data-src="<%= submitter_download_index_path(last_submitter.slug) %>" class="base-button"> <download-button data-src="<%= submitter_download_index_path(last_submitter.slug) %>" class="base-button">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton"> <span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
@@ -1,3 +1,3 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %> <% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %> <% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-authenticity-token="<%= form_authenticity_token %>" data-values="<%= submitter.values.to_json %>"></submission-form> <submission-form data-is-demo="<%= Docuseal.demo? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-authenticity-token="<%= form_authenticity_token %>" data-values="<%= submitter.values.to_json %>"></submission-form>
@@ -1,7 +1,7 @@
<% if @email_config %> <% if @email_config %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(@email_config.value['body'], submitter: @submitter)))) %> <%= auto_link(simple_format(h(ReplaceEmailVariables.call(@email_config.value['body'], submitter: @submitter)))) %>
<% else %> <% else %>
<p>Hi <%= @user.first_name %>,</p> <p>Hi there,</p>
<p><%= @submitter.email %> has completed the "<%= @submitter.submission.template.name %>" form.</p> <p>"<%= @submitter.submission.template.name %>" form has been completed by <%= @submitter.submission.submitters.order(:completed_at).map { |e| e.name || e.email || e.phone }.join(', ') %>.</p>
<p><%= link_to 'View Submission', submission_url(@submitter.submission) %></p> <p><%= link_to 'View Submission', submission_url(@submitter.submission) %></p>
<% end %> <% end %>
@@ -3,7 +3,7 @@
<% else %> <% else %>
<p>Hi there,</p> <p>Hi there,</p>
<%= simple_format(@message) %> <%= simple_format(@message) %>
<p><%= link_to 'Submit Form', submit_form_url(slug: @submitter.slug) %></p> <p><%= link_to 'Submit Form', submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email')) %></p>
<p>Please contact us by replying to this email if you didn't request this.</p> <p>Please contact us by replying to this email if you didn't request this.</p>
<p> <p>
Thanks,<br><%= @current_account.name %> Thanks,<br><%= @current_account.name %>
@@ -0,0 +1,17 @@
<%= form_for '', url: templates_upload_path, method: :post, class: 'inline', html: { enctype: 'multipart/form-data' } do %>
<button type="submit" class="btn btn-ghost text-base" onclick="[event.preventDefault(), window.upload_template.click()]">
<span class="enabled">
<span class="flex items-center justify-center space-x-2">
<%= svg_icon('upload', class: 'w-6 h-6 stroke-2') %>
<span class="hidden md:block">Upload</span>
</span>
</span>
<span class="disabled">
<span class="flex items-center justify-center space-x-2">
<%= local_assigns[:icon_disabled] || svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
<span class="hidden md:block">Uploading...</span>
</span>
</span>
</button>
<input id="upload_template" name="files[]" class="hidden" onchange="this.form.requestSubmit()" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls' if Docuseal.multitenant? %>" multiple>
<% end %>
+1 -1
View File
@@ -1,4 +1,4 @@
<%= render 'shared/turbo_modal', title: @base_template ? 'Clone Template' : 'New Template' do %> <%= render 'shared/turbo_modal', title: @base_template ? 'Clone Template' : 'New Document Template' do %>
<%= form_for @template, data: { turbo_frame: :_top } do |f| %> <%= form_for @template, data: { turbo_frame: :_top } do |f| %>
<% if @base_template %> <% if @base_template %>
<%= hidden_field_tag :base_template_id, @base_template.id %> <%= hidden_field_tag :base_template_id, @base_template.id %>
+3
View File
@@ -31,6 +31,8 @@ Rails.application.routes.draw do
namespace :api, defaults: { format: :json } do namespace :api, defaults: { format: :json } do
resources :attachments, only: %i[create] resources :attachments, only: %i[create]
resources :submitter_email_clicks, only: %i[create]
resources :submitter_form_views, only: %i[create]
resources :submissions, only: %i[create] resources :submissions, only: %i[create]
resources :templates, only: %i[update show index] do resources :templates, only: %i[update show index] do
resources :submissions, only: %i[create] resources :submissions, only: %i[create]
@@ -45,6 +47,7 @@ Rails.application.routes.draw do
resources :users, only: %i[new create edit update destroy] resources :users, only: %i[new create edit update destroy]
resources :submissions, only: %i[show destroy] resources :submissions, only: %i[show destroy]
resources :console_redirect, only: %i[index] resources :console_redirect, only: %i[index]
resource :templates_upload, only: %i[create]
resources :templates, only: %i[new create edit show destroy] do resources :templates, only: %i[new create edit show destroy] do
resources :submissions, only: %i[new create] resources :submissions, only: %i[new create]
resources :submissions_export, only: %i[index new] resources :submissions_export, only: %i[index new]
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class AddSubmittersOrderToSubmissions < ActiveRecord::Migration[7.0]
class MigrationSubmission < ApplicationRecord
self.table_name = 'submissions'
end
def change
add_column :submissions, :submitters_order, :string
MigrationSubmission.where(submitters_order: nil).update_all(submitters_order: 'random')
change_column_null :submissions, :submitters_order, false
end
end
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateSubmissionEvents < ActiveRecord::Migration[7.0]
def change
create_table :submission_events do |t|
t.references :submission, null: false, foreign_key: true, index: true
t.references :submitter, null: true, foreign_key: true, index: true
t.text :data, null: false
t.string :event_type, null: false
t.datetime :event_timestamp, null: false
t.timestamps
end
end
end
+16 -1
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[7.0].define(version: 2023_09_02_171216) do ActiveRecord::Schema[7.0].define(version: 2023_09_10_084410) 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 "plpgsql" enable_extension "plpgsql"
@@ -91,6 +91,18 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_02_171216) do
t.index ["account_id"], name: "index_encrypted_configs_on_account_id" t.index ["account_id"], name: "index_encrypted_configs_on_account_id"
end end
create_table "submission_events", force: :cascade do |t|
t.bigint "submission_id", null: false
t.bigint "submitter_id"
t.text "data", null: false
t.string "event_type", null: false
t.datetime "event_timestamp", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["submission_id"], name: "index_submission_events_on_submission_id"
t.index ["submitter_id"], name: "index_submission_events_on_submitter_id"
end
create_table "submissions", force: :cascade do |t| create_table "submissions", force: :cascade do |t|
t.bigint "template_id", null: false t.bigint "template_id", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
@@ -101,6 +113,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_02_171216) do
t.text "template_schema" t.text "template_schema"
t.text "template_submitters" t.text "template_submitters"
t.text "source", null: false t.text "source", null: false
t.string "submitters_order", null: false
t.index ["created_by_user_id"], name: "index_submissions_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_submissions_on_created_by_user_id"
t.index ["template_id"], name: "index_submissions_on_template_id" t.index ["template_id"], name: "index_submissions_on_template_id"
end end
@@ -177,6 +190,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_02_171216) do
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "document_generation_events", "submitters" add_foreign_key "document_generation_events", "submitters"
add_foreign_key "encrypted_configs", "accounts" add_foreign_key "encrypted_configs", "accounts"
add_foreign_key "submission_events", "submissions"
add_foreign_key "submission_events", "submitters"
add_foreign_key "submissions", "templates" add_foreign_key "submissions", "templates"
add_foreign_key "submissions", "users", column: "created_by_user_id" add_foreign_key "submissions", "users", column: "created_by_user_id"
add_foreign_key "submitters", "submissions" add_foreign_key "submitters", "submissions"
+4
View File
@@ -15,4 +15,8 @@ module PdfIcons
def paperclip_io def paperclip_io
@paperclip_io ||= StringIO.new(PATH.join('paperclip.png').read) @paperclip_io ||= StringIO.new(PATH.join('paperclip.png').read)
end end
def logo_io
@logo_io ||= StringIO.new(PATH.join('logo.png').read)
end
end end
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+24 -10
View File
@@ -5,28 +5,26 @@ module ReplaceEmailVariables
SUBMITTER_LINK = '{{submitter.link}}' SUBMITTER_LINK = '{{submitter.link}}'
ACCOUNT_NAME = '{{account.name}}' ACCOUNT_NAME = '{{account.name}}'
SUBMITTER_EMAIL = '{{submitter.email}}' SUBMITTER_EMAIL = '{{submitter.email}}'
SUBMITTER_NAME = '{{submitter.name}}'
SUBMISSION_LINK = '{{submission.link}}' SUBMISSION_LINK = '{{submission.link}}'
SUBMISSION_SUBMITTERS = '{{submission.submitters}}'
DOCUMENTS_LINKS = '{{documents.links}}' DOCUMENTS_LINKS = '{{documents.links}}'
module_function module_function
def call(text, submitter:) def call(text, submitter:)
submitter_link = submitter_link = build_submitter_link(submitter)
Rails.application.routes.url_helpers.submit_form_url(
slug: submitter.slug, **Docuseal.default_url_options
)
if submitter.submission submission_link = build_submission_link(submitter.submission) if submitter.submission
submission_link =
Rails.application.routes.url_helpers.submission_url(
submitter.submission, **Docuseal.default_url_options
)
end
text = text.gsub(TEMAPLTE_NAME, submitter.template.name) if submitter.template text = text.gsub(TEMAPLTE_NAME, submitter.template.name) if submitter.template
text = text.gsub(SUBMITTER_EMAIL, submitter.email) if submitter.email text = text.gsub(SUBMITTER_EMAIL, submitter.email) if submitter.email
text = text.gsub(SUBMITTER_NAME, submitter.name || submitter.email || submitter.phone)
text = text.gsub(SUBMITTER_LINK, submitter_link) text = text.gsub(SUBMITTER_LINK, submitter_link)
text = text.gsub(SUBMISSION_LINK, submission_link) if submission_link text = text.gsub(SUBMISSION_LINK, submission_link) if submission_link
if text.include?(SUBMISSION_SUBMITTERS)
text = text.gsub(SUBMISSION_SUBMITTERS, build_submission_submitters(submitter.submission))
end
text = text.gsub(DOCUMENTS_LINKS, build_documents_links_text(submitter)) text = text.gsub(DOCUMENTS_LINKS, build_documents_links_text(submitter))
text = text.gsub(ACCOUNT_NAME, submitter.template.account.name) if submitter.template text = text.gsub(ACCOUNT_NAME, submitter.template.account.name) if submitter.template
@@ -44,4 +42,20 @@ module ReplaceEmailVariables
"#{link}\n" "#{link}\n"
end.join end.join
end end
def build_submitter_link(submitter)
Rails.application.routes.url_helpers.submit_form_url(
slug: submitter.slug,
t: SubmissionEvents.build_tracking_param(submitter, 'click_email'),
**Docuseal.default_url_options
)
end
def build_submission_link(submission)
Rails.application.routes.url_helpers.submission_url(submission, **Docuseal.default_url_options)
end
def build_submission_submitters(submission)
submission.submitters.order(:completed_at).map { |e| e.name || e.email || e.phone }.join(', ')
end
end end
+33
View File
@@ -0,0 +1,33 @@
# frozen_string_literal: true
module SubmissionEvents
TRACKING_PARAM_LENGTH = 6
EVENT_NAMES = {
send_email: 'Email sent',
send_sms: 'SMS sent',
open_email: 'Email opened',
click_email: 'Email link clicked',
click_sms: 'SMS link clicked',
start_form: 'Submission started',
view_form: 'Form viewed',
complete_form: 'Submission completed'
}.freeze
module_function
def build_tracking_param(submitter, event_type = 'click_email')
Base64.urlsafe_encode64(
[submitter.slug, event_type, Rails.application.secrets.secret_key_base].join(':')
).first(TRACKING_PARAM_LENGTH)
end
def create_with_tracking_data(submitter, event_type, request)
SubmissionEvent.create!(submitter:, event_type:, data: {
ip: request.remote_ip,
ua: request.user_agent,
sid: request.session.id.to_s,
uid: request.env['warden'].user(:user)&.id
}.compact_blank)
end
end
+28 -8
View File
@@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
module Submissions module Submissions
DEFAULT_SUBMITTERS_ORDER = 'random'
module_function module_function
def update_template_fields!(submission) def update_template_fields!(submission)
@@ -24,9 +26,11 @@ module Submissions
end end
end end
def create_from_submitters(template:, user:, submissions_attrs:, source:, mark_as_sent: false) def create_from_submitters(template:, user:, submissions_attrs:, source:, mark_as_sent: false,
submitters_order: DEFAULT_SUBMITTERS_ORDER)
submissions_attrs.map do |attrs| submissions_attrs.map do |attrs|
submission = template.submissions.new(created_by_user: user, source:, template_submitters: template.submitters) submission = template.submissions.new(created_by_user: user, source:,
template_submitters: template.submitters, submitters_order:)
attrs[:submitters].each_with_index do |submitter_attrs, index| attrs[:submitters].each_with_index do |submitter_attrs, index|
uuid = uuid =
@@ -36,15 +40,31 @@ module Submissions
next if uuid.blank? next if uuid.blank?
submission.submitters.new(email: submitter_attrs[:email], is_order_sent = submitters_order == 'random' || index.zero?
phone: submitter_attrs[:phone].to_s.gsub(/[^0-9+]/, ''),
name: submitter_attrs[:name], submission.submitters.new(
sent_at: mark_as_sent && submitter_attrs[:email].present? ? Time.current : nil, email: submitter_attrs[:email],
values: submitter_attrs[:values] || {}, phone: submitter_attrs[:phone].to_s.gsub(/[^0-9+]/, ''),
uuid:) name: submitter_attrs[:name],
sent_at: mark_as_sent && submitter_attrs[:email].present? && is_order_sent ? Time.current : nil,
values: submitter_attrs[:values] || {},
uuid:
)
end end
submission.tap(&:save!) submission.tap(&:save!)
end end
end end
def send_signature_requests(submissions, params)
submissions.each do |submission|
if submission.submitters_order_preserved?
first_submitter = submission.submitters.find { |e| e.uuid == submission.template_submitters.first['uuid'] }
Submitters.send_signature_requests([first_submitter], params)
else
Submitters.send_signature_requests(submission.submitters, params)
end
end
end
end end
+273
View File
@@ -0,0 +1,273 @@
# frozen_string_literal: true
module Submissions
module GenerateAuditTrail
FONT_SIZE = 9
TEXT_COLOR = '525252'
FONT_PATH = '/LiberationSans-Regular.ttf'
FONT_BOLD_PATH = '/LiberationSans-Bold.ttf'
FONT_NAME = if File.exist?(FONT_PATH)
FONT_PATH
else
'Helvetica'
end
FONT_BOLD_NAME = if File.exist?(FONT_BOLD_PATH)
FONT_BOLD_PATH
else
'Helvetica'
end
INFO_CREATOR = "#{Docuseal::PRODUCT_NAME} (#{Docuseal::PRODUCT_URL})".freeze
SIGN_REASON = 'Signed with DocuSeal.co'
VERIFIED_TEXT = if Docuseal.multitenant?
'Verified by DocuSeal'
else
'Verified'
end
module_function
# rubocop:disable Metrics
def call(submission)
account = submission.template.account
pkcs = Accounts.load_signing_pkcs(account)
verify_url = Rails.application.routes.url_helpers.settings_esign_url(**Docuseal.default_url_options)
composer = HexaPDF::Composer.new(skip_page_creation: true)
composer.document.fonts.add(FONT_BOLD_NAME, variant: :bold)
divider = HexaPDF::Layout::Box.create(
margin: [0, 0, 15, 0],
border: {
width: [1, 0, 0, 0],
color: %w[hp-gray-light]
},
height: 1
)
composer.page_style(:default, page_size: :A4) do |canvas, style|
box = canvas.context.box(:media)
canvas.save_graphics_state do
canvas.fill_color('FAF7F5')
.rectangle(0, 0, box.width, 20)
.rectangle(0, box.height - 20, box.width, 20)
.fill
end
style.frame = style.create_frame(canvas.context, 50)
end
composer.style(:base, font: FONT_NAME, font_size: FONT_SIZE, fill_color: TEXT_COLOR, line_spacing: 1.2)
composer.style(:link, fill_color: 'hp-blue-light', underline: true)
composer.new_page
composer.column(columns: 1) do |column|
column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float)
column.formatted_text([{ text: 'DocuSeal',
link: Docuseal::PRODUCT_URL }],
font_size: 20,
font: [FONT_BOLD_NAME, { variant: :bold }],
width: 100,
padding: [12, 0, 0, 8],
position: :float, position_hint: :left)
column.text('Audit Log',
font_size: 16,
padding: [15, 0, 0, 0],
position: :float, position_hint: :right)
end
composer.column(columns: 1) do |column|
column.text("Envelope ID: #{submission.id}", font_size: 12, padding: [20, 0, 10, 0], position: :float)
column.formatted_text([
{ link: verify_url, text: 'Verify', style: :link }
], font_size: 9, padding: [22, 0, 10, 0], position: :float, align: :right)
end
composer.draw_box(divider)
last_submitter = submission.submitters.where.not(completed_at: nil).order(:completed_at).last
documents_data = Submitters.select_attachments_for_download(last_submitter).map do |document|
original_documents = submission.template.documents.select { |e| e.uuid == document.uuid }.presence
original_documents ||= submission.template.documents.select do |e|
e.image? && submission.schema.any? do |item|
item['attachment_uuid'] == e.uuid
end
end
link =
Rails.application.routes.url_helpers.rails_blob_url(document, **Docuseal.default_url_options)
[
composer.document.layout.formatted_text_box(
[{ text: document.filename.to_s, link: }]
),
composer.document.layout.formatted_text_box(
[
{ text: "Original SHA256:\n", font: [FONT_BOLD_NAME, { variant: :bold }] },
original_documents.map { |d| d.metadata['sha256'] || d.checksum }.join("\n"),
"\n",
{ text: "Result SHA256:\n", font: [FONT_BOLD_NAME, { variant: :bold }] },
document.metadata['sha256'] || document.checksum,
"\n",
{ text: 'Generated at: ', font: [FONT_BOLD_NAME, { variant: :bold }] },
I18n.l(document.created_at, format: :long, locale: account.locale)
], line_spacing: 1.8
)
]
end
composer.table(documents_data, cell_style: { padding: [0, 0, 25, 0], border: { width: 0 } })
composer.draw_box(divider)
submission.template_submitters.filter_map do |item|
submitter = submission.submitters.find { |e| e.uuid == item['uuid'] }
next if submitter.blank?
completed_event =
submission.submission_events.find { |e| e.submitter_id == submitter.id && e.complete_form? } ||
SubmissionEvent.new
click_email_event =
submission.submission_events.find { |e| e.submitter_id == submitter.id && e.click_email? }
is_phone_verified =
submission.template_fields.any? do |e|
e['type'] == 'phone' && e['submitter_uuid'] == submitter.uuid && submitter.values[e['uuid']].present?
end
submitter_field_counters = Hash.new { 0 }
info_rows = [
[
composer.document.layout.formatted_text_box(
[
submission.template_submitters.size > 1 && { text: "#{item['name']}\n" },
submitter.email && { text: "#{submitter.email}\n", font: [FONT_BOLD_NAME, { variant: :bold }] },
submitter.name && { text: "#{submitter.name}\n" },
submitter.phone && { text: "#{submitter.phone}\n" }
].compact_blank, line_spacing: 1.8, padding: [0, 20, 0, 0]
)
],
[
composer.document.layout.formatted_text_box(
[
submitter.email && {
text: "Email verification: #{click_email_event ? VERIFIED_TEXT : 'Unverififed'}\n"
},
submitter.phone && {
text: "Phone verification: #{is_phone_verified ? VERIFIED_TEXT : 'Unverififed'}\n"
},
completed_event.data['ip'] && { text: "IP: #{completed_event.data['ip']}\n" },
completed_event.data['sid'] && { text: "Session ID: #{completed_event.data['sid']}\n" },
completed_event.data['ua'] && { text: "User agent: #{completed_event.data['ua']}\n" },
"\n"
].compact_blank, line_spacing: 1.8, padding: [10, 20, 20, 0]
)
]
]
composer.table(info_rows, cell_style: { padding: [0, 0, 0, 0], border: { width: 0 } })
composer.column(columns: 1, gaps: 0, style: { padding: [0, 200, 0, 0] }) do |column|
submission.template_fields.filter_map do |field|
next if field['submitter_uuid'] != submitter.uuid
submitter_field_counters[field['type']] += 1
value = submitter.values[field['uuid']]
next if Array.wrap(value).compact_blank.blank?
[
column.formatted_text_box(
[
{
text: field['name'].to_s.upcase.presence ||
"#{field['type']} Field #{submitter_field_counters[field['type']]}\n".upcase,
font_size: 6
}
].compact_blank, line_spacing: 1.8, padding: [0, 0, 5, 0]
),
if field['type'].in?(%w[image signature])
attachment = submitter.attachments.find { |a| a.uuid == value }
image = Vips::Image.new_from_buffer(attachment.download, '').autorot
scale = [300.0 / image.width, 300.0 / image.height].min
io = StringIO.new(image.resize([scale, 1].min).write_to_buffer('.png'))
column.image(io, padding: [0, 100, 10, 0])
column.formatted_text_box([{ text: '' }])
elsif field['type'] == 'file'
column.formatted_text_box(
Array.wrap(value).map do |uuid|
attachment = submitter.attachments.find { |a| a.uuid == uuid }
link =
Rails.application.routes.url_helpers.rails_blob_url(attachment, **Docuseal.default_url_options)
{ link:, text: "#{attachment.filename}\n", style: :link }
end,
padding: [0, 0, 10, 0]
)
elsif field['type'] == 'checkbox'
column.formatted_text_box([{ text: value.to_s.titleize }], padding: [0, 0, 10, 0])
else
value = I18n.l(Date.parse(value), format: :long, locale: account.locale) if field['type'] == 'date'
value = value.join(', ') if value.is_a?(Array)
column.formatted_text_box([{ text: value.to_s.presence || 'n/a' }], padding: [0, 0, 10, 0])
end
]
end
end
composer.draw_box(divider)
end
composer.text('Event Log', font_size: 12, padding: [10, 0, 20, 0])
events_data = submission.submission_events.sort_by(&:event_timestamp).map do |event|
submitter = submission.submitters.find { |e| e.id == event.submitter_id }
[
I18n.l(event.event_timestamp, format: :long, locale: account.locale),
composer.document.layout.formatted_text_box(
[
{ text: SubmissionEvents::EVENT_NAMES[event.event_type.to_sym],
font: [FONT_BOLD_NAME, { variant: :bold }] },
event.event_type.include?('send_') ? ' to ' : ' by ',
if event.event_type.include?('sms')
submitter.phone
else
(submitter.email || submitter.name || submitter.phone)
end
]
)
]
end
composer.table(events_data, cell_style: { padding: [0, 0, 20, 0], border: { width: 0 } }) if events_data.present?
io = StringIO.new
composer.document.trailer.info[:Creator] = INFO_CREATOR
composer.document.sign(io, reason: SIGN_REASON,
certificate: pkcs.certificate,
key: pkcs.key,
certificate_chain: pkcs.ca_certs || [])
ActiveStorage::Attachment.create!(
blob: ActiveStorage::Blob.create_and_upload!(
io: StringIO.new(io.string), filename: "Audit Log - #{submission.template.name}.pdf"
),
name: 'audit_trail',
record: submission
)
end
# rubocop:enable Metrics
end
end
@@ -28,7 +28,8 @@ module Submissions
template = submitter.submission.template template = submitter.submission.template
pkcs = Accounts.load_signing_pkcs(submitter.submission.template.account) account = submitter.submission.template.account
pkcs = Accounts.load_signing_pkcs(account)
pdfs_index = build_pdfs_index(submitter) pdfs_index = build_pdfs_index(submitter)
@@ -40,6 +41,7 @@ module Submissions
pdf.fonts.add(FONT_NAME) pdf.fonts.add(FONT_NAME)
page = pdf.pages[area['page']] page = pdf.pages[area['page']]
page.rotate(0, flatten: true) if page[:Rotate] != 0
page[:Annots] ||= [] page[:Annots] ||= []
page[:Annots] = page[:Annots].reject { |e| e[:A] && e[:A][:URI].to_s.starts_with?('file:///docuseal_field') } page[:Annots] = page[:Annots].reject { |e| e[:A] && e[:A][:URI].to_s.starts_with?('file:///docuseal_field') }
@@ -148,7 +150,7 @@ module Submissions
height - (area['y'] * height)) height - (area['y'] * height))
end end
else else
value = I18n.l(Date.parse(value), format: :long) if field['type'] == 'date' value = I18n.l(Date.parse(value), format: :default, locale: account.locale) if field['type'] == 'date'
text = HexaPDF::Layout::TextFragment.create(Array.wrap(value).join(', '), font: pdf.fonts.add(FONT_NAME), text = HexaPDF::Layout::TextFragment.create(Array.wrap(value).join(', '), font: pdf.fonts.add(FONT_NAME),
font_size:) font_size:)
@@ -213,6 +215,7 @@ module Submissions
blob: ActiveStorage::Blob.create_and_upload!( blob: ActiveStorage::Blob.create_and_upload!(
io: StringIO.new(io.string), filename: "#{name}.pdf" io: StringIO.new(io.string), filename: "#{name}.pdf"
), ),
metadata: { sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) },
name: 'documents', name: 'documents',
record: submitter record: submitter
) )
+2 -2
View File
@@ -30,12 +30,12 @@ module Submitters
end end
def send_signature_requests(submitters, params) def send_signature_requests(submitters, params)
return unless params[:send_email] == true || params[:send_email] == '1' return if params[:send_email] != true && params[:send_email] != '1'
submitters.each do |submitter| submitters.each do |submitter|
next if submitter.email.blank? next if submitter.email.blank?
SubmitterMailer.invitation_email(submitter, message: params[:message]).deliver_later! SendSubmitterInvitationEmailJob.perform_later(submitter)
end end
end end
end end
+1 -1
View File
@@ -45,7 +45,7 @@ module Submitters
if field['type'].in?(%w[image signature]) if field['type'].in?(%w[image signature])
rails_storage_proxy_url(attachments_index[value]) rails_storage_proxy_url(attachments_index[value])
elsif field['type'] == 'file' elsif field['type'] == 'file'
Array.wrap(value).compact_blank.filter_map { |e| rails_storage_proxy_url(e) } Array.wrap(value).compact_blank.filter_map { |e| rails_storage_proxy_url(attachments_index[e]) }
else else
value value
end end
+31 -13
View File
@@ -2,38 +2,42 @@
module Submitters module Submitters
module SubmitValues module SubmitValues
ValidationError = Class.new(StandardError)
module_function module_function
def call(submitter, params, request) def call(submitter, params, request)
update_submitter!(submitter, params, request) if submitter.submission.template_fields.blank?
Submissions.update_template_fields!(submitter.submission)
Submissions.update_template_fields!(submitter.submission) if submitter.submission.template_fields.blank? SubmissionEvents.create_with_tracking_data(submitter, 'start_form', request)
end
update_submitter!(submitter, params, request)
submitter.submission.save! submitter.submission.save!
return unless submitter.completed_at? return submitter unless submitter.completed_at?
GenerateSubmitterResultAttachmentsJob.perform_later(submitter) ProcessSubmitterCompletionJob.perform_later(submitter)
if submitter.account.encrypted_configs.exists?(key: EncryptedConfig::WEBHOOK_URL_KEY)
SendWebhookRequestJob.perform_later(submitter)
end
submitter.submission.template.account.users.active.admins.each do |user|
SubmitterMailer.completed_email(submitter, user).deliver_later!
end
submitter submitter
end end
def update_submitter!(submitter, params, request) def update_submitter!(submitter, params, request)
submitter.values.merge!(normalized_values(params)) values = normalized_values(params)
validate_values!(values, submitter, params)
submitter.values.merge!(values)
submitter.opened_at ||= Time.current submitter.opened_at ||= Time.current
if params[:completed] == 'true' if params[:completed] == 'true'
submitter.completed_at = Time.current submitter.completed_at = Time.current
submitter.ip = request.remote_ip submitter.ip = request.remote_ip
submitter.ua = request.user_agent submitter.ua = request.user_agent
SubmissionEvents.create_with_tracking_data(submitter, 'complete_form', request)
end end
submitter.save! submitter.save!
@@ -45,10 +49,24 @@ module Submitters
params.fetch(:values, {}).to_unsafe_h.transform_values do |v| params.fetch(:values, {}).to_unsafe_h.transform_values do |v|
if params[:cast_boolean] == 'true' if params[:cast_boolean] == 'true'
v == 'true' v == 'true'
elsif params[:normalize_phone] == 'true'
v.to_s.gsub(/[^0-9+]/, '')
else else
v.is_a?(Array) ? v.compact_blank : v v.is_a?(Array) ? v.compact_blank : v
end end
end end
end end
def validate_values!(values, submitter, params)
values.each do |key, value|
field = submitter.submission.template_fields.find { |e| e['uuid'] == key }
validate_value!(value, field, params)
end
end
def validate_value!(_value, _field, _params)
true
end
end end
end end
+13 -2
View File
@@ -3,17 +3,23 @@
module Templates module Templates
module CreateAttachments module CreateAttachments
PDF_CONTENT_TYPE = 'application/pdf' PDF_CONTENT_TYPE = 'application/pdf'
InvalidFileType = Class.new(StandardError)
module_function module_function
def call(template, params) def call(template, params)
find_or_create_blobs(params).map do |blob| find_or_create_blobs(params).map do |blob|
document = template.documents.create!(blob:)
document_data = blob.download document_data = blob.download
if !blob.image? && blob.content_type != PDF_CONTENT_TYPE
blob, document_data = handle_file_types(blob, document_data)
end
document = template.documents.create!(blob:)
if blob.content_type == PDF_CONTENT_TYPE && blob.metadata['pdf'].nil? if blob.content_type == PDF_CONTENT_TYPE && blob.metadata['pdf'].nil?
blob.metadata['pdf'] = { 'annotations' => Templates::BuildAnnotations.call(document_data) } blob.metadata['pdf'] = { 'annotations' => Templates::BuildAnnotations.call(document_data) }
blob.metadata['sha256'] = Base64.urlsafe_encode64(Digest::SHA256.digest(document_data))
end end
blob.save! blob.save!
@@ -32,6 +38,7 @@ module Templates
if file.content_type == PDF_CONTENT_TYPE if file.content_type == PDF_CONTENT_TYPE
metadata = { 'identified' => true, 'analyzed' => true, metadata = { 'identified' => true, 'analyzed' => true,
'sha256' => Base64.urlsafe_encode64(Digest::SHA256.digest(data)),
'pdf' => { 'annotations' => Templates::BuildAnnotations.call(data) } } 'pdf' => { 'annotations' => Templates::BuildAnnotations.call(data) } }
end end
@@ -43,5 +50,9 @@ module Templates
) )
end end
end end
def handle_file_types(_document_data, blob)
raise InvalidFileType, blob.content_type
end
end end
end end
+1 -1
View File
@@ -81,7 +81,7 @@ RSpec.describe 'Submit Form' do
expect do expect do
click_on 'submit' click_on 'submit'
end.to change(enqueued_jobs, :size).by(3) end.to change(enqueued_jobs, :size).by(2)
end end
end end
end end