mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 14:42:32 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9277f4eeb | ||
|
|
4052b6fe13 | ||
|
|
608347b1e6 | ||
|
|
345fe674d4 | ||
|
|
92f8901a67 | ||
|
|
fcd40308ee | ||
|
|
2bd023e5d0 | ||
|
|
a697d6330e | ||
|
|
ab56e8be20 | ||
|
|
b9a906b569 | ||
|
|
02adbae758 | ||
|
|
866aab7fac | ||
|
|
c81b4d855b | ||
|
|
4ad58fc285 | ||
|
|
8227dd4f6a | ||
|
|
beb677734f | ||
|
|
03db636d51 | ||
|
|
b83abc9499 | ||
|
|
9738cdcf8f | ||
|
|
179ae01551 | ||
|
|
295860a9de | ||
|
|
0a6c15b6b8 | ||
|
|
18f80020ee | ||
|
|
3768fb94bf | ||
|
|
7b2cadcb91 | ||
|
|
4eb4e8c1f8 | ||
|
|
984df15949 | ||
|
|
aa3bd24332 | ||
|
|
250889623d | ||
|
|
862fa55986 | ||
|
|
0f2889904a | ||
|
|
49f14c7929 | ||
|
|
c3f3c5d5df | ||
|
|
274259f305 | ||
|
|
a15d7a9cf3 | ||
|
|
7d154fc28d | ||
|
|
6727d3be83 | ||
|
|
4bbd5c3bcb | ||
|
|
5995f2e427 | ||
|
|
0910c8e100 | ||
|
|
8df76dfa90 | ||
|
|
78e9e66cb0 | ||
|
|
71c61fc4b3 | ||
|
|
a728aa8689 | ||
|
|
5a3174a720 | ||
|
|
652072d660 | ||
|
|
323f5c0c15 | ||
|
|
229d78c78b | ||
|
|
9d9a955149 | ||
|
|
b40a047190 | ||
|
|
ff9e415823 | ||
|
|
2f7a4fb2b3 | ||
|
|
f8a654a5b6 | ||
|
|
4449475467 | ||
|
|
bcbbf0dd42 | ||
|
|
19b9ba52d4 | ||
|
|
bdc30db2dc | ||
|
|
74634ba96b | ||
|
|
77feb05bad | ||
|
|
a1f05d006e | ||
|
|
cc3dae1335 | ||
|
|
d9debcfb0d | ||
|
|
8770d16d52 | ||
|
|
84953481e4 | ||
|
|
df7aa59f6f | ||
|
|
008b83119d | ||
|
|
15dafcb6ff | ||
|
|
7d291ed888 | ||
|
|
a7221f0750 | ||
|
|
9a5b2a5d30 | ||
|
|
7361a00d42 | ||
|
|
e07d402782 | ||
|
|
a34354bee1 | ||
|
|
84b762b529 | ||
|
|
2a839951b4 | ||
|
|
26d7c2f047 | ||
|
|
151a5b85f3 | ||
|
|
3f506f53b4 | ||
|
|
95f734e4b8 | ||
|
|
ff74574e84 | ||
|
|
843f6c405f | ||
|
|
f8b24ebfce | ||
|
|
d1d26b5424 | ||
|
|
8aa6f45da8 | ||
|
|
48d5bee95b | ||
|
|
98c79d2dec | ||
|
|
0f51238f62 | ||
|
|
b7323b929f | ||
|
|
05eb1537f7 | ||
|
|
bc9f27cfed | ||
|
|
8f457be719 | ||
|
|
6d590e13c9 | ||
|
|
c987ec172a | ||
|
|
49f1637c47 | ||
|
|
13379bd699 | ||
|
|
5ee762c08c | ||
|
|
edb6d561f1 |
No files matched your search
@@ -59,7 +59,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16.13.1
|
||||
node-version: 20.9.0
|
||||
- name: Cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16.13.1
|
||||
node-version: 20.9.0
|
||||
- name: Install Chrome
|
||||
uses: browser-actions/setup-chrome@latest
|
||||
- name: Cache node_modules
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ Metrics/ParameterLists:
|
||||
Max: 10
|
||||
|
||||
Metrics/MethodLength:
|
||||
Max: 25
|
||||
Max: 30
|
||||
Exclude:
|
||||
- 'db/migrate/**'
|
||||
|
||||
|
||||
+3
-4
@@ -2,9 +2,7 @@ FROM ruby:3.2.2-alpine3.18 as fonts
|
||||
|
||||
WORKDIR /fonts
|
||||
|
||||
RUN apk --no-cache add fontforge wget ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf . && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansArabic/hinted/ttf/NotoSansArabic-Regular.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
|
||||
|
||||
RUN fontforge -lang=py -c 'font1 = fontforge.open("LiberationSans-Regular.ttf"); font2 = fontforge.open("NotoSansArabic-Regular.ttf"); font1.mergeFonts(font2); font1.generate("LiberationSans-Regular.ttf")'
|
||||
RUN apk --no-cache add wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
|
||||
|
||||
FROM ruby:3.2.2-alpine3.18 as webpack
|
||||
|
||||
@@ -36,6 +34,7 @@ FROM ruby:3.2.2-alpine3.18 as app
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV BUNDLE_WITHOUT="development:test"
|
||||
ENV PIDFILE=/dev/null
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -55,7 +54,7 @@ COPY ./public ./public
|
||||
COPY ./tmp ./tmp
|
||||
COPY LICENSE README.md Rakefile config.ru .version ./
|
||||
|
||||
COPY --from=fonts /fonts/LiberationSans-Regular.ttf /fonts/LiberationSans-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
|
||||
COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
|
||||
COPY --from=webpack /app/public/packs ./public/packs
|
||||
|
||||
RUN ln -s /fonts /app/public/fonts
|
||||
|
||||
@@ -32,7 +32,6 @@ gem 'rack'
|
||||
gem 'rails'
|
||||
gem 'rails_autolink'
|
||||
gem 'rails-i18n'
|
||||
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
|
||||
gem 'rotp'
|
||||
gem 'rqrcode'
|
||||
gem 'ruby-vips'
|
||||
|
||||
+1
-3
@@ -261,7 +261,7 @@ GEM
|
||||
signet (>= 0.16, < 2.a)
|
||||
hashdiff (1.1.0)
|
||||
hashery (2.1.2)
|
||||
hexapdf (0.34.1)
|
||||
hexapdf (0.37.1)
|
||||
cmdparse (~> 3.0, >= 3.0.3)
|
||||
geom2d (~> 0.4, >= 0.4.1)
|
||||
openssl (>= 2.2.1)
|
||||
@@ -433,7 +433,6 @@ GEM
|
||||
railties (>= 5.2)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.6)
|
||||
rollbar (3.4.2)
|
||||
rotp (6.3.0)
|
||||
rqrcode (2.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
@@ -605,7 +604,6 @@ DEPENDENCIES
|
||||
rails
|
||||
rails-i18n
|
||||
rails_autolink
|
||||
rollbar
|
||||
rotp
|
||||
rqrcode
|
||||
rspec-rails
|
||||
|
||||
@@ -33,17 +33,30 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
[](https://demo.docuseal.co)
|
||||
|
||||
## Features
|
||||
- [x] PDF form fields builder (WYSIWYG)
|
||||
- [x] 11 field types available (Signature, Date, File, Checkbox etc.)
|
||||
- [x] Multiple submitters per document
|
||||
- [x] Automated emails via SMTP
|
||||
- [x] Files storage on disk or AWS S3, Google Storage, Azure Cloud
|
||||
- [x] Automatic PDF eSignature
|
||||
- [x] PDF signature verification
|
||||
- [x] Users management
|
||||
- [x] Mobile-optimized
|
||||
- [x] API and Webhooks for integrations
|
||||
- [x] Easy to deploy in minutes
|
||||
- PDF form fields builder (WYSIWYG)
|
||||
- 12 field types available (Signature, Date, File, Checkbox etc.)
|
||||
- Multiple submitters per document
|
||||
- Automated emails via SMTP
|
||||
- Files storage on disk or AWS S3, Google Storage, Azure Cloud
|
||||
- Automatic PDF eSignature
|
||||
- PDF signature verification
|
||||
- Users management
|
||||
- Mobile-optimized
|
||||
- Signing available in 12 Languages
|
||||
- API and Webhooks for integrations
|
||||
- Easy to deploy in minutes
|
||||
|
||||
## Pro Features
|
||||
- Company logo and white-label
|
||||
- User roles
|
||||
- Automated reminders
|
||||
- Invitation and identify verification via SMS
|
||||
- SSO / SAML
|
||||
- Template creation with HTML API ([Guide](https://www.docuseal.co/guides/create-pdf-document-fillable-form-with-html-api))
|
||||
- Template creation with PDF or DOCX and field tags API ([Guide](https://www.docuseal.co/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form))
|
||||
- Embedded signing form ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue) or [JavaScript](https://www.docuseal.co/docs/embedded))
|
||||
- Embedded document form builder ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue) or [JavaScript](https://www.docuseal.co/docs/embedded))
|
||||
- [Learn more](https://www.docuseal.co/pricing)
|
||||
|
||||
## Deploy
|
||||
|
||||
@@ -77,7 +90,7 @@ sudo HOST=your-domain-name.com docker compose up
|
||||
```
|
||||
|
||||
## For Businesses
|
||||
### Integrate seamless document signing into your web or mobile apps with DocuSeal!
|
||||
### Integrate seamless document signing into your web or mobile apps with DocuSeal
|
||||
|
||||
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Reporting a Vulnerability
|
||||
|
||||
If you come across any security concern or vulnarability, please report the information via email to security@docuseal.co instead of opening a GitHub issue. We will promptly respond and will collaborate with you to validate the issue, and resolve it ASAP.
|
||||
|
||||
**We have a bug bounty program to reward security researchers.**
|
||||
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class ActiveStorageBlobsProxyController < ApiBaseController
|
||||
include ActiveStorage::Streaming
|
||||
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
before_action :set_cors_headers
|
||||
|
||||
def show
|
||||
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
|
||||
|
||||
if blob_uuid.blank? || purp != 'blob' || (exp && exp < Time.current.to_i)
|
||||
Rollbar.error('Blob not found') if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
|
||||
|
||||
if request.headers['Range'].present?
|
||||
send_blob_byte_range_data blob, request.headers['Range']
|
||||
else
|
||||
http_cache_forever public: true do
|
||||
response.headers['Accept-Ranges'] = 'bytes'
|
||||
response.headers['Content-Length'] = blob.byte_size.to_s
|
||||
|
||||
send_blob_stream blob, disposition: params[:disposition]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class ActiveStorageBlobsProxyLegacyController < ApiBaseController
|
||||
include ActiveStorage::Streaming
|
||||
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def show
|
||||
Rollbar.info('Blob legacy') if defined?(Rollbar)
|
||||
|
||||
blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id] || params[:signed_id])
|
||||
|
||||
return head :not_found unless blob
|
||||
|
||||
is_permitted = blob.attachments.any? do |a|
|
||||
(current_user && a.record.account.id == current_user.account_id) ||
|
||||
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||
|
||||
a.name == 'logo'
|
||||
end
|
||||
|
||||
unless is_permitted
|
||||
Rollbar.error("Blob account not found: #{blob.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: 'Not authenticated' }, status: :unauthorized
|
||||
end
|
||||
|
||||
if request.headers['Range'].present?
|
||||
send_blob_byte_range_data blob, request.headers['Range']
|
||||
else
|
||||
http_cache_forever public: true do
|
||||
response.headers['Accept-Ranges'] = 'bytes'
|
||||
response.headers['Content-Length'] = blob.byte_size.to_s
|
||||
|
||||
send_blob_stream blob, disposition: params[:disposition]
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
end
|
||||
@@ -21,13 +21,13 @@ module Api
|
||||
|
||||
if Rails.env.production?
|
||||
rescue_from CanCan::AccessDenied do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :forbidden
|
||||
end
|
||||
|
||||
rescue_from JSON::ParserError do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: "JSON parse error: #{e.message}" }, status: :unprocessable_entity
|
||||
end
|
||||
@@ -59,5 +59,13 @@ module Api
|
||||
def current_account
|
||||
current_user&.account
|
||||
end
|
||||
|
||||
def set_cors_headers
|
||||
headers['Access-Control-Allow-Origin'] = '*'
|
||||
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
|
||||
headers['Access-Control-Allow-Headers'] = '*'
|
||||
headers['Access-Control-Max-Age'] = '1728000'
|
||||
headers['Access-Control-Allow-Credentials'] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -33,12 +33,18 @@ module Api
|
||||
def show
|
||||
submitters = @submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob)
|
||||
|
||||
serialized_submitters = submitters.map do |submitter|
|
||||
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at?
|
||||
|
||||
Submitters::SerializeForApi.call(submitter)
|
||||
submitters.each do |submitter|
|
||||
if submitter.completed_at? && submitter.documents_attachments.blank?
|
||||
submitter.documents_attachments = Submissions::EnsureResultGenerated.call(submitter)
|
||||
end
|
||||
end
|
||||
|
||||
if @submission.audit_trail_attachment.blank? && submitters.all?(&:completed_at?)
|
||||
@submission.audit_trail_attachment = Submissions::GenerateAuditTrail.call(@submission)
|
||||
end
|
||||
|
||||
serialized_submitters = submitters.map { |submitter| Submitters::SerializeForApi.call(submitter) }
|
||||
|
||||
json = @submission.as_json(
|
||||
serialize_params.deep_merge(
|
||||
include: { submission_events: { only: %i[id submitter_id event_type event_timestamp] } }
|
||||
@@ -68,7 +74,7 @@ module Api
|
||||
return render json: { error: 'Template not found' }, status: :unprocessable_entity if @template.nil?
|
||||
|
||||
if @template.fields.blank?
|
||||
Rollbar.error("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
|
||||
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: 'Template does not contain fields' }, status: :unprocessable_entity
|
||||
end
|
||||
@@ -80,8 +86,16 @@ module Api
|
||||
|
||||
Submissions.send_signature_requests(submissions)
|
||||
|
||||
submissions.each do |submission|
|
||||
if submission.submitters.all?(&:completed_at?) && submission.submitters.last
|
||||
ProcessSubmitterCompletionJob.perform_later(submission.submitters.last)
|
||||
end
|
||||
end
|
||||
|
||||
render json: submissions.flat_map(&:submitters)
|
||||
rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e
|
||||
rescue Submitters::NormalizeValues::BaseError => e
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@@ -118,8 +132,13 @@ module Api
|
||||
params:
|
||||
)
|
||||
|
||||
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments,
|
||||
submissions.flat_map(&:submitters))
|
||||
submitters = submissions.flat_map(&:submitters)
|
||||
|
||||
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments, submitters)
|
||||
|
||||
submitters.each do |submitter|
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request) if submitter.completed_at?
|
||||
end
|
||||
|
||||
submissions
|
||||
end
|
||||
|
||||
@@ -8,6 +8,9 @@ module Api
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
submitter.opened_at = Time.current
|
||||
submitter.save
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
|
||||
|
||||
SendFormViewedWebhookRequestJob.perform_later(submitter)
|
||||
|
||||
@@ -55,6 +55,8 @@ module Api
|
||||
@submitter.save!
|
||||
|
||||
@submitter.submission.save!
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request) if @submitter.completed_at?
|
||||
end
|
||||
|
||||
if @submitter.completed_at?
|
||||
|
||||
@@ -7,10 +7,31 @@ module Api
|
||||
def index
|
||||
templates = filter_templates(@templates, params)
|
||||
|
||||
templates = paginate(templates.preload(:author, documents_attachments: :blob))
|
||||
templates = paginate(templates.preload(:author, :folder))
|
||||
|
||||
schema_documents =
|
||||
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
|
||||
record_type: 'Template',
|
||||
name: :documents,
|
||||
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
|
||||
.preload(:blob)
|
||||
|
||||
preview_image_attachments =
|
||||
ActiveStorage::Attachment.joins(:blob)
|
||||
.where(blob: { filename: '0.jpg' })
|
||||
.where(record_id: schema_documents.map(&:id),
|
||||
record_type: 'ActiveStorage::Attachment',
|
||||
name: :preview_images)
|
||||
.preload(:blob)
|
||||
|
||||
render json: {
|
||||
data: templates.as_json(serialize_params),
|
||||
data: templates.map do |t|
|
||||
Templates::SerializeForApi.call(
|
||||
t,
|
||||
schema_documents.select { |e| e.record_id == t.id },
|
||||
preview_image_attachments
|
||||
)
|
||||
end,
|
||||
pagination: {
|
||||
count: templates.size,
|
||||
next: templates.last&.id,
|
||||
@@ -20,14 +41,20 @@ module Api
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @template.as_json(serialize_params)
|
||||
render json: Templates::SerializeForApi.call(@template)
|
||||
end
|
||||
|
||||
def update
|
||||
if (folder_name = params.dig(:template, :folder_name))
|
||||
if (folder_name = params[:folder_name] || params.dig(:template, :folder_name))
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
|
||||
end
|
||||
|
||||
archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived)
|
||||
|
||||
if archived.in?([true, false])
|
||||
@template.archived_at = archived == true ? Time.current : nil
|
||||
end
|
||||
|
||||
@template.update!(template_params)
|
||||
|
||||
render json: @template.as_json(only: %i[id updated_at])
|
||||
@@ -55,24 +82,22 @@ module Api
|
||||
templates
|
||||
end
|
||||
|
||||
def serialize_params
|
||||
{
|
||||
methods: %i[application_key],
|
||||
include: { author: { only: %i[id email first_name last_name] },
|
||||
documents: { only: %i[id uuid], methods: %i[url preview_image_url filename] } }
|
||||
}
|
||||
end
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(
|
||||
permit_params = [
|
||||
:name,
|
||||
schema: [%i[attachment_uuid name]],
|
||||
submitters: [%i[name uuid]],
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
|
||||
{ preferences: {},
|
||||
options: [%i[value uuid]],
|
||||
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
|
||||
)
|
||||
{ schema: [%i[attachment_uuid name]],
|
||||
submitters: [%i[name uuid]],
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
|
||||
{ preferences: {},
|
||||
options: [%i[value uuid]],
|
||||
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
|
||||
]
|
||||
|
||||
if params.key?(:template)
|
||||
params.require(:template).permit(*permit_params)
|
||||
else
|
||||
params.permit(*permit_params)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -16,7 +16,7 @@ module Api
|
||||
render json: {
|
||||
schema:,
|
||||
documents: documents.as_json(
|
||||
methods: [:metadata],
|
||||
methods: %i[metadata signed_uuid],
|
||||
include: {
|
||||
preview_images: { methods: %i[url metadata filename] }
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
if Rails.env.production?
|
||||
rescue_from CanCan::AccessDenied do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
redirect_to root_path, alert: e.message
|
||||
end
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class EncryptedUserConfigsController < ApplicationController
|
||||
load_and_authorize_resource :encrypted_user_config
|
||||
|
||||
def destroy
|
||||
@encrypted_user_config.destroy!
|
||||
|
||||
redirect_back(fallback_location: root_path)
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,9 @@ class PreviewDocumentPageController < ActionController::API
|
||||
FORMAT = Templates::ProcessDocument::FORMAT
|
||||
|
||||
def show
|
||||
attachment = ActiveStorage::Attachment.find_by(uuid: params[:attachment_uuid])
|
||||
attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment)
|
||||
|
||||
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid) if attachment_uuid
|
||||
|
||||
return head :not_found unless attachment
|
||||
|
||||
@@ -21,9 +23,10 @@ class PreviewDocumentPageController < ActionController::API
|
||||
find_or_create_document_tempfile_path(attachment)
|
||||
end
|
||||
|
||||
io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
|
||||
preview_image =
|
||||
Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
|
||||
|
||||
render plain: io.tap(&:rewind).read, content_type: 'image/jpeg'
|
||||
redirect_to preview_image.url, allow_other_host: true
|
||||
end
|
||||
|
||||
def find_or_create_document_tempfile_path(attachment)
|
||||
|
||||
@@ -12,13 +12,16 @@ class SendSubmissionEmailController < ApplicationController
|
||||
def create
|
||||
@submitter =
|
||||
if params[:template_slug]
|
||||
Submitter.joins(submission: :template).find_by!(email: params[:email],
|
||||
Submitter.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
|
||||
template: { slug: params[:template_slug] })
|
||||
elsif params[:submission_slug]
|
||||
Submitter.joins(:submission).find_by!(email: params[:email].to_s.downcase,
|
||||
submission: { slug: params[:submission_slug] })
|
||||
else
|
||||
Submitter.find_by!(slug: params[:submitter_slug])
|
||||
end
|
||||
|
||||
SubmitterMailer.documents_copy_email(@submitter).deliver_later!
|
||||
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later!
|
||||
|
||||
respond_to do |f|
|
||||
f.html { redirect_to success_send_submission_email_index_path }
|
||||
|
||||
@@ -4,12 +4,14 @@ class SessionsController < Devise::SessionsController
|
||||
before_action :configure_permitted_parameters
|
||||
|
||||
def create
|
||||
if Docuseal.multitenant? && !User.exists?(email: sign_in_params[:email])
|
||||
email = sign_in_params[:email].to_s.downcase
|
||||
|
||||
if Docuseal.multitenant? && !User.exists?(email:)
|
||||
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
|
||||
notice: 'Create a new account'
|
||||
end
|
||||
|
||||
if User.exists?(email: sign_in_params[:email], otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
|
||||
if User.exists?(email:, otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
|
||||
return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ class StartFormController < ApplicationController
|
||||
end
|
||||
|
||||
def update
|
||||
return redirect_to start_form_path(@template.slug) if @template.archived_at?
|
||||
|
||||
@submitter = Submitter.where(submission: @template.submissions.where(archived_at: nil))
|
||||
.order(id: :desc)
|
||||
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
|
||||
|
||||
@@ -6,23 +6,8 @@ class SubmissionsController < ApplicationController
|
||||
|
||||
load_and_authorize_resource :submission, only: %i[show destroy]
|
||||
|
||||
PRELOAD_ALL_PAGES_AMOUNT = 200
|
||||
|
||||
def show
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@submission],
|
||||
associations: [:template, { template_schema_documents: :blob }]
|
||||
).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
|
||||
@submission = Submissions.preload_with_pages(@submission)
|
||||
|
||||
render :show, layout: 'plain'
|
||||
end
|
||||
|
||||
@@ -4,8 +4,20 @@ class SubmissionsDownloadController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
TTL = 40.minutes
|
||||
FILES_TTL = 5.minutes
|
||||
|
||||
def index
|
||||
submitter = Submitter.find_by(slug: params[:submitter_slug])
|
||||
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
|
||||
signature_valid =
|
||||
if submitter&.slug == params[:submitter_slug]
|
||||
true
|
||||
else
|
||||
submitter = nil
|
||||
end
|
||||
|
||||
submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
Submissions::EnsureResultGenerated.call(submitter)
|
||||
|
||||
@@ -13,11 +25,26 @@ class SubmissionsDownloadController < ApplicationController
|
||||
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
urls =
|
||||
Submitters.select_attachments_for_download(last_submitter).map do |attachment|
|
||||
helpers.rails_blob_url(attachment)
|
||||
end
|
||||
return head :not_found unless last_submitter.completed_at?
|
||||
|
||||
render json: urls
|
||||
if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter)
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
render json: build_urls(last_submitter)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def current_user_submitter?(submitter)
|
||||
current_user && current_user.account.submitters.exists?(id: submitter.id)
|
||||
end
|
||||
|
||||
def build_urls(submitter)
|
||||
Submitters.select_attachments_for_download(submitter).map do |attachment|
|
||||
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.minutes.from_now.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,26 +4,48 @@ class SubmissionsPreviewController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
PRELOAD_ALL_PAGES_AMOUNT = 200
|
||||
TTL = 40.minutes
|
||||
|
||||
def show
|
||||
@submission = Submission.find_by!(slug: params[:slug])
|
||||
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@submission],
|
||||
associations: [:template, { template_schema_documents: :blob }]
|
||||
).call
|
||||
signature_valid =
|
||||
if submitter && submitter.submission.slug == params[:slug]
|
||||
@submission = submitter.submission
|
||||
|
||||
total_pages =
|
||||
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
|
||||
true
|
||||
end
|
||||
|
||||
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: @submission.template_schema_documents,
|
||||
associations: [:blob, { preview_images_attachments: :blob }]
|
||||
).call
|
||||
@submission ||= Submission.find_by!(slug: params[:slug])
|
||||
|
||||
if !@submission.submitters.all?(&:completed_at?) && current_user.blank?
|
||||
raise ActionController::RoutingError, 'Not Found'
|
||||
end
|
||||
|
||||
if !submission_valid_ttl?(@submission) && !signature_valid
|
||||
Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar)
|
||||
|
||||
return redirect_to submissions_preview_completed_path(@submission.slug)
|
||||
end
|
||||
|
||||
@submission = Submissions.preload_with_pages(@submission)
|
||||
|
||||
render 'submissions/show', layout: 'plain'
|
||||
end
|
||||
|
||||
def completed
|
||||
@submission = Submission.find_by!(slug: params[:submissions_preview_slug])
|
||||
|
||||
render :completed, layout: 'form'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def submission_valid_ttl?(submission)
|
||||
return true if current_user && current_user.account.submissions.exists?(id: submission.id)
|
||||
|
||||
last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
|
||||
|
||||
last_submitter && last_submitter.completed_at > TTL.ago
|
||||
end
|
||||
end
|
||||
@@ -33,7 +33,7 @@ class TemplatesController < ApplicationController
|
||||
@template_data =
|
||||
@template.as_json.merge(
|
||||
documents: @template.schema_documents.as_json(
|
||||
methods: [:metadata],
|
||||
methods: %i[metadata signed_uuid],
|
||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||
)
|
||||
).to_json
|
||||
@@ -51,12 +51,17 @@ class TemplatesController < ApplicationController
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||
end
|
||||
|
||||
@template.account = current_account
|
||||
if params[:account_id].present? && authorized_clone_account_id?(params[:account_id])
|
||||
@template.account_id = params[:account_id]
|
||||
@template.folder = @template.account.default_template_folder
|
||||
else
|
||||
@template.account = current_account
|
||||
end
|
||||
|
||||
if @template.save
|
||||
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
|
||||
|
||||
redirect_to edit_template_path(@template)
|
||||
maybe_redirect_to_template(@template)
|
||||
else
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_entity
|
||||
end
|
||||
@@ -81,6 +86,18 @@ class TemplatesController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def authorized_clone_account_id?(account_id)
|
||||
true_user.account_id.to_s == account_id.to_s || true_user.account.linked_accounts.exists?(id: account_id)
|
||||
end
|
||||
|
||||
def maybe_redirect_to_template(template)
|
||||
if template.account == current_account
|
||||
redirect_to(edit_template_path(@template))
|
||||
else
|
||||
redirect_back(fallback_location: root_path, notice: 'Template has been clonned')
|
||||
end
|
||||
end
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(:name)
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ class TemplatesPreviewController < ApplicationController
|
||||
@template_data =
|
||||
@template.as_json.merge(
|
||||
documents: @template.schema_documents.as_json(
|
||||
methods: [:metadata],
|
||||
methods: %i[metadata signed_uuid],
|
||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||
)
|
||||
).to_json
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UserConfigsController < ApplicationController
|
||||
before_action :load_user_config
|
||||
authorize_resource :user_config
|
||||
|
||||
ALLOWED_KEYS = [
|
||||
UserConfig::RECEIVE_COMPLETED_EMAIL
|
||||
].freeze
|
||||
|
||||
def create
|
||||
@user_config.update!(user_config_params)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_user_config
|
||||
return head :not_found unless ALLOWED_KEYS.include?(user_config_params[:key])
|
||||
|
||||
@user_config =
|
||||
UserConfig.find_or_initialize_by(user: current_user, key: user_config_params[:key])
|
||||
end
|
||||
|
||||
def user_config_params
|
||||
params.required(:user_config).permit!.tap do |attrs|
|
||||
attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -90,6 +90,7 @@ window.customElements.define('template-builder', class extends HTMLElement {
|
||||
withLogo: this.dataset.withLogo !== 'false',
|
||||
editable: this.dataset.editable !== 'false',
|
||||
withPayment: this.dataset.withPayment === 'true',
|
||||
withFormula: this.dataset.withFormula === 'true',
|
||||
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
|
||||
acceptFileTypes: this.dataset.acceptFileTypes,
|
||||
isDirectUpload: this.dataset.isDirectUpload === 'true'
|
||||
|
||||
@@ -75,16 +75,16 @@ button[disabled] .enabled {
|
||||
@apply select base-input w-full font-normal;
|
||||
}
|
||||
|
||||
.tooltip-bottom-start:before {
|
||||
transform: translateX(-30%);
|
||||
.tooltip-bottom-end:before {
|
||||
transform: translateX(-95%);
|
||||
top: var(--tooltip-offset);
|
||||
left: 100%;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.tooltip-bottom-start:after {
|
||||
transform: translateX(-75%);
|
||||
.tooltip-bottom-end:after {
|
||||
transform: translateX(-25%);
|
||||
border-color: transparent transparent var(--tooltip-color) transparent;
|
||||
top: var(--tooltip-tail-offset);
|
||||
left: 50%;
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue'
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1 } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FieldArea',
|
||||
@@ -240,6 +240,7 @@ export default {
|
||||
signature: this.t('signature'),
|
||||
initials: this.t('initials'),
|
||||
date: this.t('date'),
|
||||
number: this.t('number'),
|
||||
image: this.t('image'),
|
||||
file: this.t('file'),
|
||||
select: this.t('select'),
|
||||
@@ -260,6 +261,7 @@ export default {
|
||||
text: IconTextSize,
|
||||
signature: IconWritingSign,
|
||||
date: IconCalendarEvent,
|
||||
number: IconSquareNumber1,
|
||||
image: IconPhoto,
|
||||
initials: IconLetterCaseUpper,
|
||||
file: IconPaperclip,
|
||||
@@ -307,7 +309,7 @@ export default {
|
||||
formattedDate () {
|
||||
if (this.field.type === 'date' && this.modelValue) {
|
||||
return this.formatDate(
|
||||
new Date(this.modelValue),
|
||||
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
|
||||
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -8,11 +8,16 @@
|
||||
:current-step="currentStepFields"
|
||||
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']"
|
||||
/>
|
||||
<FormulaFieldAreas
|
||||
v-if="formulaFields.length"
|
||||
:fields="formulaFields"
|
||||
:values="values"
|
||||
/>
|
||||
<button
|
||||
v-if="!isFormVisible"
|
||||
id="expand_form_button"
|
||||
class="btn btn-neutral flex text-white absolute rounded-none border-x-0 md:border md:rounded-full bottom-0 w-full md:mb-4 text-base"
|
||||
@click.prevent="isFormVisible = true"
|
||||
@click.prevent="[isFormVisible = true, scrollIntoField(currentField)]"
|
||||
>
|
||||
{{ t('submit_form') }}
|
||||
<IconArrowsDiagonal
|
||||
@@ -32,7 +37,7 @@
|
||||
v-if="!isCompleted"
|
||||
id="minimize_form_button"
|
||||
class="absolute right-0 mr-2 mt-2 top-0 hidden md:block"
|
||||
title="Minimize"
|
||||
:title="t('minimize')"
|
||||
@click.prevent="isFormVisible = false"
|
||||
>
|
||||
<IconArrowsDiagonalMinimize2
|
||||
@@ -40,13 +45,16 @@
|
||||
:height="20"
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<div
|
||||
:class="{ 'md:px-4': isBreakpointMd }"
|
||||
>
|
||||
<form
|
||||
v-if="!isCompleted"
|
||||
ref="form"
|
||||
:action="submitPath"
|
||||
method="post"
|
||||
:class="{ 'md:mx-16': isBreakpointMd }"
|
||||
class="mx-auto"
|
||||
:style="{ maxWidth: isBreakpointMd ? '582px' : '' }"
|
||||
@submit.prevent="submitStep"
|
||||
>
|
||||
<input
|
||||
@@ -69,6 +77,14 @@
|
||||
@focus="scrollIntoField(currentField)"
|
||||
/>
|
||||
</div>
|
||||
<NumberStep
|
||||
v-else-if="currentField.type === 'number'"
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:show-field-names="showFieldNames"
|
||||
:field="currentField"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
/>
|
||||
<DateStep
|
||||
v-else-if="currentField.type === 'date'"
|
||||
:key="currentField.uuid"
|
||||
@@ -254,7 +270,7 @@
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:field="currentField"
|
||||
:previous-value="previousSignatureValue"
|
||||
:previous-value="previousSignatureValueFor(currentField)"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:with-typed-signature="withTypedSignature"
|
||||
:attachments-index="attachmentsIndex"
|
||||
@@ -385,6 +401,7 @@
|
||||
|
||||
<script>
|
||||
import FieldAreas from './areas'
|
||||
import FormulaFieldAreas from './formula_areas'
|
||||
import ImageStep from './image_step'
|
||||
import SignatureStep from './signature_step'
|
||||
import InitialsStep from './initials_step'
|
||||
@@ -393,12 +410,31 @@ import MultiSelectStep from './multi_select_step'
|
||||
import PhoneStep from './phone_step'
|
||||
import PaymentStep from './payment_step'
|
||||
import TextStep from './text_step'
|
||||
import NumberStep from './number_step'
|
||||
import DateStep from './date_step'
|
||||
import FormCompleted from './completed'
|
||||
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||
import AppearsOn from './appears_on'
|
||||
import i18n from './i18n'
|
||||
|
||||
const isEmpty = (obj) => {
|
||||
if (obj == null) return true
|
||||
|
||||
if (Array.isArray(obj) || typeof obj === 'string') {
|
||||
return obj.length === 0
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
return Object.keys(obj).length === 0
|
||||
}
|
||||
|
||||
if (obj === false) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'SubmissionForm',
|
||||
components: {
|
||||
@@ -413,6 +449,8 @@ export default {
|
||||
DateStep,
|
||||
IconArrowsDiagonal,
|
||||
TextStep,
|
||||
NumberStep,
|
||||
FormulaFieldAreas,
|
||||
PhoneStep,
|
||||
PaymentStep,
|
||||
IconArrowsDiagonalMinimize2,
|
||||
@@ -448,6 +486,11 @@ export default {
|
||||
return () => {}
|
||||
}
|
||||
},
|
||||
expand: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withConfetti: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -518,6 +561,11 @@ export default {
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
i18n: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
withSendCopyButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -542,7 +590,7 @@ export default {
|
||||
data () {
|
||||
return {
|
||||
isCompleted: false,
|
||||
isFormVisible: true,
|
||||
isFormVisible: this.expand,
|
||||
showFillAllRequiredFields: false,
|
||||
currentStep: 0,
|
||||
isSubmitting: false,
|
||||
@@ -566,18 +614,13 @@ export default {
|
||||
submitterSlug () {
|
||||
return this.submitter.slug
|
||||
},
|
||||
previousSignatureValue () {
|
||||
const signatureField = [...this.fields].reverse().find((field) => field.type === 'signature' && !!this.values[field.uuid])
|
||||
|
||||
return this.values[signatureField?.uuid]
|
||||
},
|
||||
previousInitialsValue () {
|
||||
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
|
||||
|
||||
return this.values[initialsField?.uuid]
|
||||
},
|
||||
isAnonymousChecboxes () {
|
||||
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 && !e.required) && this.currentStepFields.length > 4
|
||||
},
|
||||
isButtonDisabled () {
|
||||
if (this.recalculateButtonDisabledKey) {
|
||||
@@ -605,6 +648,9 @@ export default {
|
||||
return acc
|
||||
}, [])
|
||||
},
|
||||
formulaFields () {
|
||||
return this.fields.filter((f) => f.preferences?.formula)
|
||||
},
|
||||
attachmentsIndex () {
|
||||
return this.attachments.reduce((acc, a) => {
|
||||
acc[a.uuid] = a
|
||||
@@ -616,12 +662,17 @@ export default {
|
||||
return `/s/${this.submitterSlug}`
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
expand (value) {
|
||||
this.isFormVisible = value
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.submittedValues = JSON.parse(JSON.stringify(this.values))
|
||||
|
||||
this.fields.forEach((field) => {
|
||||
if (field.default_value && !field.readonly) {
|
||||
this.values[field.uuid] = field.default_value
|
||||
this.values[field.uuid] ||= field.default_value
|
||||
}
|
||||
})
|
||||
|
||||
@@ -675,7 +726,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
t (key) {
|
||||
return i18n[this.language?.toLowerCase()]?.[key] || i18n[this.browserLanguage]?.[key] || i18n.en[key] || key
|
||||
return this.i18n[key] || i18n[this.language?.toLowerCase()]?.[key] || i18n[this.browserLanguage]?.[key] || i18n.en[key] || key
|
||||
},
|
||||
maybeTrackEmailClick () {
|
||||
const { queryParams } = this
|
||||
@@ -736,6 +787,13 @@ export default {
|
||||
})
|
||||
})
|
||||
},
|
||||
previousSignatureValueFor (field) {
|
||||
const signatureField = [...this.fields].reverse().find((f) =>
|
||||
f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid]
|
||||
)
|
||||
|
||||
return this.values[signatureField?.uuid]
|
||||
},
|
||||
goToStep (step, scrollToArea = false, clickUpload = false) {
|
||||
this.currentStep = this.stepFields.indexOf(step)
|
||||
this.showFillAllRequiredFields = false
|
||||
@@ -747,7 +805,7 @@ export default {
|
||||
this.scrollIntoField(step[0])
|
||||
}
|
||||
|
||||
this.$refs.form.querySelector('input[type="date"], input[type="text"], select')?.focus()
|
||||
this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus()
|
||||
|
||||
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) {
|
||||
this.$refs.form.querySelector('input[type="file"]')?.click()
|
||||
@@ -755,7 +813,7 @@ export default {
|
||||
})
|
||||
},
|
||||
saveStep (formData) {
|
||||
const currentFieldUuid = this.currentField.uuid
|
||||
const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
|
||||
|
||||
if (this.isCompleted) {
|
||||
return Promise.resolve({})
|
||||
@@ -765,7 +823,9 @@ export default {
|
||||
body: formData || new FormData(this.$refs.form)
|
||||
}).then((response) => {
|
||||
if (response.status === 200) {
|
||||
this.submittedValues[currentFieldUuid] = this.values[currentFieldUuid]
|
||||
currentFieldUuids.forEach((fieldUuid) => {
|
||||
this.submittedValues[fieldUuid] = this.values[fieldUuid]
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
@@ -787,7 +847,13 @@ export default {
|
||||
|
||||
stepPromise().then(async () => {
|
||||
const emptyRequiredField = this.stepFields.find((fields, index) => {
|
||||
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && (this.submittedValues[fields[0].uuid] === true ? false : !this.submittedValues[fields[0].uuid]?.length)
|
||||
if (index >= this.currentStep) {
|
||||
return false
|
||||
}
|
||||
|
||||
return fields.some((f) => {
|
||||
return f.required && (f.type === 'phone' || !this.allowToSkip) && isEmpty(this.submittedValues[f.uuid])
|
||||
})
|
||||
})
|
||||
|
||||
const formData = new FormData(this.$refs.form)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<template
|
||||
v-for="(field, fieldIndex) in fields"
|
||||
:key="field.uuid"
|
||||
>
|
||||
<template
|
||||
v-for="(area, areaIndex) in field.areas"
|
||||
:key="areaIndex"
|
||||
>
|
||||
<Teleport
|
||||
v-if="findPageElementForArea(area)"
|
||||
:to="findPageElementForArea(area)"
|
||||
>
|
||||
<FieldArea
|
||||
v-if="isMathLoaded"
|
||||
:model-value="calculateFormula(field)"
|
||||
:field="field"
|
||||
:area="area"
|
||||
:submittable="false"
|
||||
:field-index="fieldIndex"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FieldArea from './area'
|
||||
|
||||
export default {
|
||||
name: 'FormulaFieldAreas',
|
||||
components: {
|
||||
FieldArea
|
||||
},
|
||||
props: {
|
||||
fields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isMathLoaded: false
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
const {
|
||||
create,
|
||||
evaluateDependencies,
|
||||
addDependencies,
|
||||
subtractDependencies,
|
||||
divideDependencies,
|
||||
multiplyDependencies,
|
||||
powDependencies,
|
||||
roundDependencies,
|
||||
absDependencies,
|
||||
sinDependencies,
|
||||
tanDependencies,
|
||||
cosDependencies
|
||||
} = await import('mathjs')
|
||||
|
||||
this.math = create({
|
||||
evaluateDependencies,
|
||||
addDependencies,
|
||||
subtractDependencies,
|
||||
divideDependencies,
|
||||
multiplyDependencies,
|
||||
powDependencies,
|
||||
roundDependencies,
|
||||
absDependencies,
|
||||
sinDependencies,
|
||||
tanDependencies,
|
||||
cosDependencies
|
||||
})
|
||||
|
||||
this.isMathLoaded = true
|
||||
},
|
||||
methods: {
|
||||
findPageElementForArea (area) {
|
||||
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${area.attachment_uuid}-${area.page}`)
|
||||
},
|
||||
calculateFormula (field) {
|
||||
const transformedFormula = field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
return this.values[uuid] || 0.0
|
||||
})
|
||||
|
||||
return this.math.evaluate(transformedFormula.toLowerCase())
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -3,6 +3,7 @@ const en = {
|
||||
signature: 'Signature',
|
||||
initials: 'Initials',
|
||||
date: 'Date',
|
||||
number: 'Number',
|
||||
image: 'Image',
|
||||
take_photo: 'Take photo',
|
||||
file: 'File',
|
||||
@@ -12,6 +13,7 @@ const en = {
|
||||
radio: 'Radio',
|
||||
cells: 'Cells',
|
||||
stamp: 'Stamp',
|
||||
minimize: 'Minimize',
|
||||
payment: 'Payment',
|
||||
phone: 'Phone',
|
||||
submit_form: 'Submit Form',
|
||||
@@ -62,10 +64,12 @@ const en = {
|
||||
}
|
||||
|
||||
const es = {
|
||||
minimize: 'Minimizar',
|
||||
text: 'Texto',
|
||||
signature: 'Firma',
|
||||
initials: 'Iniciales',
|
||||
date: 'Fecha',
|
||||
number: 'Número',
|
||||
image: 'Imagen',
|
||||
file: 'Archivo',
|
||||
select: 'Seleccionar',
|
||||
@@ -124,10 +128,12 @@ const es = {
|
||||
}
|
||||
|
||||
const it = {
|
||||
minimize: 'Minimizza',
|
||||
text: 'Testo',
|
||||
signature: 'Firma',
|
||||
initials: 'Iniziali',
|
||||
date: 'Data',
|
||||
number: 'Numero',
|
||||
image: 'Immagine',
|
||||
file: 'File',
|
||||
select: 'Seleziona',
|
||||
@@ -186,10 +192,12 @@ const it = {
|
||||
}
|
||||
|
||||
const de = {
|
||||
minimize: 'Minimieren',
|
||||
text: 'Text',
|
||||
signature: 'Unterschrift',
|
||||
initials: 'Initialen',
|
||||
date: 'Datum',
|
||||
number: 'Nummer',
|
||||
image: 'Bild',
|
||||
file: 'Datei',
|
||||
select: 'Auswählen',
|
||||
@@ -248,10 +256,12 @@ const de = {
|
||||
}
|
||||
|
||||
const fr = {
|
||||
minimize: 'Réduire',
|
||||
text: 'Texte',
|
||||
signature: 'Signature',
|
||||
initials: 'Initiales',
|
||||
date: 'Date',
|
||||
number: 'Numéro',
|
||||
image: 'Image',
|
||||
file: 'Fichier',
|
||||
select: 'Choisir',
|
||||
@@ -310,10 +320,12 @@ const fr = {
|
||||
}
|
||||
|
||||
const pl = {
|
||||
minimize: 'Zminimalizuj',
|
||||
text: 'Tekst',
|
||||
signature: 'Podpis',
|
||||
initials: 'Inicjały',
|
||||
date: 'Data',
|
||||
number: 'Numer',
|
||||
image: 'Obraz',
|
||||
file: 'Plik',
|
||||
select: 'Wybierz',
|
||||
@@ -372,10 +384,12 @@ const pl = {
|
||||
}
|
||||
|
||||
const uk = {
|
||||
minimize: 'Зменшити',
|
||||
text: 'Текст',
|
||||
signature: 'Підпис',
|
||||
initials: 'Ініціали',
|
||||
date: 'Дата',
|
||||
number: 'Число',
|
||||
image: 'Зображення',
|
||||
file: 'Файл',
|
||||
select: 'Вибрати',
|
||||
@@ -434,10 +448,12 @@ const uk = {
|
||||
}
|
||||
|
||||
const cs = {
|
||||
minimize: 'Minimalizovat',
|
||||
text: 'Text',
|
||||
signature: 'Podpis',
|
||||
initials: 'Iniciály',
|
||||
date: 'Datum',
|
||||
number: 'Číslo',
|
||||
image: 'Obrázek',
|
||||
file: 'Soubor',
|
||||
select: 'Vybrat',
|
||||
@@ -496,10 +512,12 @@ const cs = {
|
||||
}
|
||||
|
||||
const pt = {
|
||||
minimize: 'Minimizar',
|
||||
text: 'Texto',
|
||||
signature: 'Assinatura',
|
||||
initials: 'Iniciais',
|
||||
date: 'Data',
|
||||
number: 'Número',
|
||||
image: 'Imagem',
|
||||
file: 'Arquivo',
|
||||
select: 'Selecionar',
|
||||
@@ -558,10 +576,12 @@ const pt = {
|
||||
}
|
||||
|
||||
const he = {
|
||||
minimize: 'לקטן',
|
||||
text: 'טקסט',
|
||||
signature: 'חתימה',
|
||||
initials: 'ראשי תיקיות',
|
||||
initials: 'ראשי תיבות',
|
||||
date: 'תאריך',
|
||||
number: 'מספר',
|
||||
image: 'תמונה',
|
||||
file: 'קובץ',
|
||||
select: 'בחר',
|
||||
@@ -621,10 +641,12 @@ const he = {
|
||||
}
|
||||
|
||||
const nl = {
|
||||
minimize: 'Minimaliseren',
|
||||
text: 'Tekst',
|
||||
signature: 'Handtekening',
|
||||
initials: 'Initialen',
|
||||
date: 'Datum',
|
||||
number: 'Nummer',
|
||||
image: 'Afbeelding',
|
||||
take_photo: 'Maak een foto',
|
||||
file: 'Bestand',
|
||||
@@ -683,6 +705,71 @@ const nl = {
|
||||
files: 'Bestanden'
|
||||
}
|
||||
|
||||
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl }
|
||||
const ar = {
|
||||
text: 'نص',
|
||||
signature: 'توقيع',
|
||||
initials: 'الاختصارات',
|
||||
date: 'تاريخ',
|
||||
number: 'رقم',
|
||||
image: 'صورة',
|
||||
take_photo: 'التقاط صورة',
|
||||
file: 'ملف',
|
||||
select: 'اختيار',
|
||||
checkbox: 'خانة اختيار',
|
||||
multiple: 'متعدد',
|
||||
radio: 'راديو',
|
||||
cells: 'خلايا',
|
||||
stamp: 'ختم',
|
||||
minimize: 'تصغير',
|
||||
payment: 'الدفع',
|
||||
phone: 'هاتف',
|
||||
submit_form: 'إرسال النموذج',
|
||||
type_here_: 'اكتب هنا',
|
||||
optional: 'اختياري',
|
||||
option: 'خيار',
|
||||
appears_on: 'يظهر على',
|
||||
page: 'صفحة',
|
||||
select_your_option: 'اختر خيارك',
|
||||
complete_hightlighted_checkboxes_and_click: 'أكمل الخانات المميزة وانقر',
|
||||
submit: 'إرسال',
|
||||
next: 'التالي',
|
||||
click_to_upload: 'انقر للتحميل',
|
||||
or_drag_and_drop_files: 'أو اسحب وأسقط الملفات',
|
||||
send_copy_via_email: 'إرسال نسخة عبر البريد الإلكتروني',
|
||||
download: 'تحميل',
|
||||
clear: 'مسح',
|
||||
redraw: 'إعادة الرسم',
|
||||
draw_initials: 'ارسم الاختصارات',
|
||||
type_signature_here: 'اكتب التوقيع هنا',
|
||||
type_initial_here: 'اكتب الاختصارات هنا',
|
||||
form_has_been_completed: 'تم إكمال النموذج!',
|
||||
create_a_free_account: 'إنشاء حساب مجاني',
|
||||
signed_with: 'تم التوقيع بواسطة',
|
||||
please_check_the_box_to_continue: 'الرجاء التحقق من الخانة للمتابعة',
|
||||
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: 'تم إعادة إرسال رمز التحقق عبر الرسائل القصيرة',
|
||||
please_fill_all_required_fields: 'الرجاء ملء جميع الحقول المطلوبة',
|
||||
set_today: 'تعيين اليوم',
|
||||
toggle_multiline_text: 'تبديل النصوص متعددة الأسطر',
|
||||
draw_signature: 'ارسم التوقيع',
|
||||
type_initial: 'اكتب الاختصارات',
|
||||
draw: 'ارسم',
|
||||
type: 'اكتب',
|
||||
type_text: 'اكتب نصًا',
|
||||
email_has_been_sent: 'تم إرسال البريد الإلكتروني',
|
||||
processing: 'جارٍ المعالجة',
|
||||
pay_with_strip: 'الدفع بواسطة Stripe',
|
||||
reupload: 'إعادة التحميل',
|
||||
upload: 'تحميل',
|
||||
files: 'الملفات'
|
||||
}
|
||||
|
||||
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar }
|
||||
|
||||
export default i18n
|
||||
@@ -8,7 +8,7 @@
|
||||
<span
|
||||
v-if="isDrawInitials"
|
||||
class="tooltip"
|
||||
:data-tip="t('type_initials')"
|
||||
:data-tip="t('type_initial')"
|
||||
>
|
||||
<a
|
||||
id="type_text_button"
|
||||
@@ -58,7 +58,7 @@
|
||||
{{ t('clear') }}
|
||||
</a>
|
||||
<a
|
||||
title="Minimize"
|
||||
:title="t('minimize')"
|
||||
href="#"
|
||||
class="py-1.5 inline md:hidden"
|
||||
@click.prevent="$emit('minimize')"
|
||||
@@ -84,7 +84,7 @@
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
class="bg-white border border-base-300 rounded-2xl"
|
||||
class="bg-white border border-base-300 rounded-2xl w-full"
|
||||
/>
|
||||
<input
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
|
||||
@@ -106,6 +106,8 @@ import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 }
|
||||
import SignaturePad from 'signature_pad'
|
||||
import AppearsOn from './appears_on'
|
||||
|
||||
const scale = 3
|
||||
|
||||
export default {
|
||||
name: 'InitialsStep',
|
||||
components: {
|
||||
@@ -171,8 +173,10 @@ export default {
|
||||
async mounted () {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.canvas) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 5
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||
this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 3) * scale
|
||||
|
||||
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||
}
|
||||
|
||||
this.$refs.textInput?.focus()
|
||||
@@ -190,8 +194,26 @@ export default {
|
||||
|
||||
this.$emit('start')
|
||||
})
|
||||
|
||||
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||
this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 3) * scale
|
||||
|
||||
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||
|
||||
this.intersectionObserver?.disconnect()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.intersectionObserver.observe(this.$refs.canvas)
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.intersectionObserver?.disconnect()
|
||||
},
|
||||
methods: {
|
||||
remove () {
|
||||
this.$emit('update:model-value', '')
|
||||
@@ -209,7 +231,7 @@ export default {
|
||||
}
|
||||
},
|
||||
updateWrittenInitials (e) {
|
||||
this.isInitialsStarted = true
|
||||
this.isInitialsStarted = !!e.target.value
|
||||
|
||||
const canvas = this.$refs.canvas
|
||||
const context = canvas.getContext('2d')
|
||||
@@ -222,8 +244,8 @@ export default {
|
||||
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
|
||||
context.textAlign = 'center'
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
|
||||
context.clearRect(0, 0, canvas.width / scale, canvas.height / scale)
|
||||
context.fillText(e.target.value, canvas.width / 2 / scale, canvas.height / 2 / scale + 11)
|
||||
},
|
||||
toggleTextInput () {
|
||||
this.remove()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<label
|
||||
v-if="showFieldNames && field.name"
|
||||
:for="field.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
><template v-if="field.title"><span v-html="field.title" /></template>
|
||||
<template v-else>{{ field.name }}</template>
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
</label>
|
||||
<div
|
||||
v-else
|
||||
class="py-1"
|
||||
/>
|
||||
<div
|
||||
v-if="field.description"
|
||||
class="mb-3 px-1 text-lg"
|
||||
v-html="field.description"
|
||||
/>
|
||||
<AppearsOn :field="field" />
|
||||
<div class="items-center flex">
|
||||
<input
|
||||
type="hidden"
|
||||
name="cast_number"
|
||||
value="true"
|
||||
>
|
||||
<input
|
||||
:id="field.uuid"
|
||||
v-model="number"
|
||||
type="number"
|
||||
class="base-input !text-2xl w-full"
|
||||
step="any"
|
||||
:required="field.required"
|
||||
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
:name="`values[${field.uuid}]`"
|
||||
@focus="$emit('focus')"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppearsOn from './appears_on'
|
||||
|
||||
export default {
|
||||
name: 'TextStep',
|
||||
components: {
|
||||
AppearsOn
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showFieldNames: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['update:model-value', 'focus'],
|
||||
computed: {
|
||||
number: {
|
||||
set (value) {
|
||||
this.$emit('update:model-value', value)
|
||||
},
|
||||
get () {
|
||||
return this.modelValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -6,7 +6,7 @@
|
||||
>{{ showFieldNames && field.name ? field.name : t('signature') }}</label>
|
||||
<div class="space-x-2 flex">
|
||||
<span
|
||||
v-if="isTextSignature"
|
||||
v-if="isTextSignature && field.preferences?.format !== 'typed'"
|
||||
class="tooltip"
|
||||
:data-tip="t('draw_signature')"
|
||||
>
|
||||
@@ -23,7 +23,7 @@
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="withTypedSignature"
|
||||
v-else-if="withTypedSignature && field.preferences?.format !== 'typed' && field.preferences?.format !== 'drawn'"
|
||||
class="tooltip ml-2"
|
||||
:data-tip="t('type_text')"
|
||||
>
|
||||
@@ -40,6 +40,7 @@
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
v-if="field.preferences?.format !== 'typed'"
|
||||
class="tooltip"
|
||||
:data-tip="t('take_photo')"
|
||||
>
|
||||
@@ -48,6 +49,7 @@
|
||||
>
|
||||
<IconCamera :width="16" />
|
||||
<input
|
||||
:key="uploadImageInputKey"
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/*"
|
||||
@@ -78,7 +80,7 @@
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
title="Minimize"
|
||||
:title="t('minimize')"
|
||||
class="py-1.5 inline md:hidden"
|
||||
@click.prevent="$emit('minimize')"
|
||||
>
|
||||
@@ -104,14 +106,14 @@
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
style="padding: 1px; 0"
|
||||
class="bg-white border border-base-300 rounded-2xl"
|
||||
class="bg-white border border-base-300 rounded-2xl w-full"
|
||||
/>
|
||||
<input
|
||||
v-if="isTextSignature"
|
||||
id="signature_text_input"
|
||||
ref="textInput"
|
||||
class="base-input !text-2xl w-full mt-6"
|
||||
:required="field.required"
|
||||
:required="field.required && !isSignatureStarted"
|
||||
:placeholder="`${t('type_signature_here')}...`"
|
||||
type="text"
|
||||
@input="updateWrittenSignature"
|
||||
@@ -127,6 +129,8 @@ import AppearsOn from './appears_on'
|
||||
|
||||
let isFontLoaded = false
|
||||
|
||||
const scale = 3
|
||||
|
||||
export default {
|
||||
name: 'SignatureStep',
|
||||
components: {
|
||||
@@ -183,7 +187,8 @@ export default {
|
||||
return {
|
||||
isSignatureStarted: !!this.previousValue,
|
||||
isUsePreviousValue: true,
|
||||
isTextSignature: false
|
||||
isTextSignature: this.field.preferences?.format === 'typed',
|
||||
uploadImageInputKey: Math.random().toString()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -198,8 +203,10 @@ export default {
|
||||
async mounted () {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.canvas) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
|
||||
|
||||
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -215,8 +222,26 @@ export default {
|
||||
|
||||
this.$emit('start')
|
||||
})
|
||||
|
||||
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
|
||||
|
||||
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||
|
||||
this.intersectionObserver?.disconnect()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.intersectionObserver.observe(this.$refs.canvas)
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.intersectionObserver?.disconnect()
|
||||
},
|
||||
methods: {
|
||||
remove () {
|
||||
this.$emit('update:model-value', '')
|
||||
@@ -247,7 +272,7 @@ export default {
|
||||
}
|
||||
},
|
||||
updateWrittenSignature (e) {
|
||||
this.isSignatureStarted = true
|
||||
this.isSignatureStarted = !!e.target.value
|
||||
|
||||
const canvas = this.$refs.canvas
|
||||
const context = canvas.getContext('2d')
|
||||
@@ -260,8 +285,8 @@ export default {
|
||||
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
|
||||
context.textAlign = 'center'
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
|
||||
context.clearRect(0, 0, canvas.width / scale, canvas.height / scale)
|
||||
context.fillText(e.target.value, canvas.width / 2 / scale, canvas.height / 2 / scale + 11)
|
||||
},
|
||||
toggleTextInput () {
|
||||
this.remove()
|
||||
@@ -297,14 +322,16 @@ export default {
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
const aspectRatio = img.width / img.height
|
||||
const canvasWidth = canvas.width / scale
|
||||
const canvasHeight = canvas.height / scale
|
||||
|
||||
let targetWidth = canvas.width
|
||||
let targetHeight = canvas.height
|
||||
let targetWidth = canvasWidth
|
||||
let targetHeight = canvasHeight
|
||||
|
||||
if (canvas.width / canvas.height > aspectRatio) {
|
||||
targetWidth = canvas.height * aspectRatio
|
||||
if (canvasWidth / canvasHeight > aspectRatio) {
|
||||
targetWidth = canvasHeight * aspectRatio
|
||||
} else {
|
||||
targetHeight = canvas.width / aspectRatio
|
||||
targetHeight = canvasWidth / aspectRatio
|
||||
}
|
||||
|
||||
if (targetHeight > targetWidth) {
|
||||
@@ -313,17 +340,21 @@ export default {
|
||||
targetHeight = targetHeight * scale
|
||||
}
|
||||
|
||||
const x = (canvas.width - targetWidth) / 2
|
||||
const y = (canvas.height - targetHeight) / 2
|
||||
const x = (canvasWidth - targetWidth) / 2
|
||||
const y = (canvasHeight - targetHeight) / 2
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.drawImage(img, x, y, targetWidth, targetHeight)
|
||||
setTimeout(() => {
|
||||
context.clearRect(0, 0, canvasWidth, canvasHeight)
|
||||
context.drawImage(img, x, y, targetWidth, targetHeight)
|
||||
|
||||
this.$emit('start')
|
||||
this.$emit('start')
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
reader.readAsDataURL(file)
|
||||
|
||||
this.uploadImageInputKey = Math.random().toString()
|
||||
}
|
||||
},
|
||||
async submit () {
|
||||
|
||||
@@ -123,12 +123,14 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.isTextArea = this.modelValue?.includes('\n')
|
||||
if (this.modelValue) {
|
||||
this.isTextArea = this.modelValue.toString().includes('\n')
|
||||
|
||||
if (this.isTextArea) {
|
||||
this.$nextTick(() => {
|
||||
this.resizeTextarea()
|
||||
})
|
||||
if (this.isTextArea) {
|
||||
this.$nextTick(() => {
|
||||
this.resizeTextarea()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
<button
|
||||
v-else-if="editable"
|
||||
class="pr-1"
|
||||
title="Remove"
|
||||
:title="t('remove')"
|
||||
@click.prevent="$emit('remove')"
|
||||
>
|
||||
<IconX width="14" />
|
||||
@@ -98,21 +98,32 @@
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center h-full w-full"
|
||||
dir="auto"
|
||||
:class="[bgColors[submitterIndex], field?.default_value ? '' : 'justify-center']"
|
||||
>
|
||||
<span
|
||||
v-if="field"
|
||||
class="flex justify-center items-center space-x-1 h-full"
|
||||
:class="{'w-full h-full': field.type == 'checkbox'}"
|
||||
>
|
||||
<div
|
||||
v-if="field?.default_value"
|
||||
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }"
|
||||
:class="{ 'w-full h-full': field.type == 'checkbox', 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }"
|
||||
>
|
||||
<div
|
||||
ref="textContainer"
|
||||
class="flex items-center px-0.5"
|
||||
:class="{'w-full h-full': field.type == 'checkbox'}"
|
||||
>
|
||||
<span class="whitespace-pre-wrap">{{ field.default_value }}</span>
|
||||
<IconCheck
|
||||
v-if="field.type == 'checkbox'"
|
||||
class="aspect-square mx-auto"
|
||||
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="whitespace-pre-wrap"
|
||||
>{{ field.default_value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<component
|
||||
@@ -141,13 +152,14 @@
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import FieldType from './field_type'
|
||||
import Field from './field'
|
||||
import { IconX } from '@tabler/icons-vue'
|
||||
import { IconX, IconCheck } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
name: 'FieldArea',
|
||||
components: {
|
||||
FieldType,
|
||||
IconCheck,
|
||||
FieldSubmitter,
|
||||
IconX
|
||||
},
|
||||
@@ -194,9 +206,11 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
defaultName: Field.computed.defaultName,
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
defaultName () {
|
||||
return this.buildDefaultName(this.field, this.template.fields)
|
||||
},
|
||||
optionIndexText () {
|
||||
if (this.area.option_uuid && this.field.options) {
|
||||
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
|
||||
@@ -225,6 +239,16 @@ export default {
|
||||
},
|
||||
borderColors () {
|
||||
return [
|
||||
'border-red-500/80',
|
||||
'border-sky-500/80',
|
||||
'border-emerald-500/80',
|
||||
'border-yellow-300/80',
|
||||
'border-purple-600/80',
|
||||
'border-pink-500/80',
|
||||
'border-cyan-500/80',
|
||||
'border-orange-500/80',
|
||||
'border-lime-500/80',
|
||||
'border-indigo-500/80',
|
||||
'border-red-500/80',
|
||||
'border-sky-500/80',
|
||||
'border-emerald-500/80',
|
||||
@@ -239,6 +263,16 @@ export default {
|
||||
},
|
||||
bgColors () {
|
||||
return [
|
||||
'bg-red-100/80',
|
||||
'bg-sky-100/80',
|
||||
'bg-emerald-100/80',
|
||||
'bg-yellow-100/80',
|
||||
'bg-purple-100/80',
|
||||
'bg-pink-100/80',
|
||||
'bg-cyan-100/80',
|
||||
'bg-orange-100/80',
|
||||
'bg-lime-100/80',
|
||||
'bg-indigo-100/80',
|
||||
'bg-red-100/80',
|
||||
'bg-sky-100/80',
|
||||
'bg-emerald-100/80',
|
||||
@@ -280,6 +314,7 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
buildDefaultName: Field.methods.buildDefaultName,
|
||||
onNameFocus (e) {
|
||||
this.selectedAreaRef.value = this.area
|
||||
|
||||
|
||||
@@ -273,6 +273,7 @@
|
||||
@select="startFieldDraw($event)"
|
||||
/>
|
||||
</div>
|
||||
<div id="docuseal_modal_container" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -321,6 +322,8 @@ export default {
|
||||
backgroundColor: this.backgroundColor,
|
||||
withPhone: this.withPhone,
|
||||
withPayment: this.withPayment,
|
||||
withFormula: this.withFormula,
|
||||
defaultDrawFieldType: this.defaultDrawFieldType,
|
||||
selectedAreaRef: computed(() => this.selectedAreaRef)
|
||||
}
|
||||
},
|
||||
@@ -364,6 +367,11 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
defaultDrawFieldType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'text'
|
||||
},
|
||||
currencies: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -443,6 +451,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withFormula: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
onlyDefinedFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -755,7 +768,9 @@ export default {
|
||||
|
||||
let type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text'
|
||||
|
||||
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
|
||||
if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
|
||||
type = this.defaultDrawFieldType
|
||||
} else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
|
||||
type = this.fieldTypes[0]
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<IconPencil
|
||||
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
|
||||
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
|
||||
title="Edit"
|
||||
:title="t('edit')"
|
||||
:class="{ invisible: !editable, 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
|
||||
:width="iconWidth"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
@@ -44,6 +44,7 @@ export default {
|
||||
components: {
|
||||
IconPencil
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
|
||||
@@ -102,7 +102,7 @@ export default {
|
||||
return this.previewImagesIndex[i] || {
|
||||
metadata: lazyloadMetadata,
|
||||
id: Math.random().toString(),
|
||||
url: this.basePreviewUrl + `/preview/${this.document.uuid}/${i}.jpg`
|
||||
url: this.basePreviewUrl + `/preview/${this.document.signed_uuid || this.document.uuid}/${i}.jpg`
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
>
|
||||
<button
|
||||
v-if="field && !field.areas.length"
|
||||
title="Draw"
|
||||
:title="t('draw')"
|
||||
class="relative cursor-pointer text-transparent group-hover:text-base-content"
|
||||
@click="$emit('set-draw', { field })"
|
||||
>
|
||||
@@ -65,6 +65,17 @@
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="field.preferences?.formula"
|
||||
class="relative cursor-pointer text-transparent group-hover:text-base-content"
|
||||
:disabled="!withFormula"
|
||||
@click="isShowFormulaModal = true"
|
||||
>
|
||||
<IconMathFunction
|
||||
:width="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
<PaymentSettings
|
||||
v-if="field.type === 'payment'"
|
||||
:field="field"
|
||||
@@ -77,7 +88,7 @@
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
title="Settings"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer text-transparent group-hover:text-base-content"
|
||||
>
|
||||
<IconSettings
|
||||
@@ -142,6 +153,43 @@
|
||||
{{ t('format') }}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.type === 'signature'"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<select
|
||||
:placeholder="t('format')"
|
||||
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0"
|
||||
@change="[field.preferences.format = $event.target.value, save()]"
|
||||
>
|
||||
<option
|
||||
value="any"
|
||||
:selected="!field.preferences?.format || field.preferences.format === 'any'"
|
||||
>
|
||||
{{ t('any') }}
|
||||
</option>
|
||||
<option
|
||||
value="drawn"
|
||||
:selected="field.preferences?.format === 'drawn'"
|
||||
>
|
||||
{{ t('drawn') }}
|
||||
</option>
|
||||
<option
|
||||
value="typed"
|
||||
:selected="field.preferences?.format === 'typed'"
|
||||
>
|
||||
{{ t('typed') }}
|
||||
</option>
|
||||
</select>
|
||||
<label
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>
|
||||
{{ t('format') }}
|
||||
</label>
|
||||
</div>
|
||||
<li
|
||||
v-if="field.type != 'phone'"
|
||||
@click.stop
|
||||
@@ -156,6 +204,54 @@
|
||||
<span class="label-text">{{ t('required') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type == 'number'"
|
||||
:class="{'tooltip tooltip-bottom': !withFormula}"
|
||||
:data-tip="withFormula ? '' : 'Available in Pro'"
|
||||
@click.stop
|
||||
>
|
||||
<label
|
||||
class="label-text cursor-pointer py-1.5 text-center w-full flex items-center"
|
||||
@click="isShowFormulaModal = withFormula"
|
||||
>
|
||||
<IconMathFunction
|
||||
width="18px"
|
||||
height="18px"
|
||||
class="ml-0.5 mr-1"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{{ t('formula') }}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type == 'checkbox'"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
v-model="field.default_value"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@update:model-value="[field.default_value = $event, field.readonly = $event, save()]"
|
||||
>
|
||||
<span class="label-text">{{ t('checked') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type == 'date'"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
v-model="field.readonly"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@update:model-value="[field.default_value = $event ? '{{date}}' : null, field.readonly = $event, save()]"
|
||||
>
|
||||
<span class="label-text">{{ t('set_signing_date') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type === 'text' && !defaultField"
|
||||
@click.stop
|
||||
@@ -217,7 +313,7 @@
|
||||
</span>
|
||||
<button
|
||||
class="relative text-transparent group-hover:text-base-content pr-1"
|
||||
title="Remove"
|
||||
:title="t('remove')"
|
||||
@click="$emit('remove', field)"
|
||||
>
|
||||
<IconTrashX
|
||||
@@ -256,7 +352,7 @@
|
||||
@blur="save"
|
||||
>
|
||||
<button
|
||||
title="Draw"
|
||||
:title="t('draw')"
|
||||
tabindex="-1"
|
||||
@click.prevent="$emit('set-draw', { field, option })"
|
||||
>
|
||||
@@ -294,6 +390,16 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport
|
||||
v-if="isShowFormulaModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<FormulaModal
|
||||
:field="field"
|
||||
:build-default-name="buildDefaultName"
|
||||
@close="isShowFormulaModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -301,7 +407,8 @@
|
||||
import Contenteditable from './contenteditable'
|
||||
import FieldType from './field_type'
|
||||
import PaymentSettings from './payment_settings'
|
||||
import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
|
||||
import FormulaModal from './formula_modal'
|
||||
import { IconMathFunction, IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
@@ -312,11 +419,13 @@ export default {
|
||||
IconShape,
|
||||
PaymentSettings,
|
||||
IconNewSection,
|
||||
FormulaModal,
|
||||
IconTrashX,
|
||||
IconMathFunction,
|
||||
IconCopy,
|
||||
FieldType
|
||||
},
|
||||
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't'],
|
||||
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't', 'withFormula'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -338,11 +447,15 @@ export default {
|
||||
return {
|
||||
isNameFocus: false,
|
||||
showPaymentModal: false,
|
||||
isShowFormulaModal: false,
|
||||
renderDropdown: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
modalContainerEl () {
|
||||
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
||||
},
|
||||
dateFormats () {
|
||||
return [
|
||||
'MM/DD/YYYY',
|
||||
@@ -357,22 +470,7 @@ export default {
|
||||
]
|
||||
},
|
||||
defaultName () {
|
||||
if (this.field.type === 'payment' && this.field.preferences?.price) {
|
||||
const { price, currency } = this.field.preferences || {}
|
||||
|
||||
const formattedPrice = new Intl.NumberFormat([], {
|
||||
style: 'currency',
|
||||
currency
|
||||
}).format(price)
|
||||
|
||||
return `${this.fieldNames[this.field.type]} ${formattedPrice}`
|
||||
} else {
|
||||
const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field)
|
||||
|
||||
const suffix = { multiple: this.t('select'), radio: this.t('group') }[this.field.type] || this.t('field')
|
||||
|
||||
return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}`
|
||||
}
|
||||
return this.buildDefaultName(this.field, this.template.fields)
|
||||
},
|
||||
areas () {
|
||||
return this.field.areas || []
|
||||
@@ -387,6 +485,24 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
buildDefaultName (field, fields) {
|
||||
if (field.type === 'payment' && field.preferences?.price) {
|
||||
const { price, currency } = field.preferences || {}
|
||||
|
||||
const formattedPrice = new Intl.NumberFormat([], {
|
||||
style: 'currency',
|
||||
currency
|
||||
}).format(price)
|
||||
|
||||
return `${this.fieldNames[field.type]} ${formattedPrice}`
|
||||
} else {
|
||||
const typeIndex = fields.filter((f) => f.type === field.type).indexOf(field)
|
||||
|
||||
const suffix = { multiple: this.t('select'), radio: this.t('group') }[field.type] || this.t('field')
|
||||
|
||||
return `${this.fieldNames[field.type]} ${suffix} ${typeIndex + 1}`
|
||||
}
|
||||
},
|
||||
formatDate (date, format) {
|
||||
const monthFormats = {
|
||||
M: 'numeric',
|
||||
@@ -420,11 +536,13 @@ export default {
|
||||
const areaString = JSON.stringify(field.areas[0])
|
||||
|
||||
this.template.documents.forEach((attachment) => {
|
||||
attachment.preview_images.forEach((page) => {
|
||||
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === parseInt(page.filename))) {
|
||||
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page: parseInt(page.filename) })
|
||||
const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length
|
||||
|
||||
for (let page = 0; page <= numberOfPages - 1; page++) {
|
||||
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === page)) {
|
||||
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
this.$nextTick(() => {
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
</button>
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="submitters.length < 10 && editable">
|
||||
<li v-if="submitters.length < names.length && editable">
|
||||
<a
|
||||
href="#"
|
||||
class="flex px-2"
|
||||
@@ -145,7 +145,7 @@
|
||||
<a
|
||||
href="#"
|
||||
class="flex px-2 group justify-between items-center"
|
||||
:class="{ 'active': submitter === selectedSubmitter }"
|
||||
:class="{ 'active': submitter === selectedSubmitter, 'py-0.5': submitters.length > 8 }"
|
||||
@click.prevent="selectSubmitter(submitter)"
|
||||
>
|
||||
<span class="py-1 flex items-center">
|
||||
@@ -163,7 +163,7 @@
|
||||
>
|
||||
<div class="flex-col pr-1 hidden group-hover:flex -mt-1 h-0">
|
||||
<button
|
||||
title="Up"
|
||||
:title="t('up')"
|
||||
class="relative w-2"
|
||||
style="font-size: 10px; margin-bottom: -4px"
|
||||
@click.prevent.stop="[move(submitter, -1), $refs.label.focus()] "
|
||||
@@ -171,7 +171,7 @@
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
title="Down"
|
||||
:title="t('down')"
|
||||
class="relative w-2"
|
||||
style="font-size: 10px; margin-top: -4px"
|
||||
@click.prevent.stop="[move(submitter, 1), $refs.label.focus()] "
|
||||
@@ -189,7 +189,7 @@
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="submitters.length < 10 && editable && allowAddNew">
|
||||
<li v-if="submitters.length < names.length && editable && allowAddNew">
|
||||
<a
|
||||
href="#"
|
||||
class="flex px-2"
|
||||
@@ -268,6 +268,16 @@ export default {
|
||||
computed: {
|
||||
colors () {
|
||||
return [
|
||||
'bg-red-500',
|
||||
'bg-sky-500',
|
||||
'bg-emerald-500',
|
||||
'bg-yellow-300',
|
||||
'bg-purple-600',
|
||||
'bg-pink-500',
|
||||
'bg-cyan-500',
|
||||
'bg-orange-500',
|
||||
'bg-lime-500',
|
||||
'bg-indigo-500',
|
||||
'bg-red-500',
|
||||
'bg-sky-500',
|
||||
'bg-emerald-500',
|
||||
@@ -291,7 +301,17 @@ export default {
|
||||
this.t('seventh_party'),
|
||||
this.t('eighth_party'),
|
||||
this.t('ninth_party'),
|
||||
this.t('tenth_party')
|
||||
this.t('tenth_party'),
|
||||
this.t('eleventh_party'),
|
||||
this.t('twelfth_party'),
|
||||
this.t('thirteenth_party'),
|
||||
this.t('fourteenth_party'),
|
||||
this.t('fifteenth_party'),
|
||||
this.t('sixteenth_party'),
|
||||
this.t('seventeenth_party'),
|
||||
this.t('eighteenth_party'),
|
||||
this.t('nineteenth_party'),
|
||||
this.t('twentieth_party')
|
||||
]
|
||||
},
|
||||
selectedSubmitter () {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<template
|
||||
v-for="(icon, type) in fieldIcons"
|
||||
v-for="(icon, type) in fieldIconsSorted"
|
||||
:key="type"
|
||||
>
|
||||
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')">
|
||||
@@ -50,7 +50,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue'
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1 } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FiledTypeDropdown',
|
||||
@@ -94,6 +94,7 @@ export default {
|
||||
signature: this.t('signature'),
|
||||
initials: this.t('initials'),
|
||||
date: this.t('date'),
|
||||
number: this.t('number'),
|
||||
image: this.t('image'),
|
||||
file: this.t('file'),
|
||||
select: this.t('select'),
|
||||
@@ -112,17 +113,29 @@ export default {
|
||||
signature: IconWritingSign,
|
||||
initials: IconLetterCaseUpper,
|
||||
date: IconCalendarEvent,
|
||||
number: IconSquareNumber1,
|
||||
image: IconPhoto,
|
||||
file: IconPaperclip,
|
||||
checkbox: IconCheckbox,
|
||||
multiple: IconChecks,
|
||||
file: IconPaperclip,
|
||||
radio: IconCircleDot,
|
||||
select: IconSelect,
|
||||
multiple: IconChecks,
|
||||
cells: IconColumns3,
|
||||
stamp: IconRubberStamp,
|
||||
payment: IconCreditCard,
|
||||
phone: IconPhoneCheck
|
||||
}
|
||||
},
|
||||
fieldIconsSorted () {
|
||||
if (this.fieldTypes.length) {
|
||||
return this.fieldTypes.reduce((acc, type) => {
|
||||
acc[type] = this.fieldIcons[type]
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
} else {
|
||||
return this.fieldIcons
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
class="grid grid-cols-3 gap-1 pb-2"
|
||||
>
|
||||
<template
|
||||
v-for="(icon, type) in fieldIcons"
|
||||
v-for="(icon, type) in fieldIconsSorted"
|
||||
:key="type"
|
||||
>
|
||||
<button
|
||||
@@ -93,7 +93,7 @@
|
||||
<div
|
||||
v-else-if="type == 'phone' && (fieldTypes.length === 0 || fieldTypes.includes(type))"
|
||||
class="tooltip tooltip-bottom flex"
|
||||
:class="{'tooltip-bottom-start': !withPayment, 'tooltip-bottom': withPayment }"
|
||||
:class="{'tooltip-bottom-end': withPayment, 'tooltip-bottom': !withPayment }"
|
||||
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
|
||||
>
|
||||
<a
|
||||
@@ -213,6 +213,17 @@ export default {
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
fieldIconsSorted () {
|
||||
if (this.fieldTypes.length) {
|
||||
return this.fieldTypes.reduce((acc, type) => {
|
||||
acc[type] = this.fieldIcons[type]
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
} else {
|
||||
return this.fieldIcons
|
||||
}
|
||||
},
|
||||
submitterFields () {
|
||||
return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div
|
||||
class="modal modal-open items-start !animate-none overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<span>
|
||||
{{ t('formula') }}
|
||||
</span>
|
||||
<a
|
||||
href="#"
|
||||
class="text-xl"
|
||||
@click.prevent="$emit('close')"
|
||||
>×</a>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex-inline mb-2 gap-2 space-y-1">
|
||||
<button
|
||||
v-for="f in fields"
|
||||
:key="f.uuid"
|
||||
class="mr-1 btn btn-neutral btn-outline border-base-content/20 btn-sm normal-case font-normal bg-white !rounded-xl"
|
||||
@click.prevent="insertTextUnderCursor(`{{${f.name || buildDefaultName(f, template.fields)}}}`)"
|
||||
>
|
||||
<IconCodePlus
|
||||
width="20"
|
||||
height="20"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
{{ f.name || buildDefaultName(f, template.fields) }}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex">
|
||||
<textarea
|
||||
ref="textarea"
|
||||
v-model="formula"
|
||||
class="base-textarea !rounded-xl !text-base font-mono w-full !outline-0 !ring-0 !px-3"
|
||||
required="true"
|
||||
@input="resizeTextarea"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3 mt-1">
|
||||
<div
|
||||
target="blank"
|
||||
class="text-sm mb-2 inline space-x-2"
|
||||
>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' + ')"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' - ')"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' * ')"
|
||||
>
|
||||
*
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' / ')"
|
||||
>
|
||||
/
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' % ')"
|
||||
>
|
||||
%
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor('^')"
|
||||
>
|
||||
^
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor('round()')"
|
||||
>
|
||||
round(n, d)
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor('abs()')"
|
||||
>
|
||||
abs(n)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="base-button w-full"
|
||||
@click.prevent="validateSaveAndClose"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconCodePlus } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FormulaModal',
|
||||
components: {
|
||||
IconCodePlus
|
||||
},
|
||||
inject: ['t', 'save', 'template'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
buildDefaultName: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['close'],
|
||||
data () {
|
||||
return {
|
||||
formula: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fields () {
|
||||
return this.template.fields.reduce((acc, f) => {
|
||||
if (f !== this.field && f.submitter_uuid === this.field.submitter_uuid && ['number'].includes(f.type) && !f.preferences?.formula) {
|
||||
acc.push(f)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.field.preferences ||= {}
|
||||
},
|
||||
mounted () {
|
||||
this.formula = this.humanizeFormula(this.field.preferences.formula || '')
|
||||
},
|
||||
methods: {
|
||||
humanizeFormula (text) {
|
||||
return text.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
const foundField = this.fields.find((f) => f.uuid === uuid)
|
||||
|
||||
if (foundField) {
|
||||
return `{{${foundField.name || this.buildDefaultName(foundField, this.template.fields)}}}`
|
||||
} else {
|
||||
return '{{FIELD NOT FOUND}}'
|
||||
}
|
||||
})
|
||||
},
|
||||
normalizeFormula (text) {
|
||||
return text.replace(/{{(.*?)}}/g, (match, name) => {
|
||||
const foundField = this.fields.find((f) => {
|
||||
return (f.name || this.buildDefaultName(f, this.template.fields)).trim() === name.trim()
|
||||
})
|
||||
|
||||
if (foundField) {
|
||||
return `{{${foundField.uuid}}}`
|
||||
} else {
|
||||
return '{{FIELD NOT FOUND}}'
|
||||
}
|
||||
})
|
||||
},
|
||||
validateSaveAndClose () {
|
||||
const normalizedFormula = this.normalizeFormula(this.formula)
|
||||
|
||||
if (normalizedFormula.includes('FIELD NOT FOUND')) {
|
||||
alert('Some fields are missing in the formula.')
|
||||
} else {
|
||||
this.field.preferences.formula = normalizedFormula
|
||||
this.field.readonly = !!normalizedFormula
|
||||
|
||||
this.save()
|
||||
|
||||
this.$emit('close')
|
||||
}
|
||||
},
|
||||
insertTextUnderCursor (textToInsert) {
|
||||
const textarea = this.$refs.textarea
|
||||
|
||||
const selectionEnd = textarea.selectionEnd
|
||||
const cursorPos = selectionEnd
|
||||
|
||||
const newText = textarea.value.substring(0, cursorPos) + textToInsert + textarea.value.substring(cursorPos)
|
||||
|
||||
this.formula = newText
|
||||
|
||||
textarea.setSelectionRange(cursorPos + textToInsert.length, cursorPos + textToInsert.length)
|
||||
|
||||
textarea.focus()
|
||||
},
|
||||
resizeTextarea () {
|
||||
const textarea = this.$refs.textarea
|
||||
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = textarea.scrollHeight + 'px'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,20 @@
|
||||
const en = {
|
||||
are_you_sure: 'Are you sure?',
|
||||
sign_yourself: 'Sign Yourself',
|
||||
set_signing_date: 'Set signing date',
|
||||
send: 'Send',
|
||||
remove: 'Remove',
|
||||
edit: 'Edit',
|
||||
settings: 'Settings',
|
||||
up: 'Up',
|
||||
down: 'Down',
|
||||
checked: 'Checked',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
any: 'Any',
|
||||
drawn: 'Drawn',
|
||||
formula: 'Formula',
|
||||
typed: 'Typed',
|
||||
draw_field_on_the_document: 'Draw {field} field on the document',
|
||||
click_to_upload: 'Click to upload',
|
||||
or_drag_and_drop_files: 'or drag and drop files',
|
||||
@@ -30,9 +41,21 @@ const en = {
|
||||
eighth_party: 'Eighth Party',
|
||||
ninth_party: 'Ninth Party',
|
||||
tenth_party: 'Tenth Party',
|
||||
eleventh_party: 'Eleventh Party',
|
||||
twelfth_party: 'Twelfth Party',
|
||||
thirteenth_party: 'Thirteenth Party',
|
||||
fourteenth_party: 'Fourteenth Party',
|
||||
fifteenth_party: 'Fifteenth Party',
|
||||
sixteenth_party: 'Sixteenth Party',
|
||||
seventeenth_party: 'Seventeenth Party',
|
||||
eighteenth_party: 'Eighteenth Party',
|
||||
nineteenth_party: 'Nineteenth Party',
|
||||
twentieth_party: 'Twentieth Party',
|
||||
draw: 'Draw',
|
||||
add: 'Add',
|
||||
or_add_field_without_drawing: 'Or add field without drawing',
|
||||
text: 'Text',
|
||||
number: 'Number',
|
||||
signature: 'Signature',
|
||||
initials: 'Initials',
|
||||
date: 'Date',
|
||||
|
||||
@@ -61,7 +61,7 @@ export default {
|
||||
components: {
|
||||
FieldArea
|
||||
},
|
||||
inject: ['fieldTypes'],
|
||||
inject: ['fieldTypes', 'defaultDrawFieldType'],
|
||||
props: {
|
||||
image: {
|
||||
type: Object,
|
||||
@@ -123,7 +123,9 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
defaultFieldType () {
|
||||
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes('text')) {
|
||||
if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
|
||||
return this.defaultDrawFieldType
|
||||
} else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes('text')) {
|
||||
return this.fieldTypes[0]
|
||||
} else {
|
||||
return 'text'
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
title="Settings"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer text-transparent group-hover:text-base-content"
|
||||
>
|
||||
<IconSettings
|
||||
@@ -167,7 +167,7 @@ export default {
|
||||
IconInnerShadowTop,
|
||||
IconBrandStripe
|
||||
},
|
||||
inject: ['backgroundColor', 'save', 'currencies'],
|
||||
inject: ['backgroundColor', 'save', 'currencies', 't'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
|
||||
@@ -26,12 +26,14 @@ class ProcessSubmitterCompletionJob < ApplicationJob
|
||||
|
||||
user = submission.created_by_user || submitter.template.author
|
||||
|
||||
if submitter.template.account.users.exists?(id: user.id) &&
|
||||
submission.preferences['send_email'] != false
|
||||
SubmitterMailer.completed_email(submitter, user).deliver_later!
|
||||
if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false
|
||||
if submitter.submission.submitters.map(&:email).exclude?(user.email) &&
|
||||
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false
|
||||
SubmitterMailer.completed_email(submitter, user).deliver_later!
|
||||
end
|
||||
|
||||
bcc = submission.preferences['bcc_completed'].presence ||
|
||||
submission.template.account.account_configs
|
||||
submission.account.account_configs
|
||||
.find_by(key: AccountConfig::BCC_EMAILS)&.value.presence
|
||||
|
||||
bcc.to_s.scan(User::EMAIL_REGEXP).each do |to|
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
class SendFormCompletedWebhookRequestJob < ApplicationJob
|
||||
USER_AGENT = 'DocuSeal.co Webhook'
|
||||
|
||||
def perform(submitter)
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(submitter, params = {})
|
||||
attempt = params[:attempt].to_i
|
||||
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||
|
||||
return if config.blank? || config.value.blank?
|
||||
@@ -12,13 +15,26 @@ class SendFormCompletedWebhookRequestJob < ApplicationJob
|
||||
|
||||
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.completed',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
resp = begin
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.completed',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
rescue Faraday::TimeoutError
|
||||
nil
|
||||
end
|
||||
|
||||
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
|
||||
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
|
||||
SendFormCompletedWebhookRequestJob.set(wait: (2**attempt).minutes)
|
||||
.perform_later(submitter, {
|
||||
attempt: attempt + 1,
|
||||
last_status: resp&.status.to_i
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,20 +3,36 @@
|
||||
class SendFormStartedWebhookRequestJob < ApplicationJob
|
||||
USER_AGENT = 'DocuSeal.co Webhook'
|
||||
|
||||
def perform(submitter)
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(submitter, params = {})
|
||||
attempt = params[:attempt].to_i
|
||||
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||
|
||||
return if config.blank? || config.value.blank?
|
||||
|
||||
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.started',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
resp = begin
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.started',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
rescue Faraday::TimeoutError
|
||||
nil
|
||||
end
|
||||
|
||||
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
|
||||
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
|
||||
SendFormStartedWebhookRequestJob.set(wait: (2**attempt).minutes)
|
||||
.perform_later(submitter, {
|
||||
attempt: attempt + 1,
|
||||
last_status: resp&.status.to_i
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,20 +3,36 @@
|
||||
class SendFormViewedWebhookRequestJob < ApplicationJob
|
||||
USER_AGENT = 'DocuSeal.co Webhook'
|
||||
|
||||
def perform(submitter)
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(submitter, params = {})
|
||||
attempt = params[:attempt].to_i
|
||||
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||
|
||||
return if config.blank? || config.value.blank?
|
||||
|
||||
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.viewed',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
resp = begin
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.viewed',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
rescue Faraday::TimeoutError
|
||||
nil
|
||||
end
|
||||
|
||||
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
|
||||
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
|
||||
SendFormViewedWebhookRequestJob.set(wait: (2**attempt).minutes)
|
||||
.perform_later(submitter, {
|
||||
attempt: attempt + 1,
|
||||
last_status: resp&.status.to_i
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
class SubmitterMailer < ApplicationMailer
|
||||
MAX_ATTACHMENTS_SIZE = 10.megabytes
|
||||
SIGN_TTL = 1.hour + 20.minutes
|
||||
|
||||
DEFAULT_INVITATION_SUBJECT = 'You are invited to submit a form'
|
||||
|
||||
@@ -61,9 +62,10 @@ class SubmitterMailer < ApplicationMailer
|
||||
subject:)
|
||||
end
|
||||
|
||||
def documents_copy_email(submitter, to: nil)
|
||||
def documents_copy_email(submitter, to: nil, sig: false)
|
||||
@current_account = submitter.submission.template.account
|
||||
@submitter = submitter
|
||||
@sig = submitter.signed_id(expires_in: SIGN_TTL, purpose: :download_completed) if sig
|
||||
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ class Account < ApplicationRecord
|
||||
foreign_key: :linked_account_id,
|
||||
class_name: 'AccountLinkedAccount',
|
||||
inverse_of: :linked_account
|
||||
has_many :linked_account_accounts, dependent: :destroy,
|
||||
foreign_key: :linked_account_id,
|
||||
class_name: 'AccountLinkedAccount',
|
||||
inverse_of: :linked_account
|
||||
has_many :linked_accounts, through: :account_linked_accounts
|
||||
has_many :testing_accounts, through: :account_testing_accounts, source: :linked_account
|
||||
has_many :active_users, -> { active }, dependent: :destroy,
|
||||
|
||||
@@ -76,7 +76,7 @@ class Submission < ApplicationRecord
|
||||
def audit_trail_url
|
||||
return if audit_trail.blank?
|
||||
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(audit_trail, **Docuseal.default_url_options)
|
||||
ActiveStorage::Blob.proxy_url(audit_trail.blob)
|
||||
end
|
||||
alias audit_log_url audit_trail_url
|
||||
end
|
||||
@@ -46,7 +46,8 @@ class SubmissionEvent < ApplicationRecord
|
||||
phone_verified: 'phone_verified',
|
||||
start_form: 'start_form',
|
||||
view_form: 'view_form',
|
||||
complete_form: 'complete_form'
|
||||
complete_form: 'complete_form',
|
||||
api_complete_form: 'api_complete_form'
|
||||
}, scope: false
|
||||
|
||||
private
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#
|
||||
class UserConfig < ApplicationRecord
|
||||
SIGNATURE_KEY = 'signature'
|
||||
RECEIVE_COMPLETED_EMAIL = 'receive_completed_email'
|
||||
|
||||
belongs_to :user
|
||||
|
||||
|
||||
@@ -123,26 +123,24 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if !Docuseal.multitenant? %>
|
||||
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::ESIGNING_PREFERENCE_KEY).first_or_initialize(value: 'multiple') %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<div class="px-1 mt-8 max-w-xl">
|
||||
<div class="flex justify-between items-end mb-4 mt-8">
|
||||
<h2 class="text-3xl font-bold">Preferences</h2>
|
||||
</div>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between py-2.5">
|
||||
<span>
|
||||
Apply multiple PDF digital signatures in the document per each signer
|
||||
</span>
|
||||
<%= f.check_box :value, { class: 'toggle', checked: account_config.value == 'multiple', onchange: 'this.form.requestSubmit()' }, 'multiple', 'single' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::ESIGNING_PREFERENCE_KEY).first_or_initialize(value: 'multiple') %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<div class="px-1 mt-8 max-w-xl">
|
||||
<div class="flex justify-between items-end mb-4 mt-8">
|
||||
<h2 class="text-3xl font-bold">Preferences</h2>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between py-2.5">
|
||||
<span>
|
||||
Apply multiple PDF digital signatures in the document per each signer
|
||||
</span>
|
||||
<%= f.check_box :value, { class: 'toggle', checked: account_config.value == 'multiple', onchange: 'this.form.requestSubmit()' }, 'multiple', 'single' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,6 +2,20 @@
|
||||
<%= render 'shared/settings_nav' %>
|
||||
<div class="flex-grow max-w-xl mx-auto">
|
||||
<h1 class="text-4xl font-bold mb-4">Email Notifications</h1>
|
||||
<div class="mt-2 mb-1">
|
||||
<% user_config = UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::RECEIVE_COMPLETED_EMAIL) %>
|
||||
<% if can?(:manage, user_config) %>
|
||||
<%= form_for user_config, url: user_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>
|
||||
Receive notification emails on completed submission
|
||||
</span>
|
||||
<%= f.check_box :value, class: 'toggle', checked: user_config.value != false, onchange: 'this.form.requestSubmit()' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render 'email_stats' %>
|
||||
<%= render 'bcc_form', config: @bcc_config %>
|
||||
<div class="flex justify-between items-end mb-4 mt-8">
|
||||
|
||||
@@ -1 +1 @@
|
||||
<%= render 'docuseal_logo' %>
|
||||
<%= render 'start_form/docuseal_logo' %>
|
||||
@@ -19,7 +19,7 @@
|
||||
<div dir="auto" class="text-center text-2xl font-semibold">
|
||||
<%= t('form_has_been_submitted_already') %>
|
||||
</div>
|
||||
<% if Accounts.can_send_emails?(@submitter.submission.template.account) %>
|
||||
<% if Accounts.can_send_emails?(@submitter.account) %>
|
||||
<div>
|
||||
<%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('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>
|
||||
@@ -31,4 +31,4 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= render 'shared/attribution', link_path: '/start' %>
|
||||
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
|
||||
@@ -37,4 +37,4 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= render 'shared/attribution', link_path: '/start' %>
|
||||
<%= render 'shared/attribution', link_path: '/start', account: @template.account %>
|
||||
@@ -6,7 +6,7 @@
|
||||
</a>
|
||||
<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">
|
||||
<a href="<%= ActiveStorage::Blob.proxy_url(@submission.audit_trail.blob, expires_at: 4.hours.from_now) %>" class="white-button" target="_blank">
|
||||
<%= svg_icon('external_link', class: 'w-6 h-6') %>
|
||||
<span class="hidden md:inline">
|
||||
Audit Log
|
||||
@@ -14,7 +14,7 @@
|
||||
</a>
|
||||
<% end %>
|
||||
<% 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, { sig: params[:sig] }.compact) %>" class="base-button">
|
||||
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
|
||||
<%= svg_icon('download', class: 'w-6 h-6') %>
|
||||
<span class="hidden md:inline">Download</span>
|
||||
@@ -34,7 +34,7 @@
|
||||
<% (@submission.template_schema || @submission.template.schema).each do |item| %>
|
||||
<% document = @submission.template_schema_documents.find { |a| item['attachment_uuid'] == a.uuid } %>
|
||||
<a href="#<%= "page-#{document.uuid}-0" %>" onclick="[event.preventDefault(), window[event.target.closest('a').href.split('#')[1]].scrollIntoView({ behavior: 'smooth', block: 'start' })]" class="block cursor-pointer">
|
||||
<img src="<%= document.preview_images.first.url %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
|
||||
<img src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, 0].join(':'), expires_in: 10.minutes) { document.preview_images.first.url } %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
|
||||
<div class="pb-2 pt-1.5 text-center" dir="auto">
|
||||
<%= item['name'].presence || document.filename.base %>
|
||||
</div>
|
||||
@@ -53,9 +53,9 @@
|
||||
<% 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")) %>
|
||||
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
|
||||
<div id="<%= "page-#{document.uuid}-#{index}" %>" class="relative">
|
||||
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" class="border rounded mb-4" height="<%= page.metadata['height'] %>">
|
||||
<img loading="lazy" src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, index].join(':'), expires_in: 10.minutes) { page.url } %>" width="<%= page.metadata['width'] %>" class="border rounded mb-4" height="<%= page.metadata['height'] %>">
|
||||
<div class="top-0 bottom-0 left-0 right-0 absolute">
|
||||
<% document_annots_index[index]&.each do |annot| %>
|
||||
<%= render 'submissions/annotation', annot: %>
|
||||
@@ -121,7 +121,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<% if signed_in? && submitter && submitter.phone && !submitter.completed_at && can?(:update, submitter) %>
|
||||
<%= render 'send_sms_button', submitter: %>
|
||||
<%= render 'submissions/send_sms_button', submitter: %>
|
||||
<% end %>
|
||||
<% if signed_in? && submitter && !submitter.completed_at? && can?(:create, submitter) %>
|
||||
<div class="mt-2 mb-1">
|
||||
@@ -144,10 +144,10 @@
|
||||
<div dir="auto">
|
||||
<% if field['type'].in?(%w[signature initials]) %>
|
||||
<div class="w-full bg-base-300 py-1">
|
||||
<img class="object-contain mx-auto" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
|
||||
<img class="object-contain mx-auto" style="max-height: <%= field['type'] == 'signature' ? 100 : 50 %>px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
|
||||
</div>
|
||||
<% elsif field['type'].in?(['image', 'stamp']) %>
|
||||
<img class="object-contain mx-auto max-h-28" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
|
||||
<img class="object-contain mx-auto max-h-28" style="max-height: 200px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
|
||||
<% elsif field['type'] == 'file' || field['type'] == 'payment' %>
|
||||
<div class="flex flex-col justify-center">
|
||||
<% Array.wrap(value).each do |val| %>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
|
||||
<div class="space-y-6 mx-auto">
|
||||
<div class="space-y-6">
|
||||
<% if Docuseal.multitenant? %>
|
||||
<div class="flex items-center justify-center">
|
||||
<%= render 'start_form/docuseal_logo' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
|
||||
<div class="flex items-center">
|
||||
<div class="mr-3">
|
||||
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
|
||||
</div>
|
||||
<div>
|
||||
<p dir="auto" class="text-lg font-bold mb-1"><%= @submission.template.name %></p>
|
||||
<% if submitter = @submission.submitters.map(&:completed_at).max %>
|
||||
<p dir="auto" class="text-sm"><%= t('signed_on_time', time: l(submitter.to_date, format: :long)) %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% if Accounts.can_send_emails?(@submission.account) %>
|
||||
<%= form_for '', url: send_submission_email_index_path, method: :post, html: { class: 'space-y-4', onsubmit: 'event.submitter.disabled = true' } do |f| %>
|
||||
<div dir="auto" class="form-control !mt-0">
|
||||
<%= f.hidden_field :submission_slug, value: @submission.slug %>
|
||||
<%= f.label :email, t('email'), class: 'label' %>
|
||||
<%= f.email_field :email, value: current_user&.email || params[:email], required: true, class: 'base-input', placeholder: t('send_copy_to_email') %>
|
||||
</div>
|
||||
<div dir="auto" class="form-control">
|
||||
<%= f.button button_title(title: t('send_copy_to_email'), disabled_with: t('starting')), class: 'base-button' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if Docuseal.multitenant? %>
|
||||
<div class="divider uppercase"><%= t('or') %></div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if Docuseal.multitenant? %>
|
||||
<div>
|
||||
<%= link_to 'Create free account', registration_path, class: 'white-button w-full' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= render 'shared/attribution', link_path: '/start', account: @submission.account %>
|
||||
@@ -11,11 +11,11 @@
|
||||
</div>
|
||||
<div dir="auto">
|
||||
<p class="text-lg font-bold mb-1"><%= @submitter.submission.template.name %></p>
|
||||
<p class="text-sm"><%= t('form_has_been_deleted_by_html', name: @submitter.submission.template.account.name) %></p>
|
||||
<p class="text-sm"><%= t('form_has_been_deleted_by_html', name: @submitter.account.name) %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%= render 'shared/attribution', link_path: '/start' %>
|
||||
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<% if Accounts.can_send_emails?(@submitter.submission.template.account) && @submitter.email.present? %>
|
||||
<% if Accounts.can_send_emails?(@submitter.account) && @submitter.email.present? %>
|
||||
<%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('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' %>
|
||||
<% if @submitter.submission.template.submitters.size != 1 %>
|
||||
<div class="divider uppercase"><%= t('or') %></div>
|
||||
@@ -25,18 +25,20 @@
|
||||
<div class="py-2"></div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<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">
|
||||
<%= svg_icon('download', class: 'w-6 h-6') %>
|
||||
<span><%= t('download_documents') %></span>
|
||||
</span>
|
||||
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
|
||||
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
|
||||
<span><%= t('downloading') %></span>
|
||||
</span>
|
||||
</download-button>
|
||||
<% if @submitter.completed_at > 30.minutes.ago || (current_user && current_user.account.submitters.exists?(id: @submitter.id)) %>
|
||||
<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">
|
||||
<%= svg_icon('download', class: 'w-6 h-6') %>
|
||||
<span><%= t('download_documents') %></span>
|
||||
</span>
|
||||
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
|
||||
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
|
||||
<span><%= t('downloading') %></span>
|
||||
</span>
|
||||
</download-button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @submitter.submission.template.submitters.size == 1 && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false %>
|
||||
<% if @submitter.submission.template.submitters.size == 1 && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && !@submitter.template.archived_at? %>
|
||||
<div class="divider uppercase"><%= t('or') %></div>
|
||||
<div>
|
||||
<%= button_to button_title(title: t('resubmit'), disabled_with: t('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, name: @submitter.name }, resubmit: @submitter.slug }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
|
||||
@@ -44,4 +46,4 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= render 'shared/attribution', link_path: '/start' %>
|
||||
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
|
||||
@@ -13,7 +13,7 @@
|
||||
<% 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")) %>
|
||||
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
|
||||
<div class="relative my-4 shadow-md">
|
||||
<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">
|
||||
@@ -24,13 +24,14 @@
|
||||
<% value = values[field['uuid']].presence || (field['readonly'] ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submitter.submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %>
|
||||
<% next if value.blank? %>
|
||||
<% next if !field['readonly'] && field['submitter_uuid'] == @submitter.uuid %>
|
||||
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submitter.submission.template.account.locale %>
|
||||
<% next if field.dig('preferences', 'formula') && field['submitter_uuid'] == @submitter.uuid %>
|
||||
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submitter.account.locale %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= render 'shared/attribution', link_path: '/start' %>
|
||||
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<% 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, sig: @sig)))) %>
|
||||
<% else %>
|
||||
<p>Hi there,</p>
|
||||
<p>Please check the copy of your "<%= @submitter.submission.template.name %>" submission in the email attachments.</p>
|
||||
<p>Alternatively, you can review and download your copy using:</p>
|
||||
<p>
|
||||
<%= link_to @submitter.template.name, submissions_preview_url(@submitter.submission.slug) %>
|
||||
<%= link_to @submitter.template.name, submissions_preview_url(@submitter.submission.slug, { sig: @sig }.compact) %>
|
||||
</p>
|
||||
<p>
|
||||
Thanks,<br><%= @current_account.name %>
|
||||
|
||||
@@ -12,11 +12,16 @@
|
||||
</a>
|
||||
<folder-autocomplete class="flex justify-between w-full">
|
||||
<input id="folder_name" placeholder="Folder Name" type="text" class="w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1 peer" name="folder_name" value="<%= params[:folder_name].presence || @base_template&.folder&.name || TemplateFolder::DEFAULT_NAME %>" onblur="window.folder_name.value = window.folder_name.value || 'Default'" autocomplete="off">
|
||||
<a href="#" onclick="[event.preventDefault(), window.folder_name.value = '', window.folder_name.focus()]" class="shrink-0 link peer-focus:hidden mr-1.5">
|
||||
<a href="#" onclick="[event.preventDefault(), window.folder_name.value = '', window.folder_name.focus()]" class="shrink-0 link peer-focus:hidden mr-1.5">
|
||||
Change Folder
|
||||
</a>
|
||||
</folder-autocomplete>
|
||||
</div>
|
||||
<% if @base_template && (can?(:manage, :tenants) || true_user != current_user) && true_user.account.account_linked_accounts.exists?(account_type: :linked) %>
|
||||
<div class="form-control mb-4">
|
||||
<%= select_tag :account_id, options_for_select([true_user.account, *true_user.account.linked_accounts.where.not(id: true_user.account.testing_accounts)].map { |e| [e.name, e.id] }, current_account.id), required: true, class: 'base-select' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="form-control">
|
||||
<%= f.button button_title(title: @base_template ? 'Submit' : 'Create', disabled_with: 'Creating'), class: 'base-button' %>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,8 @@ module DocuSeal
|
||||
|
||||
config.active_storage.routes_prefix = ''
|
||||
|
||||
config.active_storage.draw_routes = ENV['MULTITENANT'] != 'true'
|
||||
|
||||
config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE es it de fr pl uk cs pt he nl]
|
||||
config.i18n.fallbacks = [:en]
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ Rails.application.configure do
|
||||
end
|
||||
|
||||
config.active_storage.resolve_model_to_route = :rails_storage_proxy if ENV['ACTIVE_STORAGE_PUBLIC'] != 'true'
|
||||
config.active_storage.service_urls_expire_in = 15.minutes
|
||||
|
||||
# Mount Action Cable outside main process or domain.
|
||||
# config.action_cable.mount_path = nil
|
||||
@@ -107,7 +108,7 @@ Rails.application.configure do
|
||||
logger.formatter = config.log_formatter
|
||||
config.logger = ActiveSupport::TaggedLogging.new(logger)
|
||||
|
||||
encryption_secret = Digest::SHA256.hexdigest(ENV['SECRET_KEY_BASE'].to_s)
|
||||
encryption_secret = ENV['ENCRYPTION_SECRET'].presence || Digest::SHA256.hexdigest(ENV['SECRET_KEY_BASE'].to_s)
|
||||
|
||||
config.active_record.encryption = {
|
||||
primary_key: encryption_secret.first(32),
|
||||
@@ -121,12 +122,32 @@ Rails.application.configure do
|
||||
config.active_record.dump_schema_after_migration = false
|
||||
|
||||
config.lograge.enabled = true
|
||||
config.lograge.formatter = Lograge::Formatters::Json.new
|
||||
config.lograge.base_controller_class = ['ActionController::API', 'ActionController::Base']
|
||||
|
||||
config.lograge.custom_payload do |controller|
|
||||
{
|
||||
fwd: controller.request.ip
|
||||
}
|
||||
if ENV['MULTITENANT'] == 'true'
|
||||
config.lograge.formatter = ->(data) { data.except(:path, :location).to_json }
|
||||
|
||||
config.lograge.custom_payload do |controller|
|
||||
params = controller.request.params || {}
|
||||
|
||||
{
|
||||
fwd: controller.request.ip,
|
||||
params: {
|
||||
id: params[:id],
|
||||
sig: (params[:signed_uuid] || params[:signed_id]).to_s.split('--').first,
|
||||
slug: (params[:slug] || params[:submission_slug] || params[:template_slug]).to_s.last(5)
|
||||
}.compact_blank,
|
||||
host: controller.request.host,
|
||||
uid: controller.instance_variable_get(:@current_user).try(:id)
|
||||
}
|
||||
end
|
||||
else
|
||||
config.lograge.formatter = Lograge::Formatters::Json.new
|
||||
|
||||
config.lograge.custom_payload do |controller|
|
||||
{
|
||||
fwd: controller.request.ip
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,8 +5,37 @@ ActiveSupport.on_load(:active_storage_attachment) do
|
||||
|
||||
has_many_attached :preview_images
|
||||
|
||||
def preview_image_url
|
||||
preview_images.joins(:blob).find_by(blob: { filename: '0.jpg' })&.url
|
||||
def signed_uuid
|
||||
@signed_uuid ||= ApplicationRecord.signed_id_verifier.generate(uuid, expires_in: 6.hours, purpose: :attachment)
|
||||
end
|
||||
end
|
||||
|
||||
ActiveSupport.on_load(:active_storage_blob) do
|
||||
attribute :uuid, :string, default: -> { SecureRandom.uuid }
|
||||
|
||||
def self.proxy_url(blob, expires_at: nil)
|
||||
Rails.application.routes.url_helpers.blobs_proxy_url(
|
||||
signed_uuid: blob.signed_uuid(expires_at:), filename: blob.filename,
|
||||
**Docuseal.default_url_options
|
||||
)
|
||||
end
|
||||
|
||||
def uuid
|
||||
super || begin
|
||||
new_uuid = SecureRandom.uuid
|
||||
update_columns(uuid: new_uuid)
|
||||
new_uuid
|
||||
end
|
||||
end
|
||||
|
||||
def signed_uuid(expires_at: nil)
|
||||
expires_at = expires_at.to_i if expires_at
|
||||
|
||||
ApplicationRecord.signed_id_verifier.generate([uuid, 'blob', expires_at].compact)
|
||||
end
|
||||
|
||||
def delete
|
||||
service.delete(key)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ Devise.setup do |config|
|
||||
# This can reduce the time taken to boot the app but if your application
|
||||
# requires the Devise mappings to be loaded during boot time the application
|
||||
# won't boot properly.
|
||||
# config.reload_routes = true
|
||||
config.reload_routes = false
|
||||
|
||||
# ==> Configuration for :database_authenticatable
|
||||
# For bcrypt, this is the cost for hashing the password and defaults to 12. If
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rollbar' if ENV.key?('ROLLBAR_ACCESS_TOKEN')
|
||||
|
||||
if defined?(Rollbar)
|
||||
Rollbar.configure do |config|
|
||||
config.access_token = ENV.fetch('ROLLBAR_ACCESS_TOKEN', nil)
|
||||
|
||||
config.enabled = true
|
||||
|
||||
config.exception_level_filters['ActionController::RoutingError'] = 'ignore'
|
||||
|
||||
config.environment = ENV['ROLLBAR_ENV'].presence || Rails.env
|
||||
end
|
||||
end
|
||||
@@ -224,6 +224,27 @@ nl:
|
||||
downloading: Downloaden
|
||||
completed_successfully: Succesvol voltooid
|
||||
|
||||
ar:
|
||||
email: البريد الإلكتروني
|
||||
digitally_signed_by: تم التوقيع الرقمي بواسطة
|
||||
role: الدور
|
||||
provide_your_email_to_start: قدم بريدك الإلكتروني للبدء
|
||||
start: بدء
|
||||
starting: بداية
|
||||
form_has_been_deleted_by_html: 'تم حذف الاستمارة بواسطة <span class="font-semibold">%{name}</span>.'
|
||||
invited_by_html: 'تمت الدعوة بواسطة <span class="font-semibold">%{name}</span>'
|
||||
you_have_been_invited_to_submit_a_form: تمت دعوتك لتقديم استمارة
|
||||
signed_on_time: 'تم التوقيع في %{time}'
|
||||
form_has_been_submitted_already: تم تقديم الاستمارة بالفعل
|
||||
send_copy_to_email: إرسال نسخة إلى البريد الإلكتروني
|
||||
sending: جارٍ الإرسال
|
||||
resubmit: إعادة التقديم
|
||||
form_has_been_deleted_by_html: 'تم حذف الاستمارة بواسطة <span class="font-semibold">%{name}</span>.'
|
||||
or: أو
|
||||
download_documents: تحميل الوثائق
|
||||
downloading: جارٍ التحميل
|
||||
completed_successfully: تم الانتهاء بنجاح
|
||||
|
||||
en-US:
|
||||
<<: *en
|
||||
date:
|
||||
|
||||
+18
-2
@@ -53,6 +53,8 @@ Rails.application.routes.draw do
|
||||
resources :verify_pdf_signature, only: %i[create]
|
||||
resource :mfa_setup, only: %i[show new edit create destroy], controller: 'mfa_setup'
|
||||
resources :account_configs, only: %i[create]
|
||||
resources :user_configs, only: %i[create]
|
||||
resources :encrypted_user_configs, only: %i[destroy]
|
||||
resources :timestamp_server, only: %i[create]
|
||||
resources :dashboard, only: %i[index]
|
||||
resources :setup, only: %i[index create]
|
||||
@@ -80,7 +82,19 @@ Rails.application.routes.draw do
|
||||
resource :code_modal, only: %i[show], controller: 'templates_code_modal'
|
||||
resources :submissions_export, only: %i[index new]
|
||||
end
|
||||
resources :preview_document_page, only: %i[show], path: '/preview/:attachment_uuid'
|
||||
resources :preview_document_page, only: %i[show], path: '/preview/:signed_uuid'
|
||||
resource :blobs_proxy, only: %i[show], path: '/blobs_proxy/:signed_uuid/*filename',
|
||||
controller: 'api/active_storage_blobs_proxy'
|
||||
|
||||
if Docuseal.multitenant?
|
||||
resource :blobs_proxy_legacy, only: %i[show],
|
||||
path: '/blobs/proxy/:signed_id/*filename',
|
||||
controller: 'api/active_storage_blobs_proxy_legacy',
|
||||
as: :rails_blob
|
||||
get '/disk/:encoded_key/*filename' => 'active_storage/disk#show', as: :rails_disk_service
|
||||
put '/disk/:encoded_token' => 'active_storage/disk#update', as: :update_rails_disk_service
|
||||
post '/direct_uploads' => 'active_storage/direct_uploads#create', as: :rails_direct_uploads
|
||||
end
|
||||
|
||||
resources :start_form, only: %i[show update], path: 'd', param: 'slug' do
|
||||
get :completed
|
||||
@@ -94,7 +108,9 @@ Rails.application.routes.draw do
|
||||
get :completed
|
||||
end
|
||||
|
||||
resources :submissions_preview, only: %i[show], path: 'e', param: 'slug'
|
||||
resources :submissions_preview, only: %i[show], path: 'e', param: 'slug' do
|
||||
get :completed
|
||||
end
|
||||
|
||||
resources :send_submission_email, only: %i[create] do
|
||||
get :success, on: :collection
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddUuidToActiveStorageBlobs < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :active_storage_blobs, :uuid, :string
|
||||
|
||||
add_index :active_storage_blobs, :uuid, unique: true
|
||||
end
|
||||
end
|
||||
+3
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2024_02_03_113455) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2024_02_18_082157) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
@@ -74,7 +74,9 @@ ActiveRecord::Schema[7.1].define(version: 2024_02_03_113455) do
|
||||
t.bigint "byte_size", null: false
|
||||
t.string "checksum"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "uuid"
|
||||
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
|
||||
t.index ["uuid"], name: "index_active_storage_blobs_on_uuid", unique: true
|
||||
end
|
||||
|
||||
create_table "active_storage_variant_records", force: :cascade do |t|
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Docuseal
|
||||
URL_CACHE = ActiveSupport::Cache::MemoryStore.new
|
||||
PRODUCT_URL = 'https://www.docuseal.co'
|
||||
NEWSLETTER_URL = "#{PRODUCT_URL}/newsletters".freeze
|
||||
ENQUIRIES_URL = "#{PRODUCT_URL}/enquiries".freeze
|
||||
|
||||
@@ -9,7 +9,7 @@ module Params
|
||||
|
||||
validator.call
|
||||
rescue InvalidParameterError => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
raise e unless validator.dry_run?
|
||||
rescue StandardError => e
|
||||
|
||||
+14
-2
@@ -51,7 +51,7 @@ class PdfProcessor < HexaPDF::Content::Processor
|
||||
handler.call(self, operator, operands)
|
||||
end
|
||||
|
||||
def self.call(data, process_handler, result_handler, acc = {})
|
||||
def self.call(data, process_handler, result_handler, acc = {}, remove_tags: true)
|
||||
doc = HexaPDF::Document.new(io: StringIO.new(data))
|
||||
|
||||
doc.pages.each do |page|
|
||||
@@ -67,7 +67,19 @@ class PdfProcessor < HexaPDF::Content::Processor
|
||||
|
||||
page.process_contents(processor)
|
||||
|
||||
page.contents = process_handler_instance.contents
|
||||
page.contents = process_handler_instance.contents if process_handler_instance.result.present? && remove_tags
|
||||
end
|
||||
|
||||
page[:Annots].to_a.each do |annot|
|
||||
next unless annot
|
||||
|
||||
text = annot[:Contents].to_s.squish
|
||||
|
||||
next unless text.starts_with?('{{') && text.ends_with?('}}')
|
||||
|
||||
result_handler.call({ text:, rect: annot[:Rect] }, page, acc)
|
||||
|
||||
page[:Annots].delete(annot)
|
||||
end
|
||||
|
||||
process_handler_instance.result.each do |item|
|
||||
|
||||
@@ -18,7 +18,7 @@ module ReplaceEmailVariables
|
||||
module_function
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def call(text, submitter:, tracking_event_type: 'click_email')
|
||||
def call(text, submitter:, tracking_event_type: 'click_email', sig: nil)
|
||||
submitter_link = build_submitter_link(submitter, tracking_event_type)
|
||||
|
||||
submission_link = build_submission_link(submitter.submission) if submitter.submission
|
||||
@@ -35,8 +35,8 @@ module ReplaceEmailVariables
|
||||
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_LINK, build_documents_links_text(submitter))
|
||||
text = text.gsub(DOCUMENTS_LINKS, build_documents_links_text(submitter, sig))
|
||||
text = text.gsub(DOCUMENTS_LINK, build_documents_links_text(submitter, sig))
|
||||
|
||||
text = text.gsub(ACCOUNT_NAME, submitter.template.account.name) if submitter.template
|
||||
|
||||
@@ -44,9 +44,9 @@ module ReplaceEmailVariables
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
|
||||
def build_documents_links_text(submitter)
|
||||
def build_documents_links_text(submitter, sig = nil)
|
||||
Rails.application.routes.url_helpers.submissions_preview_url(
|
||||
submitter.submission.slug, **Docuseal.default_url_options
|
||||
submitter.submission.slug, { sig:, **Docuseal.default_url_options }.compact
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ module SubmissionEvents
|
||||
phone_verified: 'Phone verified',
|
||||
start_form: 'Submission started',
|
||||
view_form: 'Form viewed',
|
||||
complete_form: 'Submission completed'
|
||||
complete_form: 'Submission completed',
|
||||
api_complete_form: 'Submission completed via API'
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
+23
-2
@@ -3,6 +3,8 @@
|
||||
module Submissions
|
||||
DEFAULT_SUBMITTERS_ORDER = 'random'
|
||||
|
||||
PRELOAD_ALL_PAGES_AMOUNT = 200
|
||||
|
||||
module_function
|
||||
|
||||
def search(submissions, keyword)
|
||||
@@ -27,8 +29,27 @@ module Submissions
|
||||
submission.save!
|
||||
end
|
||||
|
||||
def preload_with_pages(submission)
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [submission],
|
||||
associations: [:template, { template_schema_documents: :blob }]
|
||||
).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
|
||||
|
||||
submission
|
||||
end
|
||||
|
||||
def create_from_emails(template:, user:, emails:, source:, mark_as_sent: false, params: {})
|
||||
preferences = Submitters.normalize_preferences(template.account, user, params)
|
||||
preferences = Submitters.normalize_preferences(user.account, user, params)
|
||||
|
||||
parse_emails(emails).uniq.map do |email|
|
||||
submission = template.submissions.new(created_by_user: user,
|
||||
@@ -66,7 +87,7 @@ module Submissions
|
||||
first_submitter =
|
||||
submission.template_submitters.filter_map { |s| submitters.find { |e| e.uuid == s['uuid'] } }.first
|
||||
|
||||
Submitters.send_signature_requests([first_submitter])
|
||||
Submitters.send_signature_requests([first_submitter]) if first_submitter
|
||||
else
|
||||
Submitters.send_signature_requests(submitters)
|
||||
end
|
||||
|
||||
@@ -5,10 +5,10 @@ module Submissions
|
||||
module_function
|
||||
|
||||
def call(template:, user:, submissions_attrs:, source:, submitters_order:, mark_as_sent: false, params: {})
|
||||
preferences = Submitters.normalize_preferences(template.account, user, params)
|
||||
preferences = Submitters.normalize_preferences(user.account, user, params)
|
||||
|
||||
Array.wrap(submissions_attrs).map do |attrs|
|
||||
submission_preferences = Submitters.normalize_preferences(template.account, user, attrs)
|
||||
submission_preferences = Submitters.normalize_preferences(user.account, user, attrs)
|
||||
submission_preferences = preferences.merge(submission_preferences)
|
||||
|
||||
set_submission_preferences = submission_preferences.slice('send_email', 'bcc_completed')
|
||||
@@ -109,8 +109,10 @@ module Submissions
|
||||
field['description'] = attrs['description'] if attrs['description'].present?
|
||||
field['readonly'] = attrs['readonly'] if attrs.key?('readonly')
|
||||
field['required'] = attrs['required'] if attrs.key?('required')
|
||||
field['default_value'] = attrs['default_value'] if attrs['default_value'].present? &&
|
||||
!field['type'].in?(%w[signature image initials file])
|
||||
|
||||
if attrs['default_value'].present? && !field['type'].in?(%w[signature image initials file])
|
||||
field['default_value'] = Submitters::NormalizeValues.normalize_value(field, attrs['default_value'])
|
||||
end
|
||||
|
||||
return field if attrs['validation_pattern'].blank?
|
||||
|
||||
|
||||
@@ -23,9 +23,11 @@ module Submissions
|
||||
else
|
||||
submitter.document_generation_events.create!(event_name: events.present? ? :retry : :start)
|
||||
|
||||
GenerateResultAttachments.call(submitter)
|
||||
documents = GenerateResultAttachments.call(submitter)
|
||||
|
||||
submitter.document_generation_events.create!(event_name: :complete)
|
||||
|
||||
documents
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
sleep WAIT_FOR_RETRY
|
||||
@@ -49,7 +51,7 @@ module Submissions
|
||||
DocumentGenerationEvent.where(submitter:).order(:created_at).last
|
||||
end
|
||||
|
||||
break last_event if last_event.event_name.in?(%w[complete fail])
|
||||
break submitter.documents if last_event.event_name.in?(%w[complete fail])
|
||||
|
||||
raise WaitForCompleteTimeout if total_wait_time > CHECK_COMPLETE_TIMEOUT
|
||||
end
|
||||
|
||||
@@ -4,8 +4,8 @@ module Submissions
|
||||
module GenerateAuditTrail
|
||||
FONT_SIZE = 9
|
||||
TEXT_COLOR = '525252'
|
||||
FONT_PATH = '/fonts/LiberationSans-Regular.ttf'
|
||||
FONT_BOLD_PATH = '/fonts/LiberationSans-Bold.ttf'
|
||||
FONT_PATH = '/fonts/GoNotoKurrent-Regular.ttf'
|
||||
FONT_BOLD_PATH = '/fonts/GoNotoKurrent-Bold.ttf'
|
||||
FONT_NAME = if File.exist?(FONT_PATH)
|
||||
FONT_PATH
|
||||
else
|
||||
@@ -28,6 +28,7 @@ module Submissions
|
||||
}.freeze
|
||||
|
||||
RTL_REGEXP = TextUtils::RTL_REGEXP
|
||||
MAX_IMAGE_HEIGHT = 100
|
||||
|
||||
module_function
|
||||
|
||||
@@ -61,7 +62,7 @@ module Submissions
|
||||
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(:base, font: FONT_NAME, font_size: FONT_SIZE, fill_color: TEXT_COLOR, line_spacing: 1)
|
||||
composer.style(:link, fill_color: 'hp-blue-light', underline: true)
|
||||
|
||||
composer.new_page
|
||||
@@ -71,16 +72,16 @@ module Submissions
|
||||
|
||||
column.text(account.linked_account_account&.testing? ? 'Testing Log - Not for Production Use' : 'Audit Log',
|
||||
font_size: 16,
|
||||
padding: [15, 0, 0, 0],
|
||||
position: :float, position_hint: :right)
|
||||
padding: [10, 0, 0, 0],
|
||||
position: :float, text_align: :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.text("Envelope ID: #{submission.id}", font_size: 12, padding: [15, 0, 8, 0], position: :float)
|
||||
|
||||
unless submission.source.in?(%w[embed api])
|
||||
column.formatted_text([{ link: verify_url, text: 'Verify', style: :link }],
|
||||
font_size: 9, padding: [22, 0, 10, 0], position: :float, align: :right)
|
||||
font_size: 9, padding: [15, 0, 10, 0], position: :float, text_align: :right)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -97,7 +98,7 @@ module Submissions
|
||||
end
|
||||
|
||||
link =
|
||||
Rails.application.routes.url_helpers.rails_blob_url(document, **Docuseal.default_url_options)
|
||||
ActiveStorage::Blob.proxy_url(document.blob)
|
||||
|
||||
[
|
||||
composer.document.layout.formatted_text_box(
|
||||
@@ -114,13 +115,13 @@ module Submissions
|
||||
{ text: 'Generated at: ', font: [FONT_BOLD_NAME, { variant: :bold }] },
|
||||
"#{I18n.l(document.created_at.in_time_zone(account.timezone), format: :long, locale: account.locale)} " \
|
||||
"#{TimeUtils.timezone_abbr(account.timezone, document.created_at)}"
|
||||
], line_spacing: 1.8
|
||||
], line_spacing: 1.3
|
||||
)
|
||||
]
|
||||
end
|
||||
|
||||
if documents_data.present?
|
||||
composer.table(documents_data, cell_style: { padding: [0, 0, 25, 0], border: { width: 0 } })
|
||||
composer.table(documents_data, cell_style: { padding: [0, 0, 20, 0], border: { width: 0 } })
|
||||
|
||||
composer.draw_box(divider)
|
||||
end
|
||||
@@ -151,7 +152,7 @@ module Submissions
|
||||
submitter.email && { text: "#{submitter.email}\n", font: [FONT_BOLD_NAME, { variant: :bold }] },
|
||||
submitter.name && { text: "#{TextUtils.maybe_rtl_reverse(submitter.name)}\n" },
|
||||
submitter.phone && { text: "#{submitter.phone}\n" }
|
||||
].compact_blank, line_spacing: 1.8, padding: [0, 20, 0, 0]
|
||||
].compact_blank, line_spacing: 1.3, padding: [0, 20, 0, 0]
|
||||
)
|
||||
],
|
||||
[
|
||||
@@ -167,7 +168,7 @@ module Submissions
|
||||
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]
|
||||
].compact_blank, line_spacing: 1.3, padding: [10, 20, 20, 0]
|
||||
)
|
||||
]
|
||||
]
|
||||
@@ -192,8 +193,8 @@ module Submissions
|
||||
font_size: 6
|
||||
}
|
||||
].compact_blank,
|
||||
align: field['name'].to_s.match?(RTL_REGEXP) ? :right : :left,
|
||||
line_spacing: 1.8, padding: [0, 0, 5, 0]
|
||||
text_align: field['name'].to_s.match?(RTL_REGEXP) ? :right : :left,
|
||||
line_spacing: 1.3, padding: [0, 0, 2, 0]
|
||||
),
|
||||
if field['type'].in?(%w[image signature initials stamp])
|
||||
attachment = submitter.attachments.find { |a| a.uuid == value }
|
||||
@@ -207,11 +208,16 @@ module Submissions
|
||||
width = field['type'] == 'initials' ? 100 : 200
|
||||
height = resized_image.height * (width.to_f / resized_image.width)
|
||||
|
||||
composer.image(io, width:, height:, margin: [0, 0, 10, 0])
|
||||
if height > MAX_IMAGE_HEIGHT
|
||||
width = (MAX_IMAGE_HEIGHT / height) * width
|
||||
height = MAX_IMAGE_HEIGHT
|
||||
end
|
||||
|
||||
composer.image(io, width:, height:, margin: [5, 0, 10, 0])
|
||||
composer.formatted_text_box([{ text: '' }])
|
||||
elsif field['type'].in?(%w[file payment])
|
||||
if field['type'] == 'payment'
|
||||
unit = CURRENCY_SYMBOLS[field['preferences']['currency']]
|
||||
unit = CURRENCY_SYMBOLS[field['preferences']['currency']] || field['preferences']['currency']
|
||||
|
||||
price = ApplicationController.helpers.number_to_currency(field['preferences']['price'], unit:)
|
||||
|
||||
@@ -222,7 +228,7 @@ module Submissions
|
||||
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)
|
||||
ActiveStorage::Blob.proxy_url(attachment.blob)
|
||||
|
||||
{ link:, text: "#{attachment.filename}\n", style: :link }
|
||||
end,
|
||||
@@ -236,9 +242,10 @@ module Submissions
|
||||
end
|
||||
|
||||
value = value.join(', ') if value.is_a?(Array)
|
||||
value = Submissions::GenerateResultAttachments::REPLACE_EMOJI[value] || value
|
||||
|
||||
composer.formatted_text_box([{ text: TextUtils.maybe_rtl_reverse(value.to_s.presence || 'n/a') }],
|
||||
align: value.to_s.match?(RTL_REGEXP) ? :right : :left,
|
||||
text_align: value.to_s.match?(RTL_REGEXP) ? :right : :left,
|
||||
padding: [0, 0, 10, 0])
|
||||
end
|
||||
]
|
||||
@@ -269,7 +276,7 @@ module Submissions
|
||||
]
|
||||
end
|
||||
|
||||
composer.table(events_data, cell_style: { padding: [0, 0, 20, 0], border: { width: 0 } }) if events_data.present?
|
||||
composer.table(events_data, cell_style: { padding: [0, 0, 12, 0], border: { width: 0 } }) if events_data.present?
|
||||
|
||||
io = StringIO.new
|
||||
|
||||
@@ -278,6 +285,7 @@ module Submissions
|
||||
sign_params = {
|
||||
reason: SIGN_REASON,
|
||||
certificate: pkcs.certificate,
|
||||
doc_mdp_permissions: :no_changes,
|
||||
key: pkcs.key,
|
||||
certificate_chain: pkcs.ca_certs || []
|
||||
}
|
||||
@@ -306,8 +314,8 @@ module Submissions
|
||||
font_size: 20,
|
||||
font: [FONT_BOLD_NAME, { variant: :bold }],
|
||||
width: 100,
|
||||
padding: [12, 0, 0, 8],
|
||||
position: :float, position_hint: :left)
|
||||
padding: [5, 0, 0, 8],
|
||||
position: :float, text_align: :left)
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
|
||||
@@ -80,7 +80,7 @@ module Submissions
|
||||
submission_data += submitter.documents.map.with_index(1) do |attachment, index|
|
||||
{
|
||||
name: "Document #{index}",
|
||||
value: attachment.url
|
||||
value: ActiveStorage::Blob.proxy_url(attachment.blob)
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -131,9 +131,13 @@ module Submissions
|
||||
|
||||
value =
|
||||
if template_field_type.in?(%w[image signature])
|
||||
attachments_index[submitter_value]&.url
|
||||
attachment = attachments_index[submitter_value]
|
||||
ActiveStorage::Blob.proxy_url(attachment.blob) if attachment
|
||||
elsif template_field_type == 'file'
|
||||
Array.wrap(submitter_value).compact_blank.filter_map { |e| attachments_index[e]&.url }
|
||||
Array.wrap(submitter_value).compact_blank.filter_map do |e|
|
||||
attachment = attachments_index[e]
|
||||
ActiveStorage::Blob.proxy_url(attachment.blob) if attachment
|
||||
end
|
||||
else
|
||||
submitter_value
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
module Submissions
|
||||
module GenerateResultAttachments
|
||||
FONT_SIZE = 11
|
||||
FONT_PATH = '/fonts/LiberationSans-Regular.ttf'
|
||||
FONT_PATH = '/fonts/GoNotoKurrent-Regular.ttf'
|
||||
FONT_NAME = if File.exist?(FONT_PATH)
|
||||
FONT_PATH
|
||||
else
|
||||
@@ -21,17 +21,23 @@ module Submissions
|
||||
A4_SIZE = [595, 842].freeze
|
||||
SUPPORTED_IMAGE_TYPES = ['image/png', 'image/jpeg'].freeze
|
||||
|
||||
REPLACE_EMOJI = {
|
||||
'✔️' => 'V',
|
||||
'✔' => 'V'
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def call(submitter)
|
||||
cell_layouter = HexaPDF::Layout::TextLayouter.new(valign: :center, align: :center)
|
||||
cell_layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center, text_align: :center)
|
||||
|
||||
template = submitter.submission.template
|
||||
|
||||
account = submitter.submission.template.account
|
||||
pkcs = Accounts.load_signing_pkcs(account)
|
||||
tsa_url = Accounts.load_timeserver_url(account)
|
||||
attachments_data_cache = {}
|
||||
|
||||
pdfs_index = build_pdfs_index(submitter)
|
||||
|
||||
@@ -60,8 +66,8 @@ module Submissions
|
||||
|
||||
value = submitter.values[field['uuid']]
|
||||
|
||||
layouter = HexaPDF::Layout::TextLayouter.new(valign: :center,
|
||||
align: value.to_s.match?(RTL_REGEXP) ? :right : :left,
|
||||
layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center,
|
||||
text_align: value.to_s.match?(RTL_REGEXP) ? :right : :left,
|
||||
font: pdf.fonts.add(FONT_NAME), font_size:)
|
||||
|
||||
next if Array.wrap(value).compact_blank.blank?
|
||||
@@ -73,7 +79,9 @@ module Submissions
|
||||
when 'image', 'signature', 'initials', 'stamp'
|
||||
attachment = submitter.attachments.find { |a| a.uuid == value }
|
||||
|
||||
image = Vips::Image.new_from_buffer(attachment.download, '').autorot
|
||||
attachments_data_cache[attachment.uuid] ||= attachment.download
|
||||
|
||||
image = Vips::Image.new_from_buffer(attachments_data_cache[attachment.uuid], '').autorot
|
||||
|
||||
scale = [(area['w'] * width) / image.width,
|
||||
(area['h'] * height) / image.height].min
|
||||
@@ -126,7 +134,7 @@ module Submissions
|
||||
height - (area['y'] * height) - lines[..next_index].sum(&:height) + height_diff
|
||||
],
|
||||
A: { Type: :Action, S: :URI,
|
||||
URI: h.rails_blob_url(attachment, **Docuseal.default_url_options) }
|
||||
URI: ActiveStorage::Blob.proxy_url(attachment.blob) }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -173,6 +181,7 @@ module Submissions
|
||||
end
|
||||
|
||||
value = TextUtils.maybe_rtl_reverse(Array.wrap(value).join(', '))
|
||||
value = REPLACE_EMOJI[value] || value
|
||||
|
||||
text = HexaPDF::Layout::TextFragment.create(value, font: pdf.fonts.add(FONT_NAME),
|
||||
font_size:)
|
||||
@@ -262,9 +271,10 @@ module Submissions
|
||||
end
|
||||
|
||||
ActiveStorage::Attachment.create!(
|
||||
uuid:,
|
||||
blob: ActiveStorage::Blob.create_and_upload!(io: StringIO.new(io.string), filename: "#{name}.pdf"),
|
||||
metadata: { sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) },
|
||||
metadata: { original_uuid: uuid,
|
||||
analyzed: true,
|
||||
sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) },
|
||||
name: 'documents',
|
||||
record: submitter
|
||||
)
|
||||
@@ -275,11 +285,7 @@ module Submissions
|
||||
end
|
||||
|
||||
def build_pdfs_index(submitter)
|
||||
latest_submitter =
|
||||
submitter.submission.submitters
|
||||
.select(&:completed_at?)
|
||||
.select { |e| e.id != submitter.id && e.completed_at <= submitter.completed_at }
|
||||
.max_by(&:completed_at)
|
||||
latest_submitter = find_last_submitter(submitter)
|
||||
|
||||
Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter
|
||||
|
||||
@@ -294,10 +300,23 @@ module Submissions
|
||||
HexaPDF::Document.new(io: StringIO.new(attachment.download))
|
||||
end
|
||||
|
||||
[attachment.uuid, pdf]
|
||||
begin
|
||||
pdf.acro_form&.flatten
|
||||
rescue StandardError => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
end
|
||||
|
||||
[attachment.metadata['original_uuid'] || attachment.uuid, pdf]
|
||||
end
|
||||
end
|
||||
|
||||
def find_last_submitter(submitter)
|
||||
submitter.submission.submitters
|
||||
.select(&:completed_at?)
|
||||
.select { |e| e.id != submitter.id && e.completed_at <= submitter.completed_at }
|
||||
.max_by(&:completed_at)
|
||||
end
|
||||
|
||||
def build_pdf_from_image(attachment)
|
||||
pdf = HexaPDF::Document.new
|
||||
page = pdf.pages.add
|
||||
@@ -329,8 +348,6 @@ module Submissions
|
||||
def fetch_sign_reason(submitter)
|
||||
reason_name = submitter.email || submitter.name || submitter.phone
|
||||
|
||||
return sign_reason(reason_name) if Docuseal.multitenant?
|
||||
|
||||
config =
|
||||
if Docuseal.multitenant?
|
||||
AccountConfig.where(account: submitter.account, key: AccountConfig::ESIGNING_PREFERENCE_KEY)
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@ module Submitters
|
||||
is_more_than_two_images = original_documents.count(&:image?) > 1
|
||||
|
||||
submitter.documents.preload(:blob).reject do |attachment|
|
||||
is_more_than_two_images && original_documents.find { |a| a.uuid == attachment.uuid }&.image?
|
||||
is_more_than_two_images &&
|
||||
original_documents.find { |a| a.uuid == (attachment.metadata['original_uuid'] || attachment.uuid) }&.image?
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Submitters
|
||||
module GenerateFontImage
|
||||
WIDTH = 3000
|
||||
HEIGHT = 160
|
||||
|
||||
FONTS = {
|
||||
'Dancing Script Regular' => '/fonts/DancingScript-Regular.otf',
|
||||
'Go Noto Kurrent-Bold Bold' => '/fonts/GoNotoKurrent-Bold.ttf'
|
||||
}.freeze
|
||||
|
||||
FONT_ALIASES = {
|
||||
'initials' => 'Go Noto Kurrent-Bold Bold',
|
||||
'signature' => 'Dancing Script Regular'
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def call(text, font: nil)
|
||||
font = FONT_ALIASES[font] || font
|
||||
|
||||
text_image = Vips::Image.text(text, font:, fontfile: FONTS[font],
|
||||
width: WIDTH, height: HEIGHT, wrap: :none)
|
||||
|
||||
text_mask = Vips::Image.black(text_image.width, text_image.height)
|
||||
|
||||
text_mask.bandjoin(text_image).copy(interpretation: :b_w).write_to_buffer('.png')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,8 +4,11 @@ module Submitters
|
||||
module NormalizeValues
|
||||
CHECKSUM_CACHE_STORE = ActiveSupport::Cache::MemoryStore.new
|
||||
|
||||
UnknownFieldName = Class.new(StandardError)
|
||||
UnknownSubmitterName = Class.new(StandardError)
|
||||
BaseError = Class.new(StandardError)
|
||||
|
||||
UnknownFieldName = Class.new(BaseError)
|
||||
InvalidDefaultValue = Class.new(BaseError)
|
||||
UnknownSubmitterName = Class.new(BaseError)
|
||||
|
||||
module_function
|
||||
|
||||
@@ -28,25 +31,49 @@ module Submitters
|
||||
|
||||
next if key.blank?
|
||||
|
||||
if fields_uuid_index[key]['type'].in?(%w[initials signature image file]) && value.present?
|
||||
new_value, new_attachments = normalize_attachment_value(value, template.account, for_submitter)
|
||||
field = fields_uuid_index[key]
|
||||
|
||||
if field['type'].in?(%w[initials signature image file stamp]) && value.present?
|
||||
new_value, new_attachments =
|
||||
normalize_attachment_value(value, field['type'], template.account, attachments, for_submitter)
|
||||
|
||||
attachments.push(*new_attachments)
|
||||
|
||||
value = new_value
|
||||
end
|
||||
|
||||
[key, value]
|
||||
[key, normalize_value(field, value)]
|
||||
end.to_h
|
||||
|
||||
[normalized_values, attachments]
|
||||
end
|
||||
|
||||
def normalize_value(field, value)
|
||||
if field['type'] == 'text' && value.present?
|
||||
value.to_s
|
||||
elsif field['type'] == 'number' && value.present?
|
||||
(value.to_f % 1).zero? ? value.to_i : value.to_f
|
||||
elsif field['type'] == 'date' && value.present?
|
||||
if value.is_a?(Integer)
|
||||
Time.zone.at(value.to_s.first(10).to_i).to_date
|
||||
else
|
||||
Date.parse(value).to_s
|
||||
end
|
||||
else
|
||||
value
|
||||
end
|
||||
rescue Date::Error => e
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
value
|
||||
end
|
||||
|
||||
def fetch_fields(template, submitter_name: nil, for_submitter: nil)
|
||||
if submitter_name
|
||||
submitter =
|
||||
template.submitters.find { |e| e['name'] == submitter_name } ||
|
||||
raise(UnknownSubmitterName, "Unknown submitter role: #{submitter_name}")
|
||||
raise(UnknownSubmitterName,
|
||||
"Unknown submitter role: #{submitter_name}. Template defines #{template.submitters.pluck('name')}")
|
||||
end
|
||||
|
||||
fields = for_submitter&.submission&.template_fields || template.fields
|
||||
@@ -66,20 +93,37 @@ module Submitters
|
||||
.merge(fields.index_by { |e| e['name'].to_s.downcase })
|
||||
end
|
||||
|
||||
def normalize_attachment_value(value, account, for_submitter = nil)
|
||||
def normalize_attachment_value(value, type, account, attachments, for_submitter = nil)
|
||||
if value.is_a?(Array)
|
||||
new_attachments = value.map { |v| find_or_build_attachment(v, account, for_submitter) }
|
||||
new_attachments = value.map do |v|
|
||||
new_attachment = find_or_build_attachment(v, type, account, for_submitter)
|
||||
|
||||
attachments.find { |a| a.blob_id == new_attachment.blob_id } || new_attachment
|
||||
end
|
||||
|
||||
[new_attachments.map(&:uuid), new_attachments]
|
||||
else
|
||||
new_attachment = find_or_build_attachment(value, account, for_submitter)
|
||||
new_attachment = find_or_build_attachment(value, type, account, for_submitter)
|
||||
|
||||
[new_attachment.uuid, new_attachment]
|
||||
existing_attachment = attachments.find { |a| a.blob_id == new_attachment.blob_id }
|
||||
|
||||
attachment = existing_attachment || new_attachment
|
||||
|
||||
[attachment.uuid, attachment]
|
||||
end
|
||||
end
|
||||
|
||||
def find_or_build_attachment(value, account, for_submitter = nil)
|
||||
blob = find_or_create_blobs(account, value)
|
||||
def find_or_build_attachment(value, type, account, for_submitter = nil)
|
||||
blob =
|
||||
if value.match?(%r{\Ahttps?://})
|
||||
find_or_create_blob_from_url(account, value)
|
||||
elsif type.in?(%w[signature initials]) && value.length < 50
|
||||
find_or_create_blob_from_text(account, value, type)
|
||||
elsif (data = Base64.decode64(value)) && Marcel::MimeType.for(data).include?('image')
|
||||
find_or_create_blob_from_base64(account, data, type)
|
||||
else
|
||||
raise InvalidDefaultValue, "Invalid value, url, base64 or text < 50 chars is expected: #{value.first(50)}..."
|
||||
end
|
||||
|
||||
attachment = for_submitter.attachments.find_by(blob_id: blob.id) if for_submitter
|
||||
|
||||
@@ -91,7 +135,31 @@ module Submitters
|
||||
attachment
|
||||
end
|
||||
|
||||
def find_or_create_blobs(account, url)
|
||||
def find_or_create_blob_from_base64(account, data, type)
|
||||
checksum = Digest::MD5.base64digest(data)
|
||||
|
||||
blob = find_blob_by_checksum(checksum, account)
|
||||
|
||||
blob || ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new(data),
|
||||
filename: "#{type}.png"
|
||||
)
|
||||
end
|
||||
|
||||
def find_or_create_blob_from_text(account, text, type)
|
||||
data = Submitters::GenerateFontImage.call(text, font: type)
|
||||
|
||||
checksum = Digest::MD5.base64digest(data)
|
||||
|
||||
blob = find_blob_by_checksum(checksum, account)
|
||||
|
||||
blob || ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new(data),
|
||||
filename: "#{type}.png"
|
||||
)
|
||||
end
|
||||
|
||||
def find_or_create_blob_from_url(account, url)
|
||||
cache_key = [account.id, url].join(':')
|
||||
checksum = CHECKSUM_CACHE_STORE.fetch(cache_key)
|
||||
|
||||
@@ -114,11 +182,15 @@ module Submitters
|
||||
end
|
||||
|
||||
def find_blob_by_checksum(checksum, account)
|
||||
ActiveStorage::Blob
|
||||
.joins('JOIN active_storage_attachments ON active_storage_attachments.blob_id = active_storage_blobs.id')
|
||||
.where(active_storage_attachments: { record_id: account.submitters.select(:id),
|
||||
record_type: 'Submitter' })
|
||||
.find_by(checksum:)
|
||||
blob = ActiveStorage::Blob.find_by(checksum:)
|
||||
|
||||
return unless blob
|
||||
|
||||
return blob unless blob.attachments.exists?
|
||||
|
||||
return blob if account.submitters.exists?(id: blob.attachments.where(record_type: 'Submitter').select(:record_id))
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def conn
|
||||
|
||||
@@ -19,7 +19,7 @@ module Submitters
|
||||
serialize_params = {
|
||||
include: {},
|
||||
only: %i[id slug uuid name email phone completed_at external_id
|
||||
metadata opened_at sent_at created_at updated_at],
|
||||
submission_id metadata opened_at sent_at created_at updated_at],
|
||||
methods: %i[status application_key]
|
||||
}
|
||||
|
||||
|
||||
@@ -16,15 +16,24 @@ module Submitters
|
||||
submitter_name = (submitter.submission.template_submitters ||
|
||||
submitter.submission.template.submitters).find { |e| e['uuid'] == submitter.uuid }['name']
|
||||
|
||||
submitter.as_json(methods: %i[application_key],
|
||||
include: [template: { only: %i[id name external_id created_at updated_at] }])
|
||||
submitter.as_json(methods: %i[status application_key],
|
||||
only: %i[id submission_id email phone name ua ip sent_at opened_at
|
||||
completed_at created_at updated_at external_id metadata])
|
||||
.except('uuid', 'values', 'slug')
|
||||
.merge('values' => values,
|
||||
.merge('role' => submitter_name,
|
||||
'preferences' => submitter.preferences.except('default_values'),
|
||||
'values' => values,
|
||||
'documents' => documents,
|
||||
'audit_log_url' => submitter.submission.audit_log_url,
|
||||
'submission_url' => r.submissions_preview_url(submitter.submission.slug,
|
||||
**Docuseal.default_url_options),
|
||||
'role' => submitter_name)
|
||||
'template' => submitter.template.as_json(only: %i[id name external_id created_at
|
||||
updated_at]),
|
||||
'submission' => {
|
||||
**submitter.submission.slice(:id, :audit_log_url, :created_at),
|
||||
status: submitter.submission.submitters.all?(&:completed_at?) ? 'completed' : 'pending',
|
||||
url: r.submissions_preview_url(submitter.submission.slug, **Docuseal.default_url_options)
|
||||
})
|
||||
end
|
||||
|
||||
def build_values_array(submitter)
|
||||
@@ -67,7 +76,7 @@ module Submitters
|
||||
def rails_storage_proxy_url(attachment)
|
||||
return if attachment.blank?
|
||||
|
||||
r.rails_storage_proxy_url(attachment, **Docuseal.default_url_options)
|
||||
ActiveStorage::Blob.proxy_url(attachment.blob)
|
||||
end
|
||||
|
||||
def r
|
||||
|
||||
@@ -37,6 +37,7 @@ module Submitters
|
||||
submitter.ip = request.remote_ip
|
||||
submitter.ua = request.user_agent
|
||||
submitter.values = merge_default_values(submitter)
|
||||
submitter.values = merge_formula_values(submitter)
|
||||
end
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
@@ -54,6 +55,8 @@ module Submitters
|
||||
params.fetch(:values, {}).to_unsafe_h.transform_values do |v|
|
||||
if params[:cast_boolean] == 'true'
|
||||
v == 'true'
|
||||
elsif params[:cast_number] == 'true'
|
||||
(v.to_f % 1).zero? ? v.to_i : v.to_f
|
||||
elsif params[:normalize_phone] == 'true'
|
||||
v.to_s.gsub(/[^0-9+]/, '')
|
||||
else
|
||||
@@ -90,6 +93,24 @@ module Submitters
|
||||
default_values.compact_blank.merge(submitter.values)
|
||||
end
|
||||
|
||||
def merge_formula_values(submitter)
|
||||
computed_values = submitter.submission.template_fields.each_with_object({}) do |field, acc|
|
||||
next if field['submitter_uuid'] != submitter.uuid
|
||||
|
||||
formula = field.dig('preferences', 'formula')
|
||||
|
||||
next if formula.blank?
|
||||
|
||||
acc[field['uuid']] = calculate_formula_value(formula, submitter.values.merge(acc.compact_blank))
|
||||
end
|
||||
|
||||
submitter.values.merge(computed_values.compact_blank)
|
||||
end
|
||||
|
||||
def calculate_formula_value(_formula, _values)
|
||||
0
|
||||
end
|
||||
|
||||
def template_default_value_for_submitter(value, submitter, with_time: false)
|
||||
return if value.blank?
|
||||
return if submitter.blank?
|
||||
@@ -103,6 +124,7 @@ module Submitters
|
||||
end
|
||||
|
||||
def replace_default_variables(value, attrs, template, with_time: false)
|
||||
return value if value.in?([true, false])
|
||||
return if value.blank?
|
||||
|
||||
value.to_s.gsub(VARIABLE_REGEXP) do |e|
|
||||
|
||||
@@ -10,6 +10,7 @@ module Templates
|
||||
pdf.pages.flat_map.with_index do |page, index|
|
||||
annotations = page.objects.deref!(page.attributes[:Annots]) || []
|
||||
annotations.filter_map do |annot|
|
||||
next if annot.nil?
|
||||
next if annot[:A].blank? || annot[:A][:URI].blank?
|
||||
next unless annot[:Subtype] == :Link
|
||||
next if !annot[:A][:URI].starts_with?('https://') && !annot[:A][:URI].starts_with?('http://')
|
||||
|
||||
@@ -98,8 +98,6 @@ module Templates
|
||||
record: attachment
|
||||
)
|
||||
end
|
||||
|
||||
io
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Templates
|
||||
module SerializeForApi
|
||||
module_function
|
||||
|
||||
def call(template, schema_documents = template.schema_documents, preview_image_attachments = [])
|
||||
json = template.as_json(
|
||||
methods: %i[application_key],
|
||||
include: { author: { only: %i[id email first_name last_name] } }
|
||||
)
|
||||
|
||||
json[:folder_name] = template.folder.name
|
||||
|
||||
json[:documents] = template.schema.map do |item|
|
||||
attachment = schema_documents.find { |e| e.uuid == item['attachment_uuid'] }
|
||||
|
||||
first_page_blob = preview_image_attachments.find { |e| e.record_id == attachment.id }&.blob
|
||||
first_page_blob ||= attachment.preview_images.joins(:blob).find_by(blob: { filename: '0.jpg' })&.blob
|
||||
|
||||
{
|
||||
id: attachment.id,
|
||||
uuid: attachment.uuid,
|
||||
url: ActiveStorage::Blob.proxy_url(attachment.blob),
|
||||
preview_image_url: first_page_blob && ActiveStorage::Blob.proxy_url(first_page_blob),
|
||||
filename: attachment.filename
|
||||
}
|
||||
end
|
||||
|
||||
json
|
||||
end
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -42,7 +42,7 @@ module TimeUtils
|
||||
|
||||
I18n.l(date, format: i18n_format, locale:)
|
||||
rescue Date::Error => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
string
|
||||
end
|
||||
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user