diff --git a/app/app/controllers/api/invitations_controller.rb b/app/app/controllers/api/invitations_controller.rb index 2db732e63..d1008e8f5 100644 --- a/app/app/controllers/api/invitations_controller.rb +++ b/app/app/controllers/api/invitations_controller.rb @@ -4,14 +4,14 @@ class Api::InvitationsController < ApplicationController before_action :authenticate def create - @cbv_flow_invitation = CbvInvitationService.new(event_logger).invite(cbv_flow_invitation_params, @current_user, delivery_method: nil) + @cbv_flow_invitation = CbvInvitationService.new(event_logger) + .invite(cbv_flow_invitation_params, @current_user, delivery_method: nil) - if @cbv_flow_invitation.errors.any? - return render json: @cbv_flow_invitation.errors, status: :unprocessable_entity + errors = @cbv_flow_invitation.errors + if errors.any? + return render json: errors_to_json(errors), status: :unprocessable_entity end - @cbv_client = CbvClient.create_from_invitation(@cbv_flow_invitation) - render json: { url: @cbv_flow_invitation.to_url, expiration_date: @cbv_flow_invitation.expires_at, @@ -21,26 +21,30 @@ def create # can these be inferred from the model? def cbv_flow_invitation_params + if (applicant_attributes = params.delete(:agency_partner_metadata)) + params[:cbv_applicant_attributes] = applicant_attributes.merge(client_agency_id: params[:client_agency_id]) + end + params[:email_address] = @current_user.email + params.permit( - :first_name, - :middle_name, :language, - :last_name, - :client_id_number, - :case_number, :email_address, - :snap_application_date, - :agency_id_number, - :beacon_id, + :client_agency_id, :user_id, - :client_agency_id + cbv_applicant_attributes: [ + :first_name, + :middle_name, + :last_name, + :client_agency_id, + :client_id_number, + :case_number, + :snap_application_date, + :agency_id_number, + :beacon_id + ] ) end - def client_agency_id - cbv_flow_invitation_params[:client_agency_id] - end - private def authenticate @@ -48,4 +52,25 @@ def authenticate @current_user = User.find_by_access_token(token) end end + + def errors_to_json(errors) + # Generates a Hash of attribute => error_message and translates the + # internal names of objects (cbv_applicant) to the external names + # (agency_partner_metadata) + errors.map do |error| + next if error.attribute == :cbv_applicant + + error_message = error.message + + case error + when ActiveModel::NestedError + prefix, attribute_name = error.attribute.to_s.split(".") + prefix = "agency_partner_metadata" if prefix == "cbv_applicant" + + [ "#{prefix}.#{attribute_name}", error_message ] + else + [ error.attribute, error_message ] + end + end.compact.to_h + end end diff --git a/app/app/controllers/api/pinwheel_controller.rb b/app/app/controllers/api/pinwheel_controller.rb index 2b48af46c..4b093464a 100644 --- a/app/app/controllers/api/pinwheel_controller.rb +++ b/app/app/controllers/api/pinwheel_controller.rb @@ -48,6 +48,7 @@ def user_action base_attributes = { timestamp: Time.now.to_i, cbv_flow_id: @cbv_flow.id, + cbv_applicant_id: @cbv_flow.cbv_applicant_id, invitation_id: @cbv_flow.cbv_flow_invitation_id } event_name = user_action_params[:event_name] @@ -87,6 +88,7 @@ def token_params def track_event event_logger.track("ApplicantBeganLinkingEmployer", request, { cbv_flow_id: @cbv_flow.id, + cbv_applicant_id: @cbv_flow.cbv_applicant_id, invitation_id: @cbv_flow.cbv_flow_invitation_id, response_type: token_params[:response_type] }) diff --git a/app/app/controllers/caseworker/cbv_flow_invitations_controller.rb b/app/app/controllers/caseworker/cbv_flow_invitations_controller.rb index a9940f3d6..b4cb5b24e 100644 --- a/app/app/controllers/caseworker/cbv_flow_invitations_controller.rb +++ b/app/app/controllers/caseworker/cbv_flow_invitations_controller.rb @@ -14,23 +14,24 @@ def new end def create - invitation_params = base_params.merge(client_agency_specific_params) # handle errors from the mail service begin @cbv_flow_invitation = CbvInvitationService.new(event_logger).invite( - invitation_params, + invitation_params.deep_merge(client_agency_id: client_agency_id, cbv_applicant_attributes: { client_agency_id: client_agency_id }), current_user, delivery_method: :email ) rescue => e Rails.logger.error("Error inviting applicant: #{e.message}") flash[:alert] = t(".invite_failed", - email_address: cbv_flow_invitation_params[:email_address], + email_address: invitation_params[:email_address], error_message: e.message) return redirect_to caseworker_dashboard_path(client_agency_id: params[:client_agency_id]) end if @cbv_flow_invitation.errors.any? + @cbv_flow_invitation.errors.delete(:cbv_applicant) + error_count = @cbv_flow_invitation.errors.size error_header = "#{helpers.pluralize(error_count, 'error')} occurred" @@ -44,11 +45,8 @@ def create return render :new, status: :unprocessable_entity end - # hydrate the cbv_client with the invitation if there are no cbv_flow_invitation errors - @cbv_client = CbvClient.create_from_invitation(@cbv_flow_invitation) - flash[:slim_alert] = { - message: t(".invite_success", email_address: cbv_flow_invitation_params[:email_address]), + message: t(".invite_success", email_address: invitation_params[:email_address]), type: "success" } redirect_to caseworker_dashboard_path(client_agency_id: params[:client_agency_id]) @@ -69,40 +67,20 @@ def ensure_valid_params! end end - def base_params - cbv_flow_invitation_params.slice( - :first_name, - :middle_name, - :language, - :last_name, - :email_address, - :snap_application_date, - ).merge(client_agency_id: client_agency_id) - end - - def client_agency_specific_params - case client_agency_id - when "ma" - cbv_flow_invitation_params.slice(:agency_id_number, :beacon_id) - when "nyc" - cbv_flow_invitation_params.slice(:client_id_number, :case_number) - else - {} - end - end - - def cbv_flow_invitation_params + def invitation_params params.fetch(:cbv_flow_invitation, {}).permit( - :first_name, - :middle_name, :language, - :last_name, - :client_id_number, - :case_number, :email_address, - :snap_application_date, - :agency_id_number, - :beacon_id, + cbv_applicant_attributes: [ + :first_name, + :middle_name, + :last_name, + :client_id_number, + :case_number, + :snap_application_date, + :agency_id_number, + :beacon_id + ] ) end diff --git a/app/app/controllers/cbv/base_controller.rb b/app/app/controllers/cbv/base_controller.rb index 5bc415622..0eaf756c3 100644 --- a/app/app/controllers/cbv/base_controller.rb +++ b/app/app/controllers/cbv/base_controller.rb @@ -47,7 +47,7 @@ def ensure_cbv_flow_not_yet_complete def current_agency return unless @cbv_flow.present? && @cbv_flow.client_agency_id.present? - @current_site ||= agency_config[@cbv_flow.client_agency_id] + @current_agency ||= agency_config[@cbv_flow.client_agency_id] end def next_path @@ -110,6 +110,7 @@ def track_timeout_event def track_expired_event(invitation) event_logger.track("ApplicantLinkExpired", request, { invitation_id: invitation.id, + cbv_applicant_id: invitation.cbv_applicant_id, timestamp: Time.now.to_i }) rescue => ex @@ -121,6 +122,7 @@ def track_invitation_clicked_event(invitation, cbv_flow) timestamp: Time.now.to_i, invitation_id: invitation.id, cbv_flow_id: cbv_flow.id, + cbv_applicant_id: cbv_flow.cbv_applicant_id, client_agency_id: cbv_flow.client_agency_id, seconds_since_invitation: (Time.now - invitation.created_at).to_i }) diff --git a/app/app/controllers/cbv/employer_searches_controller.rb b/app/app/controllers/cbv/employer_searches_controller.rb index 6943b3006..2b03bee06 100644 --- a/app/app/controllers/cbv/employer_searches_controller.rb +++ b/app/app/controllers/cbv/employer_searches_controller.rb @@ -31,6 +31,7 @@ def search_params def track_clicked_popular_payroll_providers_event event_logger.track("ApplicantClickedPopularPayrollProviders", request, { timestamp: Time.now.to_i, + cbv_applicant_id: @cbv_flow.cbv_applicant_id, cbv_flow_id: @cbv_flow.id, invitation_id: @cbv_flow.cbv_flow_invitation_id }) @@ -41,6 +42,7 @@ def track_clicked_popular_payroll_providers_event def track_clicked_popular_app_employers_event event_logger.track("ApplicantClickedPopularAppEmployers", request, { timestamp: Time.now.to_i, + cbv_applicant_id: @cbv_flow.cbv_applicant_id, cbv_flow_id: @cbv_flow.id, invitation_id: @cbv_flow.cbv_flow_invitation_id }) @@ -53,6 +55,7 @@ def track_accessed_search_event event_logger.track("ApplicantAccessedSearchPage", request, { timestamp: Time.now.to_i, + cbv_applicant_id: @cbv_flow.cbv_applicant_id, cbv_flow_id: @cbv_flow.id, invitation_id: @cbv_flow.cbv_flow_invitation_id }) @@ -65,6 +68,7 @@ def track_applicant_searched_event event_logger.track("ApplicantSearchedForEmployer", request, { timestamp: Time.now.to_i, + cbv_applicant_id: @cbv_flow.cbv_applicant_id, cbv_flow_id: @cbv_flow.id, invitation_id: @cbv_flow.cbv_flow_invitation_id, num_results: @employers.length, diff --git a/app/app/controllers/cbv/entries_controller.rb b/app/app/controllers/cbv/entries_controller.rb index 3033377c0..13fe5ab4b 100644 --- a/app/app/controllers/cbv/entries_controller.rb +++ b/app/app/controllers/cbv/entries_controller.rb @@ -3,6 +3,7 @@ def show event_logger.track("ApplicantViewedAgreement", request, { timestamp: Time.now.to_i, client_agency_id: @cbv_flow.client_agency_id, + cbv_applicant_id: @cbv_flow.cbv_applicant_id, cbv_flow_id: @cbv_flow.id, invitation_id: @cbv_flow.cbv_flow_invitation_id }) @@ -13,6 +14,7 @@ def create event_logger.track("ApplicantAgreed", request, { timestamp: Time.now.to_i, client_agency_id: @cbv_flow.client_agency_id, + cbv_applicant_id: @cbv_flow.cbv_applicant_id, cbv_flow_id: @cbv_flow.id, invitation_id: @cbv_flow.cbv_flow_invitation_id }) diff --git a/app/app/controllers/cbv/missing_results_controller.rb b/app/app/controllers/cbv/missing_results_controller.rb index fd4e801ec..2dc46be5b 100644 --- a/app/app/controllers/cbv/missing_results_controller.rb +++ b/app/app/controllers/cbv/missing_results_controller.rb @@ -8,6 +8,7 @@ def show def track_missing_results_event event_logger.track("ApplicantAccessedMissingResultsPage", request, { timestamp: Time.now.to_i, + cbv_applicant_id: @cbv_flow.cbv_applicant_id, cbv_flow_id: @cbv_flow.id, invitation_id: @cbv_flow.cbv_flow_invitation_id }) diff --git a/app/app/controllers/cbv/payment_details_controller.rb b/app/app/controllers/cbv/payment_details_controller.rb index bda7526e5..2bef3750a 100644 --- a/app/app/controllers/cbv/payment_details_controller.rb +++ b/app/app/controllers/cbv/payment_details_controller.rb @@ -126,6 +126,7 @@ def track_viewed_event return if @pinwheel_account.nil? event_logger.track("ApplicantViewedPaymentDetails", request, { + cbv_applicant_id: @cbv_flow.cbv_applicant_id, cbv_flow_id: @cbv_flow.id, invitation_id: @cbv_flow.cbv_flow_invitation_id, pinwheel_account_id: @pinwheel_account.id, @@ -142,6 +143,7 @@ def track_saved_event comment_data = @cbv_flow.additional_information[params[:user][:account_id]] event_logger.track("ApplicantSavedPaymentDetails", request, { + cbv_applicant_id: @cbv_flow.cbv_applicant_id, cbv_flow_id: @cbv_flow.id, invitation_id: @cbv_flow.cbv_flow_invitation_id, additional_information_length: comment_data ? comment_data["comment"].length : 0 diff --git a/app/app/controllers/cbv/successes_controller.rb b/app/app/controllers/cbv/successes_controller.rb index 06d1be6f1..9769bc5cc 100644 --- a/app/app/controllers/cbv/successes_controller.rb +++ b/app/app/controllers/cbv/successes_controller.rb @@ -8,6 +8,7 @@ def show def track_accessed_success_event event_logger.track("ApplicantAccessedSuccessPage", request, { timestamp: Time.now.to_i, + cbv_applicant_id: @cbv_flow.cbv_applicant_id, cbv_flow_id: @cbv_flow.id, invitation_id: @cbv_flow.cbv_flow_invitation_id }) diff --git a/app/app/controllers/cbv/summaries_controller.rb b/app/app/controllers/cbv/summaries_controller.rb index d90798446..0fb376727 100644 --- a/app/app/controllers/cbv/summaries_controller.rb +++ b/app/app/controllers/cbv/summaries_controller.rb @@ -24,6 +24,7 @@ def show event_logger.track("ApplicantDownloadedIncomePDF", request, { timestamp: Time.now.to_i, client_agency_id: @cbv_flow.client_agency_id, + cbv_applicant_id: @cbv_flow.cbv_applicant_id, cbv_flow_id: @cbv_flow.id, invitation_id: @cbv_flow.cbv_flow_invitation_id, locale: I18n.locale @@ -101,7 +102,7 @@ def transmit_to_caseworker time_now = Time.now beginning_date = (Date.parse(@payments_beginning_at).strftime("%b") rescue @payments_beginning_at) ending_date = (Date.parse(@payments_ending_at).strftime("%b%Y") rescue @payments_ending_at) - @file_name = "IncomeReport_#{@cbv_flow.cbv_flow_invitation.agency_id_number}_" \ + @file_name = "IncomeReport_#{@cbv_flow.cbv_applicant.agency_id_number}_" \ "#{beginning_date}-#{ending_date}_" \ "Conf#{@cbv_flow.confirmation_code}_" \ "#{time_now.strftime('%Y%m%d%H%M%S')}" @@ -166,16 +167,16 @@ def generate_csv pinwheel_account = PinwheelAccount.find_by(cbv_flow_id: @cbv_flow.id) data = { - client_id: @cbv_flow.cbv_flow_invitation.agency_id_number, - first_name: @cbv_flow.cbv_flow_invitation.first_name, - last_name: @cbv_flow.cbv_flow_invitation.last_name, - middle_name: @cbv_flow.cbv_flow_invitation.middle_name, + client_id: @cbv_flow.cbv_applicant.agency_id_number, + first_name: @cbv_flow.cbv_applicant.first_name, + last_name: @cbv_flow.cbv_applicant.last_name, + middle_name: @cbv_flow.cbv_applicant.middle_name, client_email_address: @cbv_flow.cbv_flow_invitation.email_address, - beacon_userid: @cbv_flow.cbv_flow_invitation.beacon_id, - app_date: @cbv_flow.cbv_flow_invitation.snap_application_date.strftime("%m/%d/%Y"), + beacon_userid: @cbv_flow.cbv_applicant.beacon_id, + app_date: @cbv_flow.cbv_applicant.snap_application_date.strftime("%m/%d/%Y"), report_date_created: pinwheel_account.created_at.strftime("%m/%d/%Y"), - report_date_start: @cbv_flow.cbv_flow_invitation.paystubs_query_begins_at.strftime("%m/%d/%Y"), - report_date_end: @cbv_flow.cbv_flow_invitation.snap_application_date.strftime("%m/%d/%Y"), + report_date_start: @cbv_flow.cbv_applicant.paystubs_query_begins_at.strftime("%m/%d/%Y"), + report_date_end: @cbv_flow.cbv_applicant.snap_application_date.strftime("%m/%d/%Y"), confirmation_code: @cbv_flow.confirmation_code, consent_timestamp: @cbv_flow.consented_to_authorized_use_at.strftime("%m/%d/%Y %H:%M:%S"), pdf_filename: "#{@file_name}.pdf", @@ -191,6 +192,7 @@ def track_transmitted_event(cbv_flow, payments) event_logger.track("ApplicantSharedIncomeSummary", request, { timestamp: Time.now.to_i, client_agency_id: cbv_flow.client_agency_id, + cbv_applicant_id: cbv_flow.cbv_applicant_id, cbv_flow_id: cbv_flow.id, invitation_id: cbv_flow.cbv_flow_invitation_id, account_count: cbv_flow.pinwheel_accounts.count, @@ -209,6 +211,7 @@ def track_accessed_income_summary_event(cbv_flow, payments) timestamp: Time.now.to_i, client_agency_id: cbv_flow.client_agency_id, cbv_flow_id: cbv_flow.id, + cbv_applicant_id: cbv_flow.cbv_applicant_id, invitation_id: cbv_flow.cbv_flow_invitation_id, account_count: cbv_flow.pinwheel_accounts.count, paystub_count: payments.count, diff --git a/app/app/controllers/help_controller.rb b/app/app/controllers/help_controller.rb index 4d0b8d936..435292058 100644 --- a/app/app/controllers/help_controller.rb +++ b/app/app/controllers/help_controller.rb @@ -12,16 +12,19 @@ def show cbv_flow = session[:cbv_flow_id] ? CbvFlow.find_by(id: session[:cbv_flow_id]) : nil - event_logger.track("ApplicantViewedHelpTopic", request, { - topic: @help_topic, - cbv_flow_id: session[:cbv_flow_id], - invitation_id: cbv_flow&.cbv_flow_invitation_id, - client_agency_id: current_agency&.id, - flow_started_seconds_ago: cbv_flow ? (Time.now - cbv_flow.created_at).to_i : nil, - language: I18n.locale - }.compact) - rescue => ex - Rails.logger.error "Unable to track event (ApplicantViewedHelpTopic): #{ex}" + begin + event_logger.track("ApplicantViewedHelpTopic", request, { + topic: @help_topic, + cbv_applicant_id: cbv_flow&.cbv_applicant_id, + cbv_flow_id: session[:cbv_flow_id], + invitation_id: cbv_flow&.cbv_flow_invitation_id, + client_agency_id: current_agency&.id, + flow_started_seconds_ago: cbv_flow ? (Time.now - cbv_flow.created_at).to_i : nil, + language: I18n.locale + }) + rescue => ex + Rails.logger.error "Unable to track event (ApplicantViewedHelpTopic): #{ex}" + end end private diff --git a/app/app/controllers/webhooks/pinwheel/events_controller.rb b/app/app/controllers/webhooks/pinwheel/events_controller.rb index 0377c4bfa..6c05ebf2a 100644 --- a/app/app/controllers/webhooks/pinwheel/events_controller.rb +++ b/app/app/controllers/webhooks/pinwheel/events_controller.rb @@ -59,6 +59,7 @@ def authorize_webhook def track_account_synced_event(cbv_flow, pinwheel_account) event_logger.track("ApplicantFinishedPinwheelSync", request, { + cbv_applicant_id: cbv_flow.cbv_applicant_id, cbv_flow_id: cbv_flow.id, invitation_id: cbv_flow.cbv_flow_invitation_id, identity_success: pinwheel_account.job_succeeded?("identity"), @@ -77,6 +78,7 @@ def track_account_synced_event(cbv_flow, pinwheel_account) def track_account_created_event(cbv_flow, platform_name) event_logger.track("ApplicantCreatedPinwheelAccount", request, { + cbv_applicant_id: cbv_flow.cbv_applicant_id, cbv_flow_id: cbv_flow.id, invitation_id: cbv_flow.cbv_flow_invitation_id, platform_name: platform_name diff --git a/app/app/helpers/cbv/pinwheel_data_helper.rb b/app/app/helpers/cbv/pinwheel_data_helper.rb index 50ac827d7..25a27b06b 100644 --- a/app/app/helpers/cbv/pinwheel_data_helper.rb +++ b/app/app/helpers/cbv/pinwheel_data_helper.rb @@ -2,9 +2,9 @@ module Cbv::PinwheelDataHelper include ViewHelper def set_payments(account_id = nil) - invitation = @cbv_flow.cbv_flow_invitation - to_pay_date = invitation.snap_application_date - from_pay_date = invitation.paystubs_query_begins_at + applicant = @cbv_flow.cbv_applicant + to_pay_date = applicant.snap_application_date + from_pay_date = applicant.paystubs_query_begins_at @payments = if account_id.nil? fetch_paystubs(from_pay_date, to_pay_date) @@ -36,7 +36,7 @@ def set_identities next unless pinwheel_account.job_succeeded?("identity") pinwheel.fetch_identity(account_id: pinwheel_account.pinwheel_account_id) - end + end.compact end def hours_by_earning_category(earnings) diff --git a/app/app/mailers/application_mailer.rb b/app/app/mailers/application_mailer.rb index 82a2b8744..12d6bfd82 100644 --- a/app/app/mailers/application_mailer.rb +++ b/app/app/mailers/application_mailer.rb @@ -18,6 +18,7 @@ def track_delivery # Include a couple attributes that are passed in as params to subclasses, # to help with linking metadata without including any PII. + cbv_applicant_id: params[:cbv_flow]&.cbv_applicant_id || params[:cbv_flow_invitation]&.cbv_applicant_id, cbv_flow_id: params[:cbv_flow]&.id, invitation_id: params[:cbv_flow_invitation]&.id }) diff --git a/app/app/mailers/weekly_report_mailer.rb b/app/app/mailers/weekly_report_mailer.rb index ca11c2daa..143ccf7e4 100644 --- a/app/app/mailers/weekly_report_mailer.rb +++ b/app/app/mailers/weekly_report_mailer.rb @@ -33,28 +33,29 @@ def weekly_report_data(current_agency, report_range) CbvFlowInvitation .where(client_agency_id: current_agency.id) .where(created_at: report_range) - .includes(:cbv_flows) + .includes(:cbv_flows, :cbv_applicant) .map do |invitation| cbv_flow = invitation.cbv_flows.find(&:complete?) + applicant = invitation.cbv_applicant base_fields = { invited_at: invitation.created_at, transmitted_at: cbv_flow&.transmitted_at, completed_at: cbv_flow&.consented_to_authorized_use_at, - snap_application_date: invitation.snap_application_date, + snap_application_date: applicant.snap_application_date, email_address: invitation.email_address } case current_agency.id when "nyc" base_fields.merge( - client_id_number: invitation.client_id_number, - case_number: invitation.case_number, + client_id_number: applicant.client_id_number, + case_number: applicant.case_number, ) when "ma" base_fields.merge( - agency_id_number: invitation.agency_id_number, - beacon_id: invitation.beacon_id + agency_id_number: applicant.agency_id_number, + beacon_id: applicant.beacon_id ) end end diff --git a/app/app/models/cbv_applicant.rb b/app/app/models/cbv_applicant.rb new file mode 100644 index 000000000..e7cb2ef91 --- /dev/null +++ b/app/app/models/cbv_applicant.rb @@ -0,0 +1,140 @@ +class CbvApplicant < ApplicationRecord + # Massachusetts: 7 digits + MA_AGENCY_ID_REGEX = /\A\d{7}\z/ + + # Massachusetts: 6 alphanumeric characters + MA_BEACON_ID_REGEX = /\A[a-zA-Z0-9]{6}\z/ + + # New York City: 11 digits followed by 1 uppercase letter + NYC_CASE_NUMBER_REGEX = /\A\d{11}[A-Z]\z/ + + # New York City: 2 uppercase letters, followed by 5 digits, followed by 1 uppercase letter + NYC_CLIENT_ID_REGEX = /\A[A-Z]{2}\d{5}[A-Z]\z/ + + PAYSTUB_REPORT_RANGE = 90.days + + has_many :cbv_flows + has_many :cbv_flow_invitations + + before_validation :parse_snap_application_date + before_validation :format_case_number + + validates :first_name, presence: true + validates :last_name, presence: true + validates :client_agency_id, presence: true + + # MA specific validations + with_options(if: :ma_site?) do + validates :agency_id_number, format: { with: MA_AGENCY_ID_REGEX, message: :invalid_format } + validates :beacon_id, format: { with: MA_BEACON_ID_REGEX, message: :invalid_format } + validate :ma_snap_application_date_not_more_than_1_year_ago + validate :ma_snap_application_date_not_in_future + end + + # NYC specific validations + with_options(if: :nyc_site?) do + validates :case_number, format: { with: NYC_CASE_NUMBER_REGEX, message: :invalid_format } + validates :client_id_number, format: { with: NYC_CLIENT_ID_REGEX, message: :invalid_format } + validate :nyc_snap_application_date_not_more_than_30_days_ago + validate :nyc_snap_application_date_not_in_future + end + + include Redactable + has_redactable_fields( + first_name: :string, + middle_name: :string, + last_name: :string, + client_id_number: :string, + case_number: :string, + agency_id_number: :string, + beacon_id: :string, + snap_application_date: :date + ) + + def self.create_from_invitation(cbv_flow_invitation) + client = create!( + client_agency_id: cbv_flow_invitation.client_agency_id, + case_number: cbv_flow_invitation.case_number, + first_name: cbv_flow_invitation.first_name, + middle_name: cbv_flow_invitation.middle_name, + last_name: cbv_flow_invitation.last_name, + agency_id_number: cbv_flow_invitation.agency_id_number, + client_id_number: cbv_flow_invitation.client_id_number, + snap_application_date: cbv_flow_invitation.snap_application_date, + beacon_id: cbv_flow_invitation.beacon_id + ) + cbv_flow_invitation.update_column(:cbv_applicant_id, client.id) + client + end + + def ma_site? + client_agency_id == "ma" + end + + def nyc_site? + client_agency_id == "nyc" + end + + def parse_snap_application_date + raw_snap_application_date = @attributes["snap_application_date"]&.value_before_type_cast + return if raw_snap_application_date.is_a?(Date) + + if raw_snap_application_date.is_a?(ActiveSupport::TimeWithZone) || raw_snap_application_date.is_a?(Time) + self.snap_application_date = raw_snap_application_date.to_date + # handle ISO 8601 date format, e.g. "2021-01-01" which is Ruby's default when querying a date field + elsif raw_snap_application_date.is_a?(String) && raw_snap_application_date.match?(/^\d{4}-\d{2}-\d{2}$/) + self.snap_application_date = Date.parse(raw_snap_application_date) + else + begin + new_date_format = Date.strptime(raw_snap_application_date.to_s, "%m/%d/%Y") + self.snap_application_date = new_date_format + rescue Date::Error => e + case client_agency_id + when "ma" + error = :ma_invalid_date + when "nyc" + error = :nyc_invalid_date + else + error = :default_invalid_date + end + errors.add(:snap_application_date, error) + end + end + end + + def ma_snap_application_date_not_in_future + if snap_application_date.present? && snap_application_date > Date.current + errors.add(:snap_application_date, :ma_invalid_date) + end + end + + def ma_snap_application_date_not_more_than_1_year_ago + if snap_application_date.present? && snap_application_date < 1.year.ago.to_date + errors.add(:snap_application_date, :ma_invalid_date) + end + end + + def nyc_snap_application_date_not_in_future + if snap_application_date.present? && snap_application_date > Date.current + errors.add(:snap_application_date, :nyc_invalid_date) + end + end + + def nyc_snap_application_date_not_more_than_30_days_ago + if snap_application_date.present? && snap_application_date < 30.day.ago.to_date + errors.add(:snap_application_date, :nyc_invalid_date) + end + end + + def format_case_number + return if case_number.blank? + case_number.upcase! + if case_number.length == 9 + self.case_number = "000#{case_number}" + end + end + + def paystubs_query_begins_at + PAYSTUB_REPORT_RANGE.before(snap_application_date) + end +end diff --git a/app/app/models/cbv_client.rb b/app/app/models/cbv_client.rb deleted file mode 100644 index 34af5a1bf..000000000 --- a/app/app/models/cbv_client.rb +++ /dev/null @@ -1,19 +0,0 @@ -class CbvClient < ApplicationRecord - has_one :cbv_flow - has_one :cbv_flow_invitation - - def self.create_from_invitation(cbv_flow_invitation) - client = create!( - case_number: cbv_flow_invitation.case_number, - first_name: cbv_flow_invitation.first_name, - middle_name: cbv_flow_invitation.middle_name, - last_name: cbv_flow_invitation.last_name, - agency_id_number: cbv_flow_invitation.agency_id_number, - client_id_number: cbv_flow_invitation.client_id_number, - snap_application_date: cbv_flow_invitation.snap_application_date, - beacon_id: cbv_flow_invitation.beacon_id - ) - cbv_flow_invitation.update_column(:cbv_client_id, client.id) - client - end -end diff --git a/app/app/models/cbv_flow.rb b/app/app/models/cbv_flow.rb index a3f5d06b5..54d76ffb6 100644 --- a/app/app/models/cbv_flow.rb +++ b/app/app/models/cbv_flow.rb @@ -1,9 +1,11 @@ class CbvFlow < ApplicationRecord has_many :pinwheel_accounts, dependent: :destroy belongs_to :cbv_flow_invitation, optional: true - belongs_to :cbv_client, optional: true + belongs_to :cbv_applicant, optional: true validates :client_agency_id, inclusion: Rails.application.config.client_agencies.client_agency_ids + accepts_nested_attributes_for :cbv_applicant + scope :incomplete, -> { where(confirmation_code: nil) } include Redactable @@ -20,8 +22,8 @@ def complete? def self.create_from_invitation(cbv_flow_invitation) create( cbv_flow_invitation: cbv_flow_invitation, - case_number: cbv_flow_invitation.case_number, - client_agency_id: cbv_flow_invitation.client_agency_id + cbv_applicant: cbv_flow_invitation.cbv_applicant, + client_agency_id: cbv_flow_invitation.client_agency_id, ) end diff --git a/app/app/models/cbv_flow_invitation.rb b/app/app/models/cbv_flow_invitation.rb index d73645325..ee2712b69 100644 --- a/app/app/models/cbv_flow_invitation.rb +++ b/app/app/models/cbv_flow_invitation.rb @@ -8,59 +8,30 @@ class CbvFlowInvitation < ApplicationRecord # to be of practical use here. EMAIL_REGEX = /\A([\w+\-].?)+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z\d\-]+\z/i - # Massachusetts: 7 digits - MA_AGENCY_ID_REGEX = /\A\d{7}\z/ - - # Massachusetts: 6 alphanumeric characters - MA_BEACON_ID_REGEX = /\A[a-zA-Z0-9]{6}\z/ - - # New York City: 11 digits followed by 1 uppercase letter - NYC_CASE_NUMBER_REGEX = /\A\d{11}[A-Z]\z/ - - # New York City: 2 uppercase letters, followed by 5 digits, followed by 1 uppercase letter - NYC_CLIENT_ID_REGEX = /\A[A-Z]{2}\d{5}[A-Z]\z/ - - # Invitation validity time zone INVITATION_VALIDITY_TIME_ZONE = "America/New_York" - # Paystub report range - PAYSTUB_REPORT_RANGE = 90.days - VALID_LOCALES = Rails.application.config.i18n.available_locales.map(&:to_s).freeze belongs_to :user - belongs_to :cbv_client, optional: true + belongs_to :cbv_applicant, optional: true has_many :cbv_flows has_secure_token :auth_token, length: 36 - before_validation :parse_snap_application_date - before_validation :format_case_number, if: :nyc_site? + accepts_nested_attributes_for :cbv_applicant + before_validation :normalize_language + before_create :copy_fields_back_from_cbv_applicant validates :client_agency_id, inclusion: Rails.application.config.client_agencies.client_agency_ids - validates :first_name, presence: true - validates :last_name, presence: true validates :email_address, format: { with: EMAIL_REGEX, message: :invalid_format } + validates_associated :cbv_applicant validates :language, inclusion: { in: VALID_LOCALES, message: :invalid_format, case_sensitive: false } - # MA specific validations - validates :agency_id_number, format: { with: MA_AGENCY_ID_REGEX, message: :invalid_format }, if: :ma_site? - validates :beacon_id, format: { with: MA_BEACON_ID_REGEX, message: :invalid_format }, if: :ma_site? - validate :ma_snap_application_date_not_more_than_1_year_ago, if: :ma_site? - validate :ma_snap_application_date_not_in_future, if: :ma_site? - - - # NYC specific validations - validates :case_number, format: { with: NYC_CASE_NUMBER_REGEX, message: :invalid_format }, if: :nyc_site? - validates :client_id_number, format: { with: NYC_CLIENT_ID_REGEX, message: :invalid_format }, if: :nyc_site? - validate :nyc_snap_application_date_not_more_than_30_days_ago, if: :nyc_site? - validate :nyc_snap_application_date_not_in_future, if: :nyc_site? - include Redactable has_redactable_fields( first_name: :string, @@ -77,6 +48,23 @@ class CbvFlowInvitation < ApplicationRecord scope :unstarted, -> { left_outer_joins(:cbv_flows).where(cbv_flows: { id: nil }) } + # Temporarily copy fields back to the invitation so we can not violate the + # NOT NULL constraints until we delete these columns. + def copy_fields_back_from_cbv_applicant + return unless cbv_applicant.present? + + assign_attributes( + first_name: cbv_applicant.first_name, + middle_name: cbv_applicant.middle_name, + last_name: cbv_applicant.last_name, + client_id_number: cbv_applicant.client_id_number, + case_number: cbv_applicant.case_number, + agency_id_number: cbv_applicant.agency_id_number, + beacon_id: cbv_applicant.beacon_id, + snap_application_date: cbv_applicant.snap_application_date + ) + end + # Invitations are valid until 11:59pm Eastern Time on the (e.g.) 14th day # after sending the invitation. def expires_at @@ -98,79 +86,6 @@ def to_url Rails.application.routes.url_helpers.cbv_flow_entry_url(token: auth_token, locale: language) end - def paystubs_query_begins_at - PAYSTUB_REPORT_RANGE.before(snap_application_date) - end - - private - - def nyc_site? - client_agency_id == "nyc" - end - - def ma_site? - client_agency_id == "ma" - end - - def parse_snap_application_date - raw_snap_application_date = @attributes["snap_application_date"]&.value_before_type_cast - return if raw_snap_application_date.is_a?(Date) - - if raw_snap_application_date.is_a?(ActiveSupport::TimeWithZone) || raw_snap_application_date.is_a?(Time) - self.snap_application_date = raw_snap_application_date.to_date - # handle ISO 8601 date format, e.g. "2021-01-01" which is Ruby's default when querying a date field - elsif raw_snap_application_date.is_a?(String) && raw_snap_application_date.match?(/^\d{4}-\d{2}-\d{2}$/) - self.snap_application_date = Date.parse(raw_snap_application_date) - else - begin - new_date_format = Date.strptime(raw_snap_application_date.to_s, "%m/%d/%Y") - self.snap_application_date = new_date_format - rescue Date::Error => e - case client_agency_id - when "ma" - error = :ma_invalid_date - when "nyc" - error = :nyc_invalid_date - else - error = :default_invalid_date - end - errors.add(:snap_application_date, error) - end - end - end - - def ma_snap_application_date_not_in_future - if snap_application_date.present? && snap_application_date > Date.current - errors.add(:snap_application_date, :ma_invalid_date) - end - end - - def ma_snap_application_date_not_more_than_1_year_ago - if snap_application_date.present? && snap_application_date < 1.year.ago.to_date - errors.add(:snap_application_date, :ma_invalid_date) - end - end - - def nyc_snap_application_date_not_in_future - if snap_application_date.present? && snap_application_date > Date.current - errors.add(:snap_application_date, :nyc_invalid_date) - end - end - - def nyc_snap_application_date_not_more_than_30_days_ago - if snap_application_date.present? && snap_application_date < 30.day.ago.to_date - errors.add(:snap_application_date, :nyc_invalid_date) - end - end - - def format_case_number - return if case_number.blank? - case_number.upcase! - if case_number.length == 9 - self.case_number = "000#{case_number}" - end - end - def normalize_language self.language = language.to_s.downcase if language.present? end diff --git a/app/app/services/cbv_invitation_service.rb b/app/app/services/cbv_invitation_service.rb index 4fe7ef211..a85af745d 100644 --- a/app/app/services/cbv_invitation_service.rb +++ b/app/app/services/cbv_invitation_service.rb @@ -35,6 +35,7 @@ def track_event(cbv_flow_invitation, current_user) user_id: current_user.id, caseworker_email_address: current_user.email, client_agency_id: cbv_flow_invitation.client_agency_id, + cbv_applicant_id: cbv_flow_invitation.cbv_applicant_id, invitation_id: cbv_flow_invitation.id }) end diff --git a/app/app/services/data_retention_service.rb b/app/app/services/data_retention_service.rb index 963707d87..9038393e4 100644 --- a/app/app/services/data_retention_service.rb +++ b/app/app/services/data_retention_service.rb @@ -18,7 +18,10 @@ def redact_invitations .unstarted .unredacted .find_each do |cbv_flow_invitation| - cbv_flow_invitation.redact! if Time.now.after?(cbv_flow_invitation.expires_at + REDACT_UNUSED_INVITATIONS_AFTER) + next unless Time.now.after?(cbv_flow_invitation.expires_at + REDACT_UNUSED_INVITATIONS_AFTER) + + cbv_flow_invitation.redact! + cbv_flow_invitation.cbv_applicant&.redact! end end @@ -36,6 +39,7 @@ def redact_incomplete_cbv_flows cbv_flow.redact! cbv_flow.cbv_flow_invitation.redact! + cbv_flow.cbv_applicant&.redact! else # Redact standalone CbvFlow records some period after their last # update. @@ -47,6 +51,7 @@ def redact_incomplete_cbv_flows next unless Time.now.after?(flow_redact_at) cbv_flow.redact! + cbv_flow.cbv_applicant&.redact! end end end @@ -59,14 +64,16 @@ def redact_complete_cbv_flows .find_each do |cbv_flow| cbv_flow.redact! cbv_flow.cbv_flow_invitation.redact! if cbv_flow.cbv_flow_invitation.present? + cbv_flow.cbv_applicant&.redact! end end # Use after conducting a user test or other time we want to manually redact a # specific person's data in the system. def self.manually_redact_by_case_number!(case_number) - invitation = CbvFlowInvitation.find_by!(case_number: case_number) - invitation.redact! - invitation.cbv_flows.map(&:redact!) + applicant = CbvApplicant.find_by!(case_number: case_number) + applicant.redact! + applicant.cbv_flow_invitations.map(&:redact!) + applicant.cbv_flows.map(&:redact!) end end diff --git a/app/app/views/caseworker/cbv_flow_invitations/_ma.html.erb b/app/app/views/caseworker/cbv_flow_invitations/_ma.html.erb index b2c2764c9..3f1cdd2e7 100644 --- a/app/app/views/caseworker/cbv_flow_invitations/_ma.html.erb +++ b/app/app/views/caseworker/cbv_flow_invitations/_ma.html.erb @@ -1,35 +1,38 @@ -<%= f.text_field :first_name, label: t(".invite.first_name") %> +<% cbv_applicant = @cbv_flow_invitation.cbv_applicant || @cbv_flow_invitation.build_cbv_applicant %> +<%= f.fields_for :cbv_applicant, cbv_applicant do |f2| %> + <%= f2.text_field :first_name, label: t(".invite.first_name") %> -<%= f.text_field :middle_name, label: t(".invite.middle_name") %> + <%= f2.text_field :middle_name, label: t(".invite.middle_name") %> -<%= f.text_field :last_name, label: t(".invite.last_name") %> + <%= f2.text_field :last_name, label: t(".invite.last_name") %> -<%= f.text_field :agency_id_number, label: t(".invite.agency_id_number"), hint: "Format: 1234567" %> + <%= f2.text_field :agency_id_number, label: t(".invite.agency_id_number"), hint: "Format: 1234567" %> -<%= f.date_picker :snap_application_date, label: t(".invite.todays_date") %> + <%= f2.date_picker :snap_application_date, label: t(".invite.todays_date") %> -<%= f.email_field :email_address, label: t(".invite.email_address") %> + <%= f.email_field :email_address, label: t(".invite.email_address") %> -<%= f.text_field :beacon_id, label: t(".invite.beacon_id"), hint: "Format: abc123" %> + <%= f2.text_field :beacon_id, label: t(".invite.beacon_id"), hint: "Format: abc123" %> -
<%= flash[:notice] %>
+<%= flash[:alert].html_safe %>
++