Skip to content
Closed
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
1 change: 1 addition & 0 deletions .cla/contributors.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

### Enhanced
- Introduced new status management for contacts, allowing updates to their subscription status (active, unsubscribed, unreachable).
- CSV import now supports importing contacts with all status types (active, unsubscribed, unreachable)
- Updated CSV import template to include Status column
- Contact status is now properly preserved during CSV import operations


## Version 0.17.1

Expand Down
7 changes: 4 additions & 3 deletions assets/static/downloads/keila_import_template.csv
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
Email,First name,Last name,Data,External ID
jane@example.com,Jane,Doe,"{""marketing_consent"": true, ""age"": 34, ""birthplace"": ""Paris"", ""tags"":[""rocket-scientist""]}",
john@example.com,John,Smith,"{""birthplace"": ""Berlin"", ""tags"":[""book-enthusiast""]}",
Email,First name,Last name,Data,Status,External ID
jane@example.com,Jane,Doe,"{""marketing_consent"": true, ""age"": 34, ""birthplace"": ""Paris"", ""tags"":[""rocket-scientist""]}",active,
john@example.com,John,Smith,"{""birthplace"": ""Berlin"", ""tags"":[""book-enthusiast""]}",unsubscribed,
test@example.com,Test,User,"{}",unreachable,12345
16 changes: 9 additions & 7 deletions lib/keila/contacts/contacts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ defmodule Keila.Contacts do
{:ok, Contact.t()} | {:error, Changeset.t(Contact.t())}
def create_contact(project_id, params, opts \\ [])
when is_binary(project_id) or is_integer(project_id) do
params
|> Contact.creation_changeset(project_id)
Contact.creation_changeset(%Contact{}, params, project_id)
|> maybe_update_contact_status(params, opts[:set_status])
|> Repo.insert()
end
Expand Down Expand Up @@ -193,11 +192,14 @@ defmodule Keila.Contacts do
with the format `{:contacts_import_progress, imported_contacts, import_total}`

The structure of the CSV file has to be:
| Email | First name | Last name |
| ------------ |------------| ---------- |
| foo@example.com | Foo | Bar |

The `First name` and `Last name` columns can be empty but must be present.
| Email | First name | Last name | Data | Status | External ID |
| ------------ |------------| ---------- | ---- | ------ | ----------- |
| foo@example.com | Foo | Bar | {} | active | 123 |

The `First name`, `Last name`, `Data`, `Status`, and `External ID` columns can be empty but must be present.

Valid status values are: `active`, `unsubscribed`, `unreachable` (case-insensitive).
If no status is provided or status column is missing, contacts default to `active`.

## Options
- `:notify` - PID of the process that is going to be sent progress notifications. Defaults to `self()`.
Expand Down
59 changes: 30 additions & 29 deletions lib/keila/contacts/import.ex
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,18 @@ defmodule Keila.Contacts.Import do
|> Enum.map(fn {key, _} -> key end)

fn row ->
Enum.zip(columns, row)
params = Enum.zip(columns, row)
|> Enum.into(%{})
|> Map.update(:data, nil, &update_data_param/1)
|> then(fn row ->
unless contact_not_active?(row) do
Contact.creation_changeset(row, project_id)
end
end)

changeset = Contact.creation_changeset(%Contact{}, params, project_id, [])

# If there's a status in the row data, apply status processing
if Map.has_key?(params, :status) do
Contact.update_status_changeset(changeset, params)
else
changeset
end
end
end

Expand All @@ -140,40 +144,34 @@ defmodule Keila.Contacts.Import do

defp update_data_param(_), do: nil

# If the :status column is present, it must be "active"
defp contact_not_active?(row)

defp contact_not_active?(%{status: status}) when is_binary(status) do
if status =~ ~r{active}i do
false
else
true
end
end

defp contact_not_active?(%{status: _}), do: false

defp contact_not_active?(_), do: false

