Skip to content

Commit 6a13f42

Browse files
committed
fix: update mailing schemas
fix: improve mailing with localized langs fix: work in progress fix: update views to use forms fix: make imrpovements fix: credo fix: make progress and have a working worker fix: make improvements
1 parent 3cc8dca commit 6a13f42

File tree

28 files changed

+1412
-484
lines changed

28 files changed

+1412
-484
lines changed

config/config.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ config :admin, Oban,
1616
],
1717
engine: Oban.Engines.Basic,
1818
notifier: Oban.Notifiers.Postgres,
19-
queues: [default: 10, mailers: 1],
19+
queues: [default: 10, mailing: 2],
2020
repo: Admin.Repo
2121

2222
config :admin, :scopes,

lib/admin/accounts.ex

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,16 @@ defmodule Admin.Accounts do
179179
|> update_user_and_delete_all_tokens()
180180
end
181181

182+
def change_user_name(user, attrs \\ %{}) do
183+
User.name_changeset(user, attrs)
184+
end
185+
186+
def update_user_name(user, attrs \\ %{}) do
187+
user
188+
|> User.name_changeset(attrs)
189+
|> Repo.update()
190+
end
191+
182192
## Session
183193

184194
@doc """
@@ -369,13 +379,23 @@ defmodule Admin.Accounts do
369379
def get_active_members do
370380
Repo.all(
371381
from(m in Account,
382+
select: %{name: m.name, email: m.email, lang: fragment("?->>?", m.extra, "lang")},
372383
where:
373384
not is_nil(m.last_authenticated_at) and m.last_authenticated_at > ago(90, "day") and
374385
m.type == "individual"
375386
)
376387
)
377388
end
378389

390+
def get_members_by_language(language) do
391+
Repo.all(
392+
from(m in Account,
393+
select: %{name: m.name, email: m.email, lang: fragment("?->>?", m.extra, "lang")},
394+
where: fragment("?->>? = ?", m.extra, "lang", ^language) and m.type == "individual"
395+
)
396+
)
397+
end
398+
379399
def create_member(attrs \\ %{}) do
380400
%Account{}
381401
|> Account.changeset(attrs)

lib/admin/accounts/account.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ defmodule Admin.Accounts.Account do
99
field :name, :string
1010
field :email, :string
1111
field :type, :string
12+
field :extra, :map
1213

1314
timestamps(type: :utc_datetime)
1415
end

lib/admin/accounts/scope.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ defmodule Admin.Accounts.Scope do
2020

2121
defstruct user: nil
2222

23+
@type t :: %__MODULE__{user: User.t() | nil}
24+
2325
@doc """
2426
Creates a scope for the given user.
2527

lib/admin/accounts/user.ex

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ defmodule Admin.Accounts.User do
1010
# when we unify the access control for all users and use user roles to define who is an admin or not.
1111
schema "admins" do
1212
field :email, :string
13+
field :name, :string
14+
field :language, :string
1315
field :password, :string, virtual: true, redact: true
1416
field :hashed_password, :string, redact: true
1517
field :confirmed_at, :utc_datetime
@@ -138,4 +140,16 @@ defmodule Admin.Accounts.User do
138140
Bcrypt.no_user_verify()
139141
false
140142
end
143+
144+
def name_changeset(user, attrs) do
145+
user
146+
|> cast(attrs, [:name])
147+
|> validate_required([:name])
148+
end
149+
150+
def language_changeset(user, attrs) do
151+
user
152+
|> cast(attrs, [:language])
153+
|> validate_required([:language])
154+
end
141155
end

lib/admin/accounts/user_notifier.ex

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,36 @@ defmodule Admin.Accounts.UserNotifier do
6969
)
7070
end
7171

72+
def deliver_call_to_action(user, subject, message_text, button_text, button_url) do
73+
html_body =
74+
EmailTemplates.render("call_to_action", %{
75+
name: user.name,
76+
message: message_text,
77+
button_text: button_text,
78+
button_url: button_url
79+
})
80+
81+
deliver(
82+
user.email,
83+
subject,
84+
html_body,
85+
"""
86+
87+
==============================
88+
89+
Hi #{user.name},
90+
91+
#{message_text}
92+
93+
#{button_text} #{button_url}
94+
95+
==============================
96+
#{@footer}
97+
""",
98+
reply_to: @support_email
99+
)
100+
end
101+
72102
@doc """
73103
Deliver publication removal information.
74104
"""

