From b065be458ba835c7fe95df9e9d7f284b2ecc4fb9 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Tue, 3 Feb 2026 13:23:19 +0100 Subject: [PATCH 1/6] fix: add unsubscribe feature --- lib/admin/accounts.ex | 28 ++++++++++++-- lib/admin/accounts/account.ex | 16 ++++++++ lib/admin/accounts/user_notifier.ex | 3 +- lib/admin/notifications.ex | 27 ++++++++++++-- lib/admin_web/components/layouts.ex | 37 +++++++++++++++++-- .../controllers/account_controller.ex | 29 +++++++++++++++ lib/admin_web/controllers/account_html.ex | 5 +++ .../marketing_subscription.html.heex | 23 ++++++++++++ lib/admin_web/email_templates/templates.ex | 1 + .../templates_html/call_to_action.html.heex | 7 ++++ .../notification_live/message_live/form.ex | 3 +- lib/admin_web/router.ex | 7 ++++ ...0250806110912_create_users_auth_tables.exs | 1 + 13 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 lib/admin_web/controllers/account_controller.ex create mode 100644 lib/admin_web/controllers/account_html/marketing_subscription.html.heex diff --git a/lib/admin/accounts.ex b/lib/admin/accounts.ex index 65ea33359..2ddc48bb0 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" ) ) @@ -415,4 +433,8 @@ defmodule Admin.Accounts do |> Account.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..251816828 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,21 @@ defmodule Admin.Accounts.Account do |> validate_change(:extra, fn _, value -> validate_lang(value) end) 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/notifications.ex b/lib/admin/notifications.ex index 9a09213b7..0aa217f38 100644 --- a/lib/admin/notifications.ex +++ b/lib/admin/notifications.ex @@ -333,7 +333,15 @@ defmodule Admin.Notifications do def get_target_audience(%Scope{} = _scope, "active", opts) do audience = 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} @@ -342,7 +350,15 @@ defmodule Admin.Notifications do def get_target_audience(%Scope{} = _scope, "french", opts) do audience = 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} @@ -351,7 +367,7 @@ defmodule Admin.Notifications do def get_target_audience(%Scope{} = _scope, "graasp_team", opts) do audience = 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} @@ -367,7 +383,10 @@ 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) + + audience + |> Enum.filter(fn user -> MapSet.member?(only_langs, user.lang) end) + |> Enum.filter(fn user -> user.marketing_emails_subscribed_at != nil end) 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..2c1657454 100644 --- a/lib/admin_web/components/layouts.ex +++ b/lib/admin_web/components/layouts.ex @@ -75,6 +75,29 @@ defmodule AdminWeb.Layouts do """ end + attr :flash, :map, required: true, doc: "the map of flash messages" + 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 +356,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/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/router.ex b/lib/admin_web/router.ex index 729dc6e80..612bee6d0 100644 --- a/lib/admin_web/router.ex +++ b/lib/admin_web/router.ex @@ -60,6 +60,13 @@ defmodule AdminWeb.Router do # redirections for now get "/library", RedirectionController, :library get "/auth/login", RedirectionController, :login + get "/accounts/:account_id/marketing/unsubscribe", + AccountController, + :marketing_emails_unsubscribe + + get "/accounts/:account_id/marketing/subscribe", + AccountController, + :marketing_emails_subscribe end scope "/admin", AdminWeb do diff --git a/priv/repo/migrations/20250806110912_create_users_auth_tables.exs b/priv/repo/migrations/20250806110912_create_users_auth_tables.exs index b48540ae7..20c11689e 100644 --- a/priv/repo/migrations/20250806110912_create_users_auth_tables.exs +++ b/priv/repo/migrations/20250806110912_create_users_auth_tables.exs @@ -36,6 +36,7 @@ defmodule Admin.Repo.Migrations.CreateUsersAuthTables do add :type, :string add :extra, :map add :last_authenticated_at, :utc_datetime + add :marketing_emails_subscribed_at, :utc_datetime, default: fragment("NOW()") timestamps(type: :utc_datetime) end From a60894ae9682efcc4be038d47bb5a6509cc4b787 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Thu, 5 Feb 2026 10:23:37 +0100 Subject: [PATCH 2/6] fix: udpate meta reporting of audience --- lib/admin/mailing_worker.ex | 2 +- lib/admin/notifications.ex | 27 ++++++++++------- lib/admin_web/live/notification_live/form.ex | 4 +-- lib/admin_web/live/notification_live/show.ex | 31 +++++++++++++++----- 4 files changed, 43 insertions(+), 21 deletions(-) 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 0aa217f38..66d3d4031 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,7 +331,7 @@ 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( &%{ @@ -344,11 +344,11 @@ defmodule Admin.Notifications do ) |> 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( &%{ @@ -361,20 +361,21 @@ defmodule Admin.Notifications do ) |> 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(&%{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}") @@ -384,9 +385,13 @@ 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) - |> Enum.filter(fn user -> user.marketing_emails_subscribed_at != nil end) + filtered_audience = + audience + |> Enum.filter(fn user -> MapSet.member?(only_langs, user.lang) end) + |> Enum.filter(fn user -> 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/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/show.ex b/lib/admin_web/live/notification_live/show.ex index d618a8639..818368737 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.

@@ -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.