Skip to content

Commit 16ff2ea

Browse files
committed
Add external_id to contacts
1 parent e4eeade commit 16ff2ea

File tree

12 files changed

+208
-26
lines changed

12 files changed

+208
-26
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
Email,First name,Last name,Data
2-
jane@example.com,Jane,Doe,"{""tags"":[""rocket-scientist""]}"
3-
john@example.com,John,Smith,"{""tags"":[""book-lover""]}"
1+
Email,First name,Last name,Data,External ID
2+
jane@example.com,Jane,Doe,"{""marketing_consent"": true, ""age"": 34, ""birthplace"": ""Paris"", ""tags"":[""rocket-scientist""]}",
3+
john@example.com,John,Smith,"{""birthplace"": ""Berlin"", ""tags"":[""book-enthusiast""]}",
Binary file not shown.

lib/keila/contacts/contacts.ex

+11-4
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,7 @@ defmodule Keila.Contacts do
6464
"""
6565
@spec get_project_contact(Project.id(), Contact.id()) :: Contact.t() | nil
6666
def get_project_contact(project_id, contact_id) do
67-
case get_contact(contact_id) do
68-
contact = %{project_id: ^project_id} -> contact
69-
_other -> nil
70-
end
67+
Repo.get_by(Contact, project_id: project_id, id: contact_id)
7168
end
7269

7370
@doc """
@@ -79,6 +76,16 @@ defmodule Keila.Contacts do
7976
Repo.get_by(Contact, project_id: project_id, email: email)
8077
end
8178

