Skip to content
Open
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
30 changes: 26 additions & 4 deletions lib/admin/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -404,15 +416,25 @@ 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"
)
)
end

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
23 changes: 23 additions & 0 deletions lib/admin/accounts/account.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]+$/,
Expand Down
3 changes: 2 additions & 1 deletion lib/admin/accounts/user_notifier.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion lib/admin/mailing_worker.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

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,
Expand All @@ -40,10 +40,10 @@
{:error, :notification_not_found} ->
{:cancel, :notification_not_found}

{:error, :invalid_target_audience} ->

Check warning on line 43 in lib/admin/mailing_worker.ex

View workflow job for this annotation

GitHub Actions / Test on OTP 27.3.4 / Elixir 1.19.4

pattern_match

The pattern can never match the type {:error, :notification_not_found}.

Check warning on line 43 in lib/admin/mailing_worker.ex

View workflow job for this annotation

GitHub Actions / Test on OTP 28.0.2 / Elixir 1.19.4

pattern_match

The pattern can never match the type {:error, :notification_not_found}.
{:cancel, :invalid_target_audience}

{:error, error} ->

Check warning on line 46 in lib/admin/mailing_worker.ex

View workflow job for this annotation

GitHub Actions / Test on OTP 27.3.4 / Elixir 1.19.4

pattern_match_cov

The pattern pattern {'error', _error@1} can never match the type, because it is covered by previous clauses.

Check warning on line 46 in lib/admin/mailing_worker.ex

View workflow job for this annotation

GitHub Actions / Test on OTP 28.0.2 / Elixir 1.19.4

pattern_match_cov

The pattern pattern {'error', _error@1} can never match the type, because it is covered by previous clauses.
{:error, "Failed to send notification: #{inspect(error)}"}
end
end
Expand Down
49 changes: 37 additions & 12 deletions lib/admin/notifications.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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}")
Expand All @@ -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
Expand Down
42 changes: 38 additions & 4 deletions lib/admin_web/components/layouts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
<div class="navbar bg-base-100 shadow-sm ">
<div class="flex flex-row max-w-screen-xl mx-auto w-full">
<div class="navbar-start">
<.graasp_logo_link />
</div>
<div class="navbar-end"></div>
</div>
</div>
<main class="grow flex flex-col">
<div class="grow">
{render_slot(@inner_block)}
</div>
</main>

<.flash_group flash={@flash} />
"""
end

@doc """
Shows the flash group with standard titles and content.

Expand Down Expand Up @@ -333,10 +361,7 @@ defmodule AdminWeb.Layouts do
</div>
</ul>
</div>
<.link navigate={~p"/"} class="flex flex-row items-center gap-2 text-primary">
<.logo size={44} fill="var(--color-primary)" />
<span class="text-2xl font-semibold">Graasp</span>
</.link>
<.graasp_logo_link />
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
Expand Down Expand Up @@ -368,4 +393,13 @@ defmodule AdminWeb.Layouts do
</div>
"""
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)" />
<span class="text-2xl font-semibold">Graasp</span>
</.link>
"""
end
end
29 changes: 29 additions & 0 deletions lib/admin_web/controllers/account_controller.ex
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions lib/admin_web/controllers/account_html.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
defmodule AdminWeb.AccountHTML do
use AdminWeb, :html

embed_templates "account_html/*"
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Layouts.simple flash={@flash} current_scope={@current_scope}>
<div class="flex flex-col items-center mx-auto max-w-screen-md p-8 gap-2 mt-8">
<div class="prose">
<h1>{@page_title}</h1>
<%= if is_nil(@account.marketing_emails_subscribed_at) do %>
<p>
<.icon name="hero-check-circle" class="size-6 text-success mr-2" />You have successfully unsubscribed from marketing emails.
</p>
<p>
You can subscribe to marketing emails via your account settings or with the button below.
</p>
<% else %>
<p>
<.icon name="hero-check-circle" class="size-6 text-success mr-2" />You have successfully subscribed to marketing emails.
</p>
<% end %>
</div>

<.link class="btn btn-primary" href={~p"/accounts/#{@account.id}/marketing/subscribe"}>
Subscribe to Marketing Emails
</.link>
</div>
</Layouts.simple>
1 change: 1 addition & 0 deletions lib/admin_web/email_templates/templates.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@
<img src={Admin.UmamiApi.pixel_src(@pixel)} width="0" height="0" />
</mj-raw>
<% end %>
<%= if @account do %>
<mj-text align="center" color="#aaa">
<a style="color: #aaa" href={~p"/accounts/#{@account.id}/marketing/unsubscribe"}>
{gettext("unsubscribe")}
</a>
</mj-text>
<% end %>
</mj-column>
</:footer>
</.layout>
4 changes: 2 additions & 2 deletions lib/admin_web/live/notification_live/form.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion lib/admin_web/live/notification_live/message_live/form.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading