From 75c2ce6c7566c8ffbb7b81150d531f01330b5854 Mon Sep 17 00:00:00 2001 From: Sebastian Noe Date: Mon, 11 May 2026 13:08:57 +0200 Subject: [PATCH] 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 --- README.md | 20 +++++- .../notifications_settings_controller.rb | 35 ++++++++++ .../submitter_reminders_controller.rb | 29 ++++++++ app/jobs/process_submitter_reminders_job.rb | 16 ++++- app/jobs/send_submitter_reminder_email_job.rb | 4 +- app/mailers/submitter_mailer.rb | 34 +++++++++ app/models/account_config.rb | 4 +- app/models/submission_event.rb | 1 + .../_reminder_queue.html.erb | 56 +++++++++++++++ .../notifications_settings/index.html.erb | 1 + .../_reminder_email_form.html.erb | 29 ++++++++ .../personalization_settings/show.html.erb | 1 + app/views/submissions/show.html.erb | 16 +++++ ...nvitation_reminder_email_collapse.html.erb | 9 +++ ...er_invitation_reminder_email_form.html.erb | 34 +++++++++ config/initializers/scheduled_jobs.rb | 6 ++ config/locales/i18n.yml | 21 ++++++ config/routes.rb | 1 + lib/submitter_reminders.rb | 69 +++++++++++++++++++ 19 files changed, 379 insertions(+), 7 deletions(-) create mode 100644 app/controllers/submitter_reminders_controller.rb create mode 100644 app/views/notifications_settings/_reminder_queue.html.erb create mode 100644 app/views/personalization_settings/_reminder_email_form.html.erb create mode 100644 app/views/templates_preferences/_submitter_invitation_reminder_email_form.html.erb create mode 100644 lib/submitter_reminders.rb diff --git a/README.md b/README.md index 27c4f7c7..d20feebb 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Support companies that are providing open-source solutions! | Feature | Status | Description | |---------|--------|-------------| | Company logo / white-label | Done | Upload your logo in Settings > Personalization. Displayed in signing forms and emails. | -| Automated reminders | Done | Configure reminder intervals per-account. Pending signers receive scheduled follow-up emails. | +| Automated reminders | Done | Scheduled follow-up emails with customizable templates, reminder queue visibility, and skip controls. | | Template creation via API | Done | `POST /api/templates/pdf` and `PUT /api/templates/:id/documents` — create and manage templates programmatically with field coordinates or embedded text tags. | | Professional email design | Done | Table-based responsive email layout with company branding, styled CTA buttons, and proper footer. | | Teams & user roles | Done | Multi-team support with admin/editor roles. Editors see only their team's documents. Admins can move folders between teams. | @@ -42,6 +42,24 @@ This fork implements team-based access control with two roles: - API tokens respect the user's role and team membership - Migrations handle both greenfield installs and existing deployments (auto-creates a "Default" team and backfills) +## Automated Reminders + +Reminder emails are sent to pending signers on a configurable schedule. + +**Configuration:** +- Set reminder interval (e.g., every 2 days) in Settings > Notifications +- Customize reminder email subject and body at account level (Settings > Personalization) or per-template +- Supports the same template variables as invitation emails (submitter name, template name, link, etc.) + +**Visibility & Controls:** +- Submission page shows the next scheduled reminder time per submitter (with timezone tooltip) +- Settings > Notifications includes a pending reminders queue table showing all upcoming reminders +- Skip button lets you advance past a pending reminder without sending it (fires a `skip_reminder_email` event) + +**Reliability:** +- Deduplication guard prevents the same reminder from being sent twice within 1 minute +- Job scheduling handles container restarts gracefully (clears stale scheduled jobs before re-registering) + ## What's NOT included These Pro features remain unavailable in this fork (they require significant UI/infrastructure work): diff --git a/app/controllers/notifications_settings_controller.rb b/app/controllers/notifications_settings_controller.rb index f03f09c8..63aa5348 100644 --- a/app/controllers/notifications_settings_controller.rb +++ b/app/controllers/notifications_settings_controller.rb @@ -3,6 +3,7 @@ class NotificationsSettingsController < ApplicationController before_action :load_bcc_config, only: :index before_action :load_reminder_config, only: :index + before_action :load_pending_reminders, only: :index authorize_resource :bcc_config, only: :index authorize_resource :reminder_config, only: :index @@ -38,6 +39,40 @@ class NotificationsSettingsController < ApplicationController AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::SUBMITTER_REMINDERS) end + def load_pending_reminders + @pending_reminders = [] + return unless @reminder_config&.value.is_a?(Hash) + + submitters = Submitter + .joins(:submission) + .where(account_id: current_account.id) + .where.not(sent_at: nil) + .where(completed_at: nil, declined_at: nil) + .where.not(email: [nil, '']) + .where(submissions: { archived_at: nil }) + .includes(:submission, :template, :submission_events) + .limit(50) + + submitters.each do |submitter| + next if submitter.template&.archived_at? + + next_at = SubmitterReminders.next_reminder_at(submitter, @reminder_config) + next unless next_at + + last_reminder = submitter.submission_events + .select { |e| e.event_type.in?(%w[send_reminder_email skip_reminder_email]) } + .max_by(&:created_at) + + @pending_reminders << { + submitter: submitter, + next_at: next_at, + last_sent_at: last_reminder&.created_at + } + end + + @pending_reminders.sort_by! { |r| r[:next_at] } + end + def email_config_params params.require(:account_config).permit(:key, :value, { value: {} }, { value: [] }).tap do |attrs| attrs[:key] = nil unless attrs[:key].in?([AccountConfig::BCC_EMAILS, AccountConfig::SUBMITTER_REMINDERS]) diff --git a/app/controllers/submitter_reminders_controller.rb b/app/controllers/submitter_reminders_controller.rb new file mode 100644 index 00000000..c4b77c2f --- /dev/null +++ b/app/controllers/submitter_reminders_controller.rb @@ -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 diff --git a/app/jobs/process_submitter_reminders_job.rb b/app/jobs/process_submitter_reminders_job.rb index 899311b8..16c8b758 100644 --- a/app/jobs/process_submitter_reminders_job.rb +++ b/app/jobs/process_submitter_reminders_job.rb @@ -10,11 +10,21 @@ class ProcessSubmitterRemindersJob process_account_reminders(config) end - ProcessSubmitterRemindersJob.perform_in(1.hour) + reschedule! end private + def reschedule! + require 'sidekiq/api' + + Sidekiq::ScheduledSet.new + .select { |j| j.klass == 'ProcessSubmitterRemindersJob' } + .each(&:delete) + + ProcessSubmitterRemindersJob.perform_in(1.hour) + end + def process_account_reminders(config) durations = parse_durations(config.value) return if durations.empty? @@ -35,7 +45,7 @@ class ProcessSubmitterRemindersJob end def send_reminder_if_due(submitter, durations) - reminder_count = submitter.submission_events.where(event_type: 'send_reminder_email').count + reminder_count = submitter.submission_events.where(event_type: %w[send_reminder_email skip_reminder_email]).count duration = case reminder_count when 0 then durations[:first] @@ -50,7 +60,7 @@ class ProcessSubmitterRemindersJob submitter.sent_at else submitter.submission_events - .where(event_type: 'send_reminder_email') + .where(event_type: %w[send_reminder_email skip_reminder_email]) .order(:created_at) .last&.created_at || submitter.sent_at end diff --git a/app/jobs/send_submitter_reminder_email_job.rb b/app/jobs/send_submitter_reminder_email_job.rb index d0e8fc01..fd402fe0 100644 --- a/app/jobs/send_submitter_reminder_email_job.rb +++ b/app/jobs/send_submitter_reminder_email_job.rb @@ -14,8 +14,10 @@ class SendSubmitterReminderEmailJob return if submitter.template&.archived_at? return unless submitter.email.to_s.include?('@') return unless Accounts.can_send_emails?(submitter.account) + return if submitter.submission_events.where(event_type: 'send_reminder_email') + .where('created_at > ?', 1.minute.ago).exists? - mail = SubmitterMailer.invitation_email(submitter) + mail = SubmitterMailer.reminder_email(submitter) mail.deliver_now! diff --git a/app/mailers/submitter_mailer.rb b/app/mailers/submitter_mailer.rb index 6736c21d..a7145642 100644 --- a/app/mailers/submitter_mailer.rb +++ b/app/mailers/submitter_mailer.rb @@ -45,6 +45,40 @@ class SubmitterMailer < ApplicationMailer end end + def reminder_email(submitter) + @current_account = submitter.submission.account + @submitter = submitter + + @body = @submitter.template&.preferences&.dig('invitation_reminder_email_body').presence + + @email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY) + @body ||= fetch_config_email_body(@email_config, @submitter) + + @subject = @submitter.template&.preferences&.dig('invitation_reminder_email_subject').presence + + assign_message_metadata('submitter_reminder', @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 = if @email_config || @subject + ReplaceEmailVariables.call(@subject || @email_config.value['subject'], submitter:) + else + I18n.t(:reminder_you_are_invited_to_sign_a_document) + end + + mail( + to: @submitter.friendly_name, + from: from_address_for_submitter(submitter), + subject:, + reply_to:, + template_name: 'invitation_email' + ) + end + end + def completed_email(submitter, user, to: nil) @current_account = submitter.submission.account @submitter = submitter diff --git a/app/models/account_config.rb b/app/models/account_config.rb index 5275afb5..bff2c61d 100644 --- a/app/models/account_config.rb +++ b/app/models/account_config.rb @@ -77,8 +77,8 @@ class AccountConfig < ApplicationRecord }, SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => lambda { { - 'subject' => I18n.t(:you_are_invited_to_sign_a_document), - 'body' => I18n.t(:submitter_invitation_email_sign_body) + 'subject' => I18n.t(:reminder_you_are_invited_to_sign_a_document), + 'body' => I18n.t(:submitter_reminder_email_sign_body) } }, SUBMITTER_COMPLETED_EMAIL_KEY => lambda { diff --git a/app/models/submission_event.rb b/app/models/submission_event.rb index e88d192f..0620caad 100644 --- a/app/models/submission_event.rb +++ b/app/models/submission_event.rb @@ -46,6 +46,7 @@ class SubmissionEvent < ApplicationRecord bounce_email: 'bounce_email', complaint_email: 'complaint_email', send_reminder_email: 'send_reminder_email', + skip_reminder_email: 'skip_reminder_email', send_sms: 'send_sms', send_2fa_sms: 'send_2fa_sms', send_2fa_email: 'send_2fa_email', diff --git a/app/views/notifications_settings/_reminder_queue.html.erb b/app/views/notifications_settings/_reminder_queue.html.erb new file mode 100644 index 00000000..66a5bc09 --- /dev/null +++ b/app/views/notifications_settings/_reminder_queue.html.erb @@ -0,0 +1,56 @@ +<% if @pending_reminders.present? %> +
+

