Skip to content
Merged
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
2 changes: 2 additions & 0 deletions assets/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@
transform: rotate(-3deg);
}
}
/* Define the root bg color to be a slightly darker color than the base 100 used for contrast boxes */
--root-bg: var(--color-base-200);
}

@layer base {
Expand Down
2 changes: 1 addition & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ config :admin, Oban,
],
engine: Oban.Engines.Basic,
notifier: Oban.Notifiers.Postgres,
queues: [default: 10, mailers: 1],
queues: [default: 10, mailing: 2],
repo: Admin.Repo

config :admin, :scopes,
Expand Down
6 changes: 5 additions & 1 deletion coveralls.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
{
"skip_files": [
"lib/admin/release.ex",
"lib/admin_web/controllers/error_html.ex",
"lib/admin_web/telemetry.ex",
"lib/admin/application.ex"
"lib/admin/application.ex",
"lib/admin/analytics/event_store.ex",
"lib/adminweb/analytics_live/example.ex",
"lib/adminweb/analytics_live/event_generator.ex"
],

"coverage_options": {
Expand Down
10 changes: 10 additions & 0 deletions lib/admin/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -389,13 +389,23 @@ defmodule Admin.Accounts do
def get_active_members do
Repo.all(
from(m in Account,
select: %{name: m.name, email: m.email, lang: fragment("?->>?", m.extra, "lang")},
where:
not is_nil(m.last_authenticated_at) and m.last_authenticated_at > ago(90, "day") and
m.type == "individual"
)
)
end

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")},
where: fragment("?->>? = ?", m.extra, "lang", ^language) and m.type == "individual"
)
)
end

def create_member(attrs \\ %{}) do
%Account{}
|> Account.changeset(attrs)
Expand Down
40 changes: 39 additions & 1 deletion lib/admin/accounts/account.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@ defmodule Admin.Accounts.Account do
field :name, :string
field :email, :string
field :type, :string
field :extra, :map
field :last_authenticated_at, :utc_datetime

timestamps(type: :utc_datetime)
end

@doc false
def changeset(account, attrs) do
account
|> cast(attrs, [:name, :email, :type])
|> cast(attrs, [:name, :email, :type, :extra])
|> validate_required([:name, :email, :type])
|> validate_email()
|> maybe_validate_lang(:extra)
end

defp validate_email(changeset) do
Expand All @@ -28,4 +31,39 @@ defmodule Admin.Accounts.Account do
)
|> validate_length(:email, max: 160)
end

# Validates `lang` only if present; permits nil or empty maps.
defp maybe_validate_lang(changeset, field) when is_atom(field) do
map = get_field(changeset, field)

cond do
# Skip validation if nil or empty map
is_nil(map) or (is_map(map) and map == %{}) ->
changeset

# If provided but not a map, type error
not is_map(map) ->
add_error(changeset, field, "must be a map")

true ->
map_contains_string(changeset, field, map, :lang)
end
end

defp map_contains_string(changeset, field, map, key) do
case Map.get(map, key) do
nil ->
# Key absent is OK
changeset

v when is_binary(v) and v != "" ->
changeset

v when is_binary(v) ->
add_error(changeset, field, "\"lang\" must be a non-empty string")

_ ->
add_error(changeset, field, "\"lang\" must be a string")
end
end
end
2 changes: 2 additions & 0 deletions lib/admin/accounts/scope.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@

defstruct user: nil

@type t :: %__MODULE__{user: User.t() | nil}

Check warning on line 23 in lib/admin/accounts/scope.ex

View workflow job for this annotation

GitHub Actions / Test on OTP 28.0.2 / Elixir 1.19.4

unknown_type

Unknown type: Admin.Accounts.User.t/0.

Check warning on line 23 in lib/admin/accounts/scope.ex

View workflow job for this annotation

GitHub Actions / Test on OTP 27.3.4 / Elixir 1.19.4

unknown_type

Unknown type: Admin.Accounts.User.t/0.

@doc """
Creates a scope for the given user.

Expand Down
30 changes: 30 additions & 0 deletions lib/admin/accounts/user_notifier.ex
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,36 @@ defmodule Admin.Accounts.UserNotifier do
)
end

def deliver_call_to_action(user, subject, message_text, button_text, button_url) do
html_body =
EmailTemplates.render("call_to_action", %{
name: user.name,
message: message_text,
button_text: button_text,
button_url: button_url
})

deliver(
user.email,
subject,
html_body,
"""

==============================

Hi #{user.name},

#{message_text}

#{button_text} #{button_url}

==============================
#{@footer}
""",
reply_to: @support_email
)
end

@doc """
Deliver publication removal information.
"""
Expand Down
65 changes: 0 additions & 65 deletions lib/admin/mailer_worker.ex

This file was deleted.

115 changes: 115 additions & 0 deletions lib/admin/mailing_worker.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
defmodule Admin.MailingWorker do
@moduledoc """
Worker for sending batch emails to a target audience with internationalisation.
"""

use Oban.Worker, queue: :mailing

alias Admin.Accounts
alias Admin.Accounts.Scope
alias Admin.Accounts.UserNotifier
alias Admin.Notifications

@impl Oban.Worker
def perform(%Oban.Job{
args:
%{
"user_id" => user_id,
"notification_id" => notification_id
} =
_args
}) do
user = Accounts.get_user!(user_id)
scope = Scope.for_user(user)

with {:ok, notification} <- Notifications.get_notification(scope, notification_id),
included_langs = notification.localized_emails |> Enum.map(& &1.language),
{:ok, audience} <-
Notifications.get_target_audience(
scope,
notification.audience,
if(notification.use_strict_languages, do: [only_langs: included_langs], else: [])
) do
# save number of recipients to the notification
Notifications.update_recipients(notification, %{total_recipients: length(audience)})
# start sending emails
send_emails(scope, notification, audience)
# await email progress messages
await_emails(scope, notification)
else
{:error, :notification_not_found} ->
{:cancel, :notification_not_found}

{:error, error} ->
{:error, "Failed to send notification: #{inspect(error)}"}
end
end

defp send_emails(scope, notification, audience) do
job_pid = self()

Task.async(fn ->
audience
|> Enum.with_index(1)
|> Enum.each(fn {user, index} ->
send_local_email(scope, user, notification)

current_progress = trunc(index / length(audience) * 100)

send(job_pid, {:progress, current_progress})

:timer.sleep(1000)
end)

send(job_pid, {:completed})
end)
end

defp send_local_email(scope, user, notification) do
# get the localized email
case Notifications.get_local_email_from_notification(notification, user.lang) do
nil ->
:skipped

localized_email ->
# deliver the email
UserNotifier.deliver_call_to_action(
user,
localized_email.subject,
localized_email.message,
localized_email.button_text,
localized_email.button_url
)

# save message log
Notifications.save_log(
scope,
%{
email: user.email,
status: "sent"
},
notification
)

:ok
end
end

defp await_emails(scope, notification) do
receive do
{:progress, percent} ->
Notifications.report_sending_progress(scope, {:progress, notification.name, percent})
await_emails(scope, notification)

{:completed} ->
Notifications.report_sending_progress(scope, {:completed, notification.name})

{:failed} ->
Notifications.report_sending_progress(scope, {:failed, notification.name})
after
30_000 ->
Notifications.report_sending_progress(scope, {:failed, notification.name})
raise RuntimeError, "no progress after 30s"
end
end
end
Loading
Loading