diff --git a/app/controllers/api/v1/jobs_controller.rb b/app/controllers/api/v1/jobs_controller.rb index 38f39d70c61..3a1a2130038 100644 --- a/app/controllers/api/v1/jobs_controller.rb +++ b/app/controllers/api/v1/jobs_controller.rb @@ -19,6 +19,7 @@ class Api::V1::JobsController < Api::ApplicationController "prepare_establish_claim" => PrepareEstablishClaimTasksJob, "push_priority_appeals_to_judges" => PushPriorityAppealsToJudgesJob, "reassign_old_tasks" => ReassignOldTasksJob, + "send_reminder_emails_job" => VirtualHearings::SendReminderEmailsJob, "retrieve_documents_for_reader" => RetrieveDocumentsForReaderJob, "set_appeal_age_aod" => SetAppealAgeAodJob, "stats_collector" => StatsCollectorJob, diff --git a/app/jobs/virtual_hearings/send_email.rb b/app/jobs/virtual_hearings/send_email.rb index f0c2f2f4bc0..c9b54cd5674 100644 --- a/app/jobs/virtual_hearings/send_email.rb +++ b/app/jobs/virtual_hearings/send_email.rb @@ -7,10 +7,16 @@ class RecipientIsDeceasedVeteran < StandardError; end def initialize(virtual_hearing:, type:) @virtual_hearing = virtual_hearing - @type = type + @type = type.to_s end def call + # Assumption: Reminders and confirmation/cancellation/change emails are sent + # separately, so this will return early if any reminder emails are sent. If + # reminder emails are being sent, we are assuming the other emails have all + # already been sent too. + return if send_reminder + if !virtual_hearing.appellant_email_sent virtual_hearing.update!(appellant_email_sent: send_email(appellant_recipient)) end @@ -30,19 +36,39 @@ def call delegate :appeal, to: :hearing delegate :veteran, to: :appeal + def send_reminder + if type == "appellant_reminder" && send_email(appellant_recipient) + virtual_hearing.update!(appellant_reminder_sent_at: Time.zone.now) + + return true + end + + if type == "representative_reminder" && + !virtual_hearing.representative_email.nil? && + send_email(representative_recipient) + virtual_hearing.update!(representative_reminder_sent_at: Time.zone.now) + + return true + end + + false + end + def email_for_recipient(recipient) args = { mail_recipient: recipient, virtual_hearing: virtual_hearing } - case type.to_s + case type when "confirmation" VirtualHearingMailer.confirmation(**args) when "cancellation" VirtualHearingMailer.cancellation(**args) when "updated_time_confirmation" VirtualHearingMailer.updated_time_confirmation(**args) + when "appellant_reminder", "representative_reminder" + VirtualHearingMailer.reminder(**args) else fail ArgumentError, "Invalid type of email to send: `#{type}`" end @@ -57,7 +83,10 @@ def external_message_id(msg) DataDogService.increment_counter( app_name: Constants.DATADOG_METRICS.HEARINGS.APP_NAME, metric_group: Constants.DATADOG_METRICS.HEARINGS.VIRTUAL_HEARINGS_GROUP_NAME, - metric_name: "emails.submitted" + metric_name: "emails.submitted", + attrs: { + email_type: type + } ) Rails.logger.info( @@ -101,7 +130,7 @@ def send_email(recipient) msg = email.deliver_now! rescue StandardError, Savon::Error, BGS::ShareError => error - # Savon::Error and BGS::ShareError are sometimes thrown when making requests to BGS enpoints + # Savon::Error and BGS::ShareError are sometimes thrown when making requests to BGS endpoints Raven.capture_exception(error) Rails.logger.warn("Failed to send #{type} email to #{recipient.title}: #{error}") @@ -126,11 +155,11 @@ def create_sent_hearing_email_event(recipient, external_id) ) SentHearingEmailEvent.create!( hearing: hearing, - email_type: type, + email_type: type.ends_with?("reminder") ? "reminder" : type, email_address: recipient.email, external_message_id: external_id, recipient_role: recipient_is_veteran ? "veteran" : recipient.title.downcase, - sent_by: virtual_hearing.updated_by + sent_by: type.ends_with?("reminder") ? User.system_user : virtual_hearing.updated_by ) rescue StandardError => error Raven.capture_exception(error) @@ -185,6 +214,8 @@ def appellant_recipient end def should_judge_receive_email? - !virtual_hearing.judge_email.nil? && !virtual_hearing.judge_email_sent && type.to_s != "cancellation" + !virtual_hearing.judge_email.nil? && + !virtual_hearing.judge_email_sent && + %w[confirmation updated_time_confirmation].include?(type) end end diff --git a/app/jobs/virtual_hearings/send_reminder_emails_job.rb b/app/jobs/virtual_hearings/send_reminder_emails_job.rb new file mode 100644 index 00000000000..3ffac35467a --- /dev/null +++ b/app/jobs/virtual_hearings/send_reminder_emails_job.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class VirtualHearings::SendReminderEmailsJob < ApplicationJob + def perform + VirtualHearingRepository.maybe_ready_for_reminder_email.each do |virtual_hearing| + send_reminder_emails(virtual_hearing) + end + end + + private + + def send_reminder_emails(virtual_hearing) + if should_send_appellant_reminder?(virtual_hearing) + VirtualHearings::SendEmail + .new(virtual_hearing: virtual_hearing, type: :appellant_reminder) + .call + end + + if virtual_hearing.representative_email.present? && should_sent_representative_reminder?(virtual_hearing) + VirtualHearings::SendEmail + .new(virtual_hearing: virtual_hearing, type: :representative_reminder) + .call + end + end + + def should_send_appellant_reminder?(virtual_hearing) + VirtualHearings::ReminderService + .new(virtual_hearing, virtual_hearing.appellant_reminder_sent_at) + .should_send_reminder_email? + end + + def should_sent_representative_reminder?(virtual_hearing) + VirtualHearings::ReminderService + .new(virtual_hearing, virtual_hearing.representative_reminder_sent_at) + .should_send_reminder_email? + end +end diff --git a/app/models/hearings/sent_hearing_email_event.rb b/app/models/hearings/sent_hearing_email_event.rb index 4f4ac861f2d..d67fd0f4eb0 100644 --- a/app/models/hearings/sent_hearing_email_event.rb +++ b/app/models/hearings/sent_hearing_email_event.rb @@ -28,7 +28,8 @@ class << self; undef_method :sent_to_appellant; end { confirmation: "confirmation", cancellation: "cancellation", - updated_time_confirmation: "updated_time_confirmation" + updated_time_confirmation: "updated_time_confirmation", + reminder: "reminder" } ), _prefix: :is diff --git a/app/models/hearings/virtual_hearing.rb b/app/models/hearings/virtual_hearing.rb index 4be54591238..537757c3886 100644 --- a/app/models/hearings/virtual_hearing.rb +++ b/app/models/hearings/virtual_hearing.rb @@ -86,6 +86,7 @@ def base_url HearingDay::REQUEST_TYPES[:central] ].freeze + # Whether or not all non-reminder emails were sent. def all_emails_sent? appellant_email_sent && (judge_email.nil? || judge_email_sent) && diff --git a/app/repositories/virtual_hearing_repository.rb b/app/repositories/virtual_hearing_repository.rb index 5fe154536c8..bc4a1364079 100644 --- a/app/repositories/virtual_hearing_repository.rb +++ b/app/repositories/virtual_hearing_repository.rb @@ -3,20 +3,16 @@ class VirtualHearingRepository class << self def ready_for_deletion - virtual_hearings_for_ama_hearings = VirtualHearing.eligible_for_deletion - .where(hearing_type: Hearing.name) - .joins("INNER JOIN hearings ON hearings.id = virtual_hearings.hearing_id") - .joins("INNER JOIN hearing_days ON hearing_days.id = hearings.hearing_day_id") + virtual_hearings_for_ama_hearings = virtual_hearings_joined_with_hearings_and_hearing_day(Hearing) + .eligible_for_deletion .where( "hearing_days.scheduled_for < :today OR hearings.disposition='postponed' OR hearings.disposition='cancelled' OR virtual_hearings.request_cancelled = true", today: Time.zone.today ) - virtual_hearings_for_legacy_hearings = VirtualHearing.eligible_for_deletion - .where(hearing_type: LegacyHearing.name) - .joins("INNER JOIN legacy_hearings ON legacy_hearings.id = virtual_hearings.hearing_id") - .joins("INNER JOIN hearing_days ON hearing_days.id = legacy_hearings.hearing_day_id") + virtual_hearings_for_legacy_hearings = virtual_hearings_joined_with_hearings_and_hearing_day(LegacyHearing) + .eligible_for_deletion .where( "hearing_days.scheduled_for < :today OR legacy_hearings.vacols_id in (:postponed_or_cancelled_vacols_ids) OR @@ -27,14 +23,36 @@ def ready_for_deletion virtual_hearings_for_ama_hearings + virtual_hearings_for_legacy_hearings end - def pending_appellant_or_rep_emails_sql - <<-SQL - NOT virtual_hearings.appellant_email_sent - OR ( - virtual_hearings.representative_email IS NOT null - AND NOT virtual_hearings.representative_email_sent + # Get all virtual hearings that *might* need to have a reminder email sent. + # + # @note The logic to determine whether or not a reminder email needs to be sent is + # complex enough that it's not worth putting in an SQL query for maintainability reasons. + # This method will find all active virtual hearings that are occurring within the next 7 + # days. + def maybe_ready_for_reminder_email + ama_virtual_hearings_ready_for_email = virtual_hearings_joined_with_hearings_and_hearing_day(Hearing) + .not_cancelled + .where( + "hearings.disposition NOT IN (:non_active_hearing_dispositions) OR hearings.disposition IS NULL", + non_active_hearing_dispositions: [:postponed, :cancelled] ) - SQL + .where(where_hearing_occurs_within_the_timeframe) + + legacy_virtual_hearings_ready_for_email = virtual_hearings_joined_with_hearings_and_hearing_day(LegacyHearing) + .not_cancelled + .where(where_hearing_occurs_within_the_timeframe) + + postponed_or_cancelled_legacy = postponed_or_cancelled_vacols_ids + + unless postponed_or_cancelled_legacy.empty? + # Active Hearings + legacy_virtual_hearings_ready_for_email = legacy_virtual_hearings_ready_for_email.where( + "legacy_hearings.vacols_id NOT IN (:postponed_or_cancelled_vacols_ids)", + postponed_or_cancelled_vacols_ids: postponed_or_cancelled_vacols_ids + ) + end + + ama_virtual_hearings_ready_for_email + legacy_virtual_hearings_ready_for_email end def cancelled_hearings_with_pending_emails @@ -61,13 +79,47 @@ def hearings_with_pending_conference_or_pending_emails private + # Returns virtual hearings joined with either the legacy hearing or hearings table, + # and joined with the hearing day table. + def virtual_hearings_joined_with_hearings_and_hearing_day(appeal_type) + table = appeal_type.table_name + + VirtualHearing + .where(hearing_type: appeal_type.name) + .joins("INNER JOIN #{table} ON #{table}.id = virtual_hearings.hearing_id") + .joins("INNER JOIN hearing_days ON hearing_days.id = #{table}.hearing_day_id") + end + def postponed_or_cancelled_vacols_ids VACOLS::CaseHearing.by_dispositions( [ VACOLS::CaseHearing::HEARING_DISPOSITIONS.key("postponed"), VACOLS::CaseHearing::HEARING_DISPOSITIONS.key("cancelled") ] - ).pluck(:hearing_pkseq).map(&:to_s) + ).pluck(:hearing_pkseq).map(&:to_s) || [] + end + + def pending_appellant_or_rep_emails_sql + <<-SQL + NOT virtual_hearings.appellant_email_sent + OR ( + virtual_hearings.representative_email IS NOT null + AND NOT virtual_hearings.representative_email_sent + ) + SQL + end + + # Returns a where clause that can be used to find all hearings that occur within + # a given timeframe (in days). + # + # @note Requires a join with the `hearing_days` table. + def where_hearing_occurs_within_the_timeframe + <<-SQL + DATE_PART( + 'day', + hearing_days.scheduled_for::timestamp - '#{Time.zone.today}'::timestamp + ) BETWEEN 1 AND 7 + SQL end end end diff --git a/app/services/virtual_hearings/reminder_service.rb b/app/services/virtual_hearings/reminder_service.rb new file mode 100644 index 00000000000..6e7913258d5 --- /dev/null +++ b/app/services/virtual_hearings/reminder_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +## +# Service that determines whether or not a send a reminder to the appellant or representative +# about their virtual hearing. + +class VirtualHearings::ReminderService + def initialize(virtual_hearing, last_sent_reminder) + @virtual_hearing = virtual_hearing + @last_sent_reminder = last_sent_reminder + end + + def should_send_reminder_email? + return false if days_until_hearing <= 0 + + should_send_2_day_reminder? || + should_send_3_day_friday_reminder? || + should_send_7_day_reminder? + end + + private + + attr_reader :virtual_hearing + attr_reader :last_sent_reminder + + def should_send_2_day_reminder? + days_between_hearing_and_created_at > 2 && + days_until_hearing <= 2 && days_from_hearing_day_to_last_sent_reminder > 2 + end + + # The 3 day reminder is a special reminder that is sent on Friday, *only* if the hearing + # itself is on Monday. + def should_send_3_day_friday_reminder? + days_between_hearing_and_created_at > 3 && + days_until_hearing <= 3 && virtual_hearing.hearing.scheduled_for.monday? + end + + def should_send_7_day_reminder? + days_between_hearing_and_created_at > 7 && + days_until_hearing <= 7 && days_from_hearing_day_to_last_sent_reminder > 7 + end + + # Determines the date between when the hearing is scheduled, and when the virtual hearing was scheduled. + # If the virtual hearing was scheduled within a reminder period, we skip sending the reminder for that period + # because the confirmation will have redundant information. + def days_between_hearing_and_created_at + (virtual_hearing.hearing.scheduled_for - virtual_hearing.created_at) / 1.day + end + + def days_until_hearing + ((virtual_hearing.hearing.scheduled_for - Time.zone.now.utc) / 1.day).round + end + + def days_from_hearing_day_to_last_sent_reminder + # Pick arbitrarily big value if the reminder has never been sent. + return Float::INFINITY if last_sent_reminder.nil? + + (virtual_hearing.hearing.scheduled_for - last_sent_reminder) / 1.day + end +end diff --git a/db/migrate/20201104175215_add_reminder_email_flags_to_virtual_hearings.rb b/db/migrate/20201104175215_add_reminder_email_flags_to_virtual_hearings.rb new file mode 100644 index 00000000000..d4225275622 --- /dev/null +++ b/db/migrate/20201104175215_add_reminder_email_flags_to_virtual_hearings.rb @@ -0,0 +1,12 @@ +class AddReminderEmailFlagsToVirtualHearings < Caseflow::Migration + def change + add_column :virtual_hearings, + :appellant_reminder_sent_at, + :datetime, + comment: "The datetime the last reminder email was sent to the appellant." + add_column :virtual_hearings, + :representative_reminder_sent_at, + :datetime, + comment: "The datetime the last reminder email was sent to the representative." + end +end diff --git a/db/schema.rb b/db/schema.rb index 4a7de6f9bec..79810ac6c7a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_10_20_135542) do +ActiveRecord::Schema.define(version: 2020_11_04_175215) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -1491,6 +1491,7 @@ t.string "alias_with_host", comment: "Alias for conference in pexip with client_host" t.string "appellant_email", comment: "Appellant's email address" t.boolean "appellant_email_sent", default: false, null: false, comment: "Determines whether or not a notification email was sent to the appellant" + t.datetime "appellant_reminder_sent_at", comment: "The datetime the last reminder email was sent to the appellant." t.string "appellant_tz", limit: 50, comment: "Stores appellant timezone" t.boolean "conference_deleted", default: false, null: false, comment: "Whether or not the conference was deleted from Pexip" t.integer "conference_id", comment: "ID of conference from Pexip" @@ -1506,6 +1507,7 @@ t.boolean "judge_email_sent", default: false, null: false, comment: "Whether or not a notification email was sent to the judge" t.string "representative_email", comment: "Veteran's representative's email address" t.boolean "representative_email_sent", default: false, null: false, comment: "Whether or not a notification email was sent to the veteran's representative" + t.datetime "representative_reminder_sent_at", comment: "The datetime the last reminder email was sent to the representative." t.string "representative_tz", limit: 50, comment: "Stores representative timezone" t.boolean "request_cancelled", default: false, comment: "Determines whether the user has cancelled the virtual hearing request" t.datetime "updated_at", null: false, comment: "Automatic timestamp of when virtual hearing was updated" diff --git a/docs/schema/caseflow.csv b/docs/schema/caseflow.csv index 8a16b68a493..c7d19c9f768 100644 --- a/docs/schema/caseflow.csv +++ b/docs/schema/caseflow.csv @@ -445,7 +445,7 @@ end_product_updates,original_decision_review_id,integer (8),,,,,x,The original d end_product_updates,original_decision_review_type,string,,,,,x,The original decision review type that this end product update belongs to end_product_updates,status,string,,,,,,"Status after an attempt to update the end product; expected values: 'success', 'error', ..." end_product_updates,updated_at,datetime ∗,x,,,,, -end_product_updates,user_id,integer (8) ∗,x,,,,x,The ID of the user who makes an end product update. +end_product_updates,user_id,integer (8) ∗ FK,x,,x,,x,The ID of the user who makes an end product update. form8s,,,,,,,, form8s,_initial_appellant_name,string,,,,,, form8s,_initial_appellant_relationship,string,,,,,, @@ -1124,6 +1124,7 @@ virtual_hearings,alias,string,,,,,x,Alias for conference in Pexip virtual_hearings,alias_with_host,string,,,,,,Alias for conference in pexip with client_host virtual_hearings,appellant_email,string ∗,x,,,,,Appellant's email address virtual_hearings,appellant_email_sent,boolean ∗,x,,,,,Determines whether or not a notification email was sent to the appellant +virtual_hearings,appellant_reminder_sent_at,datetime,,,,,,The datetime the last reminder email was sent to the appellant. virtual_hearings,appellant_tz,string (50),,,,,,Stores appellant timezone virtual_hearings,conference_deleted,boolean ∗,x,,,,,Whether or not the conference was deleted from Pexip virtual_hearings,conference_id,integer,,,,,x,ID of conference from Pexip @@ -1140,6 +1141,7 @@ virtual_hearings,judge_email,string,,,,,,Judge's email address virtual_hearings,judge_email_sent,boolean ∗,x,,,,,Whether or not a notification email was sent to the judge virtual_hearings,representative_email,string,,,,,,Veteran's representative's email address virtual_hearings,representative_email_sent,boolean ∗,x,,,,,Whether or not a notification email was sent to the veteran's representative +virtual_hearings,representative_reminder_sent_at,datetime,,,,,,The datetime the last reminder email was sent to the representative. virtual_hearings,representative_tz,string (50),,,,,,Stores representative timezone virtual_hearings,request_cancelled,boolean,,,,,,Determines whether the user has cancelled the virtual hearing request virtual_hearings,updated_at,datetime ∗,x,,,,x,Automatic timestamp of when virtual hearing was updated diff --git a/spec/factories/virtual_hearing.rb b/spec/factories/virtual_hearing.rb index 2ad33af5245..013d8c11b8b 100644 --- a/spec/factories/virtual_hearing.rb +++ b/spec/factories/virtual_hearing.rb @@ -20,6 +20,8 @@ association :updated_by, factory: :user establishment { build(:virtual_hearing_establishment) } guest_pin_long { nil } + created_at { Time.zone.now } + updated_at { Time.zone.now } transient do status { nil } diff --git a/spec/jobs/virtual_hearings/send_reminder_emails_job_spec.rb b/spec/jobs/virtual_hearings/send_reminder_emails_job_spec.rb new file mode 100644 index 00000000000..eeae687ac26 --- /dev/null +++ b/spec/jobs/virtual_hearings/send_reminder_emails_job_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +describe VirtualHearings::SendReminderEmailsJob do + let(:hearing_date) { Time.zone.now } + let(:ama_disposition) { nil } + let(:hearing_day) do + create( + :hearing_day, + regional_office: "RO42", + scheduled_for: hearing_date, + request_type: HearingDay::REQUEST_TYPES[:virtual] + ) + end + let(:hearing) do + create( + :hearing, + hearing_day: hearing_day, + disposition: ama_disposition + ) + end + let(:appellant_email) { "appellant@test.gov" } + let(:appellant_reminder_sent_at) { nil } + let(:representative_email) { "representative@test.gov" } + let(:representative_reminder_sent_at) { nil } + let!(:virtual_hearing) do + create( + :virtual_hearing, + :initialized, + status: :active, + appellant_email: appellant_email, + appellant_reminder_sent_at: appellant_reminder_sent_at, + representative_email: representative_email, + representative_reminder_sent_at: representative_reminder_sent_at, + hearing: hearing, + created_at: Time.zone.now - 14.days + ) + end + + describe "#perform" do + subject { VirtualHearings::SendReminderEmailsJob.new.perform } + + context "hearing date is 7 days out" do + let(:hearing_date) { Time.zone.now + 7.days } + + it "sends reminder emails", :aggregate_failures do + expect(VirtualHearingMailer).to receive(:reminder).twice.and_call_original + + subject + virtual_hearing.reload + expect(virtual_hearing.appellant_reminder_sent_at).not_to be_nil + expect(virtual_hearing.representative_reminder_sent_at).not_to be_nil + end + + it "creates sent email events" do + subject + + expect(SentHearingEmailEvent.count).to eq(2) + expect(SentHearingEmailEvent.is_reminder.count).to eq(2) + expect(SentHearingEmailEvent.is_reminder.map(&:sent_by)).to all(eq(User.system_user)) + end + + context "representative email was already sent" do + let(:representative_reminder_sent_at) { hearing_date - 3.days } + + it "sends reminder email for the appellant", :aggregate_failures do + expect(VirtualHearingMailer).to receive(:reminder).once.and_call_original + + subject + virtual_hearing.reload + expect(virtual_hearing.appellant_reminder_sent_at).not_to be_nil + expect(virtual_hearing.representative_reminder_sent_at).to( + be_within(1.second).of(representative_reminder_sent_at) + ) + end + end + + context "appellant email was already sent" do + let(:appellant_reminder_sent_at) { hearing_date - 4.days } + + it "sends reminder email for the representative", :aggregate_failures do + expect(VirtualHearingMailer).to receive(:reminder).once.and_call_original + + subject + virtual_hearing.reload + expect(virtual_hearing.appellant_reminder_sent_at).to( + be_within(1.second).of(appellant_reminder_sent_at) + ) + expect(virtual_hearing.representative_reminder_sent_at).not_to be_nil + end + end + + context "representative email is nil" do + let(:representative_email) { nil } + + it "sends reminder email to the appellant only", :aggregate_failures do + expect(VirtualHearingMailer).to receive(:reminder).once.and_call_original + + subject + virtual_hearing.reload + expect(virtual_hearing.appellant_reminder_sent_at).not_to be_nil + expect(virtual_hearing.representative_reminder_sent_at).to be_nil + end + end + end + + context "hearing date is 2 days out" do + let(:hearing_date) { Time.zone.now + 2.days } + + context "sent reminder emails 5 days out" do + let(:appellant_reminder_sent_at) { hearing_date - 4.days } + let(:representative_reminder_sent_at) { hearing_date - 4.days } + + it "sends reminder emails", :aggregate_failures do + expect(VirtualHearingMailer).to receive(:reminder).twice.and_call_original + + subject + virtual_hearing.reload + # Expect appellant_reminder_sent_at and representative_reminder_sent_at to change + # from the value we setup because the emails were sent. + expect(virtual_hearing.appellant_reminder_sent_at).not_to( + be_within(1.second).of(representative_reminder_sent_at) + ) + expect(virtual_hearing.representative_reminder_sent_at).not_to( + be_within(1.second).of(representative_reminder_sent_at) + ) + end + end + end + + context "hearing date is 1 day out" do + let(:hearing_date) { Time.zone.now + 1.day } + + context "sent reminder emails 2 days out" do + let(:appellant_reminder_sent_at) { hearing_date - 2.days } + let(:representative_reminder_sent_at) { hearing_date - 2.days } + + it "does not send reminder emails", :aggregate_failures do + expect(VirtualHearingMailer).not_to receive(:reminder) + + subject + virtual_hearing.reload + # Expect appellant_reminder_sent_at and representative_reminder_sent_at to remain + # the same as the times we setup because the emails weren't sent. + expect(virtual_hearing.appellant_reminder_sent_at).to( + be_within(1.second).of(appellant_reminder_sent_at) + ) + expect(virtual_hearing.representative_reminder_sent_at).to( + be_within(1.second).of(representative_reminder_sent_at) + ) + end + end + end + end +end diff --git a/spec/repositories/virtual_hearing_repository_spec.rb b/spec/repositories/virtual_hearing_repository_spec.rb index 1b9b576a078..4d26885e59d 100644 --- a/spec/repositories/virtual_hearing_repository_spec.rb +++ b/spec/repositories/virtual_hearing_repository_spec.rb @@ -172,4 +172,70 @@ ) end end + + context ".maybe_ready_for_reminder_email" do + let!(:virtual_hearing) { create(:virtual_hearing, :initialized, status: :active, hearing: hearing) } + + subject { described_class.maybe_ready_for_reminder_email } + + shared_examples "include or exclude hearings depending on the number of days out from the hearing" do + context "within 7 days" do + let(:hearing_date) { Time.zone.now + 7.days } + + it "returns the virtual hearing" do + expect(subject).to eq([virtual_hearing]) + end + end + + context "is in 10 days" do + let(:hearing_date) { Time.zone.now + 10.days } + + it "returns nothing" do + expect(subject).to be_empty + end + end + end + + context "for an AMA hearing" do + context "active virtual hearing" do + include_examples "include or exclude hearings depending on the number of days out from the hearing" + end + + %w[postponed cancelled no_show held].each do |disposition| + context "#{disposition} virtual hearing" do + let(:ama_disposition) { disposition } + + it "returns nothings" do + expect(subject).to be_empty + end + end + end + end + + context "for a Legacy hearing" do + let(:legacy_dispositon) { nil } + let(:hearing) do + create( + :legacy_hearing, + regional_office: regional_office, + hearing_day_id: hearing_day.id, + case_hearing: create(:case_hearing, hearing_disp: legacy_dispositon) + ) + end + + context "active virtual hearing" do + include_examples "include or exclude hearings depending on the number of days out from the hearing" + end + + %w[P C N H].each do |disposition_code| + context "#{VACOLS::CaseHearing::HEARING_DISPOSITIONS[disposition_code.to_sym]} virtual hearing" do + let(:ama_disposition) { disposition_code } + + it "returns nothings" do + expect(subject).to be_empty + end + end + end + end + end end diff --git a/spec/services/virtual_hearings/reminder_service_spec.rb b/spec/services/virtual_hearings/reminder_service_spec.rb new file mode 100644 index 00000000000..9e19947a0bd --- /dev/null +++ b/spec/services/virtual_hearings/reminder_service_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +describe VirtualHearings::ReminderService do + let(:hearing_day) { build(:hearing_day, scheduled_for: hearing_date) } + let(:hearing) { build(:hearing, hearing_day: hearing_day) } + let(:virtual_hearing) do + build( + :virtual_hearing, + :initialized, + status: :active, + hearing: hearing, + created_at: created_at + ) + end + + before do + Timecop.freeze(Time.utc(2020, 11, 5, 12, 0, 0)) # Nov 5, 2020 (Thursday) + end + + after { Timecop.return } + + context ".should_send_reminder_email?" do + subject do + described_class.new(virtual_hearing, last_sent_reminder).should_send_reminder_email? + end + + context "hearing date is 7 days out" do + let(:hearing_date) { Time.zone.now + 7.days } + let(:created_at) { hearing_date - 8.days } + + context "last_sent_reminder is nil" do + let(:last_sent_reminder) { nil } + + it "returns true" do + expect(subject).to eq(true) + end + end + + context "last_sent_reminder is 5 days out" do + let(:last_sent_reminder) { hearing_date - 5.days } + + it "returns false" do + expect(subject).to eq(false) + end + end + end + + context "hearing date is 5 days out" do + let(:hearing_date) { Time.zone.now + 5.days } + + context "created_at is 6 days from the hearing date" do + let(:created_at) { hearing_date - 6.days } + + context "last_sent_reminder is nil" do + let(:last_sent_reminder) { nil } + + it "returns false" do + expect(subject).to eq(false) + end + end + end + end + + context "hearing date is 2 days out" do + let(:hearing_date) { Time.zone.now + 2.days } + let(:created_at) { hearing_date - 3.days } + + context "last_sent_reminder is nil" do + let(:last_sent_reminder) { nil } + + it "returns true" do + expect(subject).to eq(true) + end + end + + context "last_sent_reminder is 4 days out" do + let(:last_sent_reminder) { hearing_date - 4.days } + let(:created_at) { hearing_date - 5.days } + + it "returns true" do + expect(subject).to eq(true) + end + end + + context "last_sent_reminder is 1 days out" do + let(:last_sent_reminder) { hearing_date - 1.day } + let(:created_at) { hearing_date - 2.days } + + it "returns false" do + expect(subject).to eq(false) + end + end + end + + context "hearing date is 1 day out" do + let(:hearing_date) { Time.zone.now + 1.day } + + context "created_at is 1.5 days from the hearing date" do + let(:created_at) { hearing_date - 1.day - 12.hours } + + context "last_sent_reminder is nil" do + let(:last_sent_reminder) { nil } + + it "returns false" do + expect(subject).to eq(false) + end + end + end + end + + context "hearing date is 3 days out" do + let(:created_at) { hearing_date - 4.days } + + context "hearing date is on a monday" do + let(:hearing_date) { Time.utc(2020, 11, 9, 12, 0, 0) } # Nov 9, 2020 (Monday) + + context "last_sent_reminder is 4 days out" do + let(:last_sent_reminder) { hearing_date - 4.days } + + context "today is friday" do + before do + Timecop.freeze(Time.utc(2020, 11, 6, 12, 0, 0)) # Nov 6, 2020 (Friday) + end + + it "returns true" do + expect(subject).to eq(true) + end + end + + context "today is thursday" do + it "returns false" do + expect(subject).to eq(false) + end + end + end + end + + context "hearing date is on a tuesday" do + let(:hearing_date) { Time.utc(2020, 11, 10, 12, 0, 0) } # Nov 10, 2020 (Tuesday) + + context "last_sent_reminder is 4 days out" do + let(:last_sent_reminder) { hearing_date - 4.days } + + context "today is saturday" do + before do + Timecop.freeze(Time.utc(2020, 11, 7, 12, 0, 0)) # Nov 7, 2020 (Saturday) + end + + it "returns false" do + expect(subject).to eq(false) + end + end + end + end + end + end +end