Skip to content
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
2 changes: 2 additions & 0 deletions adminapp/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ export default {
post(`/adminapi/v1/marketing_lists/${id}/destroy`, data, ...args),
rebuildMarketingList: ({ id, ...data }, ...args) =>
post(`/adminapi/v1/marketing_lists/${id}/rebuild`, data, ...args),
uploadingMarketingListCsv: ({ id, ...data }, ...args) =>
postForm(`/adminapi/v1/marketing_lists/${id}/upload_csv`, data, ...args),

getMarketingSmsBroadcasts: (data, ...args) =>
get(`/adminapi/v1/marketing_sms_broadcasts`, data, ...args),
Expand Down
56 changes: 56 additions & 0 deletions adminapp/src/components/FileUploadInput.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import useErrorSnackbar from "../hooks/useErrorSnackbar";
import useToggle from "../shared/react/useToggle";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import { Button, CircularProgress } from "@mui/material";
import ButtonGroup from "@mui/material/ButtonGroup";
import { useSnackbar } from "notistack";
import React from "react";

export default function FileUploadInput({ accept, label, onUpload }) {
label = label || "Choose File";
accept = accept || "*.*";
const [file, setFile] = React.useState("");
const uploading = useToggle();
const { enqueueErrorSnackbar } = useErrorSnackbar();
const { enqueueSnackbar } = useSnackbar();

function handleFileChange(e) {
setFile(e.target.files?.[0] ?? null);
}

async function handleUpload(e) {
e.preventDefault();
if (!file) {
return;
}
uploading.turnOn();

try {
await onUpload(file);
enqueueSnackbar(`Uploaded ${file.name}`, { variant: "success" });
setFile(null);
} catch (err) {
enqueueErrorSnackbar(err);
} finally {
uploading.turnOff();
}
}

return (
<ButtonGroup variant="outlined">
<Button component="label" variant="outlined">
{file ? file.name : label}
<input type="file" accept={accept || "*.*"} hidden onChange={handleFileChange} />
</Button>

<Button
aria-label="Upload"
variant="contained"
disabled={!file || uploading.isOn}
onClick={handleUpload}
>
{uploading.isOn ? <CircularProgress size={20} /> : <UploadFileIcon />}
</Button>
</ButtonGroup>
);
}
14 changes: 14 additions & 0 deletions adminapp/src/pages/MarketingListDetailPage.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import api from "../api";
import AdminLink from "../components/AdminLink";
import FileUploadInput from "../components/FileUploadInput";
import RelatedListRemote from "../components/RelatedListRemote";
import ResourceDetail from "../components/ResourceDetail";
import resourceDetailCommonFields from "../components/resourceDetailCommonFields";
Expand All @@ -25,6 +26,14 @@ export default function MarketingListDetailPage() {
.finally(notBusy);
}

function handleCsvUpload(file, setModel) {
busy();
api
.uploadingMarketingListCsv({ id, file })
.then((r) => setModel(r.data))
.finally(notBusy);
}

if (isBusy) {
return <CircularProgress />;
}
Expand All @@ -49,6 +58,11 @@ export default function MarketingListDetailPage() {
]}
>
{(model, setModel) => [
<FileUploadInput
accept=".csv"
label="Choose ID/Name/Email CSV"
onUpload={(f) => handleCsvUpload(f, setModel)}
/>,
model.managed && (
<div>
<Button
Expand Down
28 changes: 25 additions & 3 deletions lib/suma/admin_api/marketing_lists.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ class DetailedListEntity < MarketingListEntity
expose_related :sms_broadcasts, with: MarketingSmsBroadcastEntity
end

helpers do
def must_be_unmanaged!(m)
adminerror!(403, "Managed lists cannot be edited", code: "marketing_list_managed") if m.managed?
end

def must_be_managed!(m)
adminerror!(403, "Only managed lists can be rebuilt", code: "marketing_list_unmanaged") unless m.managed?
end
end

resource :marketing_lists do
Suma::AdminAPI::CommonEndpoints.list(
self,
Expand Down Expand Up @@ -41,7 +51,7 @@ class DetailedListEntity < MarketingListEntity
Suma::Marketing::List,
DetailedListEntity,
around: lambda do |rt, m, &block|
rt.adminerror!(403, "Managed lists cannot be edited", code: "marketing_list_managed") if m.managed?
rt.must_be_unmanaged!(m)
members = rt.params.delete(:members)
block.call
m.member_pks = members.map { |l| l.fetch(:id) } if
Expand Down Expand Up @@ -70,8 +80,7 @@ def lookup
post :rebuild do
check_admin_role_access!(:read, :marketing_sms)
list = lookup
sleep(1)
adminerror!(403, "Only managed lists can be rebuilt", code: "marketing_list_unmanaged") unless list.managed
must_be_managed!(list)
spec = Suma::Marketing::List::Specification.gather_all.find { |spec| spec.full_label == list.label }
if spec.nil?
msg = "Could not find a list specification- this list should be unmanaged, please alert a developer"
Expand All @@ -82,6 +91,19 @@ def lookup
status 200
present list, with: DetailedListEntity
end

params do
requires :file, type: File
end
post :upload_csv do
check_admin_role_access!(:write, :marketing_sms)
list = lookup
must_be_unmanaged!(list)
csv = params[:file].fetch(:tempfile).read
list.update_from_csv(csv)
status 200
present list, with: DetailedListEntity
end
end
end
end
33 changes: 33 additions & 0 deletions lib/suma/marketing/list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,39 @@ def rebuild_all(*specs)

def managed? = self.managed

# Given a CSV string, treat each cell as an ID, phone, or email.
# Add any members we find to the list.
def update_from_csv(txt)
csv = CSV.parse(txt)
ids = []
emails = []
phones = []
csv.each do |row|
row.each do |cell|
next if cell.blank?
cell = cell.strip
if cell.include?("@")
emails << cell
else
ids << cell.to_i if /^\d+$/.match?(cell)
if (norm = Suma::PhoneNumber::US.normalize_valid(cell))
phones << norm
end
end
end
end
ds = self.db[:members].where(Sequel[id: ids] | Sequel[email: emails] | Sequel[phone: phones])
ds = ds.exclude(id: self.members_dataset.select(:id))

self.db[:marketing_lists_members].
import(
[:marketing_list_id, :member_id],
ds.select(Sequel.as(self.id, :marketing_list_id), Sequel[:id].as(:member_id)),
)

self.refresh
end

def rel_admin_link = "/marketing-list/#{self.id}"

def hybrid_search_fields
Expand Down
10 changes: 10 additions & 0 deletions lib/suma/phone_number.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,27 @@ class BadFormat < StandardError; end
class US
REGEXP = /^1[0-9]{10}$/

# Normalize s. May not be valid.
def self.normalize(s)
norm = Phony.normalize(s, cc: "1")
norm = "1#{norm}" if norm.length == 10 && norm.first == "1"
return norm
end

# Return the normalized version of s if valid, nil if invalid.
def self.normalize_valid(s)
norm = self.normalize(s)
return norm if self.valid_normalized?(norm)
return nil
end

# Return true if s is valid once normalized.
def self.valid?(s)
return false if s.nil?
return self.valid_normalized?(self.normalize(s))
end

# Return true if s is a valid normalized number.
def self.valid_normalized?(s)
return REGEXP.match?(s)
end
Expand Down
4 changes: 4 additions & 0 deletions lib/suma/spec_helpers/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -415,5 +415,9 @@ def make_json_request(env, params)
j = Yajl::Encoder.encode(params)
return env, j
end

def in_memory_rack_file(str, content_type="text/plain", binary: false, filename: "inmemory")
return Rack::Test::UploadedFile.new(StringIO.new(str), content_type, binary, original_filename: filename)
end
end
end
29 changes: 29 additions & 0 deletions spec/suma/admin_api/marketing_lists_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,33 @@ def make_item(i)
expect(last_response).to have_json_body.that_includes(error: include(code: "marketing_list_spec_missing"))
end
end

describe "POST /v1/marketing_lists/:id/upload_csv" do
it "updates the members" do
m1 = Suma::Fixtures.member.create(phone: "15552223333")
m2 = Suma::Fixtures.member.create(email: "a@b.c")
m3 = Suma::Fixtures.member.create

csv_str = "#{m1.us_phone},#{m2.email}\n#{m3.id},garbage\n000,x@y.z\n"
attachment = in_memory_rack_file(csv_str, "text/csv")

o = Suma::Fixtures.marketing_list.create

post "/v1/marketing_lists/#{o.id}/upload_csv", file: attachment

expect(last_response).to have_status(200)
expect(o.members).to have_same_ids_as(m1, m2, m3)
end

it "errors if the list is managed" do
attachment = in_memory_rack_file("", "text/csv")

o = Suma::Fixtures.marketing_list.create(managed: true)

post "/v1/marketing_lists/#{o.id}/upload_csv", file: attachment

expect(last_response).to have_status(403)
expect(last_response).to have_json_body.that_includes(error: include(code: "marketing_list_managed"))
end
end
end
22 changes: 22 additions & 0 deletions spec/suma/marketing/list_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,26 @@
expect(list.members).to contain_exactly(be === member1, be === member3)
end
end

describe "update_from_csv" do
it "adds members based on their phone, email, or id" do
list = Suma::Fixtures.marketing_list.create
existing = Suma::Fixtures.member.create
m2 = Suma::Fixtures.member.create
m3 = Suma::Fixtures.member.create
m4 = Suma::Fixtures.member.create
m5 = Suma::Fixtures.member.create
_not_on_list = Suma::Fixtures.member.create

list.add_member(existing)
csv = <<~CSV
#{m2.id} , #{m3.phone} , #{m4.email}
#{m5.us_phone} ,00,a@b.z
999-999-999999,,33-33
CSV

list.update_from_csv(csv)
expect(list.members).to have_same_ids_as(existing, m2, m3, m4, m5)
end
end
end
12 changes: 12 additions & 0 deletions spec/suma/phone_number_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@
expect { described_class.format("3334445555") }.to raise_error(Suma::PhoneNumber::BadFormat)
expect(described_class.format?("3334445555")).to be_nil
end

it "handles normalization and validity" do
expect(described_class.normalize("333")).to eq("1333")
expect(described_class.normalize("3334445555")).to eq("13334445555")
expect(described_class.normalize_valid("333")).to be_nil
expect(described_class.normalize_valid("3334445555")).to eq("13334445555")
expect(described_class.valid?("333")).to be(false)
expect(described_class.valid?("3334445555")).to be(true)
expect(described_class.valid_normalized?("333")).to be(false)
expect(described_class.valid_normalized?("3334445555")).to be(false)
expect(described_class.valid_normalized?("13334445555")).to be(true)
end
end

describe "format_e164" do
Expand Down
Loading