diff --git a/lib/keila_web/controllers/segment_controller.ex b/lib/keila_web/controllers/segment_controller.ex index a2564c84..ab72a2a0 100644 --- a/lib/keila_web/controllers/segment_controller.ex +++ b/lib/keila_web/controllers/segment_controller.ex @@ -4,7 +4,9 @@ defmodule KeilaWeb.SegmentController do import Ecto.Changeset import Phoenix.LiveView.Controller - plug :authorize when action not in [:index, :new, :create, :delete] + @csv_export_chunk_size Application.compile_env!(:keila, :csv_export_chunk_size) + + plug(:authorize when action not in [:index, :new, :create, :delete]) @spec index(Plug.Conn.t(), map()) :: Plug.Conn.t() def index(conn, _params) do @@ -71,6 +73,48 @@ defmodule KeilaWeb.SegmentController do |> render("delete.html") end + @spec contacts_export(Plug.Conn.t(), map()) :: Plug.Conn.t() + def contacts_export(conn, %{"project_id" => project_id, "id" => segment_id}) do + filename = "contacts_#{project_id}_segment_#{segment_id}.csv" + + conn = + conn + |> put_resp_header("content-disposition", "attachment; filename=\"#{filename}\"") + |> put_resp_header("content-type", "text/csv") + |> send_chunked(200) + + header = + [["Email", "First name", "Last name", "Data", "Status"]] + |> NimbleCSV.RFC4180.dump_to_iodata() + |> IO.iodata_to_binary() + + {:ok, conn} = chunk(conn, header) + + args = [ + max_rows: @csv_export_chunk_size, + filter: conn.assigns.segment.filter || %{} + ] + + Keila.Repo.transaction(fn -> + Contacts.stream_project_contacts(project_id, args) + |> Stream.map(fn contact -> + data = if is_nil(contact.data), do: nil, else: Jason.encode!(contact.data) + + [[contact.email, contact.first_name, contact.last_name, data, contact.status]] + |> NimbleCSV.RFC4180.dump_to_iodata() + |> IO.iodata_to_binary() + end) + |> Stream.chunk_every(@csv_export_chunk_size) + |> Enum.reduce_while(conn, fn chunk, conn -> + case Plug.Conn.chunk(conn, chunk) do + {:ok, conn} -> {:cont, conn} + {:error, :closed} -> {:halt, conn} + end + end) + end) + |> then(fn {:ok, conn} -> conn end) + end + defp current_project(conn), do: conn.assigns.current_project defp authorize(conn, _) do diff --git a/lib/keila_web/router.ex b/lib/keila_web/router.ex index e7d2e0cf..b826bfdb 100644 --- a/lib/keila_web/router.ex +++ b/lib/keila_web/router.ex @@ -121,6 +121,7 @@ defmodule KeilaWeb.Router do get "/projects/:project_id/segments/new", SegmentController, :new post "/projects/:project_id/segments", SegmentController, :create get "/projects/:project_id/segments/:id", SegmentController, :edit + get "/projects/:project_id/segments/:id/contacts_export", SegmentController, :contacts_export delete "/projects/:project_id/segments", SegmentController, :delete get "/projects/:project_id/campaigns", CampaignController, :index diff --git a/lib/keila_web/templates/segment/edit_live.html.heex b/lib/keila_web/templates/segment/edit_live.html.heex index d47d0a3e..22daa952 100644 --- a/lib/keila_web/templates/segment/edit_live.html.heex +++ b/lib/keila_web/templates/segment/edit_live.html.heex @@ -194,6 +194,12 @@ <%= pagination_nav(@contacts, phx_click: "change-page") %> +
+ + <%= render_icon(:document_add) %> + <%= gettext("Export contacts to CSV") %> + +
<% end %> diff --git a/test/keila_web/controllers/segment_controller_test.exs b/test/keila_web/controllers/segment_controller_test.exs index 972d33f5..f33cd6c5 100644 --- a/test/keila_web/controllers/segment_controller_test.exs +++ b/test/keila_web/controllers/segment_controller_test.exs @@ -83,4 +83,42 @@ defmodule KeilaWeb.SegmentControllerTest do assert segment == Contacts.get_segment(segment.id) end end + + @tag :segment_controller + test "GET /projects/:p_id/export CSV export contacts in multiple chunks", %{conn: conn} do + {conn, project} = with_login_and_project(conn) + + segment = + insert!(:contacts_segment, + project_id: project.id, + filter: %{"email" => %{"$like" => "%keila.io"}} + ) + + insert!(:contact, project_id: project.id, email: "test@example.com") + + contact = + insert!(:contact, project_id: project.id, status: :unreachable, email: "test1@keila.io") + + insert!(:contact, project_id: project.id, email: "test2@keila.io") + insert!(:contact, project_id: project.id, email: "test3@keila.io") + insert!(:contact, project_id: project.id, email: "test4@keila.io") + conn = get(conn, Routes.segment_path(conn, :contacts_export, project.id, segment.id)) + + assert conn.state == :chunked + rows = String.split(response(conn, 200), "\r\n") + assert length(rows) == 6 + assert Enum.at(rows, 1) =~ ~r/,unreachable/ + + {_, disposition} = List.keyfind(conn.resp_headers, "content-disposition", 0) + + assert disposition == + "attachment; filename=\"contacts_#{project.id}_segment_#{segment.id}.csv\"" + + assert [ + "Email,First name,Last name,Data,Status", + contact_row | _ + ] = rows + + assert contact_row == "test1@keila.io,#{contact.first_name},#{contact.last_name},,unreachable" + end end