# If the :status column is present, validate it's a valid status
defp read_file_line_count!(filename) do
File.stream!(filename)
|> Enum.count()
|> then(fn lines -> max(lines - 1, 0) end)
end

defp insert(changeset, _n, _project_id, :ignore) do
Repo.insert(changeset, on_conflict: :nothing)
if changeset.valid? do
Repo.insert(changeset, on_conflict: :nothing)
else
{:error, changeset}
end
end

defp insert(changeset, n, project_id, :replace) do
external_id = get_change(changeset, :external_id)
if not changeset.valid? do
{:error, changeset}
else
external_id = get_change(changeset, :external_id)

if not is_nil(external_id) do
maybe_pre_set_external_id(changeset, project_id, external_id)
end
if not is_nil(external_id) do
maybe_pre_set_external_id(changeset, project_id, external_id)
end

insert_opts = replace_insert_opts(changeset, external_id)
Repo.insert(changeset, insert_opts)
insert_opts = replace_insert_opts(changeset, external_id)
Repo.insert(changeset, insert_opts)
end
rescue
e in Postgrex.Error ->
raise_import_error!(changeset, e, n + 1)
Expand All @@ -185,7 +183,10 @@ defmodule Keila.Contacts.Import do

replace_fields =
@replace_fields
|> Enum.filter(&(not is_nil(get_change(changeset, &1))))
|> Enum.filter(fn field ->
# Include the field if there's a change for it
not is_nil(get_change(changeset, field))
end)

conflict_target =
if external_id?, do: [:external_id, :project_id], else: [:email, :project_id]
Expand Down
76 changes: 73 additions & 3 deletions lib/keila/contacts/schemas/contact.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ defmodule Keila.Contacts.Contact do
timestamps()
end

@spec creation_changeset(t(), Ecto.Changeset.data(), Keila.Projects.Project.id()) ::
@spec creation_changeset(t(), Ecto.Changeset.data(), Keila.Projects.Project.id(), Keyword.t()) ::
Ecto.Changeset.t(t())
def creation_changeset(struct \\ %__MODULE__{}, params, project_id) do
def creation_changeset(struct \\ %__MODULE__{}, params, project_id, opts \\ []) do
struct
|> cast(params, [:email, :external_id, :first_name, :last_name, :project_id, :data])
|> put_change(:project_id, project_id)
Expand All @@ -36,8 +36,19 @@ defmodule Keila.Contacts.Contact do
@spec update_status_changeset(t() | Ecto.Changeset.t(t()), Ecto.Changeset.data()) ::
Ecto.Changeset.t(t())
def update_status_changeset(struct \\ %__MODULE__{}, params) do
struct
changeset = struct
|> cast(params, [:status])

# Force status to be included in changeset if it was present in params
# This is needed because Ecto won't include it if it's the same as the default value
changeset = if Map.has_key?(params, :status) and not Map.has_key?(changeset.changes, :status) do
put_change(changeset, :status, params[:status])
else
changeset
end

changeset
|> normalize_status([])
end

@spec update_changeset(t(), Ecto.Changeset.data()) :: Ecto.Changeset.t(t())
Expand Down Expand Up @@ -148,4 +159,63 @@ defmodule Keila.Contacts.Contact do
|> validate_length(:external_id, max: 40)
|> unique_constraint([:external_id, :project_id])
end

defp normalize_status(changeset, opts) do
case get_change(changeset, :status) do
nil ->
changeset
status when is_binary(status) ->
trimmed_status = String.trim(status)

case String.downcase(trimmed_status) do
"" ->
# Empty status defaults to active
handle_valid_status(changeset, :active, trimmed_status, opts)
"active" ->
handle_valid_status(changeset, :active, trimmed_status, opts)
"unsubscribed" ->
handle_valid_status(changeset, :unsubscribed, trimmed_status, opts)
"unreachable" ->
handle_valid_status(changeset, :unreachable, trimmed_status, opts)
_invalid_status ->
# Add validation error for invalid status
add_error(changeset, :status, "must be one of: active, unsubscribed, unreachable")
end

