diff --git a/apps/transport/lib/transport/notification_reason.ex b/apps/transport/lib/transport/notification_reason.ex index dac7c5e5b0..116eeb54cb 100644 --- a/apps/transport/lib/transport/notification_reason.ex +++ b/apps/transport/lib/transport/notification_reason.ex @@ -145,6 +145,14 @@ defmodule Transport.NotificationReason do |> Map.keys() end + @spec subscribable_reasons_for_role(role()) :: [reason()] + def subscribable_reasons_for_role(role) do + reasons_for_role(role) + |> MapSet.new() + |> MapSet.intersection(MapSet.new(subscribable_reasons())) + |> MapSet.to_list() + end + @doc """ iex> hidden_reasons_for_role(:reuser) [:datasets_switching_climate_resilience_bill] diff --git a/apps/transport/lib/transport_web/controllers/backoffice/contact_controller.ex b/apps/transport/lib/transport_web/controllers/backoffice/contact_controller.ex index c0b8e9a82d..d3d91284a2 100644 --- a/apps/transport/lib/transport_web/controllers/backoffice/contact_controller.ex +++ b/apps/transport/lib/transport_web/controllers/backoffice/contact_controller.ex @@ -55,6 +55,118 @@ defmodule TransportWeb.Backoffice.ContactController do |> redirect(to: backoffice_contact_path(conn, :index)) end + def csv_export(%Plug.Conn{} = conn, _params) do + filename = "contacts-#{Date.utc_today() |> Date.to_iso8601()}.csv" + + query = """ + select * + from contact + join ( + select + c.id contact_id, + count(d.id) > 0 is_producer, + count(df.id) > 0 is_reuser, + array_agg(distinct o.name) organization_names, + array_agg(distinct ns_producer.reason) producer_reasons, + array_agg(distinct ns_reuser.reason) reuser_reasons + from contact c + left join contacts_organizations co on co.contact_id = c.id + left join organization o on o.id = co.organization_id + left join dataset d on d.organization_id = co.organization_id + left join dataset_followers df on df.contact_id = c.id + left join notification_subscription ns_producer on ns_producer.contact_id = c.id and ns_producer.role = 'producer' + left join notification_subscription ns_reuser on ns_reuser.contact_id = c.id and ns_reuser.role = 'reuser' + group by 1 + ) t on t.contact_id = id + """ + + csv_header = + [ + "id", + "first_name", + "last_name", + "mailing_list_title", + "email", + "phone_number", + "job_title", + "organization", + "inserted_at", + "updated_at", + "datagouv_user_id", + "last_login_at", + "creation_source", + "organization_names", + columns_for_role(:producer), + columns_for_role(:reuser) + ] + |> List.flatten() + + # Stream the query from the database and send 100 rows at a time + {:ok, conn} = + DB.Repo.transaction( + fn -> + Ecto.Adapters.SQL.stream(DB.Repo, query) + |> Stream.map(fn %Postgrex.Result{rows: rows, columns: columns} -> + Enum.map(rows, fn row -> + build_csv_row(csv_header, Enum.zip(columns, row) |> Enum.into(%{})) + end) + end) + |> send_csv_response(filename, csv_header, conn) + end, + timeout: :timer.seconds(60) + ) + + conn + end + + defp columns_for_role(role) do + ["is_#{role}" | Enum.map(Transport.NotificationReason.subscribable_reasons_for_role(role), &"#{role}_#{&1}")] + end + + defp send_csv_response(chunks, filename, csv_header, %Plug.Conn{} = conn) do + {:ok, conn} = + conn + |> put_resp_content_type("text/csv") + |> put_resp_header("content-disposition", ~s|attachment; filename="#{filename}"|) + |> send_chunked(:ok) + |> send_csv_chunk([csv_header]) + + Enum.reduce_while(chunks, conn, fn data, conn -> + case send_csv_chunk(conn, data) do + {:ok, conn} -> {:cont, conn} + {:error, :closed} -> {:halt, conn} + end + end) + end + + defp send_csv_chunk(%Plug.Conn{} = conn, data) do + chunk(conn, data |> NimbleCSV.RFC4180.dump_to_iodata()) + end + + defp build_csv_row(csv_header, row) do + row = + row + |> Map.update!("email", &Transport.Vault.decrypt!/1) + |> add_roles_columns() + + # Build a row following same order as the CSV header + Enum.map(csv_header, &Map.fetch!(row, &1)) + end + + defp add_roles_columns(%{"producer_reasons" => producer_reasons, "reuser_reasons" => reuser_reasons} = row) do + roles_columns = + Map.new(Transport.NotificationReason.subscribable_reasons_for_role(:producer), fn reason -> + {"producer_#{reason}", to_string(reason) in producer_reasons} + end) + |> Map.merge( + Map.new(Transport.NotificationReason.subscribable_reasons_for_role(:reuser), fn reason -> + {"reuser_#{reason}", to_string(reason) in reuser_reasons} + end) + ) + + Map.merge(row, roles_columns) + end + defp render_form(%Plug.Conn{assigns: assigns} = conn) do contact_id = Map.get(assigns, :contact_id) diff --git a/apps/transport/lib/transport_web/router.ex b/apps/transport/lib/transport_web/router.ex index 742a8a7ab7..39ec8b7c28 100644 --- a/apps/transport/lib/transport_web/router.ex +++ b/apps/transport/lib/transport_web/router.ex @@ -173,6 +173,7 @@ defmodule TransportWeb.Router do scope "/contacts" do get("/", ContactController, :index) get("/new", ContactController, :new) + get("/csv_export", ContactController, :csv_export) post("/create", ContactController, :create) get("/:id/edit", ContactController, :edit) post("/:id/delete", ContactController, :delete)