Add email notifications

This commit is contained in:
Ondřej 2024-08-15 03:01:46 +02:00
parent ebc987496f
commit 58326cada3
11 changed files with 167 additions and 1 deletions

View file

@ -31,6 +31,18 @@ config :chore_tracker, ChoreTrackerWeb.Endpoint,
# at the `config/runtime.exs`. # at the `config/runtime.exs`.
config :chore_tracker, ChoreTracker.Mailer, adapter: Swoosh.Adapters.Local config :chore_tracker, ChoreTracker.Mailer, adapter: Swoosh.Adapters.Local
config :chore_tracker, Oban,
engine: Oban.Engines.Basic,
queues: [default: 10, notifications: 10],
repo: ChoreTracker.Repo,
plugins: [
{Oban.Plugins.Cron,
crontab: [
{"10 0 * * *", ChoreTracker.Notifications.NotificationScheduler}
]},
{Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7}
]
# Configure esbuild (the version is required) # Configure esbuild (the version is required)
config :esbuild, config :esbuild,
version: "0.17.11", version: "0.17.11",

View file

@ -114,4 +114,7 @@ if config_env() == :prod do
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney # config :swoosh, :api_client, Swoosh.ApiClient.Hackney
# #
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
config :chore_tracker, ChoreTracker.Notifications,
email_from: System.get_env("NOTIFICATIONS_EMAIL_FROM")
end end

View file

@ -26,6 +26,9 @@ config :chore_tracker, ChoreTrackerWeb.Endpoint,
# In test we don't send emails. # In test we don't send emails.
config :chore_tracker, ChoreTracker.Mailer, adapter: Swoosh.Adapters.Test config :chore_tracker, ChoreTracker.Mailer, adapter: Swoosh.Adapters.Test
# To prevent Oban from running jobs and plugins during test runs, enable :testing mode in test.exs:
config :chore_tracker, Oban, testing: :inline
# Disable swoosh api client as it is only required for production adapters. # Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false config :swoosh, :api_client, false

View file

@ -7,9 +7,12 @@ defmodule ChoreTracker.Application do
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
Oban.Telemetry.attach_default_logger()
children = [ children = [
ChoreTrackerWeb.Telemetry, ChoreTrackerWeb.Telemetry,
ChoreTracker.Repo, ChoreTracker.Repo,
{Oban, Application.fetch_env!(:chore_tracker, Oban)},
{DNSCluster, query: Application.get_env(:chore_tracker, :dns_cluster_query) || :ignore}, {DNSCluster, query: Application.get_env(:chore_tracker, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: ChoreTracker.PubSub}, {Phoenix.PubSub, name: ChoreTracker.PubSub},
# Start the Finch HTTP client for sending emails # Start the Finch HTTP client for sending emails

View file

@ -0,0 +1,50 @@
defmodule ChoreTracker.Notifications.Notification do
use Oban.Worker, queue: :notifications
alias ChoreTracker.Accounts
alias ChoreTracker.Mailer
alias ChoreTracker.Chores
alias Swoosh.Email
@impl true
def perform(%Oban.Job{} = job) do
%{"chore_id" => chore_id, "assignee_id" => assignee_id, "kind" => kind} = job.args
chore = Chores.get_chore!(chore_id)
assignee = Accounts.get_user!(assignee_id)
text = build_mail(kind, chore)
from =
Application.get_env(:chore_tracker, ChoreTracker.Notifications, "notifications@example.com")
Email.new()
|> Email.from(from)
|> Email.to(assignee.email)
|> Email.text_body(text)
|> Mailer.deliver()
end
defp build_mail("next_week", chore) do
"""
Za týden máš udělat úkol: #{chore.name}
#{chore.description}
"""
end
defp build_mail("today", chore) do
"""
Dnes máš udělat úkol: #{chore.name}
#{chore.description}
"""
end
defp build_mail("overdue", chore) do
"""
jsi měl udělat úkol: #{chore.name}
#{chore.description}
"""
end
end

View file

@ -0,0 +1,42 @@
defmodule ChoreTracker.Notifications.NotificationScheduler do
use Oban.Worker
alias ChoreTracker.Chores
alias ChoreTracker.Chores.Chore
alias ChoreTracker.Notifications.Notification
@moduledoc """
This workers runs daily at specified time (time can be configured in Oban crontab in `config/config.ex`).
It runs through all chores and decides if their assignees should be notified.
"""
@impl true
def perform(_job) do
chores = Chores.list_assigned_chores()
for chore <- chores do
kind = Date.diff(chore.scheduled_at, Date.utc_today()) |> notification_kind()
schedule_notification(kind, chore)
end
{:ok, nil}
end
# Specify notification type according to number of days to next scheduled execution time.
defp notification_kind(in_days) do
case in_days do
7 -> :next_week
0 -> :today
diff when diff < 0 -> :overdue
_ -> nil
end
end
defp schedule_notification(nil, _), do: nil
defp schedule_notification(kind, %Chore{} = chore) do
%{chore_id: chore.id, assignee_id: chore.next_assignee_id, kind: kind}
|> Notification.new()
|> Oban.insert()
end
end

View file

@ -60,7 +60,8 @@ defmodule ChoreTracker.MixProject do
{:dns_cluster, "~> 0.1.1"}, {:dns_cluster, "~> 0.1.1"},
{:bandit, "~> 1.2"}, {:bandit, "~> 1.2"},
{:earmark, "~> 1.4"}, {:earmark, "~> 1.4"},
{:ex_cldr_dates_times, "~> 2.0"} {:ex_cldr_dates_times, "~> 2.0"},
{:oban, "~> 2.17"}
] ]
end end

View file

@ -30,6 +30,7 @@
"mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"oban": {:hex, :oban, "2.18.0", "092d20bfd3d70c7ecb70960f8548d300b54bb9937c7f2e56b388f3a9ed02ec68", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aace1eff6f8227ae38d4274af967d96f051c2f0a5152f2ef9809dd1f97866745"},
"phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"},
"phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"},

View file

@ -0,0 +1,13 @@
defmodule ChoreTracker.Repo.Migrations.AddObanJobsTable do
use Ecto.Migration
def up do
Oban.Migration.up(version: 12)
end
# We specify `version: 1` in `down`, ensuring that we'll roll all the way back down if
# necessary, regardless of which version we've migrated `up` to.
def down do
Oban.Migration.down(version: 1)
end
end

View file

@ -0,0 +1,29 @@
defmodule ChoreTracker.NotificationsTest do
use ChoreTracker.DataCase
use ChoreTracker.ObanCase
import ChoreTracker.AccountsFixtures
import ChoreTracker.ChoresFixtures
alias ChoreTracker.Notifications.NotificationScheduler
setup do
users =
for username <- ["socrates", "plato", "aristotle"] do
user_fixture(user_login(username))
end
%{users: users}
end
test "runs notification scheduler", %{users: users} do
today = Date.utc_today()
chore_fixture(%{assignees: users, scheduled_at: Date.add(today, 12)})
chore_fixture(%{assignees: users, scheduled_at: Date.add(today, 7)})
chore_fixture(%{assignees: users, scheduled_at: today})
chore_fixture(%{assignees: users, scheduled_at: Date.add(today, -1)})
{:ok, nil} = perform_job(NotificationScheduler, %{})
end
end

View file

@ -0,0 +1,9 @@
defmodule ChoreTracker.ObanCase do
use ExUnit.CaseTemplate
using do
quote do
use Oban.Testing, repo: ChoreTracker.Repo
end
end
end