Skip to content

Commit 2ebe81d

Browse files
committed
Add School::ImportBatch operation to import a CSV of schools.
This operation parses and validates the CSV and reports on errors or enqueues the job.
1 parent 5db08fe commit 2ebe81d

File tree

2 files changed

+290
-0
lines changed

2 files changed

+290
-0
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# frozen_string_literal: true
2+
3+
class School
4+
class ImportBatch
5+
class << self
6+
def call(csv_file:, current_user:)
7+
response = OperationResponse.new
8+
9+
parsed_schools = parse_csv(csv_file)
10+
11+
if parsed_schools[:error]
12+
response[:error] = parsed_schools[:error]
13+
return response
14+
end
15+
16+
# Check for duplicate owner emails in the CSV
17+
duplicate_check = check_duplicate_owners(parsed_schools[:schools])
18+
if duplicate_check[:error]
19+
response[:error] = duplicate_check[:error]
20+
return response
21+
end
22+
23+
job = enqueue_import_job(
24+
schools_data: parsed_schools[:schools],
25+
user_id: current_user.id
26+
)
27+
28+
response[:job_id] = job.job_id
29+
response[:total_schools] = parsed_schools[:schools].length
30+
response
31+
rescue StandardError => e
32+
Sentry.capture_exception(e)
33+
response ||= OperationResponse.new
34+
response[:error] = SchoolImportError.format_error(:unknown_error, e.message)
35+
response
36+
end
37+
38+
private
39+
40+
def check_duplicate_owners(schools)
41+
owner_emails = schools.filter_map { |s| s[:owner_email]&.strip&.downcase }
42+
duplicates = owner_emails.select { |e| owner_emails.count(e) > 1 }.uniq
43+
44+
if duplicates.any?
45+
error = SchoolImportError.format_error(
46+
:duplicate_owner_email,
47+
'Duplicate owner emails found in CSV',
48+
{ duplicate_emails: duplicates }
49+
)
50+
return { error: error }
51+
end
52+
53+
{}
54+
end
55+
56+
def parse_csv(csv_file)
57+
require 'csv'
58+
59+
csv_content = csv_file.read
60+
return { error: SchoolImportError.format_error(:csv_invalid_format, 'CSV file is empty') } if csv_content.blank?
61+
62+
csv_data = CSV.parse(csv_content, headers: true, header_converters: :symbol)
63+
64+
if csv_data.headers.nil? || !valid_headers?(csv_data.headers)
65+
return {
66+
error: SchoolImportError.format_error(
67+
:csv_invalid_format,
68+
'Invalid CSV format. Required headers: name, website, address_line_1, municipality, country_code, owner_email'
69+
)
70+
}
71+
end
72+
73+
process_csv_rows(csv_data)
74+
rescue CSV::MalformedCSVError => e
75+
{ error: SchoolImportError.format_error(:csv_malformed, "Invalid CSV file format: #{e.message}") }
76+
rescue StandardError => e
77+
Sentry.capture_exception(e)
78+
{ error: SchoolImportError.format_error(:unknown_error, "Failed to parse CSV: #{e.message}") }
79+
end
80+
81+
def process_csv_rows(csv_data)
82+
schools = []
83+
errors = []
84+
85+
csv_data.each_with_index do |row, index|
86+
row_number = index + 2 # +2 because index starts at 0 and we skip header row
87+
school_data = row.to_h
88+
89+
validation_errors = validate_school_data(school_data, row_number)
90+
if validation_errors
91+
errors << validation_errors
92+
next
93+
end
94+
95+
schools << school_data
96+
end
97+
98+
if errors.any?
99+
{ error: SchoolImportError.format_row_errors(errors) }
100+
else
101+
{ schools: schools }
102+
end
103+
end
104+
105+
def valid_headers?(headers)
106+
required = %i[name website address_line_1 municipality country_code owner_email]
107+
required.all? { |h| headers.include?(h) }
108+
end
109+
110+
def validate_school_data(data, row_number)
111+
errors = []
112+
113+
# Strip whitespace from all string fields
114+
data.each do |key, value|
115+
data[key] = value.strip if value.is_a?(String)
116+
end
117+
118+
# Validate required fields
119+
required_fields = %i[name website address_line_1 municipality country_code owner_email]
120+
required_fields.each do |field|
121+
errors << { field: field.to_s, message: 'is required' } if data[field].blank?
122+
end
123+
124+
# Validate field formats
125+
validate_country_code(data, errors)
126+
validate_website_format(data, errors)
127+
validate_email_format(data, errors)
128+
129+
return nil if errors.empty?
130+
131+
{ row: row_number, errors: errors }
132+
end
133+
134+
def validate_country_code(data, errors)
135+
return if data[:country_code].blank?
136+
return if ISO3166::Country.codes.include?(data[:country_code].upcase)
137+
138+
errors << { field: 'country_code', message: "invalid code: #{data[:country_code]}" }
139+
end
140+
141+
def validate_website_format(data, errors)
142+
return if data[:website].blank?
143+
return if data[:website].match?(School::VALID_URL_REGEX)
144+
145+
errors << { field: 'website', message: 'invalid format' }
146+
end
147+
148+
def validate_email_format(data, errors)
149+
return if data[:owner_email].blank?
150+
return if data[:owner_email].match?(URI::MailTo::EMAIL_REGEXP)
151+
152+
errors << { field: 'owner_email', message: 'invalid email format' }
153+
end
154+
155+
def enqueue_import_job(schools_data:, user_id:)
156+
SchoolImportJob.perform_later(
157+
schools_data: schools_data,
158+
user_id: user_id
159+
)
160+
end
161+
end
162+
end
163+
end
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe School::ImportBatch do
6+
describe '.call' do
7+
let(:current_user) { instance_double(User, id: SecureRandom.uuid, token: 'test-token') }
8+
let(:csv_content) do
9+
<<~CSV
10+
name,website,address_line_1,municipality,country_code,owner_email
11+
Test School 1,https://test1.example.com,123 Main St,Springfield,US,[email protected]
12+
Test School 2,https://test2.example.com,456 Oak Ave,Boston,US,[email protected]
13+
CSV
14+
end
15+
let(:csv_file) { StringIO.new(csv_content) }
16+
17+
before do
18+
allow(SchoolImportJob).to receive(:perform_later).and_return(
19+
instance_double(SchoolImportJob, job_id: 'test-job-id')
20+
)
21+
end
22+
23+
context 'with valid CSV' do
24+
it 'returns success response with job_id' do
25+
result = described_class.call(csv_file: csv_file, current_user: current_user)
26+
27+
expect(result.success?).to be true
28+
expect(result[:job_id]).to eq('test-job-id')
29+
expect(result[:total_schools]).to eq(2)
30+
end
31+
32+
it 'enqueues SchoolImportJob' do
33+
described_class.call(csv_file: csv_file, current_user: current_user)
34+
35+
expect(SchoolImportJob).to have_received(:perform_later).with(
36+
hash_including(
37+
schools_data: array_including(
38+
hash_including(name: 'Test School 1'),
39+
hash_including(name: 'Test School 2')
40+
),
41+
user_id: current_user.id,
42+
token: 'test-token'
43+
)
44+
)
45+
end
46+
end
47+
48+
context 'with missing required headers' do
49+
let(:csv_content) do
50+
<<~CSV
51+
name,website
52+
Test School,https://test.example.com
53+
CSV
54+
end
55+
56+
it 'returns error response' do
57+
result = described_class.call(csv_file: csv_file, current_user: current_user)
58+
59+
expect(result.failure?).to be true
60+
expect(result[:error][:error_code]).to eq('CSV_INVALID_FORMAT')
61+
expect(result[:error][:message]).to include('Invalid CSV format')
62+
end
63+
end
64+
65+
context 'with invalid country code' do
66+
let(:csv_content) do
67+
<<~CSV
68+
name,website,address_line_1,municipality,country_code,owner_email
69+
Test School,https://test.example.com,123 Main St,Springfield,INVALID,[email protected]
70+
CSV
71+
end
72+
73+
it 'returns validation error' do
74+
result = described_class.call(csv_file: csv_file, current_user: current_user)
75+
76+
expect(result.failure?).to be true
77+
expect(result[:error][:error_code]).to eq('CSV_VALIDATION_FAILED')
78+
expect(result[:error][:details][:row_errors]).to be_present
79+
end
80+
end
81+
82+
context 'with invalid website format' do
83+
let(:csv_content) do
84+
<<~CSV
85+
name,website,address_line_1,municipality,country_code,owner_email
86+
Test School,not-a-url,123 Main St,Springfield,US,[email protected]
87+
CSV
88+
end
89+
90+
it 'returns validation error' do
91+
result = described_class.call(csv_file: csv_file, current_user: current_user)
92+
93+
expect(result.failure?).to be true
94+
expect(result[:error][:error_code]).to eq('CSV_VALIDATION_FAILED')
95+
expect(result[:error][:details][:row_errors]).to be_present
96+
end
97+
end
98+
99+
context 'with missing required fields' do
100+
let(:csv_content) do
101+
<<~CSV
102+
name,website,address_line_1,municipality,country_code,owner_email
103+
,https://test.example.com,123 Main St,Springfield,US,[email protected]
104+
CSV
105+
end
106+
107+
it 'returns validation error' do
108+
result = described_class.call(csv_file: csv_file, current_user: current_user)
109+
110+
expect(result.failure?).to be true
111+
expect(result[:error][:error_code]).to eq('CSV_VALIDATION_FAILED')
112+
expect(result[:error][:details][:row_errors]).to be_present
113+
end
114+
end
115+
116+
context 'with malformed CSV' do
117+
let(:csv_file) { StringIO.new('this is not csv,,"') }
118+
119+
it 'returns error response' do
120+
result = described_class.call(csv_file: csv_file, current_user: current_user)
121+
122+
expect(result.failure?).to be true
123+
expect(result[:error][:error_code]).to eq('CSV_MALFORMED')
124+
end
125+
end
126+
end
127+
end

0 commit comments

Comments
 (0)