lib/admin/languages.ex

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
defmodule Admin.Languages do
2+
@moduledoc """
3+
This module handles the currently supported languages.
4+
5+
It allows to get a language list suitable for displaying a select input with some options disabled.
6+
"""
7+
@languages [
8+
%{value: "en", key: "English"},
9+
%{value: "fr", key: "French"},
10+
%{value: "es", key: "Spanish"},
11+
%{value: "de", key: "German"},
12+
%{value: "it", key: "Italian"}
13+
]
14+
15+
def all do
16+
@languages
17+
end
18+
19+
def all_options do
20+
@languages |> Enum.map(&Keyword.new(&1))
21+
end
22+
23+
def all_values do
24+
@languages |> Enum.map(& &1.value)
25+
end
26+
27+
@doc """
28+
Returns a list of languages excluding the ones with the given codes.
29+
30+
## Examples
31+
iex> Admin.Languages.excluding(["en", "fr"])
32+
[%{value: "es", key: "Spanish"}, %{value: "de", key: "German"}, %{value: "it", key: "Italian"}]
33+
"""
34+
def excluding(language_codes) when is_list(language_codes) do
35+
@languages |> Enum.reject(&(&1.value in language_codes))
36+
end
37+
38+
@doc """
39+
Returns a list of keyword lists with languages with the disabled languages. Can be used in select options.
40+
41+
## Examples
42+
iex> Admin.Languages.disabling(["en", "fr"])
43+
[
44+
[value: "en", key: "English", disabled: true],
45+
[value: "fr", key: "French", disabled: true],
46+
[value: "es", key: "Spanish", disabled: false],
47+
[value: "de", key: "German", disabled: false],
48+
[value: "it", key: "Italian", disabled: false]
49+
]
50+
"""
51+
def disabling(language_codes) when is_list(language_codes) do
52+
@languages
53+
|> Enum.map(fn %{value: value, key: key} ->
54+
Keyword.new(value: value, key: key, disabled: value in language_codes)
55+
end)
56+
end
57+
end

lib/admin/mailer_worker.ex

Lines changed: 0 additions & 65 deletions
This file was deleted.

lib/admin/mailing_worker.ex

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
defmodule Admin.MailingWorker do
2+
@moduledoc """
3+
Worker for sending batch emails to a target audience with internationalisation.
4+
"""
5+
6+
use Oban.Worker, queue: :mailing
7+
8+
alias Admin.Accounts
9+
alias Admin.Accounts.Scope
10+
alias Admin.Accounts.UserNotifier
11+
alias Admin.Notifications
12+
13+
@impl Oban.Worker
14+
def perform(%Oban.Job{
15+
args:
16+
%{
17+
"user_id" => user_id,
18+
"notification_id" => notification_id
19+
} =
20+
_args
21+
}) do
22+
user = Accounts.get_user!(user_id)
23+
scope = Scope.for_user(user)
24+
25+
with {:ok, notification} <- Notifications.get_notification(scope, notification_id),
26+
included_langs = notification.localized_emails |> Enum.map(& &1.language),
27+
{:ok, audience} <-
28+
Notifications.get_target_audience(
29+
scope,
30+
notification.audience,
31+
if(notification.use_strict_languages, do: [only_langs: included_langs], else: [])
32+
) do
33+
# save number of recipients to the notification
34+
Notifications.update_recipients(notification, %{total_recipients: length(audience)})
35+
# start sending emails
36+
send_emails(scope, notification, audience)
37+
# await email progress messages
38+
await_emails(scope, notification)
39+
else
40+
{:error, :notification_not_found} ->
41+
{:cancel, :notification_not_found}
42+
43+
{:error, error} ->
44+
{:error, "Failed to send notification: #{inspect(error)}"}
45+
end
46+
end
47+
48+
defp send_emails(scope, notification, audience) do
49+
job_pid = self()
50+
51+
Task.async(fn ->
52+
audience
53+
|> Enum.with_index(1)
54+
|> Enum.each(fn {user, index} ->
55+
send_local_email(scope, user, notification)
56+
57+
current_progress = trunc(index / length(audience) * 100)
58+
59+
send(job_pid, {:progress, current_progress})
60+
61+
:timer.sleep(1000)
62+
end)
63+
64+
send(job_pid, {:completed})
65+
end)
66+
end
67+
68+
defp send_local_email(scope, user, notification) do
69+
# get the localized email
70+
case Notifications.get_local_email_from_notification(notification, user.lang) do
71+
nil ->
72+
:skipped
73+
74+
localized_email ->
75+
# deliver the email
76+
UserNotifier.deliver_call_to_action(
77+
user,
78+
localized_email.subject,
79+
localized_email.message,
80+
localized_email.button_text,
81+
localized_email.button_url
82+
)
83+
84+
# save message log
85+
Notifications.save_log(
86+
scope,
87+
%{
88+
email: user.email,
89+
status: "sent"
90+
},
91+
notification
92+
)
93+
94+
:ok
95+
end
96+
end
97+
98+
defp await_emails(scope, notification) do
99+
receive do
100+
{:progress, percent} ->
101+
Notifications.report_sending_progress(scope, {:progress, notification.name, percent})
102+
await_emails(scope, notification)
103+
104+
{:completed} ->
105+
Notifications.report_sending_progress(scope, {:completed, notification.name})
106+
107+
{:failed} ->
108+
Notifications.report_sending_progress(scope, {:failed, notification.name})
109+
after
110+
30_000 ->
111+
Notifications.report_sending_progress(scope, {:failed, notification.name})
112+
raise RuntimeError, "no progress after 30s"
113+
end
114+
end
115+
end

0 commit comments

Comments
 (0)