diff --git a/app/controllers/api/submission_documents_controller.rb b/app/controllers/api/submission_documents_controller.rb index 2f3fd78b..a26c5d86 100644 --- a/app/controllers/api/submission_documents_controller.rb +++ b/app/controllers/api/submission_documents_controller.rb @@ -30,7 +30,7 @@ module Api private def build_completed_documents(submission, merge: false) - last_submitter = submission.submitters.max_by(&:completed_at) + last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at) if merge if submission.merged_document_attachment.blank? diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb index b620025c..9c7e6911 100644 --- a/app/controllers/api/submissions_controller.rb +++ b/app/controllers/api/submissions_controller.rb @@ -7,7 +7,7 @@ module Api TEMPLATE_COLUMNS = %i[id name external_id created_at updated_at folder_id submitters].freeze load_and_authorize_resource :template, only: :create - load_and_authorize_resource :submission, only: %i[show index destroy] + load_and_authorize_resource :submission, only: %i[show index update destroy] before_action only: :create do authorize!(:create, Submission) @@ -80,8 +80,9 @@ module Api Submissions.send_signature_requests(submissions) submissions.each do |submission| - if submission.submitters.all?(&:completed_at?) && Submissions.maybe_update_completed_at(submission) - last_submitter = submission.submitters.max_by(&:completed_at) + if submission.submitters.all? { |s| s.viewer? || s.completed_at? } && + Submissions.maybe_update_completed_at(submission) + last_submitter = submission.submitters.reject(&:viewer?).max_by(&:completed_at) end submission.submitters.each do |submitter| @@ -103,6 +104,25 @@ module Api render json: { error: e.message }, status: :unprocessable_content end + def update + @submission = assign_submission_attrs(@submission, submission_params) + + @submission.save! + + if @submission.saved_change_to_archived_at? && @submission.archived_at? + WebhookUrls.enqueue_events(@submission, 'submission.archived') + end + + if @submission.saved_change_to_expire_at? && @submission.expire_at? + ProcessSubmissionExpiredJob.perform_at(@submission.expire_at, 'submission_id' => @submission.id, + 'expire_at' => @submission.expire_at.to_i) + end + + SearchEntries.enqueue_reindex(@submission) if @submission.saved_change_to_name? + + render json: Submissions::SerializeForApi.call(@submission, nil, params, with_events: false) + end + def destroy if params[:permanently].in?(['true', true]) @submission.destroy! @@ -117,6 +137,25 @@ module Api private + def assign_submission_attrs(submission, attrs) + archived = attrs.key?(:archived) ? attrs[:archived] : attrs[:archived_at] + + if archived.in?([true, false, 'true', 'false']) && current_ability.can?(:destroy, submission) + submission.archived_at = archived.in?(Submitters::TRUE_VALUES) ? Time.current : nil + end + + submission.name = attrs[:name] if attrs.key?(:name) + submission.expire_at = attrs[:expire_at].presence if attrs.key?(:expire_at) + + submission + end + + def submission_params + submission_params = params.key?(:submission) ? params.require(:submission) : params + + submission_params.permit(:name, :expire_at, :archived, :archived_at) + end + def maybe_return_template_error return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil? diff --git a/app/controllers/api/submitters_controller.rb b/app/controllers/api/submitters_controller.rb index ec15ab11..555dc92d 100644 --- a/app/controllers/api/submitters_controller.rb +++ b/app/controllers/api/submitters_controller.rb @@ -101,7 +101,7 @@ module Api private def maybe_return_submitter_error - if @submitter.completed_at? + if @submitter.completed_at? || @submitter.submission.completed_at? return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content end diff --git a/app/controllers/api/templates_controller.rb b/app/controllers/api/templates_controller.rb index 2de64605..0a989e55 100644 --- a/app/controllers/api/templates_controller.rb +++ b/app/controllers/api/templates_controller.rb @@ -5,6 +5,8 @@ module Api load_and_authorize_resource :template def index + @templates = Templates.shared(current_user) if params[:shared].in?(['true', true]) + templates = filter_templates(@templates, params) templates = paginate(templates.preload(:author, folder: :parent_folder)) @@ -54,7 +56,7 @@ module Api @template.update!(template_params) - SearchEntries.enqueue_reindex(@template) + SearchEntries.enqueue_reindex(@template) if @template.saved_change_to_name? WebhookUrls.enqueue_events(@template, 'template.updated') @@ -115,7 +117,13 @@ module Api end def filter_templates(templates, params) - templates = Templates.search(current_user, templates, params[:q]) + templates = + if params[:shared].in?(['true', true]) + Templates.search_shared(current_user, templates, params[:q]) + else + Templates.search(current_user, templates, params[:q]) + end + templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active templates = templates.where(external_id: params[:application_key]) if params[:application_key].present? templates = templates.where(external_id: params[:external_id]) if params[:external_id].present? diff --git a/app/controllers/email_smtp_settings_controller.rb b/app/controllers/email_smtp_settings_controller.rb index f918f172..00f51511 100644 --- a/app/controllers/email_smtp_settings_controller.rb +++ b/app/controllers/email_smtp_settings_controller.rb @@ -3,7 +3,7 @@ class EmailSmtpSettingsController < ApplicationController before_action :load_encrypted_config authorize_resource :encrypted_config, only: :index - authorize_resource :encrypted_config, parent: false, only: :create + authorize_resource :encrypted_config, parent: false, only: %i[create destroy] def index; end @@ -23,6 +23,12 @@ class EmailSmtpSettingsController < ApplicationController render :index, status: :unprocessable_content end + def destroy + @encrypted_config.destroy! + + redirect_to settings_email_index_path, notice: I18n.t('smtp_settings_have_been_reset') + end + private def load_encrypted_config diff --git a/app/controllers/send_submission_email_controller.rb b/app/controllers/send_submission_email_controller.rb index 41f06bf7..13bd5f16 100644 --- a/app/controllers/send_submission_email_controller.rb +++ b/app/controllers/send_submission_email_controller.rb @@ -16,18 +16,16 @@ class SendSubmissionEmailController < ApplicationController @submitter = Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase) elsif params[:submission_slug] - submission = Submission.find_by(slug: params[:submission_slug]) + submission = Submission.find_by!(slug: params[:submission_slug]) - if submission - @submitter = Submitter.completed.find_by(submission: submission, email: params[:email].to_s.downcase) - end + @submitter = submission.submitters.order(:completed_at).find_by(email: params[:email].to_s.downcase) return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter else - @submitter = Submitter.completed.find_by!(slug: params[:submitter_slug]) + @submitter = Submitter.find_by!(slug: params[:submitter_slug]) end - if @submitter + if @submitter && completed_submitter?(@submitter) RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes) SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter) @@ -41,6 +39,10 @@ class SendSubmissionEmailController < ApplicationController private + def completed_submitter?(submitter) + submitter.completed_at? || (submitter.viewer? && submitter.submission.completed_at?) + end + def can_send?(submitter) return false if submitter.account.archived_at? return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter, diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index 4a0d7890..accf6798 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -93,9 +93,12 @@ class StartFormController < ApplicationController SearchEntries.enqueue_reindex(submitter) - return unless submitter.submission.expire_at? + expire_at = submitter.submission.expire_at - ProcessSubmissionExpiredJob.perform_at(submitter.submission.expire_at, 'submission_id' => submitter.submission_id) + return unless expire_at + + ProcessSubmissionExpiredJob.perform_at(expire_at, 'submission_id' => submitter.submission_id, + 'expire_at' => expire_at.to_i) end def load_resubmit_submitter @@ -138,8 +141,7 @@ class StartFormController < ApplicationController submitter ||= Submitter - .where(submission: template.submissions.where(expire_at: Time.current..) - .or(template.submissions.where(expire_at: nil)).where(archived_at: nil)) + .where(submission: template.submissions.non_expired.active) .order(id: :desc) .where(declined_at: nil) .where(external_id: nil) @@ -147,6 +149,8 @@ class StartFormController < ApplicationController .then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel } .find_or_initialize_by(find_params) + submitter = Submitter.new(find_params) if submitter.submission&.completed_at? && submitter.viewer? + submitter.name = required_params['name'] if submitter.new_record? unless @resubmit_submitter diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index f4aebb36..31e568e2 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -87,6 +87,8 @@ class SubmissionsController < ApplicationController private def create_submissions(template, submissions_params, params) + normalize_message_submitter_uuids!(params) + submissions_attrs = submissions_params[:submission].to_h.values submissions_attrs, _, new_fields = @@ -111,4 +113,23 @@ class SubmissionsController < ApplicationController def submissions_params params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] }) end + + def normalize_message_submitter_uuids!(params) + return if params[:request_email_per_submitter] == '1' + + uuids = params[:email_message_submitter_uuids] + + return if uuids.blank? + return if params[:subject].blank? && params[:body].blank? + + params[:submitter_preferences] = + Array.wrap(uuids).index_with { { 'subject' => params[:subject], 'body' => params[:body] } } + + params[:request_email_per_submitter] = '1' + + params.delete(:subject) + params.delete(:body) + + params + end end diff --git a/app/controllers/submit_form_completed_download_controller.rb b/app/controllers/submit_form_completed_download_controller.rb index 19dfafb1..6f959d0d 100644 --- a/app/controllers/submit_form_completed_download_controller.rb +++ b/app/controllers/submit_form_completed_download_controller.rb @@ -19,7 +19,7 @@ class SubmitFormCompletedDownloadController < ApplicationController @submitter ||= Submitter.find_by!(slug: submitter_slug) - Submissions::EnsureResultGenerated.call(@submitter) + Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at? last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last @@ -28,11 +28,7 @@ class SubmitFormCompletedDownloadController < ApplicationController Submissions::EnsureResultGenerated.call(last_submitter) if !signature_valid && !current_user_submitter?(last_submitter) - unless Submitters::AuthorizedForForm.call(@submitter, current_user, request) - Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar) - - return head :not_found - end + return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request) if last_submitter.completed_at < TTL.ago Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar) diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index a65c2237..43db9514 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -18,7 +18,10 @@ class SubmitFormController < ApplicationController submission = @submitter.submission return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request) - return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at? + + if @submitter.completed_at? || submission.completed_at? + return redirect_to submit_form_completed_path(@submitter.slug) + end @form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS) @@ -71,6 +74,12 @@ class SubmitFormController < ApplicationController status: :unprocessable_content end + if @submitter.viewer? + Rollbar.warning("Submit viewer: #{@submitter.id}") if defined?(Rollbar) + + return render json: { error: I18n.t('form_is_view_only') }, status: :unprocessable_content + end + Submitters::SubmitValues.call(@submitter, params, request) head :ok diff --git a/app/controllers/submit_form_decline_controller.rb b/app/controllers/submit_form_decline_controller.rb index 73139f79..94054196 100644 --- a/app/controllers/submit_form_decline_controller.rb +++ b/app/controllers/submit_form_decline_controller.rb @@ -13,6 +13,7 @@ class SubmitFormDeclineController < ApplicationController @submitter.submission.archived_at? || @submitter.submission.expired? || @submitter.submission.template&.archived_at? || + @submitter.viewer? || !Submitters::AuthorizedForForm.call(@submitter, current_user, request) diff --git a/app/controllers/submit_form_delegate_controller.rb b/app/controllers/submit_form_delegate_controller.rb index 0a516509..6448a750 100644 --- a/app/controllers/submit_form_delegate_controller.rb +++ b/app/controllers/submit_form_delegate_controller.rb @@ -12,6 +12,7 @@ class SubmitFormDelegateController < ApplicationController @submitter.submission.archived_at? || @submitter.submission.expired? || @submitter.submission.template&.archived_at? || + @submitter.viewer? || !Submitters::AuthorizedForForm.call(@submitter, current_user, request) diff --git a/app/controllers/submit_form_download_controller.rb b/app/controllers/submit_form_download_controller.rb index e1c22eba..821267b2 100644 --- a/app/controllers/submit_form_download_controller.rb +++ b/app/controllers/submit_form_download_controller.rb @@ -14,6 +14,7 @@ class SubmitFormDownloadController < ApplicationController return head :unprocessable_content if @submitter.declined_at? || @submitter.submission.archived_at? || @submitter.submission.expired? || + @submitter.submission.completed_at? || @submitter.submission.template&.archived_at? || AccountConfig.exists?(account_id: @submitter.account_id, key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY, diff --git a/app/controllers/submit_form_draw_signature_controller.rb b/app/controllers/submit_form_draw_signature_controller.rb index 5ba141c1..54faa7d2 100644 --- a/app/controllers/submit_form_draw_signature_controller.rb +++ b/app/controllers/submit_form_draw_signature_controller.rb @@ -12,6 +12,8 @@ class SubmitFormDrawSignatureController < ApplicationController return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at? + return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer? + if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? || !Submitters::AuthorizedForForm.call(@submitter, current_user, request) return redirect_to submit_form_path(@submitter.slug) diff --git a/app/controllers/submit_form_invite_controller.rb b/app/controllers/submit_form_invite_controller.rb index ac7f6db0..8a231e1c 100644 --- a/app/controllers/submit_form_invite_controller.rb +++ b/app/controllers/submit_form_invite_controller.rb @@ -48,6 +48,7 @@ class SubmitFormInviteController < ApplicationController !submitter.submission.archived_at? && !submitter.submission.expired? && !submitter.submission.template&.archived_at? && + !submitter.viewer? && Submitters::AuthorizedForForm.call(submitter, current_user, request) end diff --git a/app/controllers/submit_form_metadata_controller.rb b/app/controllers/submit_form_metadata_controller.rb index dcdeaa20..62a73d9f 100644 --- a/app/controllers/submit_form_metadata_controller.rb +++ b/app/controllers/submit_form_metadata_controller.rb @@ -7,13 +7,7 @@ class SubmitFormMetadataController < ApplicationController def index @submitter = Submitter.find_by!(slug: params[:submit_form_slug]) - return head :not_found if @submitter.declined_at? || - @submitter.completed_at? || - @submitter.submission.archived_at? || - @submitter.submission.expired? || - @submitter.submission.template&.archived_at? || - @submitter.account.archived_at? || - !Submitters::AuthorizedForForm.call(@submitter, current_user, request) + return head :not_found unless authorized_submitter?(@submitter) submission = @submitter.submission values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } @@ -34,4 +28,17 @@ class SubmitFormMetadataController < ApplicationController render json: { text_runs: } end + + private + + def authorized_submitter?(submitter) + !submitter.declined_at? && + !submitter.completed_at? && + !submitter.submission.archived_at? && + !submitter.submission.completed_at? && + !submitter.submission.expired? && + !submitter.submission.template&.archived_at? && + !submitter.account.archived_at? && + Submitters::AuthorizedForForm.call(submitter, current_user, request) + end end diff --git a/app/controllers/submitters_controller.rb b/app/controllers/submitters_controller.rb index 1eae06d8..7a40101f 100644 --- a/app/controllers/submitters_controller.rb +++ b/app/controllers/submitters_controller.rb @@ -50,7 +50,7 @@ class SubmittersController < ApplicationController def submitter_editable?(submission) !@submitter.submission_events.exists?(event_type: 'start_form') && - !@submitter.completed_at? && !@submitter.declined_at? && + !@submitter.completed_at? && !@submitter.declined_at? && !submission.completed_at? && !submission.archived_at? && !submission.expired? && !submission.template&.archived_at? end @@ -58,7 +58,7 @@ class SubmittersController < ApplicationController if params[:send_email] == '1' && submitter.email.present? is_sent_recently = Docuseal.multitenant? && EmailEvent.exists?(email: submitter.email, - tag: 'submitter_invitation', + tag: %w[submitter_invitation submitter_view_invitation], emailable: submitter, event_type: 'send', created_at: 4.hours.ago..Time.current) diff --git a/app/controllers/templates_preferences_controller.rb b/app/controllers/templates_preferences_controller.rb index 80865e94..b49202d0 100644 --- a/app/controllers/templates_preferences_controller.rb +++ b/app/controllers/templates_preferences_controller.rb @@ -5,6 +5,8 @@ class TemplatesPreferencesController < ApplicationController RESETTABLE_PREFERENCE_KEYS = { AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY => %w[request_email_subject request_email_body submitters], + AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY => %w[invitation_view_email_subject + invitation_view_email_body], AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[invitation_reminder_email_subject invitation_reminder_email_body], AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[documents_copy_email_subject documents_copy_email_body], @@ -48,12 +50,12 @@ class TemplatesPreferencesController < ApplicationController def template_params params.require(:template).permit( preferences: %i[bcc_completed request_email_subject request_email_body + invitation_view_email_subject invitation_view_email_body invitation_reminder_email_subject invitation_reminder_email_body documents_copy_email_subject documents_copy_email_body documents_copy_email_enabled documents_copy_email_attach_audit documents_copy_email_attach_documents documents_copy_email_reply_to - completed_notification_email_attach_documents - completed_redirect_url validate_unique_submitters + completed_notification_email_attach_documents completed_redirect_url validate_unique_submitters require_all_submitters submitters_order require_phone_2fa require_email_2fa default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled completed_notification_email_subject completed_notification_email_body diff --git a/app/javascript/application.js b/app/javascript/application.js index 6aa39fc8..75178c96 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -42,6 +42,7 @@ import RequiredCheckboxGroup from './elements/required_checkbox_group' import PageContainer from './elements/page_container' import EmailEditor from './elements/email_editor' import MarkdownEditor from './elements/markdown_editor' +import HtmlEditor from './elements/html_editor' import MountOnClick from './elements/mount_on_click' import RemoveOnEvent from './elements/remove_on_event' import ScrollTo from './elements/scroll_to' @@ -135,6 +136,7 @@ safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup) safeRegisterElement('page-container', PageContainer) safeRegisterElement('email-editor', EmailEditor) safeRegisterElement('markdown-editor', MarkdownEditor) +safeRegisterElement('html-editor', HtmlEditor) safeRegisterElement('mount-on-click', MountOnClick) safeRegisterElement('remove-on-event', RemoveOnEvent) safeRegisterElement('scroll-to', ScrollTo) diff --git a/app/javascript/elements/email_editor.js b/app/javascript/elements/email_editor.js index 38f0d3b4..030eeaa4 100644 --- a/app/javascript/elements/email_editor.js +++ b/app/javascript/elements/email_editor.js @@ -9,8 +9,9 @@ function loadCodeMirror () { import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'), import(/* webpackChunkName: "email-editor" */ '@codemirror/language'), import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'), + import(/* webpackChunkName: "email-editor" */ '@codemirror/lint'), import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow') - ]).then(([view, commands, language, html, htmlflow]) => { + ]).then(([view, commands, language, html, lint, htmlflow]) => { return { minimalSetup: [ commands.history(), @@ -19,6 +20,8 @@ function loadCodeMirror () { ], EditorView: view.EditorView, html: html.html, + htmlLanguage: html.htmlLanguage, + linter: lint.linter, htmlflow: htmlflow.default || htmlflow } }) @@ -46,6 +49,70 @@ export default targetable(class extends HTMLElement { this.previewViewTab.addEventListener('click', this.showPreviewView) this.codeViewTab.addEventListener('click', this.showCodeView) + + this.form = this.closest('form') + this.form?.addEventListener('submit', this.validateOnSubmit) + } + + disconnectedCallback () { + this.form?.removeEventListener('submit', this.validateOnSubmit) + } + + validateOnSubmit = (e) => { + if (!this.htmlLanguage) return + + const bodyType = this.form.querySelector('input[name$="[body_type]"]:checked')?.value + + if (bodyType && bodyType !== 'html') return + + const diagnostics = this.buildDiagnostics(this.input.value) + + if (diagnostics.length === 0) return + + e.preventDefault() + + this.showCodeView() + + const pos = Math.min(diagnostics[0].from, this.editorView.state.doc.length) + + this.editorView.dispatch({ selection: { anchor: pos }, scrollIntoView: true }) + this.editorView.focus() + + alert(diagnostics[0].message) + } + + buildDiagnostics (value) { + const diagnostics = [] + + if (!value.trim()) return diagnostics + + if (!/^\s*(]*>\s*)? tag' + }) + } + + const seen = new Set() + + this.htmlLanguage.parser.parse(value).iterate({ + enter: (node) => { + if (!node.type.isError || seen.has(node.from) || seen.size >= 20) return + + seen.add(node.from) + + diagnostics.push({ + from: node.from, + to: Math.min(node.to + 1, value.length), + severity: 'error', + message: 'The email template contains invalid HTML' + }) + } + }) + + return diagnostics } showCodeView = () => { @@ -76,7 +143,9 @@ export default targetable(class extends HTMLElement { this.input = this.querySelector('input[type="hidden"]') this.input.style.display = 'none' - const { EditorView, minimalSetup, html, htmlflow } = await loadCodeMirror() + const { EditorView, minimalSetup, html, htmlLanguage, linter, htmlflow } = await loadCodeMirror() + + this.htmlLanguage = htmlLanguage this.editorView = new EditorView({ doc: this.input.value, @@ -85,8 +154,11 @@ export default targetable(class extends HTMLElement { html(), minimalSetup, EditorView.lineWrapping, + linter((view) => this.buildDiagnostics(view.state.doc.toString()), { delay: 600 }), EditorView.updateListener.of(update => { - if (update.docChanged) this.input.value = update.state.doc.toString() + if (update.docChanged) { + this.input.value = update.state.doc.toString() + } }), EditorView.theme({ '&': { diff --git a/app/javascript/elements/html_editor.js b/app/javascript/elements/html_editor.js new file mode 100644 index 00000000..8c9a2d2c --- /dev/null +++ b/app/javascript/elements/html_editor.js @@ -0,0 +1,649 @@ +import { target, targetable } from '@github/catalyst/lib/targetable' +import { actionable } from '@github/catalyst/lib/actionable' +import { LinkTooltip } from './markdown_editor' + +async function loadTiptap () { + const [core, document, text, hardBreak, gapcursor, dropcursor, extensions, pmState, pmView] = await Promise.all([ + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-gapcursor'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-dropcursor'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'), + import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view') + ]) + + return { + Editor: core.Editor, + Extension: core.Extension, + Node: core.Node, + Mark: core.Mark, + Document: document.default || document, + Text: text.default || text, + HardBreak: hardBreak.default || hardBreak, + Gapcursor: gapcursor.default || gapcursor, + Dropcursor: dropcursor.default || dropcursor, + UndoRedo: extensions.UndoRedo, + Plugin: pmState.Plugin, + Decoration: pmView.Decoration, + DecorationSet: pmView.DecorationSet + } +} + +const editorStylesheet = new CSSStyleSheet() + +editorStylesheet.replaceSync(` +:host { + display: block; + max-height: 360px; + overflow: auto; + border-radius: 0 0 1rem 1rem; +} + +.ProseMirror { + word-wrap: break-word; + -webkit-font-variant-ligatures: none; + font-variant-ligatures: none; + font-feature-settings: "liga" 0; + outline: none; + min-height: 220px; + padding: 12px; +} + +img.ProseMirror-separator { + display: inline !important; + border: none !important; + margin: 0 !important; + width: 0 !important; + height: 0 !important; +} + +.ProseMirror-gapcursor { + display: none; + pointer-events: none; + position: absolute; + margin: 0; +} + +.ProseMirror-gapcursor:after { + content: ""; + display: block; + position: absolute; + top: -2px; + width: 20px; + border-top: 1px solid black; + animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; +} + +@keyframes ProseMirror-cursor-blink { + to { + visibility: hidden; + } +} + +.ProseMirror-hideselection *::selection { + background: transparent; +} + +.ProseMirror-hideselection *::-moz-selection { + background: transparent; +} + +.ProseMirror-hideselection * { + caret-color: transparent; +} + +.ProseMirror-focused .ProseMirror-gapcursor { + display: block; +} + +.variable-highlight { + background-color: #fef3c7; + padding: 1px 2px; + border-radius: 4px; +} +`) + +function collectDomAttrs (dom) { + const attrs = {} + + for (let i = 0; i < dom.attributes.length; i++) { + attrs[dom.attributes[i].name] = dom.attributes[i].value + } + + return { htmlAttrs: attrs } +} + +function collectSpanDomAttrs (dom) { + const result = collectDomAttrs(dom) + + if (result.htmlAttrs.style) { + const temp = document.createElement('span') + + temp.style.cssText = result.htmlAttrs.style + + if (['bold', '700'].includes(temp.style.fontWeight)) { + temp.style.removeProperty('font-weight') + } + + if (temp.style.fontStyle === 'italic') { + temp.style.removeProperty('font-style') + } + + if (temp.style.textDecoration === 'underline') { + temp.style.removeProperty('text-decoration') + } + + if (temp.style.cssText) { + result.htmlAttrs.style = temp.style.cssText + } else { + delete result.htmlAttrs.style + } + } + + return result +} + +function buildExtensions ({ Node, Mark, Extension, Plugin, Decoration, DecorationSet }) { + const blockNode = (name, tag, content, extra = {}) => Node.create({ + name, + group: 'block', + content: content || 'block+', + ...extra, + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ tag, getAttrs: collectDomAttrs }] + }, + renderHTML ({ node }) { + return [tag, node.attrs.htmlAttrs, 0] + } + }) + + const attrsMark = (name, tag) => Mark.create({ + name, + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ tag, getAttrs: collectDomAttrs }] + }, + renderHTML ({ mark }) { + return [tag, mark.attrs.htmlAttrs, 0] + } + }) + + const SpanMark = Mark.create({ + name: 'span', + excludes: '', + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ tag: 'span', getAttrs: collectSpanDomAttrs }] + }, + renderHTML ({ mark }) { + return ['span', mark.attrs.htmlAttrs, 0] + } + }) + + const toggleMark = (name, renderTag, parseRules, shortcuts) => Mark.create({ + name, + parseHTML () { + return parseRules + }, + renderHTML () { + return [renderTag, 0] + }, + addCommands () { + const commandName = `toggle${name[0].toUpperCase()}${name.slice(1)}` + + return { + [commandName]: () => ({ commands }) => commands.toggleMark(name) + } + }, + addKeyboardShortcuts () { + return { + [shortcuts]: () => this.editor.commands.toggleMark(name) + } + } + }) + + const Heading = Node.create({ + name: 'heading', + group: 'block', + content: 'inline*', + addAttributes () { + return { + htmlAttrs: { default: {} }, + level: { default: 1 } + } + }, + parseHTML () { + return [1, 2, 3, 4, 5, 6].map((level) => ({ + tag: `h${level}`, + getAttrs: (dom) => ({ ...collectDomAttrs(dom), level }) + })) + }, + renderHTML ({ node }) { + return [`h${node.attrs.level}`, node.attrs.htmlAttrs, 0] + } + }) + + const ImageNode = Node.create({ + name: 'image', + inline: true, + group: 'inline', + draggable: true, + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ tag: 'img', getAttrs: collectDomAttrs }] + }, + renderHTML ({ node }) { + return ['img', node.attrs.htmlAttrs] + } + }) + + const HrNode = Node.create({ + name: 'horizontalRule', + group: 'block', + atom: true, + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ tag: 'hr', getAttrs: collectDomAttrs }] + }, + renderHTML ({ node }) { + return ['hr', node.attrs.htmlAttrs] + } + }) + + const StyleNode = Node.create({ + name: 'style', + group: 'block', + atom: true, + selectable: false, + addAttributes () { + return { + htmlAttrs: { default: {} }, + css: { default: '' } + } + }, + parseHTML () { + return [{ tag: 'style', getAttrs: (dom) => ({ ...collectDomAttrs(dom), css: dom.textContent }) }] + }, + renderHTML ({ node }) { + return ['style', node.attrs.htmlAttrs, node.attrs.css] + } + }) + + const EmptySpanNode = Node.create({ + name: 'emptySpan', + inline: true, + group: 'inline', + atom: true, + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ + tag: 'span', + priority: 60, + getAttrs (dom) { + if (dom.childNodes.length === 0 && dom.attributes.length > 0) { + return collectDomAttrs(dom) + } + + return false + } + }] + }, + renderHTML ({ node }) { + return ['span', node.attrs.htmlAttrs] + } + }) + + const LinkMark = Mark.create({ + name: 'link', + inclusive: true, + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ tag: 'a', getAttrs: collectDomAttrs }] + }, + renderHTML ({ mark }) { + return ['a', mark.attrs.htmlAttrs, 0] + }, + addCommands () { + return { + setLink: ({ href }) => ({ editor, commands }) => { + const htmlAttrs = { ...(editor.getAttributes('link').htmlAttrs || {}), href } + + return commands.setMark('link', { htmlAttrs }) + }, + unsetLink: () => ({ commands }) => commands.unsetMark('link', { extendEmptyMarkRange: true }) + } + } + }) + + const buildDecorations = (doc) => { + const decorations = [] + const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g + + doc.descendants((node, pos) => { + if (!node.isText) return + + let match + + while ((match = regex.exec(node.text)) !== null) { + decorations.push( + Decoration.inline(pos + match.index, pos + match.index + match[0].length, { + class: 'variable-highlight' + }) + ) + } + }) + + return DecorationSet.create(doc, decorations) + } + + const VariableHighlight = Extension.create({ + name: 'variableHighlight', + addProseMirrorPlugins () { + return [new Plugin({ + state: { + init (_, { doc }) { + return buildDecorations(doc) + }, + apply (tr, oldSet) { + return tr.docChanged ? buildDecorations(tr.doc) : oldSet + } + }, + props: { + decorations (state) { + return this.getState(state) + } + } + })] + } + }) + + return [ + blockNode('paragraph', 'p', 'inline*'), + Heading, + blockNode('section', 'section'), + blockNode('article', 'article', null, { isolating: true }), + blockNode('header', 'header', null, { isolating: true }), + blockNode('footer', 'footer', null, { isolating: true }), + blockNode('div', 'div'), + blockNode('center', 'center'), + blockNode('blockquote', 'blockquote'), + blockNode('pre', 'pre'), + blockNode('orderedList', 'ol', '(listItem | block)+'), + blockNode('bulletList', 'ul', '(listItem | block)+'), + blockNode('listItem', 'li', 'block+', { group: null }), + blockNode('table', 'table', '(colgroup | tableHead | tableBody | tableFoot | tableRow)+'), + blockNode('tableHead', 'thead', 'tableRow+', { group: null }), + blockNode('tableBody', 'tbody', 'tableRow+', { group: null }), + blockNode('tableFoot', 'tfoot', 'tableRow+', { group: null }), + blockNode('tableRow', 'tr', '(tableCell | tableHeader)+', { group: null }), + blockNode('tableCell', 'td', 'block*', { group: null }), + blockNode('tableHeader', 'th', 'block*', { group: null }), + blockNode('colgroup', 'colgroup', 'col*', { group: null }), + Node.create({ + name: 'col', + atom: true, + addAttributes () { + return { htmlAttrs: { default: {} } } + }, + parseHTML () { + return [{ tag: 'col', getAttrs: collectDomAttrs }] + }, + renderHTML ({ node }) { + return ['col', node.attrs.htmlAttrs] + } + }), + ImageNode, + HrNode, + StyleNode, + EmptySpanNode, + SpanMark, + LinkMark, + toggleMark('bold', 'strong', [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }], 'Mod-b'), + toggleMark('italic', 'em', [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }], 'Mod-i'), + toggleMark('underline', 'u', [{ tag: 'u' }, { style: 'text-decoration=underline' }], 'Mod-u'), + toggleMark('strike', 's', [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }], 'Mod-Shift-s'), + attrsMark('subscript', 'sub'), + attrsMark('superscript', 'sup'), + VariableHighlight + ] +} + +export default actionable(targetable(class extends HTMLElement { + static [target.static] = [ + 'textarea', + 'editorElement', + 'boldButton', + 'italicButton', + 'underlineButton', + 'linkButton', + 'linkTooltipTemplate' + ] + + async connectedCallback () { + if (!this.textarea || !this.editorElement) return + + this.textarea.style.display = 'none' + this.adjustShortcutsForPlatform() + + const tiptap = await loadTiptap() + + const { Editor, Extension, Document, Text, HardBreak, UndoRedo, Gapcursor, Dropcursor } = tiptap + + this.emailDocument = new DOMParser().parseFromString(this.textarea.value, 'text/html') + + const shadow = this.editorElement.attachShadow({ mode: 'open' }) + + shadow.adoptedStyleSheets = [editorStylesheet] + + this.emailDocument.head.querySelectorAll('style').forEach((style) => { + shadow.appendChild(style.cloneNode(true)) + }) + + const container = document.createElement('div') + const bodyStyle = this.emailDocument.body.getAttribute('style') + + if (bodyStyle) container.setAttribute('style', bodyStyle) + + shadow.appendChild(container) + + const LinkShortcut = Extension.create({ + name: 'linkShortcut', + addKeyboardShortcuts: () => ({ + 'Mod-k': () => { + this.toggleLink() + + return true + } + }) + }) + + this.editor = new Editor({ + element: container, + extensions: [ + Document, + Text, + HardBreak, + UndoRedo, + Gapcursor, + Dropcursor, + ...buildExtensions(tiptap), + LinkShortcut + ], + content: this.emailDocument.body.innerHTML, + injectCSS: false, + editorProps: { + attributes: { + dir: 'auto' + }, + handleDOMEvents: { + click: (_, event) => { + if (event.target.closest('a')) event.preventDefault() + + return false + } + } + }, + onUpdate: ({ editor }) => { + this.emailDocument.body.innerHTML = editor.getHTML() + + this.textarea.value = this.emailDocument.documentElement.outerHTML + this.textarea.dispatchEvent(new Event('input', { bubbles: true })) + }, + onSelectionUpdate: ({ editor }) => { + this.updateToolbarState() + this.handleLinkTooltip(editor) + }, + onBlur: () => { + setTimeout(() => { + if (!this.linkTooltip.tooltip.contains(document.activeElement)) { + this.linkTooltip.hide() + } + }, 0) + } + }) + + this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate) + } + + adjustShortcutsForPlatform () { + if ((navigator.userAgentData?.platform)?.toLowerCase()?.includes('mac')) { + this.querySelectorAll('.tooltip[data-tip]').forEach(tooltip => { + const tip = tooltip.getAttribute('data-tip') + + if (tip && tip.includes('Ctrl')) { + tooltip.setAttribute('data-tip', tip.replace(/Ctrl/g, '⌘')) + } + }) + } + } + + bold (e) { + e.preventDefault() + + this.editor.chain().focus().toggleBold().run() + this.updateToolbarState() + } + + italic (e) { + e.preventDefault() + + this.editor.chain().focus().toggleItalic().run() + this.updateToolbarState() + } + + underline (e) { + e.preventDefault() + + this.editor.chain().focus().toggleUnderline().run() + this.updateToolbarState() + } + + linkSelection (e) { + e.preventDefault() + + this.toggleLink() + this.updateToolbarState() + } + + undo (e) { + e.preventDefault() + + this.editor.chain().focus().undo().run() + this.updateToolbarState() + } + + redo (e) { + e.preventDefault() + + this.editor.chain().focus().redo().run() + this.updateToolbarState() + } + + updateToolbarState () { + this.boldButton.classList.toggle('bg-base-200', this.editor.isActive('bold')) + this.italicButton.classList.toggle('bg-base-200', this.editor.isActive('italic')) + this.underlineButton.classList.toggle('bg-base-200', this.editor.isActive('underline')) + this.linkButton.classList.toggle('bg-base-200', this.editor.isActive('link')) + } + + handleLinkTooltip (editor) { + const { from } = editor.state.selection + const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link') + + if (!mark) { + if (this.linkTooltip.isVisible()) this.linkTooltip.hide() + + return + } + + if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return + + let linkStart = from + const start = editor.state.doc.resolve(from).start() + + for (let i = from - 1; i >= start; i--) { + if (editor.state.doc.resolve(i).marks().some(m => m.eq(mark))) { + linkStart = i + } else { + break + } + } + + this.linkTooltip.hide() + this.linkTooltip.show(mark.attrs.htmlAttrs?.href, linkStart > start ? linkStart - 1 : linkStart) + this.linkTooltip.currentMark = mark + } + + toggleLink () { + if (this.editor.isActive('link')) { + this.linkTooltip.hide() + this.editor.chain().focus().extendMarkRange('link').unsetLink().run() + this.updateToolbarState() + } else { + const { from } = this.editor.state.selection + + this.linkTooltip.hide() + this.linkTooltip.show(this.editor.getAttributes('link').htmlAttrs?.href, from, { focus: true }) + } + } + + insertVariable (e) { + const variable = e.target.closest('[data-variable]')?.dataset.variable + + if (variable) { + const { from, to } = this.editor.state.selection + + if (variable.includes('link') && from !== to) { + this.editor.chain().focus().setLink({ href: `{${variable}}` }).run() + } else { + this.editor.chain().focus().insertContent(`{${variable}}`).run() + } + } + } + + disconnectedCallback () { + this.linkTooltip?.hide() + + if (this.editor) { + this.editor.destroy() + } + } +})) diff --git a/app/javascript/elements/markdown_editor.js b/app/javascript/elements/markdown_editor.js index b9937598..bd2508a5 100644 --- a/app/javascript/elements/markdown_editor.js +++ b/app/javascript/elements/markdown_editor.js @@ -35,7 +35,7 @@ function loadTiptap () { })) } -class LinkTooltip { +export class LinkTooltip { constructor (container, editor, templateEl) { this.container = container this.editor = editor diff --git a/app/javascript/submission_form/form.vue b/app/javascript/submission_form/form.vue index 95726537..20a08583 100644 --- a/app/javascript/submission_form/form.vue +++ b/app/javascript/submission_form/form.vue @@ -147,7 +147,7 @@ @submit.prevent="submitStep" />
!e.name && !e.required) && this.currentStepFields.length > 4 + if (this.currentField) { + return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4 + } else { + return false + } }, isButtonDisabled () { if (this.recalculateButtonDisabledKey) { diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 098fcfb5..e12cc78d 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -3206,15 +3206,6 @@ export default { e.preventDefault() alert(this.t('please_draw_fields_to_prepare_the_document')) - } else { - const submitterWithoutFields = - this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid)) - - if (submitterWithoutFields) { - e.preventDefault() - - alert(this.t('please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed').replaceAll('{submitter_name}', submitterWithoutFields.name)) - } } }, onSaveClick () { @@ -3231,32 +3222,25 @@ export default { if (!this.template.fields.length) { alert(this.t('please_draw_fields_to_prepare_the_document')) } else { - const submitterWithoutFields = - this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid)) - - if (submitterWithoutFields) { - alert(this.t('please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed').replaceAll('{submitter_name}', submitterWithoutFields.name)) - } else { - this.isSaving = true + this.isSaving = true - const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic) + const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic) - dynamicDocumentRefs.map((ref) => ref.update()) + dynamicDocumentRefs.map((ref) => ref.update()) - this.rebuildVariablesSchema({ disable: false }) + this.rebuildVariablesSchema({ disable: false }) - const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody()) + const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody()) - Promise.all([this.save({ force: true }), ...dynamicDocumentSaves]).then(() => { - if (this.withRevisions) { - this.captureRevision() - } + Promise.all([this.save({ force: true }), ...dynamicDocumentSaves]).then(() => { + if (this.withRevisions) { + this.captureRevision() + } - window.Turbo.visit(`/templates/${this.template.id}`) - }).finally(() => { - this.isSaving = false - }) - } + window.Turbo.visit(`/templates/${this.template.id}`) + }).finally(() => { + this.isSaving = false + }) } }, scrollToArea (area) { diff --git a/app/javascript/template_builder/field_settings.vue b/app/javascript/template_builder/field_settings.vue index c22ee574..a5930565 100644 --- a/app/javascript/template_builder/field_settings.vue +++ b/app/javascript/template_builder/field_settings.vue @@ -830,6 +830,7 @@ export default { } else if (format === 'percent') { return `${number}%` } else if (format === 'percent_space') { + // eslint-disable-next-line no-irregular-whitespace return `${String(number).replace('.', ',')} %` } else { return number diff --git a/app/jobs/process_submission_expired_job.rb b/app/jobs/process_submission_expired_job.rb index b3838c00..dde2853a 100644 --- a/app/jobs/process_submission_expired_job.rb +++ b/app/jobs/process_submission_expired_job.rb @@ -12,6 +12,7 @@ class ProcessSubmissionExpiredJob return if submission.template&.archived_at? return if submission.submitters.where.not(declined_at: nil).exists? return if submission.completed_at? + return if params['expire_at'] && submission.expire_at&.to_i != params['expire_at'] WebhookUrls.enqueue_events(submission, 'submission.expired') end diff --git a/app/jobs/process_submitter_completion_job.rb b/app/jobs/process_submitter_completion_job.rb index 932f1310..129b2363 100644 --- a/app/jobs/process_submitter_completion_job.rb +++ b/app/jobs/process_submitter_completion_job.rb @@ -13,7 +13,12 @@ class ProcessSubmitterCompletionJob if params.key?('is_last') params['is_last'] else - !submission.submitters.exists?(completed_at: nil) && + viewer_uuids = submission.template_submitters.to_a.filter_map { |s| s['uuid'] if s['is_viewer'] } + + incomplete = submission.submitters.where(completed_at: nil) + incomplete = incomplete.where.not(uuid: viewer_uuids) if viewer_uuids.present? + + !incomplete.exists? && submitter.completed_at == submission.submitters.maximum(:completed_at) end @@ -33,7 +38,9 @@ class ProcessSubmitterCompletionJob if !submission.completed_at && submission.submitters_order_preserved? && params['send_invitation_email'] != false && Submission.exists?(id: submission.id, completed_at: nil) - enqueue_next_submitter_request_notification(submitter) + next_submitters = enqueue_next_submitter_request_notification(submitter) + + enqueue_next_submitter_viewer_notification(submission, next_submitters) unless is_last end enqueue_completed_webhooks(submitter, is_last:) @@ -145,7 +152,7 @@ class ProcessSubmitterCompletionJob return if configs.value['enabled'] == false to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false } - .sort_by(&:completed_at).select(&:email?).map(&:friendly_name) + .sort_by { |e| e.completed_at || Time.current }.select(&:email?).map(&:friendly_name) return if to.blank? @@ -165,9 +172,9 @@ class ProcessSubmitterCompletionJob bcc.to_s.scan(User::EMAIL_REGEXP) end - def enqueue_next_submitter_request_notification(submitter) + def enqueue_next_submitter_request_notification(submitter) # rubocop:disable Metrics/PerceivedComplexity submission = submitter.submission - submitters_index = submission.submitters.index_by(&:uuid) + submitters_index = submission.submitters.reject(&:viewer?).index_by(&:uuid) next_submitter_items = if submission.template_submitters.any? { |s| s['order'] } @@ -196,5 +203,43 @@ class ProcessSubmitterCompletionJob next_submitters = submitters_index.values_at(*Array.wrap(next_submitter_items).pluck('uuid')).compact Submitters.send_signature_requests(next_submitters) + + next_submitters + end + + def enqueue_next_submitter_viewer_notification(submission, next_submitters) + viewers = submission.submitters.select(&:viewer?) + + return [] if viewers.blank? + + next_submitter_uuids = next_submitters.to_set(&:uuid) + viewers_index = viewers.index_by(&:uuid) + + next_viewers = + if submission.template_submitters.any? { |s| s['order'] } + next_orders = submission.template_submitters + .select { |s| next_submitter_uuids.include?(s['uuid']) } + .pluck('order') + + submission.template_submitters.filter_map do |s| + viewers_index[s['uuid']] if next_orders.include?(s['order']) + end + else + preceding_submitter_uuid = nil + + submission.template_submitters.filter_map do |template_submitter| + viewer = viewers_index[template_submitter['uuid']] + + if viewer + viewer if next_submitter_uuids.include?(preceding_submitter_uuid) + else + preceding_submitter_uuid = template_submitter['uuid'] + + nil + end + end + end + + Submitters.send_signature_requests(next_viewers) end end diff --git a/app/jobs/send_submitter_invitation_email_job.rb b/app/jobs/send_submitter_invitation_email_job.rb index d236010b..4f4686f4 100644 --- a/app/jobs/send_submitter_invitation_email_job.rb +++ b/app/jobs/send_submitter_invitation_email_job.rb @@ -19,7 +19,12 @@ class SendSubmitterInvitationEmailJob return end - mail = SubmitterMailer.invitation_email(submitter) + mail = + if submitter.viewer? + SubmitterMailer.invitation_view_email(submitter) + else + SubmitterMailer.invitation_email(submitter) + end Submitters::ValidateSending.call(submitter, mail) diff --git a/app/mailers/submitter_mailer.rb b/app/mailers/submitter_mailer.rb index ebb8f224..3a8c191b 100644 --- a/app/mailers/submitter_mailer.rb +++ b/app/mailers/submitter_mailer.rb @@ -45,6 +45,45 @@ class SubmitterMailer < ApplicationMailer end end + def invitation_view_email(submitter) + @current_account = submitter.submission.account + @submitter = submitter + + if submitter.preferences['email_message_uuid'] + @email_message = submitter.account.email_messages.find_by(uuid: submitter.preferences['email_message_uuid']) + end + + template_submitters_index = @email_message.blank? ? build_submitter_preferences_index(@submitter) : {} + + @body = @email_message&.normalized_body.presence || + @submitter.template&.preferences&.dig('invitation_view_email_body').presence || + template_submitters_index.dig(@submitter.uuid, 'request_email_body').presence + + @subject = @email_message&.subject.presence || + @submitter.template&.preferences&.dig('invitation_view_email_subject').presence || + template_submitters_index.dig(@submitter.uuid, 'request_email_subject').presence + + @email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY) + @body ||= fetch_config_email_body(@email_config, @submitter) + + assign_message_metadata('submitter_view_invitation', @submitter) + + reply_to = build_submitter_reply_to(@submitter, email_config: @email_config) + + maybe_set_custom_domain(@submitter) + + I18n.with_locale(@current_account.locale) do + subject = build_invite_subject(@subject, @email_config, submitter) + + mail( + to: @submitter.friendly_name, + from: from_address_for_submitter(submitter), + subject:, + reply_to: + ) + end + end + def completed_email(submitter, user, to: nil) @current_account = submitter.submission.account @submitter = submitter @@ -53,8 +92,6 @@ class SubmitterMailer < ApplicationMailer template_preferences = @submission.template&.preferences || {} - Submissions::EnsureResultGenerated.call(submitter) - @email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY) add_completed_email_attachments!( @@ -109,8 +146,6 @@ class SubmitterMailer < ApplicationMailer template_preferences = @submitter.template&.preferences || {} - Submissions::EnsureResultGenerated.call(@submitter) - @email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY) add_completed_email_attachments!( @@ -172,7 +207,7 @@ class SubmitterMailer < ApplicationMailer end def add_completed_email_attachments!(submitter, with_audit_log: true, with_documents: true) - documents = with_documents ? Submitters.select_attachments_for_download(submitter) : [] + documents = with_documents ? select_completed_documents(submitter) : [] filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id, key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value @@ -216,6 +251,8 @@ class SubmitterMailer < ApplicationMailer def build_invite_subject(subject, email_config, submitter) if email_config || subject ReplaceEmailVariables.call(subject || email_config.value['subject'], submitter:) + elsif submitter.viewer? + I18n.t(:you_are_invited_to_view_a_document) elsif submitter.with_signature_fields? I18n.t(:you_are_invited_to_sign_a_document) else @@ -227,6 +264,14 @@ class SubmitterMailer < ApplicationMailer submitter.template&.preferences&.dig('submitters').to_a.index_by { |e| e['uuid'] } end + def select_completed_documents(submitter) + last_submitter = Submitter.where(submission_id: submitter.submission_id).completed.order(:completed_at).last + + Submissions::EnsureResultGenerated.call(last_submitter) + + Submitters.select_attachments_for_download(last_submitter) + end + def add_attachments_with_size_limit(submitter, storage_attachments, current_size, filename_format = nil) total_size = current_size diff --git a/app/models/account_config.rb b/app/models/account_config.rb index 2db37d70..857f0081 100644 --- a/app/models/account_config.rb +++ b/app/models/account_config.rb @@ -22,6 +22,7 @@ # class AccountConfig < ApplicationRecord SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email' + SUBMITTER_VIEW_INVITATION_EMAIL_KEY = 'submitter_view_invitation_email' SUBMITTER_INVITATION_REMINDER_EMAIL_KEY = 'submitter_invitation_reminder_email' SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email' SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email' @@ -63,6 +64,7 @@ class AccountConfig < ApplicationRecord EMAIL_VARIABLES = { SUBMITTER_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name].freeze, + SUBMITTER_VIEW_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name].freeze, SUBMITTER_COMPLETED_EMAIL_KEY => %w[template.name submission.submitters submission.link].freeze, SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[template.name submitter.link account.name].freeze, SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[template.name documents.link account.name].freeze @@ -75,6 +77,12 @@ class AccountConfig < ApplicationRecord 'body' => I18n.t(:submitter_invitation_email_sign_body) } }, + SUBMITTER_VIEW_INVITATION_EMAIL_KEY => lambda { + { + 'subject' => I18n.t(:you_are_invited_to_view_a_document), + 'body' => I18n.t(:submitter_invitation_email_view_body) + } + }, SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => lambda { { 'subject' => I18n.t(:you_are_invited_to_sign_a_document), diff --git a/app/models/submission.rb b/app/models/submission.rb index 8a7b75b7..d65018c0 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -31,6 +31,7 @@ # index_submissions_on_account_id_and_id_pending (account_id,id) WHERE ((completed_at IS NULL) AND (archived_at IS NULL)) # index_submissions_on_account_id_and_template_id_and_id (account_id,template_id,id) WHERE (archived_at IS NULL) # index_submissions_on_account_id_and_template_id_and_id_archived (account_id,template_id,id) WHERE (archived_at IS NOT NULL) +# index_submissions_on_created_at (created_at) # index_submissions_on_created_by_user_id (created_by_user_id) # index_submissions_on_slug (slug) UNIQUE # index_submissions_on_template_id (template_id) @@ -91,9 +92,8 @@ class Submission < ApplicationRecord scope :active, -> { where(archived_at: nil) } scope :archived, -> { where.not(archived_at: nil) } - scope :pending, lambda { - where(expire_at: nil).or(where(expire_at: Time.current..)).where(completed_at: nil) - } + scope :non_expired, -> { where(expire_at: nil).or(where(expire_at: Time.current..)) } + scope :pending, -> { non_expired.where(completed_at: nil) } scope :completed, -> { where.not(completed_at: nil) } scope :declined, lambda { where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])) diff --git a/app/models/submitter.rb b/app/models/submitter.rb index 2c515ae2..3d21e2d4 100644 --- a/app/models/submitter.rb +++ b/app/models/submitter.rb @@ -116,6 +116,12 @@ class Submitter < ApplicationRecord end end + def viewer? + return false if submission.template_submitters.blank? + + submission.template_submitters.any? { |s| s['uuid'] == uuid && s['is_viewer'] } + end + private def anonymize_email_events diff --git a/app/views/email_smtp_settings/index.html.erb b/app/views/email_smtp_settings/index.html.erb index 821d8335..378551ed 100644 --- a/app/views/email_smtp_settings/index.html.erb +++ b/app/views/email_smtp_settings/index.html.erb @@ -57,6 +57,11 @@ <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
<% end %> + <% if Docuseal.multitenant? && @encrypted_config.persisted? %> +
+ <%= button_to t('reset_default'), settings_email_path(@encrypted_config), method: :delete, class: 'link', data: { turbo_confirm: t('are_you_sure_') } %> +
+ <% end %>
diff --git a/app/views/icons/_file_check.html.erb b/app/views/icons/_file_check.html.erb new file mode 100644 index 00000000..fe2c9db4 --- /dev/null +++ b/app/views/icons/_file_check.html.erb @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/views/icons/_file_unknown.html.erb b/app/views/icons/_file_unknown.html.erb new file mode 100644 index 00000000..eab4952e --- /dev/null +++ b/app/views/icons/_file_unknown.html.erb @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/views/icons/_writing_off.html.erb b/app/views/icons/_writing_off.html.erb new file mode 100644 index 00000000..d6ba9c06 --- /dev/null +++ b/app/views/icons/_writing_off.html.erb @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/views/personalization_settings/_editor_toolbar.html.erb b/app/views/personalization_settings/_editor_toolbar.html.erb new file mode 100644 index 00000000..d6c58698 --- /dev/null +++ b/app/views/personalization_settings/_editor_toolbar.html.erb @@ -0,0 +1,53 @@ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+ <% if local_assigns[:variables]&.any? %> + <% variable_labels = { 'account.name' => t('variables.account_name'), 'submitter.link' => t('variables.submitter_link'), 'template.name' => t('variables.template_name'), 'submission.submitters' => t('variables.submission_submitters'), 'submission.link' => t('variables.submission_link'), 'documents.link' => t('variables.documents_link') } %> + + <% end %> +
diff --git a/app/views/personalization_settings/_email_body_editor.html.erb b/app/views/personalization_settings/_email_body_editor.html.erb new file mode 100644 index 00000000..f98ad9fb --- /dev/null +++ b/app/views/personalization_settings/_email_body_editor.html.erb @@ -0,0 +1 @@ +<%= render 'personalization_settings/markdown_editor', name:, value:, variables: local_assigns[:variables] %> diff --git a/app/views/personalization_settings/_markdown_editor.html.erb b/app/views/personalization_settings/_markdown_editor.html.erb index 5e43e5cb..dd04a95f 100644 --- a/app/views/personalization_settings/_markdown_editor.html.erb +++ b/app/views/personalization_settings/_markdown_editor.html.erb @@ -1,76 +1,18 @@ -<% if value.to_s.start_with?(' - - <%= text_area_tag name, value, required: true, class: 'base-input w-full py-2 !rounded-2xl', dir: 'auto', style: 'max-height: 400px' %> - -<% else %> - - -
-
-
-
- -
-
- -
-
- -
-
- -
-
-
-
-
- -
-
- -
-
- <% if local_assigns[:variables]&.any? %> - <% variable_labels = { 'account.name' => t('variables.account_name'), 'submitter.link' => t('variables.submitter_link'), 'template.name' => t('variables.template_name'), 'submission.submitters' => t('variables.submission_submitters'), 'submission.link' => t('variables.submission_link'), 'documents.link' => t('variables.documents_link') } %> - - <% end %> -
-
+ + +
+ <%= render 'personalization_settings/editor_toolbar', editor_tag: 'markdown-editor', variables: local_assigns[:variables] %> +
+
+ <%= hidden_field_tag name, value, required: true, data: { target: 'markdown-editor.textarea' } %> +
diff --git a/app/views/shared/_settings_nav.html.erb b/app/views/shared/_settings_nav.html.erb index 147b1c31..f5fdce90 100644 --- a/app/views/shared/_settings_nav.html.erb +++ b/app/views/shared/_settings_nav.html.erb @@ -12,12 +12,12 @@
  • <%= link_to t('account'), settings_account_path, class: 'text-base hover:bg-base-300' %>
  • + <% if (!Docuseal.multitenant? || EncryptedConfig.exists?(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && ENV['SMTP_ADDRESS'].blank? && true_user == current_user %> +
  • + <%= link_to t('email'), settings_email_index_path, class: 'text-base hover:bg-base-300' %> +
  • + <% end %> <% unless Docuseal.multitenant? %> - <% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && ENV['SMTP_ADDRESS'].blank? && true_user == current_user %> -
  • - <%= link_to t('email'), settings_email_index_path, class: 'text-base hover:bg-base-300' %> -
  • - <% end %> <% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::FILES_STORAGE_KEY, account: current_account)) && true_user == current_user && ENV['S3_ATTACHMENTS_BUCKET'].blank? && ENV['GCS_BUCKET'].blank? && ENV['AZURE_CONTAINER'].blank? %>
  • <%= link_to t('storage'), settings_storage_index_path, class: 'text-base hover:bg-base-300' %> diff --git a/app/views/submissions/_detailed_form.html.erb b/app/views/submissions/_detailed_form.html.erb index aa0ce8ee..86e2f29d 100644 --- a/app/views/submissions/_detailed_form.html.erb +++ b/app/views/submissions/_detailed_form.html.erb @@ -92,7 +92,7 @@ <%= local_assigns[:variables_form] %>
    <%= render('submitters_order', f:, template:) if can_send_emails %> - <%= render 'send_email', f:, template:, can_send_emails: %> + <%= render 'send_email', f:, template:, can_send_emails:, viewer_submitter_uuids: local_assigns[:viewer_submitter_uuids] %> <% if has_phone_field %> <%= render 'send_sms', f: %> <% end %> diff --git a/app/views/submissions/_email_form.html.erb b/app/views/submissions/_email_form.html.erb index e5f32a6c..e552cf9d 100644 --- a/app/views/submissions/_email_form.html.erb +++ b/app/views/submissions/_email_form.html.erb @@ -70,7 +70,7 @@ <%= local_assigns[:variables_form] %>
    <%= render('submitters_order', f:, template:) if can_send_emails %> - <%= render 'send_email', f:, template:, can_send_emails: %> + <%= render 'send_email', f:, template:, can_send_emails:, viewer_submitter_uuids: local_assigns[:viewer_submitter_uuids] %> <%= render 'extra_fields', f: %>
    diff --git a/app/views/submissions/_send_email_base.html.erb b/app/views/submissions/_send_email_base.html.erb index 6b035cf7..36650851 100644 --- a/app/views/submissions/_send_email_base.html.erb +++ b/app/views/submissions/_send_email_base.html.erb @@ -38,9 +38,21 @@ <% end %>
    <% config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) %> +<% view_config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY) %> +<% config_body = (config.value['body_type'] == 'html' && config.value['html_body'].presence) || config.value['body'] %> +<% view_template_subject = template&.preferences&.dig('invitation_view_email_subject').presence %> +<% view_template_body = template&.preferences&.dig('invitation_view_email_body').presence %> +<% default_subject = template&.preferences&.dig('request_email_subject').presence || config.value['subject'] %> +<% default_body = template&.preferences&.dig('request_email_body').presence || config_body %> +<% is_edit_viewer = local_assigns[:submitter] && local_assigns[:viewer_submitter_uuids].include?(local_assigns[:submitter].uuid) %>