79+
@doc """
80+
Gets specified Contact within Project context. Returns `nil` if Contact couldn‘t be found
81+
or belongs to a different Project.
82+
"""
83+
@spec get_project_contact_by_external_id(Project.id(), external_id :: String.t()) ::
84+
Contact.t() | nil
85+
def get_project_contact_by_external_id(project_id, external_id) do
86+
Repo.get_by(Contact, project_id: project_id, external_id: external_id)
87+
end
88+
8289
@doc """
8390
Returns Contacts for specified Project.
8491

lib/keila/contacts/import.ex

+57-13
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,13 @@ defmodule Keila.Contacts.Import do
5959
lines = read_file_line_count!(filename)
6060
send(notify_pid, {:contacts_import_progress, 0, lines})
6161

62-
insert_opts = insert_opts(on_conflict)
63-
6462
File.stream!(filename)
6563
|> parser.parse_stream()
6664
|> Stream.map(row_function)
6765
|> Stream.reject(&is_nil/1)
6866
|> Stream.with_index()
6967
|> Stream.map(fn {changeset, n} ->
70-
case Repo.insert(changeset, insert_opts) do
68+
case insert(changeset, n, project_id, on_conflict) do
7169
{:ok, %{id: id}} ->
7270
Keila.Tracking.log_event("import", id, %{})
7371
n
@@ -107,6 +105,7 @@ defmodule Keila.Contacts.Import do
107105
columns =
108106
[
109107
email: find_header_column(headers, ~r{email.?(address)?}i),
108+
external_id: find_header_column(headers, ~r{external.?id}i),
110109
first_name: find_header_column(headers, ~r{first.?name}i),
111110
last_name: find_header_column(headers, ~r{last.?name}i),
112111
data: find_header_column(headers, ~r{data}i),
@@ -162,20 +161,65 @@ defmodule Keila.Contacts.Import do
162161
|> then(fn lines -> max(lines - 1, 0) end)
163162
end
164163

165-
defp insert_opts(on_conflict) do
166-
conflict_opts =
167-
case on_conflict do
168-
:replace -> [on_conflict: {:replace_all_except, [:id, :email, :project_id]}]
169-
:ignore -> [on_conflict: :nothing]
170-
end
164+
defp insert(changeset, _n, _project_id, :ignore) do
165+
Repo.insert(changeset, on_conflict: :nothing)
166+
end
167+
168+
defp insert(changeset, n, project_id, :replace) do
169+
external_id = get_change(changeset, :external_id)
171170

172-
[returning: false, conflict_target: [:email, :project_id]] ++ conflict_opts
171+
if not is_nil(external_id) do
172+
maybe_pre_set_external_id(changeset, project_id, external_id)
173+
end
174+
175+
insert_opts = replace_insert_opts(changeset, external_id)
176+
Repo.insert(changeset, insert_opts)
177+
rescue
178+
e in Postgrex.Error ->
179+
raise_import_error!(changeset, e, n + 1)
173180
end
174181

175-
defp raise_import_error!(changeset, line) do
182+
@replace_fields [:email, :external_id, :first_name, :last_name, :data, :updated_at, :status]
183+
defp replace_insert_opts(changeset, external_id) do
184+
external_id? = not is_nil(external_id)
185+
186+
replace_fields =
187+
@replace_fields
188+
|> Enum.filter(&(not is_nil(get_change(changeset, &1))))
189+
190+
conflict_target =
191+
if external_id?, do: [:external_id, :project_id], else: [:email, :project_id]
192+
193+
[
194+
conflict_target: conflict_target,
195+
on_conflict: {:replace, replace_fields},
196+
returning: false
197+
]
198+
end
199+
200+
# This is necessary because Postgres doesn't allow using both email and
201+
# external_id as conflict targets. Because of this and to allow updating
202+
# existing Contacts that don't have an external ID yet, this function
203+
# sets the external ID for such contacts before they are updated.
204+
defp maybe_pre_set_external_id(changeset, project_id, external_id) do
205+
email = get_change(changeset, :email)
206+
207+
if not is_nil(email) do
208+
from(c in Contact,
209+
where: c.project_id == ^project_id and c.email == ^email and is_nil(c.external_id),
210+
update: [set: [external_id: ^external_id]]
211+
)
212+
|> Repo.update_all([])
213+
end
214+
end
215+
216+
defp raise_import_error!(changeset, exception \\ nil, line) do
176217
message =
177-
case changeset.errors do
178-
[{field, {message, _}} | _] ->
218+
case {changeset, exception} do
219+
{_, %Postgrex.Error{postgres: %{code: :unique_violation}}} ->
220+
gettext("duplicate entry")
221+
222+
{%{errors: [{field, {message, _}} | _]}, _} ->
179223
gettext("Field %{field}: %{message}", field: field, message: message)
180224

181225
_other ->

lib/keila/contacts/schemas/contact.ex

+11-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ defmodule Keila.Contacts.Contact do
1111

1212
schema "contacts" do
1313
field(:email, :string)
14+
field(:external_id, :string)
1415
field(:first_name, :string)
1516
field(:last_name, :string)
1617
field(:status, Ecto.Enum, values: @statuses, default: :active)
@@ -24,10 +25,11 @@ defmodule Keila.Contacts.Contact do
2425
Ecto.Changeset.t(t())
2526
def creation_changeset(struct \\ %__MODULE__{}, params, project_id) do
2627
struct
27-
|> cast(params, [:email, :first_name, :last_name, :project_id, :data])
28+
|> cast(params, [:email, :external_id, :first_name, :last_name, :project_id, :data])
2829
|> put_change(:project_id, project_id)
2930
|> validate_email()
3031
|> validate_max_name_length()
32+
|> validate_external_id()
3133
|> check_data_size_constraint()
3234
end
3335

@@ -41,9 +43,10 @@ defmodule Keila.Contacts.Contact do
4143
@spec update_changeset(t(), Ecto.Changeset.data()) :: Ecto.Changeset.t(t())
4244
def update_changeset(struct \\ %__MODULE__{}, params) do
4345
struct
44-
|> cast(params, [:email, :first_name, :last_name, :data])
46+
|> cast(params, [:email, :external_id, :first_name, :last_name, :data])
4547
|> validate_email()
4648
|> validate_max_name_length()
49+
|> validate_external_id()
4750
|> check_data_size_constraint()
4851
|> maybe_remove_double_opt_in_at()
4952
end
@@ -139,4 +142,10 @@ defmodule Keila.Contacts.Contact do
139142
|> validate_length(:first_name, max: 50)
140143
|> validate_length(:last_name, max: 50)
141144
end
145+
146+
defp validate_external_id(changeset) do
147+
changeset
148+
|> validate_length(:external_id, max: 40)
149+
|> unique_constraint([:external_id, :project_id])
150+
end
142151
end

lib/keila_web/helpers/contacts_csv_export.ex

+11-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ defmodule KeilaWeb.ContactsCsvExport do
1717
|> send_chunked(200)
1818

1919
header =
20-
[["Email", "First name", "Last name", "Data", "Status"]]
20+
[["Email", "First name", "Last name", "Data", "Status", "External ID"]]
2121
|> NimbleCSV.RFC4180.dump_to_iodata()
2222
|> IO.iodata_to_binary()
2323

@@ -28,7 +28,16 @@ defmodule KeilaWeb.ContactsCsvExport do
2828
|> Stream.map(fn contact ->
2929
data = if is_nil(contact.data), do: nil, else: Jason.encode!(contact.data)
3030

31-
[[contact.email, contact.first_name, contact.last_name, data, contact.status]]
31+
[
32+
[
33+
contact.email,
34+
contact.first_name,
35+
contact.last_name,
36+
data,
37+
contact.status,
38+
contact.external_id
39+
]
40+
]
3241
|> NimbleCSV.RFC4180.dump_to_iodata()
3342
|> IO.iodata_to_binary()
3443
end)

lib/keila_web/templates/contact/edit.html.heex

+15
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,21 @@
9898
<% end %>
9999
<% end %>
100100
</div>
101+
<div class="form-row">
102+
<%= label(f, :email, gettext("External ID")) %>
103+
<span class="block text-sm mb-2">
104+
<%= gettext(
105+
"The external ID allows updating the contact profile from an external source even if its email address changes."
106+
) %>
107+
</span>
108+
<%= with_validation(f, :external_id) do %>
109+
<%= text_input(f, :external_id,
110+
placeholder: gettext("920207"),
111+
class: "text-black",
112+
autofocus: true
113+
) %>
114+
<% end %>
115+
</div>
101116
</.form>
102117
</div>
103118

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
defmodule Keila.Repo.Migrations.AddContactExternalId do
2+
use Ecto.Migration
3+
4+
def change do
5+
alter table("contacts") do
6+
add :external_id, :string, size: 40
7+
end
8+
9+
create unique_index("contacts", [:external_id, :project_id])
10+
end
11+
end

test/keila/contacts/contacts_test.exs

+77-1
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,19 @@ defmodule Keila.ContactsTest do
112112
end
113113

114114
@tag :contacts
115-
test "Get project contact by ID and email", %{project: project} do
115+
test "Get project contact by ID, email, and external ID", %{project: project} do
116116
contact1 = insert!(:contact, %{project_id: project.id})
117117
contact2 = insert!(:contact, %{project_id: project.id})
118+
contact3 = insert!(:contact, %{project_id: project.id, external_id: "ext"})
118119

119120
assert contact1 == Contacts.get_project_contact(contact1.project_id, contact1.id)
120121
assert contact2 == Contacts.get_project_contact_by_email(contact2.project_id, contact2.email)
122+
123+
assert contact3 ==
124+
Contacts.get_project_contact_by_external_id(
125+
contact3.project_id,
126+
contact3.external_id
127+
)
121128
end
122129

123130
@tag :contacts
@@ -274,6 +281,75 @@ defmodule Keila.ContactsTest do
274281
refute Repo.get_by(Contacts.Contact, email: "empty@example.com")
275282
end
276283

284+
@tag :contacts
285+
test "Import RFC 4180 CSV with external IDs and on_conflict: :ignore", %{project: project} do
286+
assert :ok ==
287+
Contacts.import_csv(
288+
project.id,
289+
"test/keila/contacts/import_external_ids.csv",
290+
on_conflict: :ignore
291+
)
292+
293+
contacts = Contacts.get_project_contacts(project.id)
294+
295+
expected = [
296+
%{email: "foo@example.com", external_id: nil},
297+
%{email: "foo2@example.com", external_id: "1"},
298+
%{email: "foo3@example.com", external_id: "3"}
299+
]
300+
301+
for %{email: email, external_id: external_id} <- expected do
302+
assert Enum.find(contacts, fn
303+
%{email: ^email, external_id: ^external_id} -> true
304+
_ -> false
305+
end)
306+
end
307+
308+
assert length(contacts) == length(expected)
309+
end
310+
311+
@tag :contacts
312+
test "Import CSV with external IDs and on_conflict: :replace", %{project: project} do
313+
assert :ok ==
314+
Contacts.import_csv(
315+
project.id,
316+
"test/keila/contacts/import_external_ids.csv",
317+
on_conflict: :replace
318+
)
319+
320+
contacts = Contacts.get_project_contacts(project.id)
321+
322+
expected = [
323+
%{email: "foo2@example.com", external_id: "1"},
324+
%{email: "foo3@example.com", external_id: "3"}
325+
]
326+
327+
for %{email: email, external_id: external_id} <- expected do
328+
assert Enum.find(contacts, fn
329+
%{email: ^email, external_id: ^external_id} -> true
330+
_ -> false
331+
end)
332+
end
333+
334+
assert length(contacts) == length(expected)
335+
end
336+
337+
@tag :contacts
338+
test "Gracefully handle duplicates with external IDs and on_conflict: :replace", %{
339+
project: project
340+
} do
341+
assert {:error, message} =
342+
Contacts.import_csv(
343+
project.id,
344+
"test/keila/contacts/import_external_ids_duplicate.csv",
345+
on_conflict: :replace
346+
)
347+
348+
assert message =~ "duplicate entry"
349+
350+
assert Enum.empty?(Contacts.get_project_contacts(project.id))
351+
end
352+
277353
@tag :contacts
278354
test "Import Excel TSV/CSV", %{project: project} do
279355
assert :ok == Contacts.import_csv(project.id, "test/keila/contacts/import_excel.csv")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Email,External ID
2+
foo@example.com,
3+
foo@example.com,1
4+
foo2@example.com,1
5+
foo3@example.com,3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Email,External ID
2+
foo@example.com,
3+
foo@example.com,1
4+
foo2@example.com
5+
foo2@example.com,1

test/support/factory.ex

+2-1
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ defmodule Keila.Factory do
103103
%Keila.Contacts.Contact{
104104
email: "contact-#{get_counter_value()}@example.org",
105105
first_name: "First-#{get_counter_value()}",
106-
last_name: "Last-#{get_counter_value()}"
106+
last_name: "Last-#{get_counter_value()}",
107+
external_id: Ecto.UUID.generate()
107108
}
108109
end
109110

0 commit comments

Comments
 (0)