status when is_atom(status) ->
changeset
end
end

defp handle_valid_status(changeset, normalized_status, trimmed_status, opts) do
# Check if this is an update that would downgrade status (reactivate unsubscribed/unreachable)
# Only prevent if the status was empty (not explicitly set to "active")
current_status = get_field(changeset, :status)

if should_prevent_status_downgrade?(current_status, normalized_status, trimmed_status, opts) do
# Don't change the status if it would be a downgrade and wasn't explicit
# Remove the status change entirely so the existing value is preserved
Map.update!(changeset, :changes, &Map.delete(&1, :status))
else
# Use force_change to ensure the status is included even if it's the same as the current value
force_change(changeset, :status, normalized_status)
end
end

# Prevent reactivating contacts that are unsubscribed or unreachable ONLY if status was empty
# If someone explicitly sets status to "active", allow the reactivation
defp should_prevent_status_downgrade?(_current_status, new_status, original_status_string, opts) do
# If explicit reactivation is allowed via opts, don't prevent
if Keyword.get(opts, :allow_reactivation, false) do
false
else
# During import, current_status might be empty string or nil because we're creating a changeset from scratch
# We should only prevent reactivation if the new status would be :active and the original was empty
# This logic assumes that if someone imports with empty status, they don't want to change the existing status
case {new_status, original_status_string} do
{:active, ""} -> true # Empty status trying to set to active - prevent this (preserve existing status)
_ -> false # Allow all other changes, including explicit "active", "unsubscribed", etc.
end
end
end
end
4 changes: 2 additions & 2 deletions lib/keila_web/controllers/contact_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ defmodule KeilaWeb.ContactController do
def post_new(conn, params) do
params = params["contact"] || %{}

case Contacts.create_contact(current_project(conn).id, params) do
case Contacts.create_contact(current_project(conn).id, params, set_status: true) do
{:ok, %{id: id}} ->
Keila.Tracking.log_event("create", id, %{})
redirect(conn, to: Routes.contact_path(conn, :index, current_project(conn).id))
Expand Down Expand Up @@ -205,7 +205,7 @@ defmodule KeilaWeb.ContactController do
def post_edit(conn, %{"contact" => params}) do
contact = conn.assigns.contact

with {:ok, _} <- Contacts.update_contact(contact.id, params) do
with {:ok, _} <- Contacts.update_contact(contact.id, params, update_status: true) do
redirect(conn, to: Routes.contact_path(conn, :index, current_project(conn).id))
else
{:error, changeset} -> render_edit(conn, 400, changeset)
Expand Down
13 changes: 13 additions & 0 deletions lib/keila_web/templates/contact/edit.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@
<%= text_input(f, :last_name, placeholder: gettext("Doe"), class: "text-black") %>
<% end %>
</div>
<div class="form-row">
<%= label(f, :status, gettext("Status")) %>
<span class="block text-sm mb-2">
<%= gettext("The subscription status of this contact.") %>
</span>
<%= with_validation(f, :status) do %>
<%= select(f, :status, [
{gettext("Active"), :active},
{gettext("Unsubscribed"), :unsubscribed},
{gettext("Unreachable"), :unreachable}
], class: "text-black") %>
<% end %>
</div>
<div class="form-row">
<%= label(f, :data, gettext("Data")) %>
<span class="block text-sm mb-2">
Expand Down
7 changes: 7 additions & 0 deletions lib/keila_web/templates/contact/import_live.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@
<%= gettext_md("""
Please follow the **exact** instructions on this page.
Incorrectly formatted files can not be imported.

**Status column**: Only use these exact values:
- `active` - Contact can receive emails
- `unsubscribed` - Contact has opted out
- `unreachable` - Contact's email bounces

Leave the Status column empty to keep existing contact status unchanged.
""") %>
<br />
<div x-data="{ tab: 'excel' }" class="tabs" phx-update="ignore" id="tabs">
Expand Down
1 change: 1 addition & 0 deletions priv/cldr/locales/de.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions priv/cldr/locales/fr.json

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions priv/gettext/de/LC_MESSAGES/default.po
Original file line number Diff line number Diff line change
Expand Up @@ -1835,6 +1835,31 @@ msgstr "Abgemeldete Kontakte"
msgid "with captcha"
msgstr "Mit Captcha"

