Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Split CbvApplicant into three STI subclasses #451

Merged
merged 3 commits into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 22 additions & 79 deletions app/app/models/cbv_applicant.rb
Original file line number Diff line number Diff line change
@@ -1,44 +1,35 @@
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/
# We use Single-Table Inheritance (STI) to create subclasses of this table
# logic to process subsets of the columns of this model relevant to each
# partner agency.
#
# The subclass is automatically instantiated by setting `client_agency_id`.
# For example, `client_agency_id = "ma"` will result in instantiating an
# instance of the CbvApplicant::Ma subclass, which contains all of its
# indexing data validations.
self.inheritance_column = "client_agency_id"

def self.sti_name
# "CbvApplicant::Ma" => "ma"
name.demodulize.downcase
end

# 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/
def self.sti_class_for(type_name)
# "ma" => CbvApplicant::Ma
CbvApplicant.const_get(type_name.capitalize)
end

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,
Expand Down Expand Up @@ -67,12 +58,8 @@ def self.create_from_invitation(cbv_flow_invitation)
client
end

def ma_site?
client_agency_id == "ma"
end

def nyc_site?
client_agency_id == "nyc"
def paystubs_query_begins_at
PAYSTUB_REPORT_RANGE.before(snap_application_date)
end

def parse_snap_application_date
Expand All @@ -88,53 +75,9 @@ def parse_snap_application_date
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)
rescue Date::Error
errors.add(:snap_application_date, :invalid_date)
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
13 changes: 13 additions & 0 deletions app/app/models/cbv_applicant/ma.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class CbvApplicant::Ma < CbvApplicant
# 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/

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 }
validates :snap_application_date,
inclusion: { in: Date.current.prev_year..Date.current, message: :invalid_date },
if: -> { snap_application_date.present? }
end
23 changes: 23 additions & 0 deletions app/app/models/cbv_applicant/nyc.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
class CbvApplicant::Nyc < CbvApplicant
# 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/

before_validation :format_case_number

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 }
validates :snap_application_date,
inclusion: { in: (Date.current - 30.days)..Date.current, message: :invalid_date },
if: -> { snap_application_date.present? }

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
end
2 changes: 2 additions & 0 deletions app/app/models/cbv_applicant/sandbox.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class CbvApplicant::Sandbox < CbvApplicant
end
5 changes: 4 additions & 1 deletion app/app/models/concerns/redactable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ def has_redactable_fields(fields)
end

def redact!
self.class.fields_to_redact.each do |field, type|
fields_to_redact = self.class.fields_to_redact || self.class.superclass.fields_to_redact
raise "No fields to redact in #{self.class} (or its superclass)" unless fields_to_redact.present?

fields_to_redact.each do |field, type|
self[field] = REDACTION_REPLACEMENTS[type]
end
self[REDACTED_TIMESTAMP_COLUMN] = Time.now
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<% cbv_applicant = @cbv_flow_invitation.cbv_applicant || @cbv_flow_invitation.build_cbv_applicant %>
<% cbv_applicant = @cbv_flow_invitation.cbv_applicant ||
@cbv_flow_invitation.build_cbv_applicant(client_agency_id: @cbv_flow_invitation.client_agency_id) %>
<%= f.fields_for :cbv_applicant, cbv_applicant do |f2| %>
<%= f2.text_field :first_name, label: t(".invite.first_name") %>

Expand Down
2 changes: 1 addition & 1 deletion app/config/i18n-tasks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ ignore_missing:
- "caseworker.entries.*"
# Caseworker emails that are only supported in English:
- "activerecord.errors.models.cbv_flow_invitation.*"
- "activerecord.errors.models.cbv_applicant.*"
- "activerecord.errors.models.cbv_applicant*"
- "caseworker_mailer.summary_email.*"
# Caseworker PDF strings that are only supported in English:
- "cbv.summaries.show.pdf.caseworker.*"
Expand Down
31 changes: 24 additions & 7 deletions app/config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,41 @@ en:
errors:
models:
cbv_applicant:
attributes:
first_name:
blank: Enter the client's first name.
last_name:
blank: Enter the client's last name.
snap_application_date:
invalid_date: Enter today's date or the date you contacted the client.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this would be the error that's given for agencies that are not STI? i.e. sandbox?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, it's the default used when an STI subclass doesn't override it (as cbv_applicant/ma and cbv_applicant/nyc both do.)