+ <%= t('pending_reminders') %> +

+
+ + + + + + + + + + + + <% @pending_reminders.each do |entry| %> + <%= turbo_frame_tag "reminder_row_#{entry[:submitter].id}" do %> + + + + + + + + <% end %> + <% end %> + +
<%= t('submitter') %><%= t('template') %><%= t('last_sent') %><%= t('next_due') %>
<%= entry[:submitter].name.presence || entry[:submitter].email %><%= entry[:submitter].template&.name %> + <% if entry[:last_sent_at] %> + + <%= time_ago_in_words(entry[:last_sent_at]) %> <%= t('ago') %> + + <% else %> + — + <% end %> + + <% if entry[:next_at] > Time.current %> + + <%= t('in_time', time: distance_of_time_in_words(Time.current, entry[:next_at])) %> + + <% else %> + + <%= t('overdue') %> + + <% end %> + + <%= 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}" } %> +
+
+
+<% end %> diff --git a/app/views/notifications_settings/index.html.erb b/app/views/notifications_settings/index.html.erb index b163e8e8..db4bec98 100644 --- a/app/views/notifications_settings/index.html.erb +++ b/app/views/notifications_settings/index.html.erb @@ -28,6 +28,7 @@ <%= render 'reminder_banner' %> <%= render 'reminder_form', config: @reminder_config %> + <%= render 'reminder_queue' %>
diff --git a/app/views/personalization_settings/_reminder_email_form.html.erb b/app/views/personalization_settings/_reminder_email_form.html.erb new file mode 100644 index 00000000..56cc9e44 --- /dev/null +++ b/app/views/personalization_settings/_reminder_email_form.html.erb @@ -0,0 +1,29 @@ +
+ +
+
+ <%= t('signature_request_reminder_email') %> +
+
+
+ <%= 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| %> +
+ <%= ff.label :subject, t('subject'), class: 'label' %> + <%= ff.text_field :subject, required: true, class: 'base-input', dir: 'auto' %> +
+ <%= render 'personalization_settings/email_body_field', ff:, config: f.object %> + <% if can?(:manage, :reply_to) || can?(:manage, :personalization_advanced) %> +
+ <%= ff.label :reply_to, t('reply_to'), class: 'label' %> + <%= ff.email_field :reply_to, class: 'base-input', dir: 'auto', placeholder: t(:email) %> +
+ <% end %> + <% end %> +
+ <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %> +
+ <% end %> +
+
diff --git a/app/views/personalization_settings/show.html.erb b/app/views/personalization_settings/show.html.erb index 438da311..5e44fcf0 100644 --- a/app/views/personalization_settings/show.html.erb +++ b/app/views/personalization_settings/show.html.erb @@ -6,6 +6,7 @@