#: lib/keila_web/templates/contact/edit.html.heex:79
#, elixir-autogen, elixir-format
msgid "Status"
msgstr "Status"

#: lib/keila_web/templates/contact/edit.html.heex:81
#, elixir-autogen, elixir-format
msgid "The subscription status of this contact."
msgstr "Der Abonnement-Status dieses Kontakts."

#: lib/keila_web/templates/contact/edit.html.heex:85
#, elixir-autogen, elixir-format
msgid "Active"
msgstr "Aktiv"

#: lib/keila_web/templates/contact/edit.html.heex:86
#, elixir-autogen, elixir-format
msgid "Unsubscribed"
msgstr "Abgemeldet"

#: lib/keila_web/templates/contact/edit.html.heex:87
#, elixir-autogen, elixir-format
msgid "Unreachable"
msgstr "Nicht erreichbar"

#: lib/keila_web/templates/campaign/_settings_dialog.html.heex:139
#: lib/keila_web/templates/campaign/new.html.heex:50
#, elixir-autogen, elixir-format
Expand Down
25 changes: 25 additions & 0 deletions priv/gettext/default.pot
Original file line number Diff line number Diff line change
Expand Up @@ -1773,6 +1773,31 @@ msgstr ""
msgid "with captcha"
msgstr ""

#: lib/keila_web/templates/contact/edit.html.heex:79
#, elixir-autogen, elixir-format
msgid "Status"
msgstr ""

#: lib/keila_web/templates/contact/edit.html.heex:81
#, elixir-autogen, elixir-format
msgid "The subscription status of this contact."
msgstr ""

#: lib/keila_web/templates/contact/edit.html.heex:85
#, elixir-autogen, elixir-format
msgid "Active"
msgstr ""

#: lib/keila_web/templates/contact/edit.html.heex:86
#, elixir-autogen, elixir-format
msgid "Unsubscribed"
msgstr ""

#: lib/keila_web/templates/contact/edit.html.heex:87
#, elixir-autogen, elixir-format
msgid "Unreachable"
msgstr ""

#: lib/keila_web/templates/campaign/_settings_dialog.html.heex:139
#: lib/keila_web/templates/campaign/new.html.heex:50
#, elixir-autogen, elixir-format
Expand Down
25 changes: 25 additions & 0 deletions priv/gettext/en/LC_MESSAGES/default.po
Original file line number Diff line number Diff line change
Expand Up @@ -1774,6 +1774,31 @@ msgstr ""
msgid "with captcha"
msgstr ""

#: lib/keila_web/templates/contact/edit.html.heex:79
#, elixir-autogen, elixir-format
msgid "Status"
msgstr ""

#: lib/keila_web/templates/contact/edit.html.heex:81
#, elixir-autogen, elixir-format
msgid "The subscription status of this contact."
msgstr ""

#: lib/keila_web/templates/contact/edit.html.heex:85
#, elixir-autogen, elixir-format
msgid "Active"
msgstr ""

#: lib/keila_web/templates/contact/edit.html.heex:86
#, elixir-autogen, elixir-format
msgid "Unsubscribed"
msgstr ""

#: lib/keila_web/templates/contact/edit.html.heex:87
#, elixir-autogen, elixir-format
msgid "Unreachable"
msgstr ""

#: lib/keila_web/templates/campaign/_settings_dialog.html.heex:139
#: lib/keila_web/templates/campaign/new.html.heex:50
#, elixir-autogen, elixir-format
Expand Down
Loading