cbv_applicant/ma:
attributes:
agency_id_number:
blank: Enter a valid agency ID number.
invalid_format: Agency ID number must be 7 digits.
beacon_id:
invalid_format: Your WELID must be 6 characters. It can only include letters and numbers.
snap_application_date:
invalid_date: Enter today's date or the date you contacted the client. This date must be today or in the past year.
cbv_applicant/nyc:
Copy link
Contributor

@allthesignals allthesignals Feb 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my MDR sense is that this is "scary," but IDk why — it's unusual. Why does it work?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Rails i18n gem is mysterious and important. In this case, it's because the validation error message lookup logic is aware of potential STI ancestors and tries multiple different keys until it finds one that matches.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahhh. i see. the inheriting model serializes its name with the slash. fascinating thx

attributes:
case_number:
invalid_format: Enter the case number in the ANGIE/sPOS format, i.e. 00012345678A.
client_id_number:
invalid_format: Enter the client's CIN in the correct format, XX00000X.
first_name:
blank: Enter the client's first name.
last_name:
blank: Enter the client's last name.
snap_application_date:
default_invalid_date: Enter today's date or the date you contacted the client.
ma_invalid_date: Enter today's date or the date you contacted the client. This date must be today or in the past year.
nyc_invalid_date: SNAP interview date must be today or in the past 30 days.
invalid_date: SNAP interview date must be today or in the past 30 days.
cbv_applicant/sandbox:
attributes:
agency_id_number:
blank: Enter a valid agency ID number.
invalid_format: Agency ID number must be 7 digits.
beacon_id:
invalid_format: Your WELID must be 6 characters. It can only include letters and numbers.
case_number:
invalid_format: Enter the case number in the ANGIE/sPOS format, i.e. 00012345678A.
client_id_number:
invalid_format: Enter the client's CIN in the correct format, XX00000X.
cbv_flow_invitation:
attributes:
email_address:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@
cbv_flow_invitation: cbv_flow_invitation_params
}
expected_errors = [
I18n.t('activerecord.errors.models.cbv_applicant.attributes.agency_id_number.invalid_format'),
I18n.t('activerecord.errors.models.cbv_applicant.attributes.beacon_id.invalid_format')
I18n.t('activerecord.errors.models.cbv_applicant/ma.attributes.agency_id_number.invalid_format'),
I18n.t('activerecord.errors.models.cbv_applicant/ma.attributes.beacon_id.invalid_format')
]
expected_error_message = "<ul><li>#{expected_errors.join('</li><li>')}</li></ul>"
expect(flash[:alert]).to eq(expected_error_message)
Expand Down
45 changes: 45 additions & 0 deletions app/spec/models/cbv_applicant/ma_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
require 'rails_helper'

RSpec.describe CbvApplicant::Ma, type: :model do
let(:ma_attributes) { attributes_for(:cbv_applicant, :ma) }

context "user input is invalid" do
it "requires agency_id_number" do
applicant = CbvApplicant.new(ma_attributes.without(:agency_id_number))
expect(applicant).not_to be_valid
expect(applicant.errors[:agency_id_number]).to include(
I18n.t('activerecord.errors.models.cbv_applicant/ma.attributes.agency_id_number.invalid_format'),
)
end

it "requires beacon_id" do
applicant = CbvApplicant.new(ma_attributes.without(:beacon_id))
expect(applicant).not_to be_valid
expect(applicant.errors[:beacon_id]).to include(
I18n.t('activerecord.errors.models.cbv_applicant/ma.attributes.beacon_id.invalid_format')
)
end

it "requires beacon_id to have 6 alphanumeric characters" do
applicant = CbvApplicant.new(ma_attributes.merge(beacon_id: '12345'))
expect(applicant).not_to be_valid
expect(applicant.errors[:beacon_id]).to include(
I18n.t('activerecord.errors.models.cbv_applicant/ma.attributes.beacon_id.invalid_format')
)
end

