diff --git a/lib/admin/accounts.ex b/lib/admin/accounts.ex index 65ea33359..07c7e918f 100644 --- a/lib/admin/accounts.ex +++ b/lib/admin/accounts.ex @@ -386,13 +386,25 @@ defmodule Admin.Accounts do end end - @type audience :: %{name: String.t(), email: String.t(), lang: String.t()} + @type audience :: %{ + id: Ecto.UUID.t(), + name: String.t(), + email: String.t(), + lang: String.t(), + marketing_emails_subscribed_at: DateTime.t() + } @spec get_active_members() :: [audience] def get_active_members do Repo.all( from(m in Account, - select: %{name: m.name, email: m.email, lang: fragment("?->>?", m.extra, "lang")}, + select: %{ + id: m.id, + name: m.name, + email: m.email, + lang: fragment("?->>?", m.extra, "lang"), + marketing_emails_subscribed_at: m.marketing_emails_subscribed_at + }, where: not is_nil(m.last_authenticated_at) and m.last_authenticated_at > ago(90, "day") and m.type == "individual" @@ -404,7 +416,13 @@ defmodule Admin.Accounts do def get_members_by_language(language) do Repo.all( from(m in Account, - select: %{name: m.name, email: m.email, lang: fragment("?->>?", m.extra, "lang")}, + select: %{ + id: m.id, + name: m.name, + email: m.email, + lang: fragment("?->>?", m.extra, "lang"), + marketing_emails_subscribed_at: m.marketing_emails_subscribed_at + }, where: fragment("?->>? = ?", m.extra, "lang", ^language) and m.type == "individual" ) ) @@ -412,7 +430,11 @@ defmodule Admin.Accounts do def create_member(attrs \\ %{}) do %Account{} - |> Account.changeset(attrs) + |> Account.create_changeset(attrs) |> Repo.insert() end + + def member_marketing_emails(%Account{} = account, enable_emails) do + account |> Account.marketing_emails_changeset(enable_emails) |> Repo.update() + end end diff --git a/lib/admin/accounts/account.ex b/lib/admin/accounts/account.ex index c46a7d72f..4aad7cac5 100644 --- a/lib/admin/accounts/account.ex +++ b/lib/admin/accounts/account.ex @@ -11,6 +11,7 @@ defmodule Admin.Accounts.Account do field :type, :string field :extra, :map field :last_authenticated_at, :utc_datetime + field :marketing_emails_subscribed_at, :utc_datetime timestamps(type: :utc_datetime) end @@ -24,6 +25,28 @@ defmodule Admin.Accounts.Account do |> validate_change(:extra, fn _, value -> validate_lang(value) end) end + @doc false + def create_changeset(account, attrs) do + account + |> changeset(attrs) + |> put_change(:marketing_emails_subscribed_at, DateTime.utc_now(:second)) + end + + def marketing_emails_changeset(account, true) do + account + |> change(%{ + marketing_emails_subscribed_at: DateTime.utc_now(:second) + }) + |> validate_required([:marketing_emails_subscribed_at]) + end + + def marketing_emails_changeset(account, false) do + account + |> change(%{ + marketing_emails_subscribed_at: nil + }) + end + defp validate_email(changeset) do changeset |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, diff --git a/lib/admin/accounts/user_notifier.ex b/lib/admin/accounts/user_notifier.ex index 1dbec2268..13fa0e803 100644 --- a/lib/admin/accounts/user_notifier.ex +++ b/lib/admin/accounts/user_notifier.ex @@ -78,7 +78,8 @@ defmodule Admin.Accounts.UserNotifier do message: message_text, button_text: button_text, button_url: button_url, - pixel: pixel + pixel: pixel, + account: user }) deliver( diff --git a/lib/admin/mailing_worker.ex b/lib/admin/mailing_worker.ex index c04493ffb..cd55bb88e 100644 --- a/lib/admin/mailing_worker.ex +++ b/lib/admin/mailing_worker.ex @@ -24,7 +24,7 @@ defmodule Admin.MailingWorker do with {:ok, notification} <- Notifications.get_notification(scope, notification_id), included_langs = notification.localized_emails |> Enum.map(& &1.language), - {:ok, audience} <- + {:ok, audience, _meta} <- Notifications.get_target_audience( scope, notification.audience, diff --git a/lib/admin/notifications.ex b/lib/admin/notifications.ex index 9a09213b7..ddd727dc2 100644 --- a/lib/admin/notifications.ex +++ b/lib/admin/notifications.ex @@ -308,7 +308,7 @@ defmodule Admin.Notifications do @type audience :: %{name: String.t(), email: String.t(), lang: String.t()} @spec get_target_audience(Scope.t(), String.t(), Keyword.t()) :: - {:ok, [audience]} | {:error, String.t()} + {:ok, [audience], %{total: integer, excluded: integer}} | {:error, String.t()} @doc """ Get the target audience for a notification. @@ -331,34 +331,51 @@ defmodule Admin.Notifications do def get_target_audience(scope, target_audience, opts \\ []) def get_target_audience(%Scope{} = _scope, "active", opts) do - audience = + {audience, meta} = Accounts.get_active_members() - |> Enum.map(&%{name: &1.name, email: &1.email, lang: &1.lang}) + |> Enum.map( + &%{ + id: &1.id, + name: &1.name, + email: &1.email, + lang: &1.lang, + marketing_emails_subscribed_at: &1.marketing_emails_subscribed_at + } + ) |> filter_audience_with_options(opts) - {:ok, audience} + {:ok, audience, meta} end def get_target_audience(%Scope{} = _scope, "french", opts) do - audience = + {audience, meta} = Accounts.get_members_by_language("fr") - |> Enum.map(&%{name: &1.name, email: &1.email, lang: &1.lang}) + |> Enum.map( + &%{ + id: &1.id, + name: &1.name, + email: &1.email, + lang: &1.lang, + marketing_emails_subscribed_at: &1.marketing_emails_subscribed_at + } + ) |> filter_audience_with_options(opts) - {:ok, audience} + {:ok, audience, meta} end def get_target_audience(%Scope{} = _scope, "graasp_team", opts) do - audience = + {audience, meta} = Accounts.list_users() - |> Enum.map(&%{name: &1.name, email: &1.email, lang: &1.language}) + |> Enum.map(&%{id: &1.id, name: &1.name, email: &1.email, lang: &1.language}) |> filter_audience_with_options(opts) - {:ok, audience} + {:ok, audience, meta} end # support legacy audience, this is what the pervious audience is converted to. - def get_target_audience(%Scope{} = _scope, "custom", _opts), do: {:ok, []} + def get_target_audience(%Scope{} = _scope, "custom", _opts), + do: {:ok, [], %{total: 0, excluded: 0}} def get_target_audience(%Scope{} = _scope, target_audience, _opts) do Logger.error("Invalid target audience: #{target_audience}") @@ -367,7 +384,15 @@ defmodule Admin.Notifications do defp filter_audience_with_options(audience, opts) do only_langs = Keyword.get(opts, :only_langs, Admin.Languages.all_values()) |> MapSet.new() - audience |> Enum.filter(fn user -> MapSet.member?(only_langs, user.lang) end) + + filtered_audience = + audience + |> Enum.filter(fn user -> + MapSet.member?(only_langs, user.lang) and user.marketing_emails_subscribed_at != nil + end) + + {filtered_audience, + %{total: length(audience), excluded: length(audience) - length(filtered_audience)}} end def create_pixel(%Scope{} = scope, %Admin.Notifications.Notification{} = notification) do diff --git a/lib/admin_web/components/layouts.ex b/lib/admin_web/components/layouts.ex index 614a0d99b..3ab660d6a 100644 --- a/lib/admin_web/components/layouts.ex +++ b/lib/admin_web/components/layouts.ex @@ -75,6 +75,34 @@ defmodule AdminWeb.Layouts do """ end + attr :flash, :map, required: true, doc: "the map of flash messages" + + attr :current_scope, :map, + default: nil, + doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)" + + slot :inner_block, required: true, doc: "the inner block of the layout" + + def simple(assigns) do + ~H""" + +
+
+ {render_slot(@inner_block)} +
+
+ + <.flash_group flash={@flash} /> + """ + end + @doc """ Shows the flash group with standard titles and content. @@ -333,10 +361,7 @@ defmodule AdminWeb.Layouts do - <.link navigate={~p"/"} class="flex flex-row items-center gap-2 text-primary"> - <.logo size={44} fill="var(--color-primary)" /> - Graasp - + <.graasp_logo_link /> """ end + + def graasp_logo_link(assigns) do + ~H""" + <.link navigate={~p"/"} class="flex flex-row items-center gap-2 text-primary"> + <.logo size={44} fill="var(--color-primary)" /> + Graasp + + """ + end end diff --git a/lib/admin_web/controllers/account_controller.ex b/lib/admin_web/controllers/account_controller.ex new file mode 100644 index 000000000..5a24c5a61 --- /dev/null +++ b/lib/admin_web/controllers/account_controller.ex @@ -0,0 +1,29 @@ +defmodule AdminWeb.AccountController do + use AdminWeb, :controller + + alias Admin.Accounts + + def marketing_emails_unsubscribe(conn, %{"account_id" => account_id}) do + account = Accounts.get_member!(account_id) + {:ok, account} = Accounts.member_marketing_emails(account, false) + + conn + |> put_flash(:info, "Unsubscribed from marketing emails") + |> render(:marketing_subscription, + page_title: "Unsubscribed from Marketing Emails", + account: account + ) + end + + def marketing_emails_subscribe(conn, %{"account_id" => account_id}) do + account = Accounts.get_member!(account_id) + {:ok, account} = Accounts.member_marketing_emails(account, true) + + conn + |> put_flash(:info, "Subscribed to marketing emails") + |> render(:marketing_subscription, + page_title: "Subscribed to Marketing Emails", + account: account + ) + end +end diff --git a/lib/admin_web/controllers/account_html.ex b/lib/admin_web/controllers/account_html.ex index e69de29bb..736dbf9dd 100644 --- a/lib/admin_web/controllers/account_html.ex +++ b/lib/admin_web/controllers/account_html.ex @@ -0,0 +1,5 @@ +defmodule AdminWeb.AccountHTML do + use AdminWeb, :html + + embed_templates "account_html/*" +end diff --git a/lib/admin_web/controllers/account_html/marketing_subscription.html.heex b/lib/admin_web/controllers/account_html/marketing_subscription.html.heex new file mode 100644 index 000000000..b456ae411 --- /dev/null +++ b/lib/admin_web/controllers/account_html/marketing_subscription.html.heex @@ -0,0 +1,23 @@ + +
+
+

{@page_title}

+ <%= if is_nil(@account.marketing_emails_subscribed_at) do %> +

+ <.icon name="hero-check-circle" class="size-6 text-success mr-2" />You have successfully unsubscribed from marketing emails. +

+

+ You can subscribe to marketing emails via your account settings or with the button below. +

+ <% else %> +

+ <.icon name="hero-check-circle" class="size-6 text-success mr-2" />You have successfully subscribed to marketing emails. +

+ <% end %> +
+ + <.link class="btn btn-primary" href={~p"/accounts/#{@account.id}/marketing/subscribe"}> + Subscribe to Marketing Emails + +
+
diff --git a/lib/admin_web/email_templates/templates.ex b/lib/admin_web/email_templates/templates.ex index 57884e7e4..0597a8b6e 100644 --- a/lib/admin_web/email_templates/templates.ex +++ b/lib/admin_web/email_templates/templates.ex @@ -58,6 +58,7 @@ defmodule AdminWeb.EmailTemplates do attr :name, :string, required: true attr :message, :string, required: true, doc: "The primary message of the email" attr :pixel, :string, doc: "The tracking pixel" + attr :account, :string, doc: "The account (for emails targetting graasp members)" attr :button_text, :string, doc: "The text of the button" attr :button_url, :string, doc: "The URL of the button" def call_to_action(assigns) diff --git a/lib/admin_web/email_templates/templates_html/call_to_action.html.heex b/lib/admin_web/email_templates/templates_html/call_to_action.html.heex index f0c6c14c1..200d0522e 100644 --- a/lib/admin_web/email_templates/templates_html/call_to_action.html.heex +++ b/lib/admin_web/email_templates/templates_html/call_to_action.html.heex @@ -37,6 +37,13 @@ <% end %> + <%= if @account do %> + + + {gettext("unsubscribe")} + + + <% end %> diff --git a/lib/admin_web/live/notification_live/form.ex b/lib/admin_web/live/notification_live/form.ex index 275c006eb..c0552bbeb 100644 --- a/lib/admin_web/live/notification_live/form.ex +++ b/lib/admin_web/live/notification_live/form.ex @@ -133,7 +133,7 @@ defmodule AdminWeb.NotificationLive.Form do notification = Notifications.get_notification!(socket.assigns.current_scope, id) included_langs = notification.localized_emails |> Enum.map(& &1.language) - {:ok, recipients} = + {:ok, recipients, _meta} = Notifications.get_target_audience( socket.assigns.current_scope, notification.audience, @@ -153,7 +153,7 @@ defmodule AdminWeb.NotificationLive.Form do @impl true def handle_event("fetch_recipients", %{"audience" => audience}, socket) do - {:ok, recipients} = + {:ok, recipients, _meta} = Notifications.get_target_audience(socket.assigns.current_scope, audience) socket = socket |> assign(:recipients, recipients) diff --git a/lib/admin_web/live/notification_live/message_live/form.ex b/lib/admin_web/live/notification_live/message_live/form.ex index c7da65489..d35e3c448 100644 --- a/lib/admin_web/live/notification_live/message_live/form.ex +++ b/lib/admin_web/live/notification_live/message_live/form.ex @@ -200,7 +200,8 @@ defmodule AdminWeb.NotificationMessageLive.Form do message: Ecto.Changeset.get_field(changeset, :message), button_text: Ecto.Changeset.get_field(changeset, :button_text), button_url: Ecto.Changeset.get_field(changeset, :button_url), - pixel: nil + pixel: nil, + account: nil }) end end diff --git a/lib/admin_web/live/notification_live/show.ex b/lib/admin_web/live/notification_live/show.ex index d618a8639..0a7f0095c 100644 --- a/lib/admin_web/live/notification_live/show.ex +++ b/lib/admin_web/live/notification_live/show.ex @@ -21,14 +21,23 @@ defmodule AdminWeb.NotificationLive.Show do <.list> <:item title="Name">{@notification.name} - <:item title="Target Audience">{@notification.audience} {length(@recipients)} + <:item title="Target Audience"> +
+ {@notification.audience} {@recipients.included} + + {gettext("Excluded: %{count} (incompatible language, or not subscribed)", + count: @recipients.excluded + )} + +
+ <:item title="Default language">
{@notification.default_language}
<:item title="Tracking Pixel"> -

+

A tracking pixel is a small image that is embedded in an email to track user interactions. The interactions are recorded in the Umami analytics platform.

@@ -136,7 +145,7 @@ defmodule AdminWeb.NotificationLive.Show do <.button navigate={~p"/admin/notifications/#{@notification}/messages/new"}> Add a localized message - <%= if length(@recipients) > 0 do %> + <%= if @recipients.included > 0 do %>
<.button variant="primary" phx-click="confirm_send_notification"> Send Notification @@ -158,7 +167,7 @@ defmodule AdminWeb.NotificationLive.Show do

Confirm Send Notification

Are you sure you want to send this notification?

- We will send an email to {length(@recipients)} users + We will send an email to {@recipients.included} users matching the audience criteria.