diff --git a/config/config.exs b/config/config.exs index e0ac67c..858bf59 100644 --- a/config/config.exs +++ b/config/config.exs @@ -31,6 +31,18 @@ config :chore_tracker, ChoreTrackerWeb.Endpoint, # at the `config/runtime.exs`. 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) config :esbuild, version: "0.17.11", diff --git a/config/runtime.exs b/config/runtime.exs index 61c4bfd..3264afd 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -114,4 +114,7 @@ if config_env() == :prod do # config :swoosh, :api_client, Swoosh.ApiClient.Hackney # # 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 diff --git a/config/test.exs b/config/test.exs index 0723e8d..49b8f28 100644 --- a/config/test.exs +++ b/config/test.exs @@ -26,6 +26,9 @@ config :chore_tracker, ChoreTrackerWeb.Endpoint, # In test we don't send emails. 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. config :swoosh, :api_client, false diff --git a/lib/chore_tracker/application.ex b/lib/chore_tracker/application.ex index 0a77b19..aa25d64 100644 --- a/lib/chore_tracker/application.ex +++ b/lib/chore_tracker/application.ex @@ -7,9 +7,12 @@ defmodule ChoreTracker.Application do @impl true def start(_type, _args) do + Oban.Telemetry.attach_default_logger() + children = [ ChoreTrackerWeb.Telemetry, ChoreTracker.Repo, + {Oban, Application.fetch_env!(:chore_tracker, Oban)}, {DNSCluster, query: Application.get_env(:chore_tracker, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: ChoreTracker.PubSub}, # Start the Finch HTTP client for sending emails diff --git a/lib/chore_tracker/notifications/notification.ex b/lib/chore_tracker/notifications/notification.ex new file mode 100644 index 0000000..c0ed888 --- /dev/null +++ b/lib/chore_tracker/notifications/notification.ex @@ -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 + """ + Už jsi měl udělat úkol: #{chore.name} + + #{chore.description} + """ + end +end diff --git a/lib/chore_tracker/notifications/notification_scheduler.ex b/lib/chore_tracker/notifications/notification_scheduler.ex new file mode 100644 index 0000000..3365474 --- /dev/null +++ b/lib/chore_tracker/notifications/notification_scheduler.ex @@ -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 diff --git a/mix.exs b/mix.exs index f97b6c9..5badd68 100644 --- a/mix.exs +++ b/mix.exs @@ -60,7 +60,8 @@ defmodule ChoreTracker.MixProject do {:dns_cluster, "~> 0.1.1"}, {:bandit, "~> 1.2"}, {:earmark, "~> 1.4"}, - {:ex_cldr_dates_times, "~> 2.0"} + {:ex_cldr_dates_times, "~> 2.0"}, + {:oban, "~> 2.17"} ] end diff --git a/mix.lock b/mix.lock index 45628b6..507f3de 100644 --- a/mix.lock +++ b/mix.lock @@ -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"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "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_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"}, diff --git a/priv/repo/migrations/20240814192205_add_oban_jobs_table.exs b/priv/repo/migrations/20240814192205_add_oban_jobs_table.exs new file mode 100644 index 0000000..6c823f5 --- /dev/null +++ b/priv/repo/migrations/20240814192205_add_oban_jobs_table.exs @@ -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 diff --git a/test/chore_tracker/notifications_test.exs b/test/chore_tracker/notifications_test.exs new file mode 100644 index 0000000..da60978 --- /dev/null +++ b/test/chore_tracker/notifications_test.exs @@ -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 diff --git a/test/support/oban_case.ex b/test/support/oban_case.ex new file mode 100644 index 0000000..761c518 --- /dev/null +++ b/test/support/oban_case.ex @@ -0,0 +1,9 @@ +defmodule ChoreTracker.ObanCase do + use ExUnit.CaseTemplate + + using do + quote do + use Oban.Testing, repo: ChoreTracker.Repo + end + end +end