mirror of https://github.com/docusealco/docuseal
feat: reminder email templates, job dedup fix, and reminder visibility (#3)
* feat: add customizable reminder email templates - Add SubmitterMailer#reminder_email with separate subject/body resolution - Support per-template and per-account reminder email customization - Add GUI forms for reminder email templates (account + template level) - Add i18n keys for reminder emails and reminder visibility UI - Update SendSubmitterReminderEmailJob to use reminder_email instead of invitation_email - Add race condition guard (1-minute dedup) in send job * fix: prevent reminder job duplication on container restart - Add deduplication in scheduled_jobs.rb initializer (clear existing before scheduling) - Add reschedule! method in ProcessSubmitterRemindersJob that clears stale copies - Prevents exponential job accumulation across container restarts * feat: add reminder visibility and queue management - Add SubmitterReminders module (lib/) for next-reminder-at calculation - Show next reminder time per submitter on submission page (with timezone tooltip) - Add pending reminders queue table on notifications settings page - Add Skip button to advance past current pending reminder (Turbo Stream) - Add skip_reminder_email event type to SubmissionEvent - Update ProcessSubmitterRemindersJob to count skip events in reminder_count - Add SubmitterRemindersController with destroy action for skip * docs: document reminder email templates feature in README --------- Co-authored-by: Sebastian Noe <sebastian.schneider@boxine.de>pull/681/head
parent
f70c6ebabf
commit
75c2ce6c75
@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitterRemindersController < ApplicationController
|
||||
before_action :load_submitter
|
||||
authorize_resource :submitter
|
||||
|
||||
def destroy
|
||||
SubmissionEvent.create!(
|
||||
submitter: @submitter,
|
||||
event_type: 'skip_reminder_email'
|
||||
)
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.remove("reminder_row_#{@submitter.id}")
|
||||
end
|
||||
format.html do
|
||||
redirect_back fallback_location: settings_notifications_path,
|
||||
notice: I18n.t('reminder_skipped')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_submitter
|
||||
@submitter = current_account.submitters.find(params[:id])
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,56 @@
|
||||
<% if @pending_reminders.present? %>
|
||||
<div class="mt-8">
|
||||
<h3 class="text-2xl font-bold mb-4">
|
||||
<%= t('pending_reminders') %>
|
||||
</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t('submitter') %></th>
|
||||
<th><%= t('template') %></th>
|
||||
<th><%= t('last_sent') %></th>
|
||||
<th><%= t('next_due') %></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @pending_reminders.each do |entry| %>
|
||||
<%= turbo_frame_tag "reminder_row_#{entry[:submitter].id}" do %>
|
||||
<tr>
|
||||
<td><%= entry[:submitter].name.presence || entry[:submitter].email %></td>
|
||||
<td><%= entry[:submitter].template&.name %></td>
|
||||
<td>
|
||||
<% if entry[:last_sent_at] %>
|
||||
<span class="tooltip tooltip-bottom" data-tip="<%= l(entry[:last_sent_at].in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %> <%= current_account.timezone %>">
|
||||
<%= time_ago_in_words(entry[:last_sent_at]) %> <%= t('ago') %>
|
||||
</span>
|
||||
<% else %>
|
||||
—
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<% if entry[:next_at] > Time.current %>
|
||||
<span class="tooltip tooltip-bottom" data-tip="<%= l(entry[:next_at].in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %> <%= current_account.timezone %>">
|
||||
<%= t('in_time', time: distance_of_time_in_words(Time.current, entry[:next_at])) %>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="tooltip tooltip-bottom" data-tip="<%= l(entry[:next_at].in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %> <%= current_account.timezone %>">
|
||||
<%= t('overdue') %>
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<%= button_to t('skip'), settings_submitter_reminder_path(entry[:submitter]),
|
||||
method: :delete,
|
||||
class: 'btn btn-xs btn-outline',
|
||||
data: { turbo_frame: "reminder_row_#{entry[:submitter].id}" } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@ -0,0 +1,29 @@
|
||||
<div class="collapse collapse-plus bg-base-200 overflow-visible">
|
||||
<input type="checkbox">
|
||||
<div class="collapse-title text-xl font-medium capitalize">
|
||||
<div>
|
||||
<%= t('signature_request_reminder_email') %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<%= f.fields_for :value, Struct.new(:subject, :body, :reply_to).new(*f.object.value.values_at('subject', 'body', 'reply_to')) do |ff| %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :subject, t('subject'), class: 'label' %>
|
||||
<%= ff.text_field :subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<%= render 'personalization_settings/email_body_field', ff:, config: f.object %>
|
||||
<% if can?(:manage, :reply_to) || can?(:manage, :personalization_advanced) %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :reply_to, t('reply_to'), class: 'label' %>
|
||||
<%= ff.email_field :reply_to, class: 'base-input', dir: 'auto', placeholder: t(:email) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,9 @@
|
||||
<div class="collapse collapse-arrow join-item border border-base-300">
|
||||
<input type="checkbox" name="accordion">
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
<%= t('signature_request_reminder_email') %>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<%= render 'templates_preferences/submitter_invitation_reminder_email_form' %>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,34 @@
|
||||
<div id="<%= AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY %>_form">
|
||||
<% configs = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY).value %>
|
||||
<% template_email_preferences_values = @template.preferences.values_at('invitation_reminder_email_subject', 'invitation_reminder_email_body').compact_blank.presence %>
|
||||
<% is_custom_template_email = template_email_preferences_values.present? %>
|
||||
<% if is_custom_template_email %>
|
||||
<%= button_to nil, template_preferences_path(@template), id: 'submitter_invitation_reminder_email_reset_link', method: :delete, class: 'hidden', params: { config_key: AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY }, data: { turbo_confirm: t('are_you_sure_') }, form: { data: { close_on_submit: false } } %>
|
||||
<% end %>
|
||||
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: 'submitter_invitation_reminder_email_template_form' }, data: { close_on_submit: false } do |f| %>
|
||||
<toggle-on-submit data-element-id="email_saved_alert_reminder"></toggle-on-submit>
|
||||
<%= f.fields_for :preferences, Struct.new(:invitation_reminder_email_subject, :invitation_reminder_email_body).new(@template.preferences['invitation_reminder_email_subject'].presence || configs['subject'], @template.preferences['invitation_reminder_email_body'].presence || configs['body']) do |ff| %>
|
||||
<div class="form-control">
|
||||
<div class="flex justify-between">
|
||||
<%= ff.label :invitation_reminder_email_subject, t('email_subject'), class: 'label' %>
|
||||
<% if is_custom_template_email %>
|
||||
<label for="submitter_invitation_reminder_email_reset_link" class="label underline">
|
||||
<%= t('reset_default') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= ff.text_field :invitation_reminder_email_subject, required: true, class: 'base-input', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<%= ff.label :invitation_reminder_email_body, t('email_body'), class: 'label' %>
|
||||
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:invitation_reminder_email_body), value: ff.object.invitation_reminder_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY] %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= button_tag button_title(title: t('save'), disabled_with: t('saving')), form: 'submitter_invitation_reminder_email_template_form', class: 'base-button' %>
|
||||
<div class="flex justify-center">
|
||||
<span id="email_saved_alert_reminder" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,5 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
ActiveSupport.on_load(:sidekiq_config) do
|
||||
require 'sidekiq/api'
|
||||
|
||||
Sidekiq::ScheduledSet.new
|
||||
.select { |j| j.klass == 'ProcessSubmitterRemindersJob' }
|
||||
.each(&:delete)
|
||||
|
||||
ProcessSubmitterRemindersJob.perform_in(1.minute)
|
||||
end
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SubmitterReminders
|
||||
module_function
|
||||
|
||||
def next_reminder_at(submitter, reminder_config)
|
||||
return nil unless reminder_config&.value.is_a?(Hash)
|
||||
return nil if submitter.completed_at? || submitter.declined_at?
|
||||
return nil if submitter.submission.archived_at?
|
||||
return nil if submitter.template&.archived_at?
|
||||
return nil unless submitter.sent_at
|
||||
|
||||
durations = parse_durations(reminder_config.value)
|
||||
return nil if durations.empty?
|
||||
|
||||
reminder_events = submitter.submission_events
|
||||
.select { |e| e.event_type.in?(%w[send_reminder_email skip_reminder_email]) }
|
||||
reminder_count = reminder_events.size
|
||||
|
||||
duration = case reminder_count
|
||||
when 0 then durations[:first]
|
||||
when 1 then durations[:second]
|
||||
when 2 then durations[:third]
|
||||
end
|
||||
|
||||
return nil unless duration
|
||||
|
||||
base_time = if reminder_count == 0
|
||||
submitter.sent_at
|
||||
else
|
||||
reminder_events.max_by(&:created_at)&.created_at || submitter.sent_at
|
||||
end
|
||||
|
||||
return nil if base_time.nil?
|
||||
|
||||
base_time + duration
|
||||
end
|
||||
|
||||
def parse_durations(value)
|
||||
return {} unless value.is_a?(Hash)
|
||||
|
||||
result = {}
|
||||
result[:first] = duration_to_seconds(value['first_duration']) if value['first_duration'].present?
|
||||
result[:second] = duration_to_seconds(value['second_duration']) if value['second_duration'].present?
|
||||
result[:third] = duration_to_seconds(value['third_duration']) if value['third_duration'].present?
|
||||
result
|
||||
end
|
||||
|
||||
def duration_to_seconds(key)
|
||||
case key
|
||||
when 'one_hour' then 1.hour
|
||||
when 'two_hours' then 2.hours
|
||||
when 'four_hours' then 4.hours
|
||||
when 'eight_hours' then 8.hours
|
||||
when 'twelve_hours' then 12.hours
|
||||
when 'twenty_four_hours' then 24.hours
|
||||
when 'two_days' then 2.days
|
||||
when 'three_days' then 3.days
|
||||
when 'four_days' then 4.days
|
||||
when 'five_days' then 5.days
|
||||
when 'six_days' then 6.days
|
||||
when 'seven_days' then 7.days
|
||||
when 'eight_days' then 8.days
|
||||
when 'fifteen_days' then 15.days
|
||||
when 'twenty_one_days' then 21.days
|
||||
when 'thirty_days' then 30.days
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in new issue