<%= render 'signature_request_email_form' %> + <%= render 'reminder_email_form' %> <%= render 'documents_copy_email_form' %> <%= render 'submitter_completed_email_form' %>
diff --git a/app/views/submissions/show.html.erb b/app/views/submissions/show.html.erb index 83187f47..c0d70ec6 100644 --- a/app/views/submissions/show.html.erb +++ b/app/views/submissions/show.html.erb @@ -148,6 +148,7 @@ <% colors = %w[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] %> <% submitter_fields_index = (@submission.template_fields || @submission.template.fields).group_by { |f| f['submitter_uuid'] } %> <% submitter_field_counters = Hash.new { 0 } %> + <% reminder_config_for_page = signed_in? ? AccountConfig.find_by(account_id: @submission.account_id, key: AccountConfig::SUBMITTER_REMINDERS) : nil %> <% (@submission.template_submitters || @submission.template.submitters).each_with_index do |item, index| %> <% submitter = @submission.submitters.find { |e| e.uuid == item['uuid'] } %>
@@ -217,6 +218,21 @@ <% end %>
+ <% if signed_in? && submitter && !submitter.completed_at? && !submitter.declined_at? && submitter.sent_at? %> + <% next_at = SubmitterReminders.next_reminder_at(submitter, reminder_config_for_page) if reminder_config_for_page %> + <% if next_at %> +
+ <%= svg_icon('clock', class: 'w-5 h-5') %> + + <% if next_at > Time.current %> + <%= t('next_reminder_in_time', time: distance_of_time_in_words(Time.current, next_at)) %> + <% else %> + <%= t('reminder_overdue') %> + <% end %> + +
+ <% end %> + <% end %> <% if submitter&.declined_at? %>
diff --git a/app/views/templates_preferences/_submitter_invitation_reminder_email_collapse.html.erb b/app/views/templates_preferences/_submitter_invitation_reminder_email_collapse.html.erb index e69de29b..739ba184 100644 --- a/app/views/templates_preferences/_submitter_invitation_reminder_email_collapse.html.erb +++ b/app/views/templates_preferences/_submitter_invitation_reminder_email_collapse.html.erb @@ -0,0 +1,9 @@ +
+ +
+ <%= t('signature_request_reminder_email') %> +
+
+ <%= render 'templates_preferences/submitter_invitation_reminder_email_form' %> +
+
diff --git a/app/views/templates_preferences/_submitter_invitation_reminder_email_form.html.erb b/app/views/templates_preferences/_submitter_invitation_reminder_email_form.html.erb new file mode 100644 index 00000000..580f1475 --- /dev/null +++ b/app/views/templates_preferences/_submitter_invitation_reminder_email_form.html.erb @@ -0,0 +1,34 @@ +
+ <% 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| %> + + <%= 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| %> +
+
+ <%= ff.label :invitation_reminder_email_subject, t('email_subject'), class: 'label' %> + <% if is_custom_template_email %> + + <% end %> +
+ <%= ff.text_field :invitation_reminder_email_subject, required: true, class: 'base-input', dir: 'auto' %> +
+
+ <%= 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] %> +
+ <% end %> + <% end %> +
+ <%= button_tag button_title(title: t('save'), disabled_with: t('saving')), form: 'submitter_invitation_reminder_email_template_form', class: 'base-button' %> +
+ +
+
+
diff --git a/config/initializers/scheduled_jobs.rb b/config/initializers/scheduled_jobs.rb index b8b4b5a0..e176d0db 100644 --- a/config/initializers/scheduled_jobs.rb +++ b/config/initializers/scheduled_jobs.rb @@ -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 diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index eacf6304..710bdac6 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -114,6 +114,18 @@ en: &en Please contact us by replying to this email if you have any questions. + Thanks, + {account.name} + reminder_you_are_invited_to_sign_a_document: 'Reminder: You have a document awaiting your signature' + submitter_reminder_email_sign_body: | + Hi there, + + This is a friendly reminder that you have been invited to sign the "{template.name}". + + [Review and Sign]({submitter.link}) + + Please contact us by replying to this email if you have any questions. + Thanks, {account.name} submitter_completed_email_body: | @@ -301,6 +313,15 @@ en: &en first_reminder_in: First reminder in second_reminder_in: Second reminder in third_reminder_in: Third reminder in + next_reminder_in_time: "Next reminder: in %{time}" + reminder_overdue: Reminder overdue + pending_reminders: Pending Reminders + last_sent: Last Sent + next_due: Next Due + in_time: "in %{time}" + overdue: Overdue + ago: ago + reminder_skipped: Reminder skipped learn_more: Learn More unable_to_save: Unable to save. invalid_timeserver: Invalid Timeserver diff --git a/config/routes.rb b/config/routes.rb index 1556c0fa..96d791c2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -189,6 +189,7 @@ Rails.application.routes.draw do resources :email, only: %i[index create], controller: 'email_smtp_settings' resources :sso, only: %i[index], controller: 'sso_settings' resources :notifications, only: %i[index create], controller: 'notifications_settings' + resources :submitter_reminders, only: [:destroy], controller: 'submitter_reminders' resource :esign, only: %i[show create new update destroy], controller: 'esign_settings' resources :teams, only: %i[index new create edit update destroy] resources :users, only: %i[index] diff --git a/lib/submitter_reminders.rb b/lib/submitter_reminders.rb new file mode 100644 index 00000000..1ee4eff0 --- /dev/null +++ b/lib/submitter_reminders.rb @@ -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