diff --git a/.formatter.exs b/.formatter.exs index a8ea5b5..8690b3d 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,7 @@ [ import_deps: [ + :ash_oban, + :oban, :ash_authentication_phoenix, :ash_authentication, :ash_postgres, diff --git a/config/config.exs b/config/config.exs index 00d6ad2..28e4c6d 100644 --- a/config/config.exs +++ b/config/config.exs @@ -7,6 +7,15 @@ # General application configuration import Config +config :ash_oban, pro?: false + +config :helpcenter, Oban, + engine: Oban.Engines.Basic, + notifier: Oban.Notifiers.Postgres, + queues: [default: 10], + repo: Helpcenter.Repo, + plugins: [{Oban.Plugins.Cron, []}] + config :ash, allow_forbidden_field_for_relationships_by_default?: true, include_embedded_source_by_default?: false, diff --git a/config/dev.exs b/config/dev.exs index 02aadbd..e5d02e5 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -84,3 +84,15 @@ config :phoenix_live_view, # Disable swoosh api client as it is only required for production adapters. config :swoosh, :api_client, false + +config :helpcenter, Helpcenter.Mailer, + adapter: Swoosh.Adapters.SMTP, + relay: System.get_env("MAILTRAP_SERVER", "smtp.mailtrap.io"), + username: System.get_env("MAILTRAP_USERNAME", "76b2c94cffd164"), + password: System.get_env("MAILTRAP_PASSWORD", "ed60898722b1c7"), + ssl: false, + tls: :never, + auth: :always, + port: System.get_env("MAILTRAP_PORT", "2525"), + retries: System.get_env("MAILTRAP_RETRIES", "2"), + no_mx_lookups: System.get_env("MAILTRAP_NO_MX_LOOKUPS", "false") diff --git a/config/test.exs b/config/test.exs index adc8de6..575929f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,4 +1,5 @@ import Config +config :helpcenter, Oban, testing: :manual config :helpcenter, token_signing_secret: "eEDPOnkZl5Qv+3SbqpbbmRoZeyjgYKp6" config :ash, disable_async?: true diff --git a/lib/helpcenter/accounts.ex b/lib/helpcenter/accounts.ex index dd0c71c..13195e8 100644 --- a/lib/helpcenter/accounts.ex +++ b/lib/helpcenter/accounts.ex @@ -3,16 +3,10 @@ defmodule Helpcenter.Accounts do use Ash.Domain, otp_app: :helpcenter resources do - # Authentication - resource Helpcenter.Accounts.Token - resource Helpcenter.Accounts.User - resource Helpcenter.Accounts.Team - resource Helpcenter.Accounts.UserTeam + # the rest of the domain resources - # Authorization - resource Helpcenter.Accounts.Group - # Delete this resource Helpcenter.Accounts.Permission - resource Helpcenter.Accounts.GroupPermission - resource Helpcenter.Accounts.UserGroup + resource Helpcenter.Accounts.UserNotification do + define :notify, action: :create + end end end diff --git a/lib/helpcenter/accounts/user_notification.ex b/lib/helpcenter/accounts/user_notification.ex new file mode 100644 index 0000000..9317fc6 --- /dev/null +++ b/lib/helpcenter/accounts/user_notification.ex @@ -0,0 +1,128 @@ +# lib/helpcenter/accounts/user_notification.ex +defmodule Helpcenter.Accounts.UserNotification do + use Ash.Resource, + domain: Helpcenter.Accounts, + data_layer: AshPostgres.DataLayer, + # > Add Ash Oban extension for background jobs + extensions: [AshOban] + + postgres do + table "user_notifications" + repo Helpcenter.Repo + end + + # ================================================================ + # Ash Oban configuration to add background jobs for your resource. + # ================================================================ + oban do + triggers do + # > Ensure this trigger runs for all tenants. + list_tenants fn -> Helpcenter.Repo.all_tenants() end + + trigger :send do + # Enable debug logging for testing + debug? true + # Specify the queue to use for this trigger + queue :default + # Action on this resource that is run when the trigger is invoked + action :send + + trigger_once? true + # The action on this resource that is used to retrieve data to work with + # on this resource. In this case, we want to read unprocessed notifications. + # check in the actions block for the `unprocessed` action. + worker_read_action :unprocessed + + # The worker module that will process the job automatically added for you by the Ash Oban extension. + # You can also specify a custom worker module if needed. It is based on action name + worker_module_name Helpcenter.Accounts.UserNotification.AshOban.Worker.Send + scheduler_module_name Helpcenter.Accounts.UserNotification.AshOban.Scheduler.Send + end + end + end + + actions do + default_accept [:sender_user_id, :recipient_user_id, :subject, :body, :read_at, :status] + defaults [:read, :create, :update, :destroy] + + update :send do + description "Send a new user notification to the user" + change Helpcenter.Accounts.UserNotification.Changes.DeliverEmail + end + + read :unprocessed do + description "Read unprocessed notifications" + filter expr(processed == false) + prepare build(limit: 100, load: :recipient) + end + end + + preparations do + prepare Helpcenter.Preparations.SetTenant + end + + changes do + change Helpcenter.Changes.SetTenant + end + + multitenancy do + strategy :context + end + + attributes do + uuid_primary_key :id + + attribute :sender_user_id, :uuid do + description "The user who sent the notification" + allow_nil? true + end + + attribute :recipient_user_id, :uuid do + description "The user who received the notification" + allow_nil? false + end + + attribute :subject, :string do + description "The subject of the notification" + allow_nil? false + end + + attribute :body, :string do + description "The body of the notification" + allow_nil? false + end + + attribute :read_at, :datetime do + description "The time a notification has been read" + default nil + allow_nil? true + end + + attribute :status, :atom do + description "The status of the notification" + default :unread + allow_nil? false + constraints one_of: [:unread, :read, :archived] + end + + attribute :processed, :boolean do + description "Whether the notification has been processed" + default false + allow_nil? false + end + + timestamps() + end + + relationships do + belongs_to :sender, Helpcenter.Accounts.User do + description "The user who sent the notification" + source_attribute :recipient_user_id + end + + belongs_to :recipient, Helpcenter.Accounts.User do + description "The user who received the notification" + source_attribute :recipient_user_id + end + end +end diff --git a/lib/helpcenter/accounts/user_notification/changes/deliver_email.ex b/lib/helpcenter/accounts/user_notification/changes/deliver_email.ex new file mode 100644 index 0000000..dde57d7 --- /dev/null +++ b/lib/helpcenter/accounts/user_notification/changes/deliver_email.ex @@ -0,0 +1,39 @@ +# lib/helpcenter/accounts/user_notification/changes/deliver_email.ex +defmodule Helpcenter.Accounts.UserNotification.Changes.DeliverEmail do + use Ash.Resource.Change + import Swoosh.Email + + def change(changeset, _opts, _context) do + changeset + |> Ash.Changeset.change_attribute(:processed, true) + |> Ash.Changeset.after_action(&deliver_email/2) + end + + def atomic?(), do: true + + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end + + defp deliver_email(_changeset, notification) do + %{recipient_user_id: user_id} = notification + + # We need to know the recipient's email address + # so we can send the email. At this point + # We assume the recipient is an existing user + recipient = + Helpcenter.Accounts.User + |> Ash.get!(user_id, authorize?: false) + + # Rely on Phoenix mailer infrastructure to send the email + new() + |> from({"noreply", "noreply@example.com"}) + |> to(to_string(recipient.email)) + |> subject(notification.subject) + |> text_body(notification.body) + |> html_body(notification.body) + |> Helpcenter.Mailer.deliver!() + + {:ok, notification} + end +end diff --git a/lib/helpcenter/accounts/user_notification/changes/send_email.ex b/lib/helpcenter/accounts/user_notification/changes/send_email.ex new file mode 100644 index 0000000..ed32105 --- /dev/null +++ b/lib/helpcenter/accounts/user_notification/changes/send_email.ex @@ -0,0 +1,23 @@ +defmodule Helpcenter.Accounts.UserNotification.Changes.SendEmail do + use Ash.Resource.Change + import Swoosh.Email + + def change(changeset, _opts, _context) do + Ash.Changeset.after_action(changeset, &send_email/2) + end + + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end + + defp send_email(_changeset, notification) do + new() + |> from({"noreply", "noreply@example.com"}) + |> to("noreply@example.com") + |> subject(notification.subject || "New Notification") + |> html_body(notification.body) + |> Helpcenter.Mailer.deliver!() + + {:ok, notification} + end +end diff --git a/lib/helpcenter/application.ex b/lib/helpcenter/application.ex index f6c3c91..edd4bab 100644 --- a/lib/helpcenter/application.ex +++ b/lib/helpcenter/application.ex @@ -11,6 +11,11 @@ defmodule Helpcenter.Application do HelpcenterWeb.Telemetry, Helpcenter.Repo, {DNSCluster, query: Application.get_env(:helpcenter, :dns_cluster_query) || :ignore}, + {Oban, + AshOban.config( + Application.fetch_env!(:helpcenter, :ash_domains), + Application.fetch_env!(:helpcenter, Oban) + )}, {Phoenix.PubSub, name: Helpcenter.PubSub}, # Start the Finch HTTP client for sending emails {Finch, name: Helpcenter.Finch}, diff --git a/lib/helpcenter/changes/set_tenant.ex b/lib/helpcenter/changes/set_tenant.ex index c8c3995..524aa64 100644 --- a/lib/helpcenter/changes/set_tenant.ex +++ b/lib/helpcenter/changes/set_tenant.ex @@ -19,4 +19,8 @@ defmodule Helpcenter.Changes.SetTenant do end def change(changeset, _opts, _context), do: changeset + + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end end diff --git a/mix.exs b/mix.exs index f309d53..1ddd017 100644 --- a/mix.exs +++ b/mix.exs @@ -33,6 +33,9 @@ defmodule Helpcenter.MixProject do # Type `mix help deps` for examples and options. defp deps do [ + {:gen_smtp, "~> 1.0"}, + {:oban, "~> 2.0"}, + {:ash_oban, "~> 0.4"}, {:bcrypt_elixir, "~> 3.0"}, {:ash_authentication, "~> 4.0"}, {:ash_authentication_phoenix, "~> 2.0"}, diff --git a/mix.lock b/mix.lock index f451173..63d2fa2 100644 --- a/mix.lock +++ b/mix.lock @@ -3,6 +3,7 @@ "ash": {:hex, :ash, "3.4.71", "ce8fa3c38bb59d067647bdc87aa9198335fdeeab36660c869b72c47339fc9d69", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.24 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f255da731b5b3ec4d916b5282faecbbbe9beb64a3641b4a45ac91160ffea3cc9"}, "ash_authentication": {:hex, :ash_authentication, "4.6.0", "f466524b89166b76ec9847dc89283085119420d20bccaca6b52724081cb63911", [:mix], [{:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, "~> 2.0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "~> 0.2.13", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "6c369d50fc8c702403ceedc329552ac631e3a83e5e0677b4901d1b77bbdbdb30"}, "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.5.1", "aa11c8d58f26cd85e1de331d7e4423af2cbdfa9c35f1b5ec2c115a6badeb30d6", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.1", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "45980793197482cbcb3b76a5e60c87901ebc35f55bcc5c3399ec4cb44c605c88"}, + "ash_oban": {:hex, :ash_oban, "0.4.9", "91873f1e2c9d462554ec4902e1a90378f4351277dd81f4371c0984aed4ee300e", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:oban, "~> 2.15", [hex: :oban, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.18", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "f2d2ad468e0983f7a330f1511530d244209389a28e10bd0a05a0e30c7da6eae1"}, "ash_phoenix": {:hex, :ash_phoenix, "2.1.23", "75c5500142d44c07431fcf7473784e6eed8d32777b68616de30b2ee7c3909110", [:mix], [{:ash, ">= 3.4.31 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.4.3 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "2d640dbd57020102d1a8997ab4b42035418696ebacfa5f8cbb94466e67deb8cf"}, "ash_postgres": {:hex, :ash_postgres, "2.5.12", "cca37fb0a72114ea899f9a80cf7385b1826872263c75a7735f01898f4ff68a23", [:mix], [{:ash, ">= 3.4.69 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.62 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, ">= 3.12.1 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "b69a116b3d5b57fe868da914e1ed15286c46a0865b45f14950a38a6af06a915f"}, "ash_sql": {:hex, :ash_sql, "0.2.62", "fcf1dde5a453cb024799bd43ab25aee3a7cc4ce7a48f1456310a65aec9e7ea7a", [:mix], [{:ash, ">= 3.4.65 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "df8c72b9b1c7b2c3147334eb63e819bc8d15288e1c6f0ddcd7691530db272ce0"}, @@ -25,6 +26,7 @@ "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, + "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, @@ -41,6 +43,7 @@ "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, "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.19.4", "045adb10db1161dceb75c254782f97cdc6596e7044af456a59decb6d06da73c1", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fcc6219e6464525b808d97add17896e724131f498444a292071bf8991c99f97"}, "owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"}, "phoenix": {:hex, :phoenix, "1.7.20", "6bababaf27d59f5628f9b608de902a021be2cecefb8231e1dbdc0a2e2e480e9b", [: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", "6be2ab98302e8784a31829e0d50d8bdfa81a23cd912c395bafd8b8bfb5a086c2"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [: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", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, @@ -56,6 +59,7 @@ "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "reactor": {:hex, :reactor, "0.15.0", "556937d9310e1a6dd06083592b9eb9e0d212540b6d82faecba70823ee7a0747d", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "f634383a7760ba3106d31a3185f2e2c39e1485d899d884d94c22c62c9b5e7a4a"}, "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, "rewrite": {:hex, :rewrite, "1.1.2", "f5a5d10f5fed1491a6ff48e078d4585882695962ccc9e6c779bae025d1f92eda", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "7f8b94b1e3528d0a47b3e8b7bfeca559d2948a65fa7418a9ad7d7712703d39d4"}, diff --git a/priv/repo/migrations/20250616192944_add_user_notifications.exs b/priv/repo/migrations/20250616192944_add_user_notifications.exs new file mode 100644 index 0000000..fb22236 --- /dev/null +++ b/priv/repo/migrations/20250616192944_add_user_notifications.exs @@ -0,0 +1,33 @@ +defmodule Helpcenter.Repo.Migrations.AddUserNotifications do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create table(:user_notifications, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :sender_user_id, :uuid + add :recipient_user_id, :uuid, null: false + add :subject, :text, null: false + add :body, :text, null: false + add :read_at, :utc_datetime + add :status, :text, null: false, default: "unread" + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + end + end + + def down do + drop table(:user_notifications) + end +end diff --git a/priv/repo/migrations/20250616200854_add_oban.exs b/priv/repo/migrations/20250616200854_add_oban.exs new file mode 100644 index 0000000..34ec51a --- /dev/null +++ b/priv/repo/migrations/20250616200854_add_oban.exs @@ -0,0 +1,9 @@ +defmodule Helpcenter.Repo.Migrations.AddOban do + use Ecto.Migration + + use Ecto.Migration + + def up, do: Oban.Migration.up() + + def down, do: Oban.Migration.down(version: 1) +end diff --git a/priv/repo/tenant_migrations/20250616193643_migrate_resources1.exs b/priv/repo/tenant_migrations/20250616193643_migrate_resources1.exs new file mode 100644 index 0000000..ef5e612 --- /dev/null +++ b/priv/repo/tenant_migrations/20250616193643_migrate_resources1.exs @@ -0,0 +1,33 @@ +defmodule Helpcenter.Repo.TenantMigrations.MigrateResources1 do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create table(:user_notifications, primary_key: false, prefix: prefix()) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :sender_user_id, :uuid + add :recipient_user_id, :uuid, null: false + add :subject, :text, null: false + add :body, :text, null: false + add :read_at, :utc_datetime + add :status, :text, null: false, default: "unread" + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + end + end + + def down do + drop table(:user_notifications, prefix: prefix()) + end +end diff --git a/priv/repo/tenant_migrations/20250618182247_add_processed_attribute_to_user_notifications.exs b/priv/repo/tenant_migrations/20250618182247_add_processed_attribute_to_user_notifications.exs new file mode 100644 index 0000000..5c58f80 --- /dev/null +++ b/priv/repo/tenant_migrations/20250618182247_add_processed_attribute_to_user_notifications.exs @@ -0,0 +1,21 @@ +defmodule Helpcenter.Repo.TenantMigrations.AddProcessedAttributeToUserNotifications do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:user_notifications, prefix: prefix()) do + add :processed, :boolean, null: false, default: false + end + end + + def down do + alter table(:user_notifications, prefix: prefix()) do + remove :processed + end + end +end diff --git a/priv/resource_snapshots/repo/tenants/user_notifications/20250616193643.json b/priv/resource_snapshots/repo/tenants/user_notifications/20250616193643.json new file mode 100644 index 0000000..73717fe --- /dev/null +++ b/priv/resource_snapshots/repo/tenants/user_notifications/20250616193643.json @@ -0,0 +1,109 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "sender_user_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "recipient_user_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "subject", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "body", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "read_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "\"unread\"", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "2E04FA18E1B3296F6A6D39557DD4A93E4BA26BB4E4D8E1071489F9FE46C73DCF", + "identities": [], + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "repo": "Elixir.Helpcenter.Repo", + "schema": null, + "table": "user_notifications" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/tenants/user_notifications/20250618182247.json b/priv/resource_snapshots/repo/tenants/user_notifications/20250618182247.json new file mode 100644 index 0000000..f8ff6de --- /dev/null +++ b/priv/resource_snapshots/repo/tenants/user_notifications/20250618182247.json @@ -0,0 +1,119 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "sender_user_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "recipient_user_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "subject", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "body", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "read_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "\"unread\"", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "processed", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "9DEE15C6A9B7AFDF8C84C993AD47585831E419B5853E98CBCCD3703D26103C68", + "identities": [], + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "repo": "Elixir.Helpcenter.Repo", + "schema": null, + "table": "user_notifications" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/user_notifications/20250616192944.json b/priv/resource_snapshots/repo/user_notifications/20250616192944.json new file mode 100644 index 0000000..a43534f --- /dev/null +++ b/priv/resource_snapshots/repo/user_notifications/20250616192944.json @@ -0,0 +1,109 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "sender_user_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "recipient_user_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "subject", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "body", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "read_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "\"unread\"", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "1B094F16FD8395C17081428A4B394B7F037ADC124A02080D57EB1FFE3CE5118B", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Helpcenter.Repo", + "schema": null, + "table": "user_notifications" +} \ No newline at end of file diff --git a/test/helpcenter/accounts/user_notification_test.exs b/test/helpcenter/accounts/user_notification_test.exs new file mode 100644 index 0000000..86f7b29 --- /dev/null +++ b/test/helpcenter/accounts/user_notification_test.exs @@ -0,0 +1,41 @@ +defmodule Helpcenter.Accounts.UserNotificationTest do + use HelpcenterWeb.ConnCase, async: false + require Ash.Query + + describe "User Notifications" do + test "User notification can be send" do + user = create_user() + + attrs = %{ + recipient_user_id: user.id, + subject: "You have been added to the new team", + body: "This is a test notification body text." + } + + {:ok, _notification} = Helpcenter.Accounts.notify(attrs, actor: user) + + # Confirm we have the notification in the database + assert Helpcenter.Accounts.UserNotification + |> Ash.Query.filter(recipient_user_id == ^user.id) + |> Ash.Query.filter(subject == ^attrs.subject) + |> Ash.Query.filter(body == ^attrs.body) + |> Ash.Query.filter(processed == false) + |> Ash.exists?(actor: user) + + # Confirm the job can be queued and triggered in the background + assert %{success: 2} = + AshOban.Test.schedule_and_run_triggers( + Helpcenter.Accounts.UserNotification, + actor: user + ) + + # Confirm the notification was processed and marked as such + assert Helpcenter.Accounts.UserNotification + |> Ash.Query.filter(recipient_user_id == ^user.id) + |> Ash.Query.filter(subject == ^attrs.subject) + |> Ash.Query.filter(body == ^attrs.body) + |> Ash.Query.filter(processed == true) + |> Ash.exists?(actor: user) + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 57ba52b..41cb47a 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -22,6 +22,8 @@ defmodule HelpcenterWeb.ConnCase do # The default endpoint for testing @endpoint HelpcenterWeb.Endpoint + use Oban.Testing, repo: Helpcenter.Repo + use HelpcenterWeb, :verified_routes # Add convenience for testing with Gettext translations