it "validates agency_id_number format" do
applicant = CbvApplicant.new(ma_attributes.merge(agency_id_number: 'invalid'))
expect(applicant).not_to be_valid
expect(applicant.errors[:agency_id_number]).to include(
I18n.t('activerecord.errors.models.cbv_applicant/ma.attributes.agency_id_number.invalid_format')
)
end

it "does not require client_id_number" do
applicant = CbvApplicant.new(ma_attributes.merge(client_id_number: nil))
expect(applicant).to be_valid
expect(applicant.errors[:client_id_number]).to be_empty
end
end
end
83 changes: 83 additions & 0 deletions app/spec/models/cbv_applicant/nyc_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
require 'rails_helper'

RSpec.describe CbvApplicant::Nyc, type: :model do
let(:nyc_attributes) { attributes_for(:cbv_applicant, :nyc) }

context "user input is valid" do
it "formats a 9-character case number with leading zeros" do
applicant = CbvApplicant.new(nyc_attributes.merge(case_number: '12345678A'))
expect(applicant).to be_valid
expect(applicant.case_number).to eq('00012345678A')
end

it "converts case number to uppercase" do
applicant = CbvApplicant.new(nyc_attributes.merge(case_number: '12345678a'))
expect(applicant).to be_valid
expect(applicant.case_number).to eq('00012345678A')
end

it "validates snap_application_date is not older than 30 days" do
applicant = CbvApplicant.new(nyc_attributes.merge(snap_application_date: 31.days.ago))
expect(applicant).not_to be_valid
expect(applicant.errors[:snap_application_date]).to include(
I18n.t('activerecord.errors.models.cbv_applicant/nyc.attributes.snap_application_date.invalid_date')
)
end
end

context "user input is invalid" do
it "requires case_number" do
applicant = CbvApplicant.new(nyc_attributes.merge(case_number: nil))
expect(applicant).not_to be_valid
expect(applicant.errors[:case_number]).to include(
I18n.t('activerecord.errors.models.cbv_applicant/nyc.attributes.case_number.invalid_format'),
)
end

it "validates invalid case_number format" do
applicant = CbvApplicant.new(nyc_attributes.merge(case_number: 'invalid'))
expect(applicant).not_to be_valid
expect(applicant.errors[:case_number]).to include(
I18n.t('activerecord.errors.models.cbv_applicant/nyc.attributes.case_number.invalid_format')
)
end

it "checks that a shorter case number is invalid" do
applicant = CbvApplicant.new(nyc_attributes.merge(case_number: '123A'))
expect(applicant).not_to be_valid
expect(applicant.errors[:case_number]).to include(
I18n.t('activerecord.errors.models.cbv_applicant/nyc.attributes.case_number.invalid_format')
)
end

it "validates an invalid 11 char string" do
applicant = CbvApplicant.new(nyc_attributes.merge(case_number: '1234567890A'))
expect(applicant).not_to be_valid
expect(applicant.case_number).to eq('1234567890A')
end

it "validates client_id_number format when present" do
applicant = CbvApplicant.new(nyc_attributes.merge(client_id_number: 'invalid'))
expect(applicant).not_to be_valid
expect(applicant.errors[:client_id_number]).to include(
I18n.t('activerecord.errors.models.cbv_applicant/nyc.attributes.client_id_number.invalid_format')
)
end

it "requires valid snap_application_date" do
applicant = CbvApplicant.new(nyc_attributes.merge(snap_application_date: "invalid"))
expect(applicant).not_to be_valid
expect(applicant.errors[:snap_application_date]).to include(
I18n.t('activerecord.errors.models.cbv_applicant/nyc.attributes.snap_application_date.invalid_date')
)
end

it "requires client_id_number" do
applicant = CbvApplicant.new(nyc_attributes.merge(client_id_number: nil))
expect(applicant).not_to be_valid
expect(applicant.errors[:client_id_number]).to include(
I18n.t('activerecord.errors.models.cbv_applicant/nyc.attributes.client_id_number.invalid_format')
)
end
end
end
Loading