mirror of
https://github.com/docusealco/docuseal
synced 2026-11-05 06:42:32 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce39494f84 | ||
|
|
c639a3f733 | ||
|
|
74bef133b3 | ||
|
|
33142966dc | ||
|
|
42ec8d5fc9 | ||
|
|
01d752e811 | ||
|
|
99b33d4165 | ||
|
|
11a90d797a | ||
|
|
9da7463657 | ||
|
|
b6d350ebbd | ||
|
|
9e87a0373f | ||
|
|
aee55165c5 |
No files matched your search
@@ -67,6 +67,9 @@ RSpec/MultipleMemoizedHelpers:
|
|||||||
Rails/I18nLocaleTexts:
|
Rails/I18nLocaleTexts:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
Rails/FindEach:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
Rails/SkipsModelValidations:
|
Rails/SkipsModelValidations:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ module Api
|
|||||||
|
|
||||||
def submissions_params
|
def submissions_params
|
||||||
params.permit(submission: [{
|
params.permit(submission: [{
|
||||||
submitters: [[:uuid, :name, :email, :role, :completed, :phone,
|
submitters: [[:uuid, :name, :email, :role, :completed, :phone, :application_key,
|
||||||
{ values: {}, readonly_fields: [],
|
{ values: {}, readonly_fields: [],
|
||||||
fields: [%i[name default_value readonly validation_pattern invalid_message]] }]]
|
fields: [%i[name default_value readonly validation_pattern invalid_message]] }]]
|
||||||
}])
|
}])
|
||||||
|
|||||||
@@ -4,6 +4,27 @@ module Api
|
|||||||
class SubmittersController < ApiBaseController
|
class SubmittersController < ApiBaseController
|
||||||
load_and_authorize_resource :submitter
|
load_and_authorize_resource :submitter
|
||||||
|
|
||||||
|
def index
|
||||||
|
submitters = Submitters.search(@submitters, params[:q])
|
||||||
|
|
||||||
|
submitters = submitters.where(application_key: params[:application_key]) if params[:application_key].present?
|
||||||
|
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
|
||||||
|
|
||||||
|
submitters = paginate(
|
||||||
|
submitters.preload(:template, :submission, :submission_events,
|
||||||
|
documents_attachments: :blob, attachments_attachments: :blob)
|
||||||
|
)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
data: submitters.map { |s| Submitters::SerializeForApi.call(s, with_template: true, with_events: true) },
|
||||||
|
pagination: {
|
||||||
|
count: submitters.size,
|
||||||
|
next: submitters.last&.id,
|
||||||
|
prev: submitters.first&.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
|
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ module Api
|
|||||||
render json: {
|
render json: {
|
||||||
schema:,
|
schema:,
|
||||||
documents: documents.as_json(
|
documents: documents.as_json(
|
||||||
|
methods: [:metadata],
|
||||||
include: {
|
include: {
|
||||||
preview_images: { methods: %i[url metadata filename] }
|
preview_images: { methods: %i[url metadata filename] }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class EnquiriesController < ApplicationController
|
||||||
|
skip_before_action :authenticate_user!
|
||||||
|
skip_authorization_check
|
||||||
|
|
||||||
|
def create
|
||||||
|
if params[:talk_to_sales] == 'on'
|
||||||
|
Faraday.post(Docuseal::ENQUIRIES_URL,
|
||||||
|
enquiry_params.merge(type: :talk_to_sales).to_json,
|
||||||
|
'Content-Type' => 'application/json')
|
||||||
|
end
|
||||||
|
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def enquiry_params
|
||||||
|
params.require(:user).permit(:email)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class PasswordsController < Devise::PasswordsController
|
||||||
|
class Current < ActiveSupport::CurrentAttributes
|
||||||
|
attribute :user
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
super do |resource|
|
||||||
|
Current.user = resource
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class PreviewDocumentPageController < ActionController::API
|
||||||
|
include ActiveStorage::SetCurrent
|
||||||
|
|
||||||
|
FORMAT = Templates::ProcessDocument::FORMAT
|
||||||
|
|
||||||
|
def show
|
||||||
|
if Docuseal.multitenant?
|
||||||
|
Rollbar.warning('load page')
|
||||||
|
|
||||||
|
return head :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
attachment = ActiveStorage::Attachment.find_by(uuid: params[:attachment_uuid])
|
||||||
|
|
||||||
|
return head :not_found unless attachment
|
||||||
|
|
||||||
|
preview_image = attachment.preview_images.joins(:blob).find_by(blob: { filename: "#{params[:id]}#{FORMAT}" })
|
||||||
|
|
||||||
|
return redirect_to preview_image.url, allow_other_host: true if preview_image
|
||||||
|
|
||||||
|
file_path =
|
||||||
|
if attachment.service.name == :disk
|
||||||
|
ActiveStorage::Blob.service.path_for(attachment.key)
|
||||||
|
else
|
||||||
|
find_or_create_document_tempfile_path(attachment)
|
||||||
|
end
|
||||||
|
|
||||||
|
io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
|
||||||
|
|
||||||
|
render plain: io.tap(&:rewind).read
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_or_create_document_tempfile_path(attachment)
|
||||||
|
file_path = "#{Dir.tmpdir}/#{attachment.uuid}"
|
||||||
|
|
||||||
|
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
|
||||||
|
f.flock(File::LOCK_EX)
|
||||||
|
|
||||||
|
# rubocop:disable Style/ZeroLengthPredicate
|
||||||
|
if f.size.zero?
|
||||||
|
f.binmode
|
||||||
|
|
||||||
|
f.write(attachment.download)
|
||||||
|
end
|
||||||
|
# rubocop:enable Style/ZeroLengthPredicate
|
||||||
|
end
|
||||||
|
|
||||||
|
file_path
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -31,10 +31,10 @@ class RegistrationsController < Devise::RegistrationsController
|
|||||||
redirect_to after_sign_up_path_for(current_user), allow_other_host: true
|
redirect_to after_sign_up_path_for(current_user), allow_other_host: true
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_no_authentication
|
def set_flash_message(key, kind, options = {})
|
||||||
super
|
return if key == :alert && kind == 'already_authenticated'
|
||||||
|
|
||||||
flash.clear
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_resource(_hash = {})
|
def build_resource(_hash = {})
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ class SessionsController < Devise::SessionsController
|
|||||||
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
|
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_no_authentication
|
def set_flash_message(key, kind, options = {})
|
||||||
super
|
return if key == :alert && kind == 'already_authenticated'
|
||||||
|
|
||||||
flash.clear
|
super
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -14,7 +14,8 @@ class StartFormController < ApplicationController
|
|||||||
|
|
||||||
def update
|
def update
|
||||||
@submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil))
|
@submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil))
|
||||||
.find_or_initialize_by(email: submitter_params[:email])
|
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
|
||||||
|
.find_or_initialize_by(**submitter_params.compact_blank)
|
||||||
|
|
||||||
if @submitter.completed_at?
|
if @submitter.completed_at?
|
||||||
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
|
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
|
||||||
@@ -46,7 +47,7 @@ class StartFormController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def submitter_params
|
def submitter_params
|
||||||
params.require(:submitter).permit(:email).tap do |attrs|
|
params.require(:submitter).permit(:email, :phone).tap do |attrs|
|
||||||
attrs[:email] = Submissions.normalize_email(attrs[:email])
|
attrs[:email] = Submissions.normalize_email(attrs[:email])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,12 +6,24 @@ class SubmissionsController < ApplicationController
|
|||||||
|
|
||||||
load_and_authorize_resource :submission, only: %i[show destroy]
|
load_and_authorize_resource :submission, only: %i[show destroy]
|
||||||
|
|
||||||
|
PRELOAD_ALL_PAGES_AMOUNT = 200
|
||||||
|
|
||||||
def show
|
def show
|
||||||
ActiveRecord::Associations::Preloader.new(
|
ActiveRecord::Associations::Preloader.new(
|
||||||
records: [@submission],
|
records: [@submission],
|
||||||
associations: [:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }]
|
associations: [:template, { template_schema_documents: :blob }]
|
||||||
).call
|
).call
|
||||||
|
|
||||||
|
total_pages =
|
||||||
|
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
|
||||||
|
|
||||||
|
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
|
||||||
|
ActiveRecord::Associations::Preloader.new(
|
||||||
|
records: @submission.template_schema_documents,
|
||||||
|
associations: [:blob, { preview_images_attachments: :blob }]
|
||||||
|
).call
|
||||||
|
end
|
||||||
|
|
||||||
render :show, layout: 'plain'
|
render :show, layout: 'plain'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,28 @@ class SubmitFormController < ApplicationController
|
|||||||
skip_before_action :authenticate_user!
|
skip_before_action :authenticate_user!
|
||||||
skip_authorization_check
|
skip_authorization_check
|
||||||
|
|
||||||
|
PRELOAD_ALL_PAGES_AMOUNT = 200
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@submitter =
|
@submitter = Submitter.find_by!(slug: params[:slug])
|
||||||
Submitter.preload(submission: [
|
|
||||||
:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }
|
|
||||||
])
|
|
||||||
.find_by!(slug: params[:slug])
|
|
||||||
|
|
||||||
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
||||||
|
|
||||||
|
ActiveRecord::Associations::Preloader.new(
|
||||||
|
records: [@submitter],
|
||||||
|
associations: [submission: [:template, { template_schema_documents: :blob }]]
|
||||||
|
).call
|
||||||
|
|
||||||
|
total_pages =
|
||||||
|
@submitter.submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
|
||||||
|
|
||||||
|
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
|
||||||
|
ActiveRecord::Associations::Preloader.new(
|
||||||
|
records: @submitter.submission.template_schema_documents,
|
||||||
|
associations: [:blob, { preview_images_attachments: :blob }]
|
||||||
|
).call
|
||||||
|
end
|
||||||
|
|
||||||
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
|
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
|
||||||
|
|
||||||
cookies[:submitter_sid] = @submitter.signed_id
|
cookies[:submitter_sid] = @submitter.signed_id
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ class TemplatesController < ApplicationController
|
|||||||
submissions = submissions.active if @template.deleted_at.blank?
|
submissions = submissions.active if @template.deleted_at.blank?
|
||||||
submissions = Submissions.search(submissions, params[:q])
|
submissions = Submissions.search(submissions, params[:q])
|
||||||
|
|
||||||
|
@base_submissions = submissions
|
||||||
|
|
||||||
|
submissions = submissions.pending if params[:status] == 'pending'
|
||||||
|
submissions = submissions.completed if params[:status] == 'completed'
|
||||||
|
|
||||||
@pagy, @submissions = pagy(submissions.preload(:submitters).order(id: :desc))
|
@pagy, @submissions = pagy(submissions.preload(:submitters).order(id: :desc))
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
redirect_to root_path
|
redirect_to root_path
|
||||||
@@ -25,6 +30,14 @@ class TemplatesController < ApplicationController
|
|||||||
associations: [schema_documents: { preview_images_attachments: :blob }]
|
associations: [schema_documents: { preview_images_attachments: :blob }]
|
||||||
).call
|
).call
|
||||||
|
|
||||||
|
@template_data =
|
||||||
|
@template.as_json.merge(
|
||||||
|
documents: @template.schema_documents.as_json(
|
||||||
|
methods: [:metadata],
|
||||||
|
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||||
|
)
|
||||||
|
).to_json
|
||||||
|
|
||||||
render :edit, layout: 'plain'
|
render :edit, layout: 'plain'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
>
|
>
|
||||||
{{ t('signed_with') }}
|
{{ t('signed_with') }}
|
||||||
<a
|
<a
|
||||||
href="https://www.docuseal.co"
|
href="https://www.docuseal.co/start"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="underline"
|
class="underline"
|
||||||
>DocuSeal</a> - {{ t('open_source_documents_software') }}
|
>DocuSeal</a> - {{ t('open_source_documents_software') }}
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ export default {
|
|||||||
allowToSkip: {
|
allowToSkip: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
default: true
|
default: false
|
||||||
},
|
},
|
||||||
goToLast: {
|
goToLast: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -461,7 +461,6 @@ export default {
|
|||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
submittedValues: {},
|
submittedValues: {},
|
||||||
isSecondWalkthrough: false,
|
|
||||||
recalculateButtonDisabledKey: ''
|
recalculateButtonDisabledKey: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -662,7 +661,7 @@ export default {
|
|||||||
|
|
||||||
stepPromise().then(async () => {
|
stepPromise().then(async () => {
|
||||||
const emptyRequiredField = this.stepFields.find((fields, index) => {
|
const emptyRequiredField = this.stepFields.find((fields, index) => {
|
||||||
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip || !this.isSecondWalkthrough) && !this.submittedValues[fields[0].uuid]
|
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && !this.submittedValues[fields[0].uuid]
|
||||||
})
|
})
|
||||||
|
|
||||||
const formData = new FormData(this.$refs.form)
|
const formData = new FormData(this.$refs.form)
|
||||||
|
|||||||
@@ -56,8 +56,26 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
numberOfPages () {
|
||||||
|
return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length
|
||||||
|
},
|
||||||
sortedPreviewImages () {
|
sortedPreviewImages () {
|
||||||
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))
|
const lazyloadMetadata = this.document.preview_images[this.document.preview_images.length - 1].metadata
|
||||||
|
|
||||||
|
return [...Array(this.numberOfPages).keys()].map((i) => {
|
||||||
|
return this.previewImagesIndex[i] || {
|
||||||
|
metadata: lazyloadMetadata,
|
||||||
|
id: Math.random().toString(),
|
||||||
|
url: `/preview/${this.document.uuid}/${i}.jpg`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
previewImagesIndex () {
|
||||||
|
return this.document.preview_images.reduce((acc, e) => {
|
||||||
|
acc[parseInt(e.filename)] = e
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeUpdate () {
|
beforeUpdate () {
|
||||||
|
|||||||
@@ -5,11 +5,12 @@
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
ref="image"
|
ref="image"
|
||||||
|
loading="lazy"
|
||||||
:src="image.url"
|
:src="image.url"
|
||||||
:width="width"
|
:width="width"
|
||||||
class="border rounded mb-4"
|
|
||||||
:height="height"
|
:height="height"
|
||||||
loading="lazy"
|
class="border rounded mb-4"
|
||||||
|
@load="onImageLoad"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="top-0 bottom-0 left-0 right-0 absolute"
|
class="top-0 bottom-0 left-0 right-0 absolute"
|
||||||
@@ -117,6 +118,10 @@ export default {
|
|||||||
this.areaRefs = []
|
this.areaRefs = []
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
onImageLoad (e) {
|
||||||
|
e.target.setAttribute('width', e.target.naturalWidth)
|
||||||
|
e.target.setAttribute('height', e.target.naturalHeight)
|
||||||
|
},
|
||||||
setAreaRefs (el) {
|
setAreaRefs (el) {
|
||||||
if (el) {
|
if (el) {
|
||||||
this.areaRefs.push(el)
|
this.areaRefs.push(el)
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default {
|
|||||||
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace'],
|
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace'],
|
||||||
computed: {
|
computed: {
|
||||||
previewImage () {
|
previewImage () {
|
||||||
return this.document.preview_images[0]
|
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))[0]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class SubmitterMailer < ApplicationMailer
|
class SubmitterMailer < ApplicationMailer
|
||||||
|
MAX_ATTACHMENTS_SIZE = 10.megabytes
|
||||||
|
|
||||||
def invitation_email(submitter, body: nil, subject: nil)
|
def invitation_email(submitter, body: nil, subject: nil)
|
||||||
@current_account = submitter.submission.template.account
|
@current_account = submitter.submission.template.account
|
||||||
@submitter = submitter
|
@submitter = submitter
|
||||||
@@ -75,14 +77,26 @@ class SubmitterMailer < ApplicationMailer
|
|||||||
def add_completed_email_attachments!(submitter)
|
def add_completed_email_attachments!(submitter)
|
||||||
documents = Submitters.select_attachments_for_download(submitter)
|
documents = Submitters.select_attachments_for_download(submitter)
|
||||||
|
|
||||||
documents.each do |attachment|
|
total_size = 0
|
||||||
attachments[attachment.filename.to_s] = attachment.download
|
audit_trail_data = nil
|
||||||
end
|
|
||||||
|
|
||||||
if submitter.submission.audit_trail.present?
|
if submitter.submission.audit_trail.present?
|
||||||
attachments[submitter.submission.audit_trail.filename.to_s] = submitter.submission.audit_trail.download
|
audit_trail_data = submitter.submission.audit_trail.download
|
||||||
|
|
||||||
|
total_size = audit_trail_data.size
|
||||||
end
|
end
|
||||||
|
|
||||||
|
documents.each do |attachment|
|
||||||
|
data = attachment.download
|
||||||
|
total_size += data.size
|
||||||
|
|
||||||
|
break if total_size >= MAX_ATTACHMENTS_SIZE
|
||||||
|
|
||||||
|
attachments[attachment.filename.to_s] = data
|
||||||
|
end
|
||||||
|
|
||||||
|
attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data
|
||||||
|
|
||||||
documents
|
documents
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: encrypted_user_configs
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# key :string not null
|
||||||
|
# value :text not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# user_id :bigint not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_encrypted_user_configs_on_user_id (user_id)
|
||||||
|
# index_encrypted_user_configs_on_user_id_and_key (user_id,key) UNIQUE
|
||||||
|
#
|
||||||
|
# Foreign Keys
|
||||||
|
#
|
||||||
|
# fk_rails_... (user_id => users.id)
|
||||||
|
#
|
||||||
|
class EncryptedUserConfig < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
encrypts :value
|
||||||
|
|
||||||
|
serialize :value, JSON
|
||||||
|
end
|
||||||
@@ -48,6 +48,8 @@ class Submission < ApplicationRecord
|
|||||||
through: :template, source: :documents_attachments
|
through: :template, source: :documents_attachments
|
||||||
|
|
||||||
scope :active, -> { where(deleted_at: nil) }
|
scope :active, -> { where(deleted_at: nil) }
|
||||||
|
scope :pending, -> { joins(:submitters).where(submitters: { completed_at: nil }).distinct }
|
||||||
|
scope :completed, -> { left_joins(:submitters).where.not(submitters: { completed_at: nil }).distinct }
|
||||||
|
|
||||||
enum :source, {
|
enum :source, {
|
||||||
invite: 'invite',
|
invite: 'invite',
|
||||||
|
|||||||
+16
-15
@@ -4,21 +4,22 @@
|
|||||||
#
|
#
|
||||||
# Table name: submitters
|
# Table name: submitters
|
||||||
#
|
#
|
||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
# completed_at :datetime
|
# application_key :string
|
||||||
# email :string
|
# completed_at :datetime
|
||||||
# ip :string
|
# email :string
|
||||||
# name :string
|
# ip :string
|
||||||
# opened_at :datetime
|
# name :string
|
||||||
# phone :string
|
# opened_at :datetime
|
||||||
# sent_at :datetime
|
# phone :string
|
||||||
# slug :string not null
|
# sent_at :datetime
|
||||||
# ua :string
|
# slug :string not null
|
||||||
# uuid :string not null
|
# ua :string
|
||||||
# values :text not null
|
# uuid :string not null
|
||||||
# created_at :datetime not null
|
# values :text not null
|
||||||
# updated_at :datetime not null
|
# created_at :datetime not null
|
||||||
# submission_id :bigint not null
|
# updated_at :datetime not null
|
||||||
|
# submission_id :bigint not null
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class User < ApplicationRecord
|
|||||||
has_one :access_token, dependent: :destroy
|
has_one :access_token, dependent: :destroy
|
||||||
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
|
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
|
||||||
has_many :user_configs, dependent: :destroy
|
has_many :user_configs, dependent: :destroy
|
||||||
|
has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig'
|
||||||
|
|
||||||
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
|
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
|
||||||
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
|
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
|
||||||
@@ -75,6 +76,14 @@ class User < ApplicationRecord
|
|||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.sign_in_after_reset_password
|
||||||
|
if PasswordsController::Current.user.present?
|
||||||
|
!PasswordsController::Current.user.otp_required_for_login
|
||||||
|
else
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def initials
|
def initials
|
||||||
[first_name&.first, last_name&.first].compact_blank.join.upcase
|
[first_name&.first, last_name&.first].compact_blank.join.upcase
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -41,6 +41,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<%= f.button button_title(title: 'Sign up', disabled_with: 'Signing up'), class: 'base-button' %>
|
<%= f.button button_title(title: 'Sign up', disabled_with: 'Signing up'), class: 'base-button' %>
|
||||||
|
<% if Docuseal.multitenant? %>
|
||||||
|
<button id="talk_to_sales_button" class="hidden" type="submit" formaction="<%= enquiries_path %>" formnovalidate="true" formmethod="post"></button>
|
||||||
|
<label class="flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" name="talk_to_sales" class="base-checkbox" onchange="window.talk_to_sales_button.click()">
|
||||||
|
<span class="label">I would like to talk to sales</span>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= render 'devise/shared/links' %>
|
<%= render 'devise/shared/links' %>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<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 d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
|
||||||
|
<path d="M12 7v5l3 3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 362 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<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 d="M9 6l11 0" />
|
||||||
|
<path d="M9 12l11 0" />
|
||||||
|
<path d="M9 18l11 0" />
|
||||||
|
<path d="M5 6l0 .01" />
|
||||||
|
<path d="M5 12l0 .01" />
|
||||||
|
<path d="M5 18l0 .01" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 440 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="2" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path d="M19.933 13.041a8 8 0 1 1 -9.925 -8.788c3.899 -1 7.935 1.007 9.425 4.747"></path>
|
||||||
|
<path d="M20 4v5h-5"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 409 B |
Whitespace-only changes.
@@ -21,6 +21,7 @@
|
|||||||
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
|
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<%= render 'email_configs' %>
|
||||||
<p class="text-2xl font-bold mt-8 mb-4">Signature</p>
|
<p class="text-2xl font-bold mt-8 mb-4">Signature</p>
|
||||||
<% signature = UserConfigs.load_signature(current_user) %>
|
<% signature = UserConfigs.load_signature(current_user) %>
|
||||||
<% if signature %>
|
<% if signature %>
|
||||||
|
|||||||
@@ -10,4 +10,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%= render 'shared/attribution' %>
|
<%= render 'shared/attribution', link_path: '/start' %>
|
||||||
@@ -1 +1 @@
|
|||||||
<%= render 'shared/powered_by', with_counter: local_assigns[:with_counter] %>
|
<%= render 'shared/powered_by', with_counter: local_assigns[:with_counter], link_path: local_assigns[:link_path] %>
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
---
|
---
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Sent using <a href="<%= Docuseal::PRODUCT_URL %>"><%= Docuseal.product_name %></a> free document signing.
|
Sent using <a href="<%= Docuseal::PRODUCT_URL %>/start"><%= Docuseal.product_name %></a> free document signing.
|
||||||
</p>
|
</p>
|
||||||
@@ -9,5 +9,5 @@
|
|||||||
<% else %>
|
<% else %>
|
||||||
Powered by
|
Powered by
|
||||||
<% end %>
|
<% end %>
|
||||||
<a href="<%= Docuseal::PRODUCT_URL %>" class="underline"><%= Docuseal.product_name %></a> - open source documents software
|
<a href="<%= Docuseal::PRODUCT_URL %><%= local_assigns[:link_path] %>" class="underline"><%= Docuseal.product_name %></a> - open source documents software
|
||||||
</div>
|
</div>
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<form action="<%= request.path %>" method="get" class="items-center hidden md:flex">
|
<form action="<%= url_for %>" method="get" class="items-center hidden md:flex">
|
||||||
|
<% if params[:status].present? %>
|
||||||
|
<input name="status" value="<%= params[:status] %>" class="hidden">
|
||||||
|
<% end %>
|
||||||
<% if params[:q].present? %>
|
<% if params[:q].present? %>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<a href="<%= request.path %>" title="Clear" class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-auto text-neutral text-2xl font-extralight">
|
<a href="<%= url_for(params.to_unsafe_h.except(:q)) %>" title="Clear" class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-auto text-neutral text-2xl font-extralight">
|
||||||
×
|
×
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
<%= button_to button_title(title: 'Send copy to Email', disabled_with: 'Sending', icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'base-button w-full' %>
|
<%= button_to button_title(title: 'Send copy to Email', disabled_with: 'Sending', icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'base-button w-full' %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<div>
|
||||||
|
<%= button_to button_title(title: 'Resubmit', disabled_with: 'Resubmit', icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: { email: params[:email] }, resubmit: true }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%= render 'shared/attribution' %>
|
<%= render 'shared/attribution', link_path: '/start' %>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% unless @template.deleted_at? %>
|
<% unless @template.deleted_at? %>
|
||||||
<%= form_for @submitter, url: start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4' } do |f| %>
|
<%= form_for @submitter, url: start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4', onsubmit: 'event.submitter.disabled = true' } do |f| %>
|
||||||
<div class="form-control !mt-0">
|
<div class="form-control !mt-0">
|
||||||
<%= f.label :email, class: 'label' %>
|
<%= f.label :email, class: 'label' %>
|
||||||
<%= f.email_field :email, value: current_user&.email, required: true, class: 'base-input', placeholder: 'Provide your email to start' %>
|
<%= f.email_field :email, value: current_user&.email, required: true, class: 'base-input', placeholder: 'Provide your email to start' %>
|
||||||
@@ -36,4 +36,4 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%= render 'shared/attribution' %>
|
<%= render 'shared/attribution', link_path: '/start' %>
|
||||||
Whitespace-only changes.
@@ -41,6 +41,7 @@
|
|||||||
<%= f.text_area :body, value: config.value['body'], required: true, class: 'base-textarea w-full', rows: 10 %>
|
<%= f.text_area :body, value: config.value['body'], required: true, class: 'base-textarea w-full', rows: 10 %>
|
||||||
</autoresize-textarea>
|
</autoresize-textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<%= render 'message_fields' %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,4 +23,5 @@
|
|||||||
<%= render 'detailed_form', template: @template %>
|
<%= render 'detailed_form', template: @template %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<%= content_for(:modal_extra) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -44,12 +44,16 @@
|
|||||||
<% fields_index = Templates.build_field_areas_index(@submission.template_fields || @submission.template.fields) %>
|
<% fields_index = Templates.build_field_areas_index(@submission.template_fields || @submission.template.fields) %>
|
||||||
<% values = @submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
|
<% values = @submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
|
||||||
<% attachments_index = ActiveStorage::Attachment.where(record: @submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %>
|
<% attachments_index = ActiveStorage::Attachment.where(record: @submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %>
|
||||||
|
<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %>
|
||||||
<% (@submission.template_schema || @submission.template.schema).each do |item| %>
|
<% (@submission.template_schema || @submission.template.schema).each do |item| %>
|
||||||
<% document = @submission.template_schema_documents.find { |e| e.uuid == item['attachment_uuid'] } %>
|
<% document = @submission.template_schema_documents.find { |e| e.uuid == item['attachment_uuid'] } %>
|
||||||
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
|
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
|
||||||
<% document.preview_images.sort_by { |a| a.filename.base.to_i }.each_with_index do |page, index| %>
|
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
|
||||||
|
<% lazyload_metadata = document.preview_images.first.metadata %>
|
||||||
|
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
|
||||||
|
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.uuid, "#{index}.jpg")) %>
|
||||||
<div id="<%= "page-#{document.uuid}-#{index}" %>" class="relative">
|
<div id="<%= "page-#{document.uuid}-#{index}" %>" class="relative">
|
||||||
<img src="<%= page.url %>" width="<%= page.metadata['width'] %>" class="shadow-md mb-4" height="<%= page.metadata['height'] %>" loading="lazy">
|
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" class="shadow-md mb-4" height="<%= page.metadata['height'] %>">
|
||||||
<div class="top-0 bottom-0 left-0 right-0 absolute">
|
<div class="top-0 bottom-0 left-0 right-0 absolute">
|
||||||
<% document_annots_index[index]&.each do |annot| %>
|
<% document_annots_index[index]&.each do |annot| %>
|
||||||
<%= render 'submissions/annotation', annot: %>
|
<%= render 'submissions/annotation', annot: %>
|
||||||
|
|||||||
@@ -18,4 +18,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%= render 'shared/attribution' %>
|
<%= render 'shared/attribution', link_path: '/start' %>
|
||||||
@@ -19,7 +19,11 @@
|
|||||||
<div>
|
<div>
|
||||||
<% if Accounts.can_send_emails?(@submitter.submission.template.account) && @submitter.email.present? %>
|
<% if Accounts.can_send_emails?(@submitter.submission.template.account) && @submitter.email.present? %>
|
||||||
<%= button_to button_title(title: 'Send copy to Email', disabled_with: 'Sending', icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
|
<%= button_to button_title(title: 'Send copy to Email', disabled_with: 'Sending', icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
|
||||||
<div class="divider">OR</div>
|
<% if @submitter.submission.template.submitters.size != 1 %>
|
||||||
|
<div class="divider">OR</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="py-2"></div>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<download-button data-src="<%= submitter_download_index_path(@submitter.slug) %>" class="base-button w-full">
|
<download-button data-src="<%= submitter_download_index_path(@submitter.slug) %>" class="base-button w-full">
|
||||||
<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">
|
||||||
@@ -32,6 +36,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</download-button>
|
</download-button>
|
||||||
</div>
|
</div>
|
||||||
|
<% if @submitter.submission.template.submitters.size == 1 %>
|
||||||
|
<div class="divider">OR</div>
|
||||||
|
<div>
|
||||||
|
<%= button_to button_title(title: 'Resubmit', disabled_with: 'Resubmit', icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@submitter.submission.template.slug), params: { submitter: { email: @submitter.email, phone: @submitter.phone }, resubmit: true }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<%= render 'shared/attribution' %>
|
<%= render 'shared/attribution', link_path: '/start' %>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<% fields_index = Templates.build_field_areas_index(@submitter.submission.template_fields || @submitter.submission.template.fields) %>
|
<% fields_index = Templates.build_field_areas_index(@submitter.submission.template_fields || @submitter.submission.template.fields) %>
|
||||||
<% values = @submitter.submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
|
<% values = @submitter.submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
|
||||||
<% attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %>
|
<% attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %>
|
||||||
|
<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %>
|
||||||
<div style="max-height: -webkit-fill-available;">
|
<div style="max-height: -webkit-fill-available;">
|
||||||
<div id="scrollbox">
|
<div id="scrollbox">
|
||||||
<div class="mx-auto block pb-72" style="max-width: 1000px">
|
<div class="mx-auto block pb-72" style="max-width: 1000px">
|
||||||
@@ -10,9 +11,12 @@
|
|||||||
<% (@submitter.submission.template_schema || @submitter.submission.template.schema).each do |item| %>
|
<% (@submitter.submission.template_schema || @submitter.submission.template.schema).each do |item| %>
|
||||||
<% document = @submitter.submission.template_schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
|
<% document = @submitter.submission.template_schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
|
||||||
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
|
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
|
||||||
<% document.preview_images.sort_by { |a| a.filename.base.to_i }.each_with_index do |page, index| %>
|
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
|
||||||
|
<% lazyload_metadata = document.preview_images.last.metadata %>
|
||||||
|
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
|
||||||
|
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.uuid, "#{index}.jpg")) %>
|
||||||
<div class="relative my-4 shadow-md">
|
<div class="relative my-4 shadow-md">
|
||||||
<img src="<%= page.url %>" width="<%= page.metadata['width'] %>" height="<%= page.metadata['height'] %>" loading="lazy">
|
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" height="<%= page.metadata['height'] %>">
|
||||||
<div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute">
|
<div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute">
|
||||||
<% document_annots_index[index]&.each do |annot| %>
|
<% document_annots_index[index]&.each do |annot| %>
|
||||||
<%= render 'submissions/annotation', annot: %>
|
<%= render 'submissions/annotation', annot: %>
|
||||||
@@ -27,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= render 'shared/attribution' %>
|
<%= render 'shared/attribution', link_path: '/start' %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<template-builder class="grid" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-template="<%= @template.as_json.merge(documents: @template.schema_documents.as_json(include: { preview_images: { methods: %i[url metadata filename] } })).to_json %>"></template-builder>
|
<template-builder class="grid" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-template="<%= @template_data %>"></template-builder>
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<%= render 'title', template: @template %>
|
<%= render 'title', template: @template %>
|
||||||
|
<% is_show_tabs = @pagy.pages > 1 || params[:status].present? %>
|
||||||
<% if !@pagy.count.zero? || @template.submitters.to_a.size == 1 || params[:q].present? %>
|
<% if !@pagy.count.zero? || @template.submitters.to_a.size == 1 || params[:q].present? %>
|
||||||
<div class="flex justify-between mb-6 md:items-end flex-col md:flex-row">
|
<div class="flex justify-between md:items-end flex-col md:flex-row <%= is_show_tabs ? 'mb-4' : 'mb-6' %>">
|
||||||
<p class="text-3xl font-bold">Submissions</p>
|
<p class="text-3xl font-bold">Submissions</p>
|
||||||
<div class="flex space-x-2 mt-3 md:mt-0">
|
<div class="flex md:space-x-2 mt-3 md:mt-0">
|
||||||
<% if params[:q].present? || @pagy.pages > 1 %>
|
<% if params[:q].present? || params[:status].present? || @pagy.pages > 1 %>
|
||||||
<%= render 'shared/search_input' %>
|
<%= render 'shared/search_input' %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= link_to new_template_submissions_export_path(@template), class: 'order-3 md:order-1 btn btn-ghost text-base', data: { turbo_frame: 'modal' } do %>
|
<%= link_to new_template_submissions_export_path(@template), class: 'order-3 md:order-1 btn btn-ghost text-base', data: { turbo_frame: 'modal' } do %>
|
||||||
@@ -24,6 +25,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% if is_show_tabs %>
|
||||||
|
<div class="flex items-center md:items-end flex-col space-y-2 md:space-y-0 md:flex-row md:space-x-2 mb-4">
|
||||||
|
<a href="<%= url_for(params.to_unsafe_h.except(:status)) %>" class="<%= params[:status].blank? ? 'border-neutral-700' : 'border-neutral-300' %> flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-48 hover:border-neutral-600">
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<%= svg_icon('list', class: 'w-5 h-5') %>
|
||||||
|
<span class="font-normal">All</span>
|
||||||
|
</div>
|
||||||
|
<div class="badge badge-neutral badge-outline font-medium">
|
||||||
|
<%= params[:status].blank? ? @pagy.count : @base_submissions.count %>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="<%= url_for(params.to_unsafe_h.merge(status: :pending)) %>" class="<%= params[:status] == 'pending' ? 'border-neutral-700' : 'border-neutral-300' %> flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-48 hover:border-neutral-600">
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<%= svg_icon('clock', class: 'w-5 h-5') %>
|
||||||
|
<span class="font-normal">Pending</span>
|
||||||
|
</div>
|
||||||
|
<div class="badge badge-neutral badge-outline font-medium">
|
||||||
|
<%= params[:status] == 'pending' ? @pagy.count : @base_submissions.pending.count %>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="<%= url_for(params.to_unsafe_h.merge(status: :completed)) %>" class="<%= params[:status] == 'completed' ? 'border-neutral-700' : 'border-neutral-300' %> flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-48 hover:border-neutral-600">
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<%= svg_icon('circle_check', class: 'w-5 h-5') %>
|
||||||
|
<span class="font-normal">Completed</span>
|
||||||
|
</div>
|
||||||
|
<div class="badge badge-neutral badge-outline font-medium">
|
||||||
|
<%= params[:status] == 'completed' ? @pagy.count : @base_submissions.completed.count %>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<% if @submissions.present? %>
|
<% if @submissions.present? %>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<%= render partial: 'submission', collection: @submissions %>
|
<%= render partial: 'submission', collection: @submissions %>
|
||||||
|
|||||||
+4
-2
@@ -9,7 +9,7 @@ Rails.application.routes.draw do
|
|||||||
devise_for :users,
|
devise_for :users,
|
||||||
path: '/', only: %i[sessions passwords omniauth_callbacks],
|
path: '/', only: %i[sessions passwords omniauth_callbacks],
|
||||||
controllers: begin
|
controllers: begin
|
||||||
options = { sessions: 'sessions' }
|
options = { sessions: 'sessions', passwords: 'passwords' }
|
||||||
options[:omniauth_callbacks] = 'omniauth_callbacks' if Docuseal.multitenant?
|
options[:omniauth_callbacks] = 'omniauth_callbacks' if Docuseal.multitenant?
|
||||||
options
|
options
|
||||||
end
|
end
|
||||||
@@ -35,7 +35,7 @@ Rails.application.routes.draw do
|
|||||||
resources :template_folders_autocomplete, only: %i[index]
|
resources :template_folders_autocomplete, only: %i[index]
|
||||||
resources :submitter_email_clicks, only: %i[create]
|
resources :submitter_email_clicks, only: %i[create]
|
||||||
resources :submitter_form_views, only: %i[create]
|
resources :submitter_form_views, only: %i[create]
|
||||||
resources :submitters, only: %i[show]
|
resources :submitters, only: %i[index show]
|
||||||
resources :submissions, only: %i[index show create destroy] do
|
resources :submissions, only: %i[index show create destroy] do
|
||||||
collection do
|
collection do
|
||||||
resources :emails, only: %i[create], controller: 'submissions', as: :submissions_emails
|
resources :emails, only: %i[create], controller: 'submissions', as: :submissions_emails
|
||||||
@@ -52,6 +52,7 @@ Rails.application.routes.draw do
|
|||||||
resources :dashboard, only: %i[index]
|
resources :dashboard, only: %i[index]
|
||||||
resources :setup, only: %i[index create]
|
resources :setup, only: %i[index create]
|
||||||
resource :newsletter, only: %i[show update]
|
resource :newsletter, only: %i[show update]
|
||||||
|
resources :enquiries, only: %i[create]
|
||||||
resources :users, only: %i[new create edit update destroy]
|
resources :users, only: %i[new create edit update destroy]
|
||||||
resource :user_signature, only: %i[edit update]
|
resource :user_signature, only: %i[edit update]
|
||||||
resources :submissions, only: %i[show destroy]
|
resources :submissions, only: %i[show destroy]
|
||||||
@@ -66,6 +67,7 @@ Rails.application.routes.draw do
|
|||||||
resource :folder, only: %i[edit update], controller: 'templates_folders'
|
resource :folder, only: %i[edit update], controller: 'templates_folders'
|
||||||
resources :submissions_export, only: %i[index new]
|
resources :submissions_export, only: %i[index new]
|
||||||
end
|
end
|
||||||
|
resources :preview_document_page, only: %i[show], path: '/preview/:attachment_uuid'
|
||||||
|
|
||||||
resources :start_form, only: %i[show update], path: 'd', param: 'slug' do
|
resources :start_form, only: %i[show update], path: 'd', param: 'slug' do
|
||||||
get :completed
|
get :completed
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateEncryptedUserConfigs < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
create_table :encrypted_user_configs do |t|
|
||||||
|
t.references :user, null: false, foreign_key: true, index: true
|
||||||
|
t.string :key, null: false
|
||||||
|
t.text :value, null: false
|
||||||
|
|
||||||
|
t.index %i[user_id key], unique: true
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddApplicationIdToSubmitters < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
add_column :submitters, :application_key, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
+13
-1
@@ -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_10_07_052818) do
|
ActiveRecord::Schema[7.0].define(version: 2023_10_17_213639) 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,16 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_07_052818) 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 "encrypted_user_configs", force: :cascade do |t|
|
||||||
|
t.bigint "user_id", null: false
|
||||||
|
t.string "key", null: false
|
||||||
|
t.text "value", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["user_id", "key"], name: "index_encrypted_user_configs_on_user_id_and_key", unique: true
|
||||||
|
t.index ["user_id"], name: "index_encrypted_user_configs_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "submission_events", force: :cascade do |t|
|
create_table "submission_events", force: :cascade do |t|
|
||||||
t.bigint "submission_id", null: false
|
t.bigint "submission_id", null: false
|
||||||
t.bigint "submitter_id"
|
t.bigint "submitter_id"
|
||||||
@@ -133,6 +143,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_07_052818) do
|
|||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "name"
|
t.string "name"
|
||||||
t.string "phone"
|
t.string "phone"
|
||||||
|
t.string "application_key"
|
||||||
t.index ["email"], name: "index_submitters_on_email"
|
t.index ["email"], name: "index_submitters_on_email"
|
||||||
t.index ["slug"], name: "index_submitters_on_slug", unique: true
|
t.index ["slug"], name: "index_submitters_on_slug", unique: true
|
||||||
t.index ["submission_id"], name: "index_submitters_on_submission_id"
|
t.index ["submission_id"], name: "index_submitters_on_submission_id"
|
||||||
@@ -216,6 +227,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_07_052818) 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 "encrypted_user_configs", "users"
|
||||||
add_foreign_key "submission_events", "submissions"
|
add_foreign_key "submission_events", "submissions"
|
||||||
add_foreign_key "submission_events", "submitters"
|
add_foreign_key "submission_events", "submitters"
|
||||||
add_foreign_key "submissions", "templates"
|
add_foreign_key "submissions", "templates"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class Ability
|
|||||||
can :manage, Submitter, template: { account_id: user.account_id }
|
can :manage, Submitter, template: { account_id: user.account_id }
|
||||||
can :manage, User, account_id: user.account_id
|
can :manage, User, account_id: user.account_id
|
||||||
can :manage, EncryptedConfig, account_id: user.account_id
|
can :manage, EncryptedConfig, account_id: user.account_id
|
||||||
|
can :manage, EncryptedUserConfig, user_id: user.id
|
||||||
can :manage, AccountConfig, account_id: user.account_id
|
can :manage, AccountConfig, account_id: user.account_id
|
||||||
can :manage, UserConfig, user_id: user.id
|
can :manage, UserConfig, user_id: user.id
|
||||||
can :manage, Account, id: user.account_id
|
can :manage, Account, id: user.account_id
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
module Docuseal
|
module Docuseal
|
||||||
PRODUCT_URL = 'https://www.docuseal.co'
|
PRODUCT_URL = 'https://www.docuseal.co'
|
||||||
NEWSLETTER_URL = "#{PRODUCT_URL}/newsletters".freeze
|
NEWSLETTER_URL = "#{PRODUCT_URL}/newsletters".freeze
|
||||||
|
ENQUIRIES_URL = "#{PRODUCT_URL}/enquiries".freeze
|
||||||
PRODUCT_NAME = 'DocuSeal'
|
PRODUCT_NAME = 'DocuSeal'
|
||||||
DEFAULT_APP_URL = 'http://localhost:3000'
|
DEFAULT_APP_URL = 'http://localhost:3000'
|
||||||
GITHUB_URL = 'https://github.com/docusealco/docuseal'
|
GITHUB_URL = 'https://github.com/docusealco/docuseal'
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ module Submissions
|
|||||||
email:,
|
email:,
|
||||||
phone: attrs[:phone].to_s.gsub(/[^0-9+]/, ''),
|
phone: attrs[:phone].to_s.gsub(/[^0-9+]/, ''),
|
||||||
name: attrs[:name],
|
name: attrs[:name],
|
||||||
|
application_key: attrs[:application_key],
|
||||||
completed_at: attrs[:completed] ? Time.current : nil,
|
completed_at: attrs[:completed] ? Time.current : nil,
|
||||||
sent_at: mark_as_sent && email.present? && is_order_sent ? Time.current : nil,
|
sent_at: mark_as_sent && email.present? && is_order_sent ? Time.current : nil,
|
||||||
values: attrs[:values] || {},
|
values: attrs[:values] || {},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ module Submissions
|
|||||||
module EnsureResultGenerated
|
module EnsureResultGenerated
|
||||||
WAIT_FOR_RETRY = 2.seconds
|
WAIT_FOR_RETRY = 2.seconds
|
||||||
CHECK_EVENT_INTERVAL = 1.second
|
CHECK_EVENT_INTERVAL = 1.second
|
||||||
CHECK_COMPLETE_TIMEOUT = 20.seconds
|
CHECK_COMPLETE_TIMEOUT = 90.seconds
|
||||||
|
|
||||||
WaitForCompleteTimeout = Class.new(StandardError)
|
WaitForCompleteTimeout = Class.new(StandardError)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ module Submitters
|
|||||||
|
|
||||||
serialize_params = {
|
serialize_params = {
|
||||||
include: {},
|
include: {},
|
||||||
only: %i[id slug uuid name email phone completed_at
|
only: %i[id slug uuid name email phone completed_at application_key
|
||||||
opened_at sent_at created_at updated_at]
|
opened_at sent_at created_at updated_at]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ module Submitters
|
|||||||
end
|
end
|
||||||
|
|
||||||
def build_values_array(submitter)
|
def build_values_array(submitter)
|
||||||
fields_index = (submitter.submission.template_fields ||
|
fields_index = (submitter.submission.template.fields +
|
||||||
submitter.submission.template.fields).index_by { |e| e['uuid'] }
|
submitter.submission.template_fields.to_a).index_by { |e| e['uuid'] }
|
||||||
attachments_index = submitter.attachments.index_by(&:uuid)
|
attachments_index = submitter.attachments.index_by(&:uuid)
|
||||||
submitter_field_counters = Hash.new { 0 }
|
submitter_field_counters = Hash.new { 0 }
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ module Templates
|
|||||||
record: template
|
record: template
|
||||||
)
|
)
|
||||||
|
|
||||||
document.preview_images_attachments.each do |preview_image|
|
ApplicationRecord.no_touching do
|
||||||
ActiveStorage::Attachment.create!(
|
document.preview_images_attachments.each do |preview_image|
|
||||||
uuid: preview_image.uuid,
|
ActiveStorage::Attachment.create!(
|
||||||
blob_id: preview_image.blob_id,
|
uuid: preview_image.uuid,
|
||||||
name: 'preview_images',
|
blob_id: preview_image.blob_id,
|
||||||
record: new_document
|
name: 'preview_images',
|
||||||
)
|
record: new_document
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
module Templates
|
module Templates
|
||||||
module CreateAttachments
|
module CreateAttachments
|
||||||
PDF_CONTENT_TYPE = 'application/pdf'
|
PDF_CONTENT_TYPE = 'application/pdf'
|
||||||
|
ANNOTATIONS_SIZE_LIMIT = 6.megabytes
|
||||||
InvalidFileType = Class.new(StandardError)
|
InvalidFileType = Class.new(StandardError)
|
||||||
|
|
||||||
module_function
|
module_function
|
||||||
@@ -18,7 +19,10 @@ module Templates
|
|||||||
document = template.documents.create!(blob:)
|
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) }
|
annotations =
|
||||||
|
document_data.size > ANNOTATIONS_SIZE_LIMIT ? [] : Templates::BuildAnnotations.call(document_data)
|
||||||
|
|
||||||
|
blob.metadata['pdf'] = { 'annotations' => annotations }
|
||||||
blob.metadata['sha256'] = Base64.urlsafe_encode64(Digest::SHA256.digest(document_data))
|
blob.metadata['sha256'] = Base64.urlsafe_encode64(Digest::SHA256.digest(document_data))
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -37,9 +41,10 @@ module Templates
|
|||||||
data = file.read
|
data = file.read
|
||||||
|
|
||||||
if file.content_type == PDF_CONTENT_TYPE
|
if file.content_type == PDF_CONTENT_TYPE
|
||||||
|
annotations = data.size > ANNOTATIONS_SIZE_LIMIT ? [] : Templates::BuildAnnotations.call(data)
|
||||||
metadata = { 'identified' => true, 'analyzed' => true,
|
metadata = { 'identified' => true, 'analyzed' => true,
|
||||||
'sha256' => Base64.urlsafe_encode64(Digest::SHA256.digest(data)),
|
'sha256' => Base64.urlsafe_encode64(Digest::SHA256.digest(data)),
|
||||||
'pdf' => { 'annotations' => Templates::BuildAnnotations.call(data) } }
|
'pdf' => { 'annotations' => annotations } }
|
||||||
end
|
end
|
||||||
|
|
||||||
ActiveStorage::Blob.create_and_upload!(
|
ActiveStorage::Blob.create_and_upload!(
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ module Templates
|
|||||||
PDF_CONTENT_TYPE = 'application/pdf'
|
PDF_CONTENT_TYPE = 'application/pdf'
|
||||||
Q = 35
|
Q = 35
|
||||||
MAX_WIDTH = 1400
|
MAX_WIDTH = 1400
|
||||||
|
MAX_NUMBER_OF_PAGES_PROCESSED =
|
||||||
|
if Docuseal.multitenant?
|
||||||
|
70
|
||||||
|
else
|
||||||
|
40
|
||||||
|
end
|
||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
@@ -42,14 +48,38 @@ module Templates
|
|||||||
|
|
||||||
def generate_pdf_preview_images(attachment, data)
|
def generate_pdf_preview_images(attachment, data)
|
||||||
ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all
|
ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all
|
||||||
number_of_pages = HexaPDF::Document.new(io: StringIO.new(data)).pages.size - 1
|
number_of_pages = HexaPDF::Document.new(io: StringIO.new(data)).pages.size
|
||||||
|
|
||||||
(0..number_of_pages).each do |page_number|
|
(attachment.metadata['pdf'] ||= {})[:number_of_pages] = number_of_pages
|
||||||
|
|
||||||
|
attachment.save!
|
||||||
|
|
||||||
|
(0..[number_of_pages - 1, MAX_NUMBER_OF_PAGES_PROCESSED].min).each do |page_number|
|
||||||
page = Vips::Image.new_from_buffer(data, '', dpi: DPI, page: page_number)
|
page = Vips::Image.new_from_buffer(data, '', dpi: DPI, page: page_number)
|
||||||
page = page.resize(MAX_WIDTH / page.width.to_f)
|
page = page.resize(MAX_WIDTH / page.width.to_f)
|
||||||
|
|
||||||
io = StringIO.new(page.write_to_buffer(FORMAT, Q: Q, interlace: true))
|
io = StringIO.new(page.write_to_buffer(FORMAT, Q: Q, interlace: true))
|
||||||
|
|
||||||
|
ApplicationRecord.no_touching do
|
||||||
|
ActiveStorage::Attachment.create!(
|
||||||
|
blob: ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io:, filename: "#{page_number}#{FORMAT}",
|
||||||
|
metadata: { analyzed: true, identified: true, width: page.width, height: page.height }
|
||||||
|
),
|
||||||
|
name: ATTACHMENT_NAME,
|
||||||
|
record: attachment
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_pdf_preview_from_file(attachment, file_path, page_number)
|
||||||
|
page = Vips::Image.new_from_file(file_path, dpi: DPI, page: page_number)
|
||||||
|
page = page.resize(MAX_WIDTH / page.width.to_f)
|
||||||
|
|
||||||
|
io = StringIO.new(page.write_to_buffer(FORMAT, Q: Q, interlace: true))
|
||||||
|
|
||||||
|
ApplicationRecord.no_touching do
|
||||||
ActiveStorage::Attachment.create!(
|
ActiveStorage::Attachment.create!(
|
||||||
blob: ActiveStorage::Blob.create_and_upload!(
|
blob: ActiveStorage::Blob.create_and_upload!(
|
||||||
io:, filename: "#{page_number}#{FORMAT}",
|
io:, filename: "#{page_number}#{FORMAT}",
|
||||||
@@ -59,6 +89,8 @@ module Templates
|
|||||||
record: attachment
|
record: attachment
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
io
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Reference in new issue
Block a user