From f490086fc2bf51fffb15434ce4826679ff9f602c Mon Sep 17 00:00:00 2001 From: spaenleh Date: Mon, 3 Nov 2025 11:44:16 +0100 Subject: [PATCH 1/9] fix: add oban for jobs --- .formatter.exs | 2 +- config/config.exs | 6 ++++++ config/test.exs | 1 + lib/admin/application.ex | 1 + mix.exs | 5 ++++- mix.lock | 8 ++++++++ priv/repo/migrations/20251103104204_add_oban.exs | 7 +++++++ 7 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 priv/repo/migrations/20251103104204_add_oban.exs diff --git a/.formatter.exs b/.formatter.exs index ef8840ce6..2f55c0e2c 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,5 @@ [ - import_deps: [:ecto, :ecto_sql, :phoenix], + import_deps: [:oban, :ecto, :ecto_sql, :phoenix], subdirectories: ["priv/*/migrations"], plugins: [Phoenix.LiveView.HTMLFormatter], inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] diff --git a/config/config.exs b/config/config.exs index b8c2b95f6..976c684f3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -7,6 +7,12 @@ # General application configuration import Config +config :admin, Oban, + engine: Oban.Engines.Basic, + notifier: Oban.Notifiers.Postgres, + queues: [default: 10], + repo: Admin.Repo + config :admin, :scopes, user: [ default: true, diff --git a/config/test.exs b/config/test.exs index 1106c33d1..ab1bb202d 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,4 +1,5 @@ import Config +config :admin, Oban, testing: :manual # Only in tests, remove the complexity from the password hashing algorithm config :bcrypt_elixir, :log_rounds, 1 diff --git a/lib/admin/application.ex b/lib/admin/application.ex index 28b7e470e..a6327c21f 100644 --- a/lib/admin/application.ex +++ b/lib/admin/application.ex @@ -19,6 +19,7 @@ defmodule Admin.Application do AdminWeb.Telemetry, Admin.Repo, {DNSCluster, query: Application.get_env(:admin, :dns_cluster_query) || :ignore}, + {Oban, Application.fetch_env!(:admin, Oban)}, {Phoenix.PubSub, name: Admin.PubSub}, # Start a worker by calling: Admin.Worker.start_link(arg) # {Admin.Worker, arg}, diff --git a/mix.exs b/mix.exs index c10b61267..f0ba35e38 100644 --- a/mix.exs +++ b/mix.exs @@ -111,7 +111,10 @@ defmodule Admin.MixProject do {:poison, "~> 6.0"}, {:hackney, "~> 1.9"}, # optional dependency to parse XML - {:sweet_xml, "~> 0.7"} + {:sweet_xml, "~> 0.7"}, + # jobs with Oban + {:oban, "~> 2.19"}, + {:igniter, "~> 0.5", only: [:dev]} ] end diff --git a/mix.lock b/mix.lock index a3c41659e..1bccddfcf 100644 --- a/mix.lock +++ b/mix.lock @@ -27,6 +27,7 @@ "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, "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"}, "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, @@ -35,6 +36,7 @@ "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.3", "67b3d9fa8691b727317e0cc96b9b3093be00ee45419ffb221cdeee88e75d1360", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "87748d3c4afe949c7c6eb7150c958c2bcba43fc5b2a02686af30e636b74bccb7"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "igniter": {:hex, :igniter, "0.6.30", "83a466369ebb8fe009e0823c7bf04314dc545122c2d48f896172fc79df33e99d", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "76a14d5b7f850bb03b5243088c3649d54a2e52e34a2aa1104dee23cf50a8bae0"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, @@ -46,6 +48,7 @@ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "oban": {:hex, :oban, "2.20.1", "39d0b68787e5cf251541c0d657a698f6142a24d8744e1e40b2cf045d4fa232a6", [: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.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17a45277dbeb41a455040b41dd8c467163fad685d1366f2f59207def3bcdd1d8"}, "opentelemetry": {:hex, :opentelemetry, "1.6.0", "0954dbe12f490ee7b126c9e924cf60141b1238a02dfc700907eadde4dcc20460", [:rebar3], [{:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "5fd0123d65d2649f10e478e7444927cd9fbdffcaeb8c1c2fcae3d486d18c5e62"}, "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.1", "e071429a37441a0fe9097eeea0ff921ebadce8eba8e1ce297b05a43c7a0d121f", [:mix, :rebar3], [], "hexpm", "39bdb6ad740bc13b16215cb9f233d66796bbae897f3bf6eb77abb712e87c3c26"}, "opentelemetry_bandit": {:hex, :opentelemetry_bandit, "0.3.0", "2c242dfdaabd747c75f4d8331fc9c17cfc9fb1db0638309762a4fcfa6d49a147", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:plug, ">= 1.15.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5aa12378f5ff7cc3368f02905693571833f9449df86211fd99f4d764720cff60"}, @@ -56,6 +59,7 @@ "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "1.27.0", "acd0194a94a1e57d63da982ee9f4a9f88834ae0b31b0bd850815fe9be4bbb45f", [:mix, :rebar3], [], "hexpm", "9681ccaa24fd3d810b4461581717661fd85ff7019b082c2dff89c7d5b1fc2864"}, "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.1.2", "410ab4d76b0921f42dbccbe5a7c831b8125282850be649ee1f70050d3961118a", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "641ab469deb181957ac6d59bce6e1321d5fe2a56df444fc9c19afcad623ab253"}, "otel_http": {:hex, :otel_http, "0.2.0", "b17385986c7f1b862f5d577f72614ecaa29de40392b7618869999326b9a61d8a", [:rebar3], [], "hexpm", "f2beadf922c8cfeb0965488dd736c95cc6ea8b9efce89466b3904d317d7cc717"}, + "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {: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", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [: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", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, @@ -71,7 +75,10 @@ "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [: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", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [: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", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, + "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [: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", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, "sentry": {:hex, :sentry, "11.0.4", "60371c96cefd247e0fc98840bba2648f64f19aa0b8db8e938f5a98421f55b619", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 0.3.0 or ~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:opentelemetry, ">= 0.0.0", [hex: :opentelemetry, repo: "hexpm", optional: true]}, {:opentelemetry_api, ">= 0.0.0", [hex: :opentelemetry_api, repo: "hexpm", optional: true]}, {:opentelemetry_exporter, ">= 0.0.0", [hex: :opentelemetry_exporter, repo: "hexpm", optional: true]}, {:opentelemetry_semantic_conventions, ">= 0.0.0", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "feaafc284dc204c82aadaddc884227aeaa3480decb274d30e184b9d41a700c66"}, + "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, + "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"}, @@ -79,6 +86,7 @@ "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"}, "tls_certificate_check": {:hex, :tls_certificate_check, "1.29.0", "4473005eb0bbdad215d7083a230e2e076f538d9ea472c8009fd22006a4cfc5f6", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "5b0d0e5cb0f928bc4f210df667304ed91c5bff2a391ce6bdedfbfe70a8f096c5"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, diff --git a/priv/repo/migrations/20251103104204_add_oban.exs b/priv/repo/migrations/20251103104204_add_oban.exs new file mode 100644 index 000000000..820df0f08 --- /dev/null +++ b/priv/repo/migrations/20251103104204_add_oban.exs @@ -0,0 +1,7 @@ +defmodule Admin.Repo.Migrations.AddOban do + use Ecto.Migration + + def up, do: Oban.Migration.up() + + def down, do: Oban.Migration.down(version: 1) +end From dffae08134609630683e8c9a8546bc711f9ac297 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Tue, 4 Nov 2025 11:53:28 +0100 Subject: [PATCH 2/9] fix: add notifications --- config/config.exs | 8 +- lib/admin/accounts.ex | 14 ++ lib/admin/accounts/user_notifier.ex | 14 ++ lib/admin/mailer_worker.ex | 30 +++ lib/admin/notifications.ex | 152 +++++++++++++++ lib/admin/notifications/log.ex | 19 ++ lib/admin/notifications/notification.ex | 20 ++ lib/admin_web/components/layouts.ex | 4 + .../planned_maintenance_html/index.html.heex | 2 +- lib/admin_web/live/notification_live/index.ex | 77 ++++++++ lib/admin_web/live/notification_live/new.ex | 184 ++++++++++++++++++ .../live/notification_live/new.html.heex | 96 +++++++++ .../live/service_message_live/form.ex | 122 ++++++++++++ .../live/service_message_live/index.ex | 80 ++++++++ .../live/service_message_live/send.ex | 82 ++++++++ .../live/service_message_live/show.ex | 86 ++++++++ lib/admin_web/router.ex | 15 ++ mix.exs | 2 +- mix.lock | 2 + .../20251103143048_create_notifications.exs | 29 +++ test/admin/notifications_test.exs | 93 +++++++++ .../live/service_message_live_test.exs | 125 ++++++++++++ .../fixtures/notifications_fixtures.ex | 20 ++ 23 files changed, 1273 insertions(+), 3 deletions(-) create mode 100644 lib/admin/mailer_worker.ex create mode 100644 lib/admin/notifications.ex create mode 100644 lib/admin/notifications/log.ex create mode 100644 lib/admin/notifications/notification.ex create mode 100644 lib/admin_web/live/notification_live/index.ex create mode 100644 lib/admin_web/live/notification_live/new.ex create mode 100644 lib/admin_web/live/notification_live/new.html.heex create mode 100644 lib/admin_web/live/service_message_live/form.ex create mode 100644 lib/admin_web/live/service_message_live/index.ex create mode 100644 lib/admin_web/live/service_message_live/send.ex create mode 100644 lib/admin_web/live/service_message_live/show.ex create mode 100644 priv/repo/migrations/20251103143048_create_notifications.exs create mode 100644 test/admin/notifications_test.exs create mode 100644 test/admin_web/live/service_message_live_test.exs create mode 100644 test/support/fixtures/notifications_fixtures.ex diff --git a/config/config.exs b/config/config.exs index 976c684f3..d305c46c8 100644 --- a/config/config.exs +++ b/config/config.exs @@ -8,9 +8,15 @@ import Config config :admin, Oban, + plugins: [ + # retain jobs for 7 days + {Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7}, + # rescue orphan jobs after 2h + {Oban.Plugins.Lifeline, rescue_after: :timer.hours(2)} + ], engine: Oban.Engines.Basic, notifier: Oban.Notifiers.Postgres, - queues: [default: 10], + queues: [default: 10, mailers: 5], repo: Admin.Repo config :admin, :scopes, diff --git a/lib/admin/accounts.ex b/lib/admin/accounts.ex index d9729b4e3..e1708b698 100644 --- a/lib/admin/accounts.ex +++ b/lib/admin/accounts.ex @@ -4,6 +4,7 @@ defmodule Admin.Accounts do """ require Logger import Ecto.Query, warn: false + alias Admin.Accounts.Account alias Admin.Repo alias Admin.Accounts.{User, UserNotifier, UserToken} @@ -352,4 +353,17 @@ defmodule Admin.Accounts do confirmed: Admin.Repo.aggregate(from(u in User, where: not is_nil(u.confirmed_at)), :count) } end + + # Graasp Members + + def get_member!(id) do + Repo.get!(Account, id) + end + + def get_member_by_email(email) do + case Repo.get_by(Account, email: email) do + %Account{} = user -> {:ok, user} + nil -> {:error, :not_found} + end + end end diff --git a/lib/admin/accounts/user_notifier.ex b/lib/admin/accounts/user_notifier.ex index 13c6ea096..3c3f98492 100644 --- a/lib/admin/accounts/user_notifier.ex +++ b/lib/admin/accounts/user_notifier.ex @@ -24,6 +24,20 @@ defmodule Admin.Accounts.UserNotifier do end end + def deliver_notification(user, subject, message_text) do + deliver(user.email, subject, """ + + ============================== + + Hi #{user.name}, + + #{message_text} + + ============================== + #{@footer} + """) + end + @doc """ Deliver publication removal information. """ diff --git a/lib/admin/mailer_worker.ex b/lib/admin/mailer_worker.ex new file mode 100644 index 000000000..5570256eb --- /dev/null +++ b/lib/admin/mailer_worker.ex @@ -0,0 +1,30 @@ +defmodule Admin.MailerWorker do + use Oban.Worker, queue: :mailers + alias Admin.Accounts.Scope + + @impl Oban.Worker + def perform(%Oban.Job{ + args: + %{ + "user_id" => user_id, + "member_id" => member_id, + "notification_id" => notification_id + } = + _args + }) do + user = Admin.Accounts.get_user!(user_id) + scope = Scope.for_user(user) + member = Admin.Accounts.get_member!(member_id) + notification = Admin.Notifications.get_service_message!(scope, notification_id) + + with {:ok, _} <- + Admin.Accounts.UserNotifier.deliver_notification( + member, + notification.subject, + notification.message + ) do + Admin.Notifications.save_log(member.email, notification) + :ok + end + end +end diff --git a/lib/admin/notifications.ex b/lib/admin/notifications.ex new file mode 100644 index 000000000..b66237d78 --- /dev/null +++ b/lib/admin/notifications.ex @@ -0,0 +1,152 @@ +defmodule Admin.Notifications do + @moduledoc """ + The Notifications context. + """ + + import Ecto.Query, warn: false + alias Admin.Repo + + alias Admin.Notifications.Log + alias Admin.Notifications.Notification + alias Admin.Accounts.Scope + + # Notifications + def new_notification, do: %Notification{} + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking notification changes. + + ## Examples + + iex> change_notification(scope, notification) + %Ecto.Changeset{data: %Notification{}} + + """ + def change_notification(%Scope{} = scope, %Notification{} = notification, attrs \\ %{}) do + Notification.changeset(notification, attrs, scope) + end + + def create_notification(%Scope{} = scope, attrs) do + change_notification(scope, %Notification{}, attrs) + |> Repo.insert() + end + + @doc """ + Subscribes to scoped notifications about any service_message changes. + + The broadcasted messages match the pattern: + + * {:created, %ServiceMessage{}} + * {:updated, %ServiceMessage{}} + * {:deleted, %ServiceMessage{}} + + """ + def subscribe_service_messages(%Scope{} = _scope) do + Phoenix.PubSub.subscribe(Admin.PubSub, "service_messages") + end + + defp broadcast_service_message(%Scope{} = _scope, message) do + Phoenix.PubSub.broadcast(Admin.PubSub, "service_messages", message) + end + + @doc """ + Returns the list of notifications. + + ## Examples + + iex> list_notifications(scope) + [%Notification{}, ...] + + """ + def list_notifications(%Scope{} = _scope) do + Repo.all(Notification) |> Repo.preload([:logs]) + end + + @doc """ + Gets a single service_message. + + Raises `Ecto.NoResultsError` if the Service message does not exist. + + ## Examples + + iex> get_service_message!(scope, 123) + %ServiceMessage{} + + iex> get_service_message!(scope, 456) + ** (Ecto.NoResultsError) + + """ + def get_service_message!(%Scope{} = _scope, id) do + Repo.get_by!(Notification, id: id) |> Repo.preload(:message_logs) + end + + @doc """ + Creates a service_message. + + ## Examples + + iex> create_service_message(scope, %{field: value}) + {:ok, %ServiceMessage{}} + + iex> create_service_message(scope, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_service_message(%Scope{} = scope, attrs) do + with {:ok, service_message = %Notification{}} <- + %Notification{} + |> Notification.changeset(attrs, scope) + |> Repo.insert() do + broadcast_service_message(scope, {:created, service_message}) + {:ok, service_message} + end + end + + @doc """ + Updates a service_message. + + ## Examples + + iex> update_service_message(scope, service_message, %{field: new_value}) + {:ok, %ServiceMessage{}} + + iex> update_service_message(scope, service_message, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_service_message(%Scope{} = scope, %Notification{} = service_message, attrs) do + with {:ok, service_message = %Notification{}} <- + service_message + |> Notification.changeset(attrs, scope) + |> Repo.update() do + broadcast_service_message(scope, {:updated, service_message}) + {:ok, service_message} + end + end + + @doc """ + Deletes a service_message. + + ## Examples + + iex> delete_service_message(scope, service_message) + {:ok, %ServiceMessage{}} + + iex> delete_service_message(scope, service_message) + {:error, %Ecto.Changeset{}} + + """ + def delete_service_message(%Scope{} = scope, %Notification{} = service_message) do + with {:ok, service_message = %Notification{}} <- + Repo.delete(service_message) do + broadcast_service_message(scope, {:deleted, service_message}) + {:ok, service_message} + end + end + + def save_log(email, %Notification{} = notification) do + %Log{} + |> Log.changeset(%{email: email}, notification) + |> Repo.insert() + end +end diff --git a/lib/admin/notifications/log.ex b/lib/admin/notifications/log.ex new file mode 100644 index 000000000..bdf1fbfe7 --- /dev/null +++ b/lib/admin/notifications/log.ex @@ -0,0 +1,19 @@ +defmodule Admin.Notifications.Log do + use Admin.Schema + import Ecto.Changeset + + schema "notification_logs" do + field :email, :string + + belongs_to :notification, Admin.Notifications.Notification + + timestamps(type: :utc_datetime, updated_at: false) + end + + def changeset(message_log, attrs, notification) do + message_log + |> cast(attrs, [:email]) + |> validate_required([:email]) + |> put_change(:notification_id, notification.id) + end +end diff --git a/lib/admin/notifications/notification.ex b/lib/admin/notifications/notification.ex new file mode 100644 index 000000000..3a30eb592 --- /dev/null +++ b/lib/admin/notifications/notification.ex @@ -0,0 +1,20 @@ +defmodule Admin.Notifications.Notification do + use Admin.Schema + import Ecto.Changeset + + schema "notifications" do + field :title, :string + field :message, :string + field :recipients, {:array, :string} + + has_many :logs, Admin.Notifications.Log + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(notification, attrs, _user_scope) do + notification + |> cast(attrs, [:title, :message]) + |> validate_required([:title, :message]) + end +end diff --git a/lib/admin_web/components/layouts.ex b/lib/admin_web/components/layouts.ex index f001916e0..cf93a5947 100644 --- a/lib/admin_web/components/layouts.ex +++ b/lib/admin_web/components/layouts.ex @@ -169,7 +169,9 @@ defmodule AdminWeb.Layouts do
  • <.link navigate={~p"/publishers"}>Apps
  • +
  • <.link navigate={~p"/notifications"}>Notifications
  • <.link navigate={~p"/users/settings"}>Settings
  • +
  • <.link navigate={~p"/oban"}>Oban
  • <%= if @current_scope do %> @@ -203,7 +205,9 @@ defmodule AdminWeb.Layouts do
  • <.link navigate={~p"/publishers"}>Apps
  • +
  • <.link navigate={~p"/notifications"}>Notifications
  • <.link navigate={~p"/users/settings"}>Settings
  • +
  • <.link navigate={~p"/oban"}>Oban
  • <% end %>
    diff --git a/lib/admin_web/controllers/planned_maintenance_html/index.html.heex b/lib/admin_web/controllers/planned_maintenance_html/index.html.heex index 812508441..07b733e5a 100644 --- a/lib/admin_web/controllers/planned_maintenance_html/index.html.heex +++ b/lib/admin_web/controllers/planned_maintenance_html/index.html.heex @@ -1,4 +1,4 @@ - + <.header> Listing Maintenances <:actions> diff --git a/lib/admin_web/live/notification_live/index.ex b/lib/admin_web/live/notification_live/index.ex new file mode 100644 index 000000000..2d1091833 --- /dev/null +++ b/lib/admin_web/live/notification_live/index.ex @@ -0,0 +1,77 @@ +defmodule AdminWeb.NotificationLive.Index do + use AdminWeb, :live_view + alias Admin.Notifications + + @impl true + def render(assigns) do + ~H""" + + <.header> + Notifications + <:actions> + <.button variant="primary" navigate={~p"/notifications/new"}> + <.icon name="hero-plus" /> New Notification + + + + + <.table + id="notifications" + rows={@streams.notifications} + row_click={fn {_id, notification} -> JS.navigate(~p"/notifications/#{notification}") end} + > + <:col :let={{_id, notification}} label="Subject">{notification.subject} + <:col :let={{_id, notification}} label="Message">{notification.message} + <:col :let={{_id, notification}} label="Sent">{length(notification.message_logs)} + <:action :let={{_id, notification}}> +
    + <.link navigate={~p"/notifications/#{notification}"}>Show +
    + <.link navigate={~p"/notifications/#{notification}/edit"}>Edit + + <:action :let={{id, notification}}> + <.link + phx-click={JS.push("delete", value: %{id: notification.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + +
    + """ + end + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + Notifications.subscribe_service_messages(socket.assigns.current_scope) + end + + {:ok, + socket + |> assign(:page_title, "Listing Service messages") + |> stream(:notifications, Notifications.list_notifications(socket.assigns.current_scope))} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + service_message = Notifications.get_service_message!(socket.assigns.current_scope, id) + {:ok, _} = Notifications.delete_service_message(socket.assigns.current_scope, service_message) + + {:noreply, stream_delete(socket, :service_messages, service_message)} + end + + @impl true + def handle_info({type, %Admin.Notifications.Notification{}}, socket) + when type in [:created, :updated, :deleted] do + {:noreply, + stream(socket, :service_messages, list_service_messages(socket.assigns.current_scope), + reset: true + )} + end + + defp list_service_messages(current_scope) do + Notifications.list_notifications(current_scope) + end +end diff --git a/lib/admin_web/live/notification_live/new.ex b/lib/admin_web/live/notification_live/new.ex new file mode 100644 index 000000000..54894fc77 --- /dev/null +++ b/lib/admin_web/live/notification_live/new.ex @@ -0,0 +1,184 @@ +defmodule AdminWeb.NotificationLive.New do + use AdminWeb, :live_view + + alias Admin.Notifications + alias Admin.Notifications.Notification + alias Admin.Accounts + + @impl true + def mount(_params, _session, socket) do + notification = + Notifications.change_notification(%{"title" => "", "message" => "", "recipients" => []}) + + # UI state: recipient_method can be "manual" or "active_users" + socket = + socket + |> assign(:form, notification) + |> assign(:recipient_method, "manual") + # start with one empty input + |> assign(:manual_recipients, [""]) + |> assign(:active_users, []) + |> assign(:loading_active_users, false) + + {:ok, socket} + end + + @impl true + def handle_event("validate", %{"notification" => params}, socket) do + # Merge recipients from UI state before validating + {recipient_method, params} = ensure_recipients_from_ui(socket, params) + + changeset = + Notifications.change_notification(params) + |> Map.put(:action, :validate) + + {:noreply, + socket + |> assign(:form, changeset) + |> assign(:recipient_method, recipient_method)} + end + + @impl true + def handle_event("change_method", %{"recipient_method" => method}, socket) do + method = method || "manual" + + case method do + "manual" -> + # Switch to manual; keep current manual state + {:noreply, assign(socket, :recipient_method, "manual")} + + "active_users" -> + # Fetch active users and set recipients to that list + # You can do this async if Accounts.get_active_users/0 is slow. + active = + safe_get_active_users() + + params = %{ + "title" => input_value(socket, :title), + "message" => input_value(socket, :message), + "recipients" => active + } + + changeset = + Notifications.new_notification() + |> Notification.changeset(params) + |> Map.put(:action, :validate) + + {:noreply, + socket + |> assign(:recipient_method, "active_users") + |> assign(:active_users, active) + |> assign(:form, changeset)} + end + end + + @impl true + def handle_event("manual_add_row", _params, socket) do + {:noreply, update(socket, :manual_recipients, fn list -> list ++ [""] end)} + end + + @impl true + def handle_event("manual_remove_row", %{"index" => idx_str}, socket) do + idx = parse_index(idx_str) + + updated = + socket.assigns.manual_recipients + |> Enum.with_index() + |> Enum.reject(fn {_v, i} -> i == idx end) + |> Enum.map(fn {v, _i} -> v end) + + {:noreply, assign(socket, :manual_recipients, updated)} + end + + @impl true + def handle_event("manual_update_row", %{"index" => idx_str, "value" => value}, socket) do + idx = parse_index(idx_str) + + updated = + socket.assigns.manual_recipients + |> Enum.with_index() + |> Enum.map(fn {v, i} -> if i == idx, do: value, else: v end) + + # Keep live validation in sync + params = %{ + "title" => input_value(socket, :title), + "message" => input_value(socket, :message), + "recipients" => updated + } + + changeset = + Notifications.change_notification(socket.assigns.current_scope, %Notification{}, params) + |> Map.put(:action, :validate) + + {:noreply, + socket + |> assign(:manual_recipients, updated) + |> assign(:form, changeset)} + end + + @impl true + def handle_event("submit", %{"notification" => params}, socket) do + {recipient_method, params} = ensure_recipients_from_ui(socket, params) + + case Notifications.create_notification(socket.assigns.current_scope, params) do + {:ok, _notif} -> + {:noreply, + socket + |> put_flash(:info, "Notification created") + |> push_navigate(to: ~p"/notifications")} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, + socket + |> assign(:form, changeset) + |> assign(:recipient_method, recipient_method)} + end + end + + defp ensure_recipients_from_ui(socket, params) do + method = socket.assigns.recipient_method + + recipients = + case method do + "manual" -> + socket.assigns.manual_recipients + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + "active_users" -> + socket.assigns.active_users + + _ -> + [] + end + + {method, Map.put(params, "recipients", recipients)} + end + + defp parse_index(idx) do + case Integer.parse(to_string(idx)) do + {n, _} -> n + :error -> 0 + end + end + + # Safely get active users; in a real app consider async if slow + defp safe_get_active_users do + try do + Accounts.get_active_users() + |> Enum.uniq() + rescue + _ -> [] + end + end + + # Pull current values from the changeset to keep validation stable + defp input_value(socket, field) do + socket.assigns.changeset + |> Ecto.Changeset.get_field(field) + |> case do + nil -> "" + v -> to_string(v) + end + end +end diff --git a/lib/admin_web/live/notification_live/new.html.heex b/lib/admin_web/live/notification_live/new.html.heex new file mode 100644 index 000000000..6acb1c526 --- /dev/null +++ b/lib/admin_web/live/notification_live/new.html.heex @@ -0,0 +1,96 @@ + + <.header> + New Notification + + + <.form + for={@form} + as={:notification} + id="notification-form" + phx-change="validate" + phx-submit="submit" + > + <.input field={@form[:title]} type="text" label="Title" /> + <.input field={@form[:message]} type="textarea" label="Message" rows="6" /> + +
    + + +

    + Choose how to populate recipient emails. +

    +
    + + <%= if @recipient_method == "manual" do %> +
    + + +
    + <%= for {email, idx} <- Enum.with_index(@manual_recipients) do %> +
    + + +
    + <% end %> +
    + + + + <%= if @form.errors[:recipients] do %> +

    + {elem(@form.errors[:recipients], 0)} +

    + <% end %> +
    + <% else %> +
    + + <%= if @active_users == [] do %> +

    No active users found.

    + <% else %> +
      + <%= for email <- @active_users do %> +
    • {email}
    • + <% end %> +
    + <% end %> +

    + Recipients will be set to the current active users list on submit. +

    +
    + <% end %> + +
    + <.button variant="primary">Create Notification +
    + +
    diff --git a/lib/admin_web/live/service_message_live/form.ex b/lib/admin_web/live/service_message_live/form.ex new file mode 100644 index 000000000..c003fda85 --- /dev/null +++ b/lib/admin_web/live/service_message_live/form.ex @@ -0,0 +1,122 @@ +defmodule AdminWeb.NotificationLive.Form do + use AdminWeb, :live_view + + alias Admin.Notifications + alias Admin.Notifications.Notification + + @impl true + def render(assigns) do + ~H""" + + <.header> + {@page_title} + <:subtitle>Use this form to manage service_message records in your database. + + + <.form for={@form} id="service_message-form" phx-change="validate" phx-submit="save"> + <.input field={@form[:subject]} type="text" label="Subject" /> + <.input field={@form[:message]} type="textarea" label="Message" /> +
    + <.button phx-disable-with="Saving..." variant="primary">Save Service message + <.button navigate={return_path(@current_scope, @return_to, @service_message)}> + Cancel + +
    + +
    + """ + end + + @impl true + def mount(params, _session, socket) do + {:ok, + socket + |> assign(:return_to, return_to(params["return_to"])) + |> apply_action(socket.assigns.live_action, params)} + end + + defp return_to("show"), do: "show" + defp return_to(_), do: "index" + + defp apply_action(socket, :edit, %{"id" => id}) do + service_message = Notifications.get_service_message!(socket.assigns.current_scope, id) + + socket + |> assign(:page_title, "Edit Service message") + |> assign(:service_message, service_message) + |> assign( + :form, + to_form(Notifications.change_service_message(socket.assigns.current_scope, service_message)) + ) + end + + defp apply_action(socket, :new, _params) do + service_message = %Notification{} + + socket + |> assign(:page_title, "New Service message") + |> assign(:service_message, service_message) + |> assign( + :form, + to_form(Notifications.change_service_message(socket.assigns.current_scope, service_message)) + ) + end + + @impl true + def handle_event("validate", %{"service_message" => service_message_params}, socket) do + changeset = + Notifications.change_service_message( + socket.assigns.current_scope, + socket.assigns.service_message, + service_message_params + ) + + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + end + + def handle_event("save", %{"service_message" => service_message_params}, socket) do + save_service_message(socket, socket.assigns.live_action, service_message_params) + end + + defp save_service_message(socket, :edit, service_message_params) do + case Notifications.update_service_message( + socket.assigns.current_scope, + socket.assigns.service_message, + service_message_params + ) do + {:ok, service_message} -> + {:noreply, + socket + |> put_flash(:info, "Service message updated successfully") + |> push_navigate( + to: + return_path(socket.assigns.current_scope, socket.assigns.return_to, service_message) + )} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_service_message(socket, :new, service_message_params) do + case Notifications.create_service_message( + socket.assigns.current_scope, + service_message_params + ) do + {:ok, service_message} -> + {:noreply, + socket + |> put_flash(:info, "Service message created successfully") + |> push_navigate( + to: + return_path(socket.assigns.current_scope, socket.assigns.return_to, service_message) + )} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp return_path(_scope, "index", _service_message), do: ~p"/service_messages" + defp return_path(_scope, "show", service_message), do: ~p"/service_messages/#{service_message}" +end diff --git a/lib/admin_web/live/service_message_live/index.ex b/lib/admin_web/live/service_message_live/index.ex new file mode 100644 index 000000000..3e9473d5c --- /dev/null +++ b/lib/admin_web/live/service_message_live/index.ex @@ -0,0 +1,80 @@ +defmodule AdminWeb.ServiceMessageLive.Index do + use AdminWeb, :live_view + + alias Admin.Notifications + + @impl true + def render(assigns) do + ~H""" + + <.header> + Service messages + <:actions> + <.button variant="primary" navigate={~p"/service_messages/new"}> + <.icon name="hero-plus" /> New Service message + + + + + <.table + id="service_messages" + rows={@streams.service_messages} + row_click={ + fn {_id, service_message} -> JS.navigate(~p"/service_messages/#{service_message}") end + } + > + <:col :let={{_id, service_message}} label="Subject">{service_message.subject} + <:col :let={{_id, service_message}} label="Message">{service_message.message} + <:col :let={{_id, service_message}} label="Sent">{length(service_message.message_logs)} + <:action :let={{_id, service_message}}> +
    + <.link navigate={~p"/service_messages/#{service_message}"}>Show +
    + <.link navigate={~p"/service_messages/#{service_message}/edit"}>Edit + + <:action :let={{id, service_message}}> + <.link + phx-click={JS.push("delete", value: %{id: service_message.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + +
    + """ + end + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + Notifications.subscribe_service_messages(socket.assigns.current_scope) + end + + {:ok, + socket + |> assign(:page_title, "Listing Service messages") + |> stream(:service_messages, list_service_messages(socket.assigns.current_scope))} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + service_message = Notifications.get_service_message!(socket.assigns.current_scope, id) + {:ok, _} = Notifications.delete_service_message(socket.assigns.current_scope, service_message) + + {:noreply, stream_delete(socket, :service_messages, service_message)} + end + + @impl true + def handle_info({type, %Admin.Notifications.Notification{}}, socket) + when type in [:created, :updated, :deleted] do + {:noreply, + stream(socket, :service_messages, list_service_messages(socket.assigns.current_scope), + reset: true + )} + end + + defp list_service_messages(current_scope) do + Notifications.list_service_messages(current_scope) + end +end diff --git a/lib/admin_web/live/service_message_live/send.ex b/lib/admin_web/live/service_message_live/send.ex new file mode 100644 index 000000000..4ea9eda9c --- /dev/null +++ b/lib/admin_web/live/service_message_live/send.ex @@ -0,0 +1,82 @@ +defmodule AdminWeb.ServiceMessageLive.Send do + use AdminWeb, :live_view + + alias Admin.Notifications + + @impl true + def render(assigns) do + ~H""" + + <.header> + Send + <:subtitle>Use this form to manage service_message records in your database. + + +
    +

    Subject: {@service_message.subject}

    +

    Message: {@service_message.message}

    +
    + + <.form for={@form} id="service_message-form" phx-submit="send"> + <.input field={@form[:email]} type="email" label="Email" /> +
    + <.button phx-disable-with="Saving..." variant="primary">Save Service message + <.button navigate={return_path(@current_scope, @return_to, @service_message)}> + Cancel + +
    + +
    + """ + end + + @impl true + def mount(params, _session, socket) do + {:ok, + socket + |> assign(:form, to_form(%{"email" => ""})) + |> assign(:return_to, return_to(params["return_to"])) + |> apply_action(socket.assigns.live_action, params)} + end + + defp return_to("show"), do: "show" + defp return_to(_), do: "index" + + def apply_action(socket, :send, %{"id" => id}) do + service_message = Notifications.get_service_message!(socket.assigns.current_scope, id) + + socket |> assign(:service_message, service_message) + end + + @impl true + def handle_event("send", %{"email" => email}, socket) do + notification_id = socket.assigns.service_message.id + + with {:ok, member} <- Admin.Accounts.get_member_by_email(email) do + %{ + user_id: socket.assigns.current_scope.user.id, + member_id: member.id, + notification_id: notification_id + } + |> Admin.MailerWorker.new(tags: ["notification"]) + |> Oban.insert() + + {:noreply, + socket |> push_navigate(to: ~p"/service_messages/#{socket.assigns.service_message}")} + else + _ -> + {:noreply, + socket + |> assign( + :form, + to_form(%{"email" => email}, + # add custom error message + errors: [email: {"No member for this email", []}] + ) + )} + end + end + + defp return_path(_scope, "index", _service_message), do: ~p"/service_messages" + defp return_path(_scope, "show", service_message), do: ~p"/service_messages/#{service_message}" +end diff --git a/lib/admin_web/live/service_message_live/show.ex b/lib/admin_web/live/service_message_live/show.ex new file mode 100644 index 000000000..7065b6f9d --- /dev/null +++ b/lib/admin_web/live/service_message_live/show.ex @@ -0,0 +1,86 @@ +defmodule AdminWeb.ServiceMessageLive.Show do + use AdminWeb, :live_view + + alias Admin.Notifications + + @impl true + def render(assigns) do + ~H""" + + <.header> + Message: {@service_message.subject} + <:actions> + <.button navigate={~p"/service_messages"}> + <.icon name="hero-arrow-left" /> + + <.button + variant="primary" + navigate={~p"/service_messages/#{@service_message}/edit?return_to=show"} + > + <.icon name="hero-pencil-square" /> Edit + + + + + <.list> + <:item title="Subject">{@service_message.subject} + <:item title="Message">{@service_message.message} + + + <%= if length(@service_message.message_logs) > 0 do %> + <.table id="message_logs" rows={@service_message.message_logs}> + <:col :let={message_log} label="Email">{message_log.email} + <:col :let={message_log} label="Created At">{message_log.created_at} + + <% else %> +

    No messages sent yet

    + <% end %> + + <.button + variant="primary" + navigate={~p"/service_messages/#{@service_message}/send?return_to=show"} + > + <.icon name="hero-paper-airplane" /> Send to + +
    + """ + end + + @impl true + def mount(%{"id" => id}, _session, socket) do + if connected?(socket) do + Notifications.subscribe_service_messages(socket.assigns.current_scope) + end + + {:ok, + socket + |> assign(:page_title, "Show Service message") + |> assign( + :service_message, + Notifications.get_service_message!(socket.assigns.current_scope, id) + )} + end + + @impl true + def handle_info( + {:updated, %Admin.Notifications.Notification{id: id} = service_message}, + %{assigns: %{service_message: %{id: id}}} = socket + ) do + {:noreply, assign(socket, :service_message, service_message)} + end + + def handle_info( + {:deleted, %Admin.Notifications.Notification{id: id}}, + %{assigns: %{service_message: %{id: id}}} = socket + ) do + {:noreply, + socket + |> put_flash(:error, "The current service_message was deleted.") + |> push_navigate(to: ~p"/notifications")} + end + + def handle_info({type, %Admin.Notifications.Notification{}}, socket) + when type in [:created, :updated, :deleted] do + {:noreply, socket} + end +end diff --git a/lib/admin_web/router.ex b/lib/admin_web/router.ex index f37dd35a4..ed339889f 100644 --- a/lib/admin_web/router.ex +++ b/lib/admin_web/router.ex @@ -1,6 +1,7 @@ defmodule AdminWeb.Router do use AdminWeb, :router + import Oban.Web.Router import AdminWeb.UserAuth pipeline :browser do @@ -96,6 +97,17 @@ defmodule AdminWeb.Router do end end end + + scope "/notifications" do + live "/", NotificationLive.Index, :index + live "/new", NotificationLive.New, :new + + scope "/:id" do + live "/", ServiceMessageLive.Show, :show + live "/send", ServiceMessageLive.Send, :send + live "/edit", ServiceMessageLive.Form, :edit + end + end end post "/users/update-password", UserSessionController, :update_password @@ -128,5 +140,8 @@ defmodule AdminWeb.Router do get "/published_items/featured", PublishedItemController, :featured resources "/published_items", PublishedItemController, except: [:update, :delete, :edit] post "/published_items/search", PublishedItemController, :search + + # oban dashboard for jobs + oban_dashboard("/oban") end end diff --git a/mix.exs b/mix.exs index f0ba35e38..a7ff835ae 100644 --- a/mix.exs +++ b/mix.exs @@ -114,7 +114,7 @@ defmodule Admin.MixProject do {:sweet_xml, "~> 0.7"}, # jobs with Oban {:oban, "~> 2.19"}, - {:igniter, "~> 0.5", only: [:dev]} + {:oban_web, "~> 2.11"} ] end diff --git a/mix.lock b/mix.lock index 1bccddfcf..5c3647f91 100644 --- a/mix.lock +++ b/mix.lock @@ -49,6 +49,8 @@ "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "oban": {:hex, :oban, "2.20.1", "39d0b68787e5cf251541c0d657a698f6142a24d8744e1e40b2cf045d4fa232a6", [: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.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17a45277dbeb41a455040b41dd8c467163fad685d1366f2f59207def3bcdd1d8"}, + "oban_met": {:hex, :oban_met, "1.0.3", "ea8f7a4cef3c8a7aef3b900b4458df46e83508dcbba9374c75dd590efda7a32a", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "23db1a0ee58b93afe324b221530594bdf3647a9bd4e803af762c3e00ad74b9cf"}, + "oban_web": {:hex, :oban_web, "2.11.6", "53933cb4253c4d9f1098ee311c06f07935259f0e564dcf2d66bae4cc98e317fe", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_met, "~> 1.0", [hex: :oban_met, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "576d94b705688c313694c2c114ca21aa0f8f2ad1b9ca45c052c5ba316d3e8d10"}, "opentelemetry": {:hex, :opentelemetry, "1.6.0", "0954dbe12f490ee7b126c9e924cf60141b1238a02dfc700907eadde4dcc20460", [:rebar3], [{:opentelemetry_api, "~> 1.4.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "5fd0123d65d2649f10e478e7444927cd9fbdffcaeb8c1c2fcae3d486d18c5e62"}, "opentelemetry_api": {:hex, :opentelemetry_api, "1.4.1", "e071429a37441a0fe9097eeea0ff921ebadce8eba8e1ce297b05a43c7a0d121f", [:mix, :rebar3], [], "hexpm", "39bdb6ad740bc13b16215cb9f233d66796bbae897f3bf6eb77abb712e87c3c26"}, "opentelemetry_bandit": {:hex, :opentelemetry_bandit, "0.3.0", "2c242dfdaabd747c75f4d8331fc9c17cfc9fb1db0638309762a4fcfa6d49a147", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:plug, ">= 1.15.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5aa12378f5ff7cc3368f02905693571833f9449df86211fd99f4d764720cff60"}, diff --git a/priv/repo/migrations/20251103143048_create_notifications.exs b/priv/repo/migrations/20251103143048_create_notifications.exs new file mode 100644 index 000000000..85e7b6218 --- /dev/null +++ b/priv/repo/migrations/20251103143048_create_notifications.exs @@ -0,0 +1,29 @@ +defmodule Admin.Repo.Migrations.CreateNotifications do + use Ecto.Migration + + def change do + create table(:notifications, primary_key: false) do + add :id, :uuid, primary_key: true + add :title, :string + add :message, :string + add :recipients, {:array, :string} + + timestamps(type: :utc_datetime) + end + + create table(:notification_logs, primary_key: false) do + add :id, :uuid, primary_key: true + add :email, :string + + add :notification_id, + references(:notifications, type: :binary_id, on_delete: :delete_all), + null: false + + # Only inserted_at, stored as UTC + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:notification_logs, [:notification_id]) + create index(:notification_logs, [:email]) + end +end diff --git a/test/admin/notifications_test.exs b/test/admin/notifications_test.exs new file mode 100644 index 000000000..60ce6fe54 --- /dev/null +++ b/test/admin/notifications_test.exs @@ -0,0 +1,93 @@ +defmodule Admin.NotificationsTest do + use Admin.DataCase + + alias Admin.Notifications + + describe "service_messages" do + alias Admin.Notifications.ServiceMessage + + import Admin.AccountsFixtures, only: [user_scope_fixture: 0] + import Admin.NotificationsFixtures + + @invalid_attrs %{message: nil, subject: nil} + + test "list_service_messages/1 returns all scoped service_messages" do + scope = user_scope_fixture() + other_scope = user_scope_fixture() + service_message = service_message_fixture(scope) + other_service_message = service_message_fixture(other_scope) + assert Notifications.list_service_messages(scope) == [service_message] + assert Notifications.list_service_messages(other_scope) == [other_service_message] + end + + test "get_service_message!/2 returns the service_message with given id" do + scope = user_scope_fixture() + service_message = service_message_fixture(scope) + other_scope = user_scope_fixture() + assert Notifications.get_service_message!(scope, service_message.id) == service_message + assert_raise Ecto.NoResultsError, fn -> Notifications.get_service_message!(other_scope, service_message.id) end + end + + test "create_service_message/2 with valid data creates a service_message" do + valid_attrs = %{message: "some message", subject: "some subject"} + scope = user_scope_fixture() + + assert {:ok, %ServiceMessage{} = service_message} = Notifications.create_service_message(scope, valid_attrs) + assert service_message.message == "some message" + assert service_message.subject == "some subject" + assert service_message.user_id == scope.user.id + end + + test "create_service_message/2 with invalid data returns error changeset" do + scope = user_scope_fixture() + assert {:error, %Ecto.Changeset{}} = Notifications.create_service_message(scope, @invalid_attrs) + end + + test "update_service_message/3 with valid data updates the service_message" do + scope = user_scope_fixture() + service_message = service_message_fixture(scope) + update_attrs = %{message: "some updated message", subject: "some updated subject"} + + assert {:ok, %ServiceMessage{} = service_message} = Notifications.update_service_message(scope, service_message, update_attrs) + assert service_message.message == "some updated message" + assert service_message.subject == "some updated subject" + end + + test "update_service_message/3 with invalid scope raises" do + scope = user_scope_fixture() + other_scope = user_scope_fixture() + service_message = service_message_fixture(scope) + + assert_raise MatchError, fn -> + Notifications.update_service_message(other_scope, service_message, %{}) + end + end + + test "update_service_message/3 with invalid data returns error changeset" do + scope = user_scope_fixture() + service_message = service_message_fixture(scope) + assert {:error, %Ecto.Changeset{}} = Notifications.update_service_message(scope, service_message, @invalid_attrs) + assert service_message == Notifications.get_service_message!(scope, service_message.id) + end + + test "delete_service_message/2 deletes the service_message" do + scope = user_scope_fixture() + service_message = service_message_fixture(scope) + assert {:ok, %ServiceMessage{}} = Notifications.delete_service_message(scope, service_message) + assert_raise Ecto.NoResultsError, fn -> Notifications.get_service_message!(scope, service_message.id) end + end + + test "delete_service_message/2 with invalid scope raises" do + scope = user_scope_fixture() + other_scope = user_scope_fixture() + service_message = service_message_fixture(scope) + assert_raise MatchError, fn -> Notifications.delete_service_message(other_scope, service_message) end + end + + test "change_service_message/2 returns a service_message changeset" do + scope = user_scope_fixture() + service_message = service_message_fixture(scope) + assert %Ecto.Changeset{} = Notifications.change_service_message(scope, service_message) + end + end +end diff --git a/test/admin_web/live/service_message_live_test.exs b/test/admin_web/live/service_message_live_test.exs new file mode 100644 index 000000000..ac5116445 --- /dev/null +++ b/test/admin_web/live/service_message_live_test.exs @@ -0,0 +1,125 @@ +defmodule AdminWeb.ServiceMessageLiveTest do + use AdminWeb.ConnCase + + import Phoenix.LiveViewTest + import Admin.NotificationsFixtures + + @create_attrs %{message: "some message", subject: "some subject"} + @update_attrs %{message: "some updated message", subject: "some updated subject"} + @invalid_attrs %{message: nil, subject: nil} + + setup :register_and_log_in_user + + defp create_service_message(%{scope: scope}) do + service_message = service_message_fixture(scope) + + %{service_message: service_message} + end + + describe "Index" do + setup [:create_service_message] + + test "lists all service_messages", %{conn: conn, service_message: service_message} do + {:ok, _index_live, html} = live(conn, ~p"/service_messages") + + assert html =~ "Listing Service messages" + assert html =~ service_message.subject + end + + test "saves new service_message", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/service_messages") + + assert {:ok, form_live, _} = + index_live + |> element("a", "New Service message") + |> render_click() + |> follow_redirect(conn, ~p"/service_messages/new") + + assert render(form_live) =~ "New Service message" + + assert form_live + |> form("#service_message-form", service_message: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert {:ok, index_live, _html} = + form_live + |> form("#service_message-form", service_message: @create_attrs) + |> render_submit() + |> follow_redirect(conn, ~p"/service_messages") + + html = render(index_live) + assert html =~ "Service message created successfully" + assert html =~ "some subject" + end + + test "updates service_message in listing", %{conn: conn, service_message: service_message} do + {:ok, index_live, _html} = live(conn, ~p"/service_messages") + + assert {:ok, form_live, _html} = + index_live + |> element("#service_messages-#{service_message.id} a", "Edit") + |> render_click() + |> follow_redirect(conn, ~p"/service_messages/#{service_message}/edit") + + assert render(form_live) =~ "Edit Service message" + + assert form_live + |> form("#service_message-form", service_message: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert {:ok, index_live, _html} = + form_live + |> form("#service_message-form", service_message: @update_attrs) + |> render_submit() + |> follow_redirect(conn, ~p"/service_messages") + + html = render(index_live) + assert html =~ "Service message updated successfully" + assert html =~ "some updated subject" + end + + test "deletes service_message in listing", %{conn: conn, service_message: service_message} do + {:ok, index_live, _html} = live(conn, ~p"/service_messages") + + assert index_live |> element("#service_messages-#{service_message.id} a", "Delete") |> render_click() + refute has_element?(index_live, "#service_messages-#{service_message.id}") + end + end + + describe "Show" do + setup [:create_service_message] + + test "displays service_message", %{conn: conn, service_message: service_message} do + {:ok, _show_live, html} = live(conn, ~p"/service_messages/#{service_message}") + + assert html =~ "Show Service message" + assert html =~ service_message.subject + end + + test "updates service_message and returns to show", %{conn: conn, service_message: service_message} do + {:ok, show_live, _html} = live(conn, ~p"/service_messages/#{service_message}") + + assert {:ok, form_live, _} = + show_live + |> element("a", "Edit") + |> render_click() + |> follow_redirect(conn, ~p"/service_messages/#{service_message}/edit?return_to=show") + + assert render(form_live) =~ "Edit Service message" + + assert form_live + |> form("#service_message-form", service_message: @invalid_attrs) + |> render_change() =~ "can't be blank" + + assert {:ok, show_live, _html} = + form_live + |> form("#service_message-form", service_message: @update_attrs) + |> render_submit() + |> follow_redirect(conn, ~p"/service_messages/#{service_message}") + + html = render(show_live) + assert html =~ "Service message updated successfully" + assert html =~ "some updated subject" + end + end +end diff --git a/test/support/fixtures/notifications_fixtures.ex b/test/support/fixtures/notifications_fixtures.ex new file mode 100644 index 000000000..8c63d270f --- /dev/null +++ b/test/support/fixtures/notifications_fixtures.ex @@ -0,0 +1,20 @@ +defmodule Admin.NotificationsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Admin.Notifications` context. + """ + + @doc """ + Generate a service_message. + """ + def service_message_fixture(scope, attrs \\ %{}) do + attrs = + Enum.into(attrs, %{ + message: "some message", + subject: "some subject" + }) + + {:ok, service_message} = Admin.Notifications.create_service_message(scope, attrs) + service_message + end +end From 9e2badc1780f4b4d46d6ac6f1977e980d37d6ccc Mon Sep 17 00:00:00 2001 From: spaenleh Date: Wed, 5 Nov 2025 07:32:40 +0100 Subject: [PATCH 3/9] fix: add mailing function --- config/config.exs | 2 +- lib/admin/accounts.ex | 12 ++ lib/admin/file_size.ex | 5 - lib/admin/mailer_worker.ex | 48 +++++-- lib/admin/notifications.ex | 100 +++++++------- lib/admin/notifications/log.ex | 15 ++- lib/admin/notifications/notification.ex | 12 +- lib/admin_web/live/notification_live/index.ex | 26 ++-- lib/admin_web/live/notification_live/new.ex | 73 ++++++----- .../live/notification_live/new.html.heex | 36 +++--- lib/admin_web/live/notification_live/show.ex | 73 +++++++++++ .../live/service_message_live/form.ex | 122 ------------------ .../live/service_message_live/index.ex | 80 ------------ .../live/service_message_live/send.ex | 82 ------------ .../live/service_message_live/show.ex | 86 ------------ lib/admin_web/router.ex | 5 +- .../20251103143048_create_notifications.exs | 6 + test/admin/notifications_test.exs | 36 ++++-- 18 files changed, 296 insertions(+), 523 deletions(-) create mode 100644 lib/admin_web/live/notification_live/show.ex delete mode 100644 lib/admin_web/live/service_message_live/form.ex delete mode 100644 lib/admin_web/live/service_message_live/index.ex delete mode 100644 lib/admin_web/live/service_message_live/send.ex delete mode 100644 lib/admin_web/live/service_message_live/show.ex diff --git a/config/config.exs b/config/config.exs index d305c46c8..257a387f2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -16,7 +16,7 @@ config :admin, Oban, ], engine: Oban.Engines.Basic, notifier: Oban.Notifiers.Postgres, - queues: [default: 10, mailers: 5], + queues: [default: 10, mailers: 1], repo: Admin.Repo config :admin, :scopes, diff --git a/lib/admin/accounts.ex b/lib/admin/accounts.ex index e1708b698..4ad5a6a73 100644 --- a/lib/admin/accounts.ex +++ b/lib/admin/accounts.ex @@ -366,4 +366,16 @@ defmodule Admin.Accounts do nil -> {:error, :not_found} end end + + def get_active_users do + Repo.all( + from(m in Account, + where: + not is_nil(m.last_authenticated_at) and m.last_authenticated_at > ago(90, "day") and + m.type == "individual", + limit: 100, + order_by: [desc: m.created_at] + ) + ) + end end diff --git a/lib/admin/file_size.ex b/lib/admin/file_size.ex index 8d17e22a4..0219c045d 100644 --- a/lib/admin/file_size.ex +++ b/lib/admin/file_size.ex @@ -37,11 +37,6 @@ defmodule Admin.Utils.FileSize do "#{format_number(value, 2)} #{unit}" end - # Formats numbers with fixed precision, trimming trailing zeros neatly. - defp format_number(number, precision) when precision <= 0 do - Integer.to_string(trunc(number)) - end - defp format_number(number, precision) do formatted = number diff --git a/lib/admin/mailer_worker.ex b/lib/admin/mailer_worker.ex index 5570256eb..d95771085 100644 --- a/lib/admin/mailer_worker.ex +++ b/lib/admin/mailer_worker.ex @@ -1,30 +1,62 @@ defmodule Admin.MailerWorker do + @moduledoc """ + Worker for sending notifications via email. + """ + use Oban.Worker, queue: :mailers + + alias Admin.Accounts alias Admin.Accounts.Scope + alias Admin.Accounts.UserNotifier + alias Admin.Notifications + alias Admin.Notifications.Notification @impl Oban.Worker def perform(%Oban.Job{ args: %{ "user_id" => user_id, - "member_id" => member_id, + "member_email" => member_email, "notification_id" => notification_id } = _args }) do - user = Admin.Accounts.get_user!(user_id) + user = Accounts.get_user!(user_id) scope = Scope.for_user(user) - member = Admin.Accounts.get_member!(member_id) - notification = Admin.Notifications.get_service_message!(scope, notification_id) - with {:ok, _} <- - Admin.Accounts.UserNotifier.deliver_notification( + with {:ok, member} <- Accounts.get_member_by_email(member_email), + notification <- Notifications.get_notification!(scope, notification_id), + {:ok, _} <- + UserNotifier.deliver_notification( member, - notification.subject, + notification.title, notification.message ) do - Admin.Notifications.save_log(member.email, notification) + Notifications.save_log( + scope, + %{ + email: member.email, + status: "sent" + }, + notification + ) + :ok + else + {:error, :not_found} -> + Notifications.save_log( + scope, + %{ + email: member_email, + status: "failed" + }, + %Notification{id: notification_id} + ) + + {:cancel, "Member was not found"} + + {:error, _} -> + {:error, "Failed to send notification"} end end end diff --git a/lib/admin/notifications.ex b/lib/admin/notifications.ex index b66237d78..90a70e92b 100644 --- a/lib/admin/notifications.ex +++ b/lib/admin/notifications.ex @@ -6,9 +6,9 @@ defmodule Admin.Notifications do import Ecto.Query, warn: false alias Admin.Repo + alias Admin.Accounts.Scope alias Admin.Notifications.Log alias Admin.Notifications.Notification - alias Admin.Accounts.Scope # Notifications def new_notification, do: %Notification{} @@ -26,27 +26,35 @@ defmodule Admin.Notifications do Notification.changeset(notification, attrs, scope) end + def update_recipients(%Ecto.Changeset{} = notification, %{recipients: _} = attrs) do + Notification.update_recipients(notification, attrs) + end + def create_notification(%Scope{} = scope, attrs) do - change_notification(scope, %Notification{}, attrs) - |> Repo.insert() + with {:ok, notification = %Notification{}} <- + change_notification(scope, %Notification{}, attrs) + |> Repo.insert() do + broadcast_notification(scope, {:created, notification}) + {:ok, notification} + end end @doc """ - Subscribes to scoped notifications about any service_message changes. + Subscribes to scoped notifications about any notification changes. The broadcasted messages match the pattern: - * {:created, %ServiceMessage{}} - * {:updated, %ServiceMessage{}} - * {:deleted, %ServiceMessage{}} + * {:created, %Notification{}} + * {:updated, %Notification{}} + * {:deleted, %Notification{}} """ - def subscribe_service_messages(%Scope{} = _scope) do - Phoenix.PubSub.subscribe(Admin.PubSub, "service_messages") + def subscribe_notifications(%Scope{} = _scope) do + Phoenix.PubSub.subscribe(Admin.PubSub, "notifications") end - defp broadcast_service_message(%Scope{} = _scope, message) do - Phoenix.PubSub.broadcast(Admin.PubSub, "service_messages", message) + defp broadcast_notification(%Scope{} = _scope, message) do + Phoenix.PubSub.broadcast(Admin.PubSub, "notifications", message) end @doc """ @@ -63,43 +71,21 @@ defmodule Admin.Notifications do end @doc """ - Gets a single service_message. + Gets a single notification. - Raises `Ecto.NoResultsError` if the Service message does not exist. + Raises `Ecto.NoResultsError` if the Notification does not exist. ## Examples - iex> get_service_message!(scope, 123) - %ServiceMessage{} + iex> get_notification!(scope, 123) + %Notification{} - iex> get_service_message!(scope, 456) + iex> get_notification!(scope, 456) ** (Ecto.NoResultsError) """ - def get_service_message!(%Scope{} = _scope, id) do - Repo.get_by!(Notification, id: id) |> Repo.preload(:message_logs) - end - - @doc """ - Creates a service_message. - - ## Examples - - iex> create_service_message(scope, %{field: value}) - {:ok, %ServiceMessage{}} - - iex> create_service_message(scope, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - def create_service_message(%Scope{} = scope, attrs) do - with {:ok, service_message = %Notification{}} <- - %Notification{} - |> Notification.changeset(attrs, scope) - |> Repo.insert() do - broadcast_service_message(scope, {:created, service_message}) - {:ok, service_message} - end + def get_notification!(%Scope{} = _scope, id) do + Repo.get_by!(Notification, id: id) |> Repo.preload(:logs) end @doc """ @@ -114,39 +100,41 @@ defmodule Admin.Notifications do {:error, %Ecto.Changeset{}} """ - def update_service_message(%Scope{} = scope, %Notification{} = service_message, attrs) do - with {:ok, service_message = %Notification{}} <- - service_message + def update_notification(%Scope{} = scope, %Notification{} = notification, attrs) do + with {:ok, notification = %Notification{}} <- + notification |> Notification.changeset(attrs, scope) |> Repo.update() do - broadcast_service_message(scope, {:updated, service_message}) - {:ok, service_message} + broadcast_notification(scope, {:updated, notification}) + {:ok, notification} end end @doc """ - Deletes a service_message. + Deletes a notification. ## Examples - iex> delete_service_message(scope, service_message) - {:ok, %ServiceMessage{}} + iex> delete_notification(scope, notification) + {:ok, %Notification{}} - iex> delete_service_message(scope, service_message) + iex> delete_notification(scope, notification) {:error, %Ecto.Changeset{}} """ - def delete_service_message(%Scope{} = scope, %Notification{} = service_message) do - with {:ok, service_message = %Notification{}} <- - Repo.delete(service_message) do - broadcast_service_message(scope, {:deleted, service_message}) - {:ok, service_message} + def delete_notification(%Scope{} = scope, %Notification{} = notification) do + with {:ok, notification = %Notification{}} <- + Repo.delete(notification) do + broadcast_notification(scope, {:deleted, notification}) + {:ok, notification} end end - def save_log(email, %Notification{} = notification) do + def save_log(%Scope{} = scope, log, %Notification{id: notification_id} = notification) do %Log{} - |> Log.changeset(%{email: email}, notification) + |> Log.changeset(log, notification_id) |> Repo.insert() + + broadcast_notification(scope, {:updated, notification}) end end diff --git a/lib/admin/notifications/log.ex b/lib/admin/notifications/log.ex index bdf1fbfe7..13e049980 100644 --- a/lib/admin/notifications/log.ex +++ b/lib/admin/notifications/log.ex @@ -1,19 +1,26 @@ defmodule Admin.Notifications.Log do + @moduledoc """ + Schema for storing notification logs. + """ use Admin.Schema import Ecto.Changeset + @statuses ~w(active inactive banned)a + schema "notification_logs" do field :email, :string + field :status, :string belongs_to :notification, Admin.Notifications.Notification timestamps(type: :utc_datetime, updated_at: false) end - def changeset(message_log, attrs, notification) do + def changeset(message_log, attrs, notification_id) do message_log - |> cast(attrs, [:email]) - |> validate_required([:email]) - |> put_change(:notification_id, notification.id) + |> cast(attrs, [:email, :status]) + |> validate_required([:email, :status]) + |> validate_inclusion(:status, @statuses) + |> put_change(:notification_id, notification_id) end end diff --git a/lib/admin/notifications/notification.ex b/lib/admin/notifications/notification.ex index 3a30eb592..b1b504449 100644 --- a/lib/admin/notifications/notification.ex +++ b/lib/admin/notifications/notification.ex @@ -1,4 +1,8 @@ defmodule Admin.Notifications.Notification do + @moduledoc """ + Schema for storing notifications. + """ + use Admin.Schema import Ecto.Changeset @@ -14,7 +18,13 @@ defmodule Admin.Notifications.Notification do @doc false def changeset(notification, attrs, _user_scope) do notification - |> cast(attrs, [:title, :message]) + |> cast(attrs, [:title, :message, :recipients]) |> validate_required([:title, :message]) end + + def update_recipients(notification, %{recipients: _} = attrs) do + notification + |> cast(attrs, [:recipients]) + |> validate_required([:recipients]) + end end diff --git a/lib/admin_web/live/notification_live/index.ex b/lib/admin_web/live/notification_live/index.ex index 2d1091833..ff32e4636 100644 --- a/lib/admin_web/live/notification_live/index.ex +++ b/lib/admin_web/live/notification_live/index.ex @@ -20,14 +20,17 @@ defmodule AdminWeb.NotificationLive.Index do rows={@streams.notifications} row_click={fn {_id, notification} -> JS.navigate(~p"/notifications/#{notification}") end} > - <:col :let={{_id, notification}} label="Subject">{notification.subject} + <:col :let={{_id, notification}} label="Title">{notification.title} <:col :let={{_id, notification}} label="Message">{notification.message} - <:col :let={{_id, notification}} label="Sent">{length(notification.message_logs)} + <:col :let={{_id, notification}} label="Recipients"> + {length(notification.recipients || [])} + + <:col :let={{_id, notification}} label="Sent">{length(notification.logs)} <:action :let={{_id, notification}}>
    <.link navigate={~p"/notifications/#{notification}"}>Show
    - <.link navigate={~p"/notifications/#{notification}/edit"}>Edit + <%!-- <.link navigate={~p"/notifications/#{notification}/edit"}>Edit --%> <:action :let={{id, notification}}> <.link @@ -45,7 +48,7 @@ defmodule AdminWeb.NotificationLive.Index do @impl true def mount(_params, _session, socket) do if connected?(socket) do - Notifications.subscribe_service_messages(socket.assigns.current_scope) + Notifications.subscribe_notifications(socket.assigns.current_scope) end {:ok, @@ -56,22 +59,21 @@ defmodule AdminWeb.NotificationLive.Index do @impl true def handle_event("delete", %{"id" => id}, socket) do - service_message = Notifications.get_service_message!(socket.assigns.current_scope, id) - {:ok, _} = Notifications.delete_service_message(socket.assigns.current_scope, service_message) + notification = Notifications.get_notification!(socket.assigns.current_scope, id) + {:ok, _} = Notifications.delete_notification(socket.assigns.current_scope, notification) - {:noreply, stream_delete(socket, :service_messages, service_message)} + {:noreply, stream_delete(socket, :notifications, notification)} end @impl true def handle_info({type, %Admin.Notifications.Notification{}}, socket) when type in [:created, :updated, :deleted] do {:noreply, - stream(socket, :service_messages, list_service_messages(socket.assigns.current_scope), + stream( + socket, + :notifications, + Notifications.list_notifications(socket.assigns.current_scope), reset: true )} end - - defp list_service_messages(current_scope) do - Notifications.list_notifications(current_scope) - end end diff --git a/lib/admin_web/live/notification_live/new.ex b/lib/admin_web/live/notification_live/new.ex index 54894fc77..5b3a30744 100644 --- a/lib/admin_web/live/notification_live/new.ex +++ b/lib/admin_web/live/notification_live/new.ex @@ -1,14 +1,18 @@ defmodule AdminWeb.NotificationLive.New do use AdminWeb, :live_view + alias Admin.Accounts alias Admin.Notifications alias Admin.Notifications.Notification - alias Admin.Accounts @impl true def mount(_params, _session, socket) do notification = - Notifications.change_notification(%{"title" => "", "message" => "", "recipients" => []}) + Notifications.change_notification(socket.assigns.current_scope, %Notification{}, %{ + "title" => "", + "message" => "", + "recipients" => [] + }) # UI state: recipient_method can be "manual" or "active_users" socket = @@ -29,7 +33,7 @@ defmodule AdminWeb.NotificationLive.New do {recipient_method, params} = ensure_recipients_from_ui(socket, params) changeset = - Notifications.change_notification(params) + Notifications.change_notification(socket.assigns.current_scope, %Notification{}, params) |> Map.put(:action, :validate) {:noreply, @@ -52,16 +56,14 @@ defmodule AdminWeb.NotificationLive.New do # You can do this async if Accounts.get_active_users/0 is slow. active = safe_get_active_users() - - params = %{ - "title" => input_value(socket, :title), - "message" => input_value(socket, :message), - "recipients" => active - } + # take only email + |> Enum.map(& &1.email) changeset = - Notifications.new_notification() - |> Notification.changeset(params) + Notifications.update_recipients( + socket.assigns.form, + %{recipients: active} + ) |> Map.put(:action, :validate) {:noreply, @@ -91,7 +93,10 @@ defmodule AdminWeb.NotificationLive.New do end @impl true - def handle_event("manual_update_row", %{"index" => idx_str, "value" => value}, socket) do + def handle_event("manual_update_row", params, socket) do + [key | _] = Map.fetch!(params, "_target") + value = params[key] + "manual_email_" <> idx_str = key idx = parse_index(idx_str) updated = @@ -99,15 +104,11 @@ defmodule AdminWeb.NotificationLive.New do |> Enum.with_index() |> Enum.map(fn {v, i} -> if i == idx, do: value, else: v end) - # Keep live validation in sync - params = %{ - "title" => input_value(socket, :title), - "message" => input_value(socket, :message), - "recipients" => updated - } - changeset = - Notifications.change_notification(socket.assigns.current_scope, %Notification{}, params) + Notifications.update_recipients( + socket.assigns.form, + %{recipients: updated} + ) |> Map.put(:action, :validate) {:noreply, @@ -121,7 +122,18 @@ defmodule AdminWeb.NotificationLive.New do {recipient_method, params} = ensure_recipients_from_ui(socket, params) case Notifications.create_notification(socket.assigns.current_scope, params) do - {:ok, _notif} -> + {:ok, %Notification{} = notif} -> + Enum.each( + notif.recipients, + &(%{ + "member_email" => &1, + "user_id" => socket.assigns.current_scope.user.id, + "notification_id" => notif.id + } + |> Admin.MailerWorker.new() + |> Oban.insert()) + ) + {:noreply, socket |> put_flash(:info, "Notification created") @@ -164,21 +176,8 @@ defmodule AdminWeb.NotificationLive.New do # Safely get active users; in a real app consider async if slow defp safe_get_active_users do - try do - Accounts.get_active_users() - |> Enum.uniq() - rescue - _ -> [] - end - end - - # Pull current values from the changeset to keep validation stable - defp input_value(socket, field) do - socket.assigns.changeset - |> Ecto.Changeset.get_field(field) - |> case do - nil -> "" - v -> to_string(v) - end + Accounts.get_active_users() + rescue + _ -> [] end end diff --git a/lib/admin_web/live/notification_live/new.html.heex b/lib/admin_web/live/notification_live/new.html.heex index 6acb1c526..bcc27c3a4 100644 --- a/lib/admin_web/live/notification_live/new.html.heex +++ b/lib/admin_web/live/notification_live/new.html.heex @@ -4,21 +4,21 @@ <.form + :let={form} for={@form} - as={:notification} id="notification-form" phx-change="validate" phx-submit="submit" > - <.input field={@form[:title]} type="text" label="Title" /> - <.input field={@form[:message]} type="textarea" label="Message" rows="6" /> + <.input field={form[:title]} type="text" label="Title" /> + <.input field={form[:message]} type="textarea" label="Message" rows="6" /> -
    - +
    + Recipients source -

    + Choose how to populate recipient emails. -

    -
    + + <%= if @recipient_method == "manual" do %> -
    +
    @@ -49,21 +49,21 @@ phx-value-index={idx} phx-value-value={email} /> - + <.icon name="hero-trash" class="size-5" /> +
    <% end %>
    - + <.button type="button" class="btn btn-soft btn-secondary" phx-click="manual_add_row"> + <.icon name="hero-plus" class="size-5" /> Add email + <%= if @form.errors[:recipients] do %>

    @@ -72,7 +72,7 @@ <% end %>

    <% else %> -
    +
    <%= if @active_users == [] do %>

    No active users found.

    diff --git a/lib/admin_web/live/notification_live/show.ex b/lib/admin_web/live/notification_live/show.ex new file mode 100644 index 000000000..8a863e552 --- /dev/null +++ b/lib/admin_web/live/notification_live/show.ex @@ -0,0 +1,73 @@ +defmodule AdminWeb.NotificationLive.Show do + use AdminWeb, :live_view + + alias Admin.Notifications + + @impl true + def render(assigns) do + ~H""" + + <.header> + Message: {@notification.title} + <:actions> + <.button navigate={~p"/notifications"}> + <.icon name="hero-arrow-left" /> + + + + + <.list> + <:item title="Title">{@notification.title} + <:item title="Message">{@notification.message} + + + <%= if length(@notification.logs) > 0 do %> + <.table id="notification_logs" rows={@notification.logs}> + <:col :let={message_log} label="Email">{message_log.email} + <:col :let={message_log} label="Sent at">{message_log.created_at} + + <% else %> +

    No messages sent yet

    + <% end %> +
    + """ + end + + @impl true + def mount(%{"id" => id}, _session, socket) do + if connected?(socket) do + Notifications.subscribe_notifications(socket.assigns.current_scope) + end + + {:ok, + socket + |> assign(:page_title, "Show Notification") + |> assign( + :notification, + Notifications.get_notification!(socket.assigns.current_scope, id) + )} + end + + @impl true + def handle_info( + {:updated, %Admin.Notifications.Notification{id: id} = notification}, + %{assigns: %{notification: %{id: id}}} = socket + ) do + {:noreply, assign(socket, :notification, notification)} + end + + def handle_info( + {:deleted, %Admin.Notifications.Notification{id: id}}, + %{assigns: %{notification: %{id: id}}} = socket + ) do + {:noreply, + socket + |> put_flash(:error, "The current notification was deleted.") + |> push_navigate(to: ~p"/notifications")} + end + + def handle_info({type, %Admin.Notifications.Notification{}}, socket) + when type in [:created, :updated, :deleted] do + {:noreply, socket} + end +end diff --git a/lib/admin_web/live/service_message_live/form.ex b/lib/admin_web/live/service_message_live/form.ex deleted file mode 100644 index c003fda85..000000000 --- a/lib/admin_web/live/service_message_live/form.ex +++ /dev/null @@ -1,122 +0,0 @@ -defmodule AdminWeb.NotificationLive.Form do - use AdminWeb, :live_view - - alias Admin.Notifications - alias Admin.Notifications.Notification - - @impl true - def render(assigns) do - ~H""" - - <.header> - {@page_title} - <:subtitle>Use this form to manage service_message records in your database. - - - <.form for={@form} id="service_message-form" phx-change="validate" phx-submit="save"> - <.input field={@form[:subject]} type="text" label="Subject" /> - <.input field={@form[:message]} type="textarea" label="Message" /> -
    - <.button phx-disable-with="Saving..." variant="primary">Save Service message - <.button navigate={return_path(@current_scope, @return_to, @service_message)}> - Cancel - -
    - -
    - """ - end - - @impl true - def mount(params, _session, socket) do - {:ok, - socket - |> assign(:return_to, return_to(params["return_to"])) - |> apply_action(socket.assigns.live_action, params)} - end - - defp return_to("show"), do: "show" - defp return_to(_), do: "index" - - defp apply_action(socket, :edit, %{"id" => id}) do - service_message = Notifications.get_service_message!(socket.assigns.current_scope, id) - - socket - |> assign(:page_title, "Edit Service message") - |> assign(:service_message, service_message) - |> assign( - :form, - to_form(Notifications.change_service_message(socket.assigns.current_scope, service_message)) - ) - end - - defp apply_action(socket, :new, _params) do - service_message = %Notification{} - - socket - |> assign(:page_title, "New Service message") - |> assign(:service_message, service_message) - |> assign( - :form, - to_form(Notifications.change_service_message(socket.assigns.current_scope, service_message)) - ) - end - - @impl true - def handle_event("validate", %{"service_message" => service_message_params}, socket) do - changeset = - Notifications.change_service_message( - socket.assigns.current_scope, - socket.assigns.service_message, - service_message_params - ) - - {:noreply, assign(socket, form: to_form(changeset, action: :validate))} - end - - def handle_event("save", %{"service_message" => service_message_params}, socket) do - save_service_message(socket, socket.assigns.live_action, service_message_params) - end - - defp save_service_message(socket, :edit, service_message_params) do - case Notifications.update_service_message( - socket.assigns.current_scope, - socket.assigns.service_message, - service_message_params - ) do - {:ok, service_message} -> - {:noreply, - socket - |> put_flash(:info, "Service message updated successfully") - |> push_navigate( - to: - return_path(socket.assigns.current_scope, socket.assigns.return_to, service_message) - )} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, form: to_form(changeset))} - end - end - - defp save_service_message(socket, :new, service_message_params) do - case Notifications.create_service_message( - socket.assigns.current_scope, - service_message_params - ) do - {:ok, service_message} -> - {:noreply, - socket - |> put_flash(:info, "Service message created successfully") - |> push_navigate( - to: - return_path(socket.assigns.current_scope, socket.assigns.return_to, service_message) - )} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, form: to_form(changeset))} - end - end - - defp return_path(_scope, "index", _service_message), do: ~p"/service_messages" - defp return_path(_scope, "show", service_message), do: ~p"/service_messages/#{service_message}" -end diff --git a/lib/admin_web/live/service_message_live/index.ex b/lib/admin_web/live/service_message_live/index.ex deleted file mode 100644 index 3e9473d5c..000000000 --- a/lib/admin_web/live/service_message_live/index.ex +++ /dev/null @@ -1,80 +0,0 @@ -defmodule AdminWeb.ServiceMessageLive.Index do - use AdminWeb, :live_view - - alias Admin.Notifications - - @impl true - def render(assigns) do - ~H""" - - <.header> - Service messages - <:actions> - <.button variant="primary" navigate={~p"/service_messages/new"}> - <.icon name="hero-plus" /> New Service message - - - - - <.table - id="service_messages" - rows={@streams.service_messages} - row_click={ - fn {_id, service_message} -> JS.navigate(~p"/service_messages/#{service_message}") end - } - > - <:col :let={{_id, service_message}} label="Subject">{service_message.subject} - <:col :let={{_id, service_message}} label="Message">{service_message.message} - <:col :let={{_id, service_message}} label="Sent">{length(service_message.message_logs)} - <:action :let={{_id, service_message}}> -
    - <.link navigate={~p"/service_messages/#{service_message}"}>Show -
    - <.link navigate={~p"/service_messages/#{service_message}/edit"}>Edit - - <:action :let={{id, service_message}}> - <.link - phx-click={JS.push("delete", value: %{id: service_message.id}) |> hide("##{id}")} - data-confirm="Are you sure?" - > - Delete - - - -
    - """ - end - - @impl true - def mount(_params, _session, socket) do - if connected?(socket) do - Notifications.subscribe_service_messages(socket.assigns.current_scope) - end - - {:ok, - socket - |> assign(:page_title, "Listing Service messages") - |> stream(:service_messages, list_service_messages(socket.assigns.current_scope))} - end - - @impl true - def handle_event("delete", %{"id" => id}, socket) do - service_message = Notifications.get_service_message!(socket.assigns.current_scope, id) - {:ok, _} = Notifications.delete_service_message(socket.assigns.current_scope, service_message) - - {:noreply, stream_delete(socket, :service_messages, service_message)} - end - - @impl true - def handle_info({type, %Admin.Notifications.Notification{}}, socket) - when type in [:created, :updated, :deleted] do - {:noreply, - stream(socket, :service_messages, list_service_messages(socket.assigns.current_scope), - reset: true - )} - end - - defp list_service_messages(current_scope) do - Notifications.list_service_messages(current_scope) - end -end diff --git a/lib/admin_web/live/service_message_live/send.ex b/lib/admin_web/live/service_message_live/send.ex deleted file mode 100644 index 4ea9eda9c..000000000 --- a/lib/admin_web/live/service_message_live/send.ex +++ /dev/null @@ -1,82 +0,0 @@ -defmodule AdminWeb.ServiceMessageLive.Send do - use AdminWeb, :live_view - - alias Admin.Notifications - - @impl true - def render(assigns) do - ~H""" - - <.header> - Send - <:subtitle>Use this form to manage service_message records in your database. - - -
    -

    Subject: {@service_message.subject}

    -

    Message: {@service_message.message}

    -
    - - <.form for={@form} id="service_message-form" phx-submit="send"> - <.input field={@form[:email]} type="email" label="Email" /> -
    - <.button phx-disable-with="Saving..." variant="primary">Save Service message - <.button navigate={return_path(@current_scope, @return_to, @service_message)}> - Cancel - -
    - -
    - """ - end - - @impl true - def mount(params, _session, socket) do - {:ok, - socket - |> assign(:form, to_form(%{"email" => ""})) - |> assign(:return_to, return_to(params["return_to"])) - |> apply_action(socket.assigns.live_action, params)} - end - - defp return_to("show"), do: "show" - defp return_to(_), do: "index" - - def apply_action(socket, :send, %{"id" => id}) do - service_message = Notifications.get_service_message!(socket.assigns.current_scope, id) - - socket |> assign(:service_message, service_message) - end - - @impl true - def handle_event("send", %{"email" => email}, socket) do - notification_id = socket.assigns.service_message.id - - with {:ok, member} <- Admin.Accounts.get_member_by_email(email) do - %{ - user_id: socket.assigns.current_scope.user.id, - member_id: member.id, - notification_id: notification_id - } - |> Admin.MailerWorker.new(tags: ["notification"]) - |> Oban.insert() - - {:noreply, - socket |> push_navigate(to: ~p"/service_messages/#{socket.assigns.service_message}")} - else - _ -> - {:noreply, - socket - |> assign( - :form, - to_form(%{"email" => email}, - # add custom error message - errors: [email: {"No member for this email", []}] - ) - )} - end - end - - defp return_path(_scope, "index", _service_message), do: ~p"/service_messages" - defp return_path(_scope, "show", service_message), do: ~p"/service_messages/#{service_message}" -end diff --git a/lib/admin_web/live/service_message_live/show.ex b/lib/admin_web/live/service_message_live/show.ex deleted file mode 100644 index 7065b6f9d..000000000 --- a/lib/admin_web/live/service_message_live/show.ex +++ /dev/null @@ -1,86 +0,0 @@ -defmodule AdminWeb.ServiceMessageLive.Show do - use AdminWeb, :live_view - - alias Admin.Notifications - - @impl true - def render(assigns) do - ~H""" - - <.header> - Message: {@service_message.subject} - <:actions> - <.button navigate={~p"/service_messages"}> - <.icon name="hero-arrow-left" /> - - <.button - variant="primary" - navigate={~p"/service_messages/#{@service_message}/edit?return_to=show"} - > - <.icon name="hero-pencil-square" /> Edit - - - - - <.list> - <:item title="Subject">{@service_message.subject} - <:item title="Message">{@service_message.message} - - - <%= if length(@service_message.message_logs) > 0 do %> - <.table id="message_logs" rows={@service_message.message_logs}> - <:col :let={message_log} label="Email">{message_log.email} - <:col :let={message_log} label="Created At">{message_log.created_at} - - <% else %> -

    No messages sent yet

    - <% end %> - - <.button - variant="primary" - navigate={~p"/service_messages/#{@service_message}/send?return_to=show"} - > - <.icon name="hero-paper-airplane" /> Send to - -
    - """ - end - - @impl true - def mount(%{"id" => id}, _session, socket) do - if connected?(socket) do - Notifications.subscribe_service_messages(socket.assigns.current_scope) - end - - {:ok, - socket - |> assign(:page_title, "Show Service message") - |> assign( - :service_message, - Notifications.get_service_message!(socket.assigns.current_scope, id) - )} - end - - @impl true - def handle_info( - {:updated, %Admin.Notifications.Notification{id: id} = service_message}, - %{assigns: %{service_message: %{id: id}}} = socket - ) do - {:noreply, assign(socket, :service_message, service_message)} - end - - def handle_info( - {:deleted, %Admin.Notifications.Notification{id: id}}, - %{assigns: %{service_message: %{id: id}}} = socket - ) do - {:noreply, - socket - |> put_flash(:error, "The current service_message was deleted.") - |> push_navigate(to: ~p"/notifications")} - end - - def handle_info({type, %Admin.Notifications.Notification{}}, socket) - when type in [:created, :updated, :deleted] do - {:noreply, socket} - end -end diff --git a/lib/admin_web/router.ex b/lib/admin_web/router.ex index ed339889f..487824d3b 100644 --- a/lib/admin_web/router.ex +++ b/lib/admin_web/router.ex @@ -103,9 +103,8 @@ defmodule AdminWeb.Router do live "/new", NotificationLive.New, :new scope "/:id" do - live "/", ServiceMessageLive.Show, :show - live "/send", ServiceMessageLive.Send, :send - live "/edit", ServiceMessageLive.Form, :edit + live "/", NotificationLive.Show, :show + # live "/edit", ServiceMessageLive.Form, :edit end end end diff --git a/priv/repo/migrations/20251103143048_create_notifications.exs b/priv/repo/migrations/20251103143048_create_notifications.exs index 85e7b6218..3a26ce2aa 100644 --- a/priv/repo/migrations/20251103143048_create_notifications.exs +++ b/priv/repo/migrations/20251103143048_create_notifications.exs @@ -14,6 +14,7 @@ defmodule Admin.Repo.Migrations.CreateNotifications do create table(:notification_logs, primary_key: false) do add :id, :uuid, primary_key: true add :email, :string + add :status, :string, null: false add :notification_id, references(:notifications, type: :binary_id, on_delete: :delete_all), @@ -23,6 +24,11 @@ defmodule Admin.Repo.Migrations.CreateNotifications do timestamps(type: :utc_datetime, updated_at: false) end + # Enforce allowed values at the DB level + create constraint(:notification_logs, :status_must_be_valid, + check: "status IN ('sent', 'failed')" + ) + create index(:notification_logs, [:notification_id]) create index(:notification_logs, [:email]) end diff --git a/test/admin/notifications_test.exs b/test/admin/notifications_test.exs index 60ce6fe54..ee6a3d537 100644 --- a/test/admin/notifications_test.exs +++ b/test/admin/notifications_test.exs @@ -25,14 +25,19 @@ defmodule Admin.NotificationsTest do service_message = service_message_fixture(scope) other_scope = user_scope_fixture() assert Notifications.get_service_message!(scope, service_message.id) == service_message - assert_raise Ecto.NoResultsError, fn -> Notifications.get_service_message!(other_scope, service_message.id) end + + assert_raise Ecto.NoResultsError, fn -> + Notifications.get_service_message!(other_scope, service_message.id) + end end test "create_service_message/2 with valid data creates a service_message" do valid_attrs = %{message: "some message", subject: "some subject"} scope = user_scope_fixture() - assert {:ok, %ServiceMessage{} = service_message} = Notifications.create_service_message(scope, valid_attrs) + assert {:ok, %ServiceMessage{} = service_message} = + Notifications.create_service_message(scope, valid_attrs) + assert service_message.message == "some message" assert service_message.subject == "some subject" assert service_message.user_id == scope.user.id @@ -40,7 +45,9 @@ defmodule Admin.NotificationsTest do test "create_service_message/2 with invalid data returns error changeset" do scope = user_scope_fixture() - assert {:error, %Ecto.Changeset{}} = Notifications.create_service_message(scope, @invalid_attrs) + + assert {:error, %Ecto.Changeset{}} = + Notifications.create_service_message(scope, @invalid_attrs) end test "update_service_message/3 with valid data updates the service_message" do @@ -48,7 +55,9 @@ defmodule Admin.NotificationsTest do service_message = service_message_fixture(scope) update_attrs = %{message: "some updated message", subject: "some updated subject"} - assert {:ok, %ServiceMessage{} = service_message} = Notifications.update_service_message(scope, service_message, update_attrs) + assert {:ok, %ServiceMessage{} = service_message} = + Notifications.update_service_message(scope, service_message, update_attrs) + assert service_message.message == "some updated message" assert service_message.subject == "some updated subject" end @@ -66,22 +75,33 @@ defmodule Admin.NotificationsTest do test "update_service_message/3 with invalid data returns error changeset" do scope = user_scope_fixture() service_message = service_message_fixture(scope) - assert {:error, %Ecto.Changeset{}} = Notifications.update_service_message(scope, service_message, @invalid_attrs) + + assert {:error, %Ecto.Changeset{}} = + Notifications.update_service_message(scope, service_message, @invalid_attrs) + assert service_message == Notifications.get_service_message!(scope, service_message.id) end test "delete_service_message/2 deletes the service_message" do scope = user_scope_fixture() service_message = service_message_fixture(scope) - assert {:ok, %ServiceMessage{}} = Notifications.delete_service_message(scope, service_message) - assert_raise Ecto.NoResultsError, fn -> Notifications.get_service_message!(scope, service_message.id) end + + assert {:ok, %ServiceMessage{}} = + Notifications.delete_service_message(scope, service_message) + + assert_raise Ecto.NoResultsError, fn -> + Notifications.get_service_message!(scope, service_message.id) + end end test "delete_service_message/2 with invalid scope raises" do scope = user_scope_fixture() other_scope = user_scope_fixture() service_message = service_message_fixture(scope) - assert_raise MatchError, fn -> Notifications.delete_service_message(other_scope, service_message) end + + assert_raise MatchError, fn -> + Notifications.delete_service_message(other_scope, service_message) + end end test "change_service_message/2 returns a service_message changeset" do From 05d6e4c859da8f4007f7f9b91bdef98ea51398d5 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Wed, 5 Nov 2025 09:18:47 +0100 Subject: [PATCH 4/9] fix: update tests --- lib/admin/notifications.ex | 2 +- lib/admin_web/live/notification_live/new.ex | 102 ++++++++++++++ .../live/notification_live/new.html.heex | 96 -------------- test/admin/notifications_test.exs | 108 +++++++-------- .../admin_web/live/notification_live_test.exs | 87 ++++++++++++ .../live/service_message_live_test.exs | 125 ------------------ .../fixtures/notifications_fixtures.ex | 11 +- 7 files changed, 241 insertions(+), 290 deletions(-) delete mode 100644 lib/admin_web/live/notification_live/new.html.heex create mode 100644 test/admin_web/live/notification_live_test.exs delete mode 100644 test/admin_web/live/service_message_live_test.exs diff --git a/lib/admin/notifications.ex b/lib/admin/notifications.ex index 90a70e92b..c42adc35a 100644 --- a/lib/admin/notifications.ex +++ b/lib/admin/notifications.ex @@ -35,7 +35,7 @@ defmodule Admin.Notifications do change_notification(scope, %Notification{}, attrs) |> Repo.insert() do broadcast_notification(scope, {:created, notification}) - {:ok, notification} + {:ok, notification |> Repo.preload([:logs])} end end diff --git a/lib/admin_web/live/notification_live/new.ex b/lib/admin_web/live/notification_live/new.ex index 5b3a30744..fa37c8ffb 100644 --- a/lib/admin_web/live/notification_live/new.ex +++ b/lib/admin_web/live/notification_live/new.ex @@ -5,6 +5,108 @@ defmodule AdminWeb.NotificationLive.New do alias Admin.Notifications alias Admin.Notifications.Notification + @impl true + def render(assigns) do + ~H""" + + <.header> + New Notification + + + <.form + :let={form} + for={@form} + id="notification-form" + phx-change="validate" + phx-submit="submit" + > + <.input field={form[:title]} type="text" label="Title" /> + <.input field={form[:message]} type="textarea" label="Message" rows="6" /> + +
    + Recipients source + + + Choose how to populate recipient emails. + +
    + + <%= if @recipient_method == "manual" do %> +
    + + +
    + <%= for {email, idx} <- Enum.with_index(@manual_recipients) do %> +
    + + <.button + type="button" + class="btn btn-soft btn-error" + phx-click="manual_remove_row" + phx-value-index={idx} + > + <.icon name="hero-trash" class="size-5" /> + +
    + <% end %> +
    + + <.button type="button" class="btn btn-soft btn-secondary" phx-click="manual_add_row"> + <.icon name="hero-plus" class="size-5" /> Add email + + + <%= if @form.errors[:recipients] do %> +

    + {elem(@form.errors[:recipients], 0)} +

    + <% end %> +
    + <% else %> +
    + + <%= if @active_users == [] do %> +

    No active users found.

    + <% else %> +
      + <%= for email <- @active_users do %> +
    • {email}
    • + <% end %> +
    + <% end %> +

    + Recipients will be set to the current active users list on submit. +

    +
    + <% end %> + +
    + <.button variant="primary">Create Notification +
    + +
    + """ + end + @impl true def mount(_params, _session, socket) do notification = diff --git a/lib/admin_web/live/notification_live/new.html.heex b/lib/admin_web/live/notification_live/new.html.heex deleted file mode 100644 index bcc27c3a4..000000000 --- a/lib/admin_web/live/notification_live/new.html.heex +++ /dev/null @@ -1,96 +0,0 @@ - - <.header> - New Notification - - - <.form - :let={form} - for={@form} - id="notification-form" - phx-change="validate" - phx-submit="submit" - > - <.input field={form[:title]} type="text" label="Title" /> - <.input field={form[:message]} type="textarea" label="Message" rows="6" /> - -
    - Recipients source - - - Choose how to populate recipient emails. - -
    - - <%= if @recipient_method == "manual" do %> -
    - - -
    - <%= for {email, idx} <- Enum.with_index(@manual_recipients) do %> -
    - - <.button - type="button" - class="btn btn-soft btn-error" - phx-click="manual_remove_row" - phx-value-index={idx} - > - <.icon name="hero-trash" class="size-5" /> - -
    - <% end %> -
    - - <.button type="button" class="btn btn-soft btn-secondary" phx-click="manual_add_row"> - <.icon name="hero-plus" class="size-5" /> Add email - - - <%= if @form.errors[:recipients] do %> -

    - {elem(@form.errors[:recipients], 0)} -

    - <% end %> -
    - <% else %> -
    - - <%= if @active_users == [] do %> -

    No active users found.

    - <% else %> -
      - <%= for email <- @active_users do %> -
    • {email}
    • - <% end %> -
    - <% end %> -

    - Recipients will be set to the current active users list on submit. -

    -
    - <% end %> - -
    - <.button variant="primary">Create Notification -
    - -
    diff --git a/test/admin/notifications_test.exs b/test/admin/notifications_test.exs index ee6a3d537..f31dbac4c 100644 --- a/test/admin/notifications_test.exs +++ b/test/admin/notifications_test.exs @@ -3,111 +3,93 @@ defmodule Admin.NotificationsTest do alias Admin.Notifications - describe "service_messages" do - alias Admin.Notifications.ServiceMessage + describe "notifications" do + alias Admin.Notifications.Notification import Admin.AccountsFixtures, only: [user_scope_fixture: 0] import Admin.NotificationsFixtures - @invalid_attrs %{message: nil, subject: nil} + @invalid_attrs %{message: nil, title: nil, recipients: nil} - test "list_service_messages/1 returns all scoped service_messages" do + test "list_notifications/1 returns all notifications" do scope = user_scope_fixture() other_scope = user_scope_fixture() - service_message = service_message_fixture(scope) - other_service_message = service_message_fixture(other_scope) - assert Notifications.list_service_messages(scope) == [service_message] - assert Notifications.list_service_messages(other_scope) == [other_service_message] + notifications = notification_fixture(scope) + other_notifications = notification_fixture(other_scope) + assert Notifications.list_notifications(scope) == [notifications, other_notifications] end - test "get_service_message!/2 returns the service_message with given id" do + test "get_notification!/2 returns the notification with given id" do scope = user_scope_fixture() - service_message = service_message_fixture(scope) + notification = notification_fixture(scope) other_scope = user_scope_fixture() - assert Notifications.get_service_message!(scope, service_message.id) == service_message - - assert_raise Ecto.NoResultsError, fn -> - Notifications.get_service_message!(other_scope, service_message.id) - end + assert Notifications.get_notification!(scope, notification.id) == notification + # it is also possible to fetch it with another scope + assert Notifications.get_notification!(other_scope, notification.id) == notification end - test "create_service_message/2 with valid data creates a service_message" do - valid_attrs = %{message: "some message", subject: "some subject"} + test "create_notification/2 with valid data creates a notification" do + valid_attrs = %{ + message: "some message", + title: "some subject", + recipients: ["user1@example.com", "user2@example.com"] + } + scope = user_scope_fixture() - assert {:ok, %ServiceMessage{} = service_message} = - Notifications.create_service_message(scope, valid_attrs) + assert {:ok, %Notification{} = notification} = + Notifications.create_notification(scope, valid_attrs) - assert service_message.message == "some message" - assert service_message.subject == "some subject" - assert service_message.user_id == scope.user.id + assert notification.message == "some message" + assert notification.title == "some subject" + assert notification.recipients == ["user1@example.com", "user2@example.com"] end - test "create_service_message/2 with invalid data returns error changeset" do + test "create_notification/2 with invalid data returns error changeset" do scope = user_scope_fixture() assert {:error, %Ecto.Changeset{}} = - Notifications.create_service_message(scope, @invalid_attrs) + Notifications.create_notification(scope, @invalid_attrs) end - test "update_service_message/3 with valid data updates the service_message" do + test "update_notification/3 with valid data updates the notification" do scope = user_scope_fixture() - service_message = service_message_fixture(scope) - update_attrs = %{message: "some updated message", subject: "some updated subject"} + notification = notification_fixture(scope) + update_attrs = %{message: "some updated message", title: "some updated subject"} - assert {:ok, %ServiceMessage{} = service_message} = - Notifications.update_service_message(scope, service_message, update_attrs) - - assert service_message.message == "some updated message" - assert service_message.subject == "some updated subject" - end - - test "update_service_message/3 with invalid scope raises" do - scope = user_scope_fixture() - other_scope = user_scope_fixture() - service_message = service_message_fixture(scope) + assert {:ok, %Notification{} = notification} = + Notifications.update_notification(scope, notification, update_attrs) - assert_raise MatchError, fn -> - Notifications.update_service_message(other_scope, service_message, %{}) - end + assert notification.message == "some updated message" + assert notification.title == "some updated subject" end - test "update_service_message/3 with invalid data returns error changeset" do + test "update_notification/3 with invalid data returns error changeset" do scope = user_scope_fixture() - service_message = service_message_fixture(scope) + notification = notification_fixture(scope) assert {:error, %Ecto.Changeset{}} = - Notifications.update_service_message(scope, service_message, @invalid_attrs) + Notifications.update_notification(scope, notification, @invalid_attrs) - assert service_message == Notifications.get_service_message!(scope, service_message.id) + assert notification == Notifications.get_notification!(scope, notification.id) end - test "delete_service_message/2 deletes the service_message" do + test "delete_notification/2 deletes the notification" do scope = user_scope_fixture() - service_message = service_message_fixture(scope) + notification = notification_fixture(scope) - assert {:ok, %ServiceMessage{}} = - Notifications.delete_service_message(scope, service_message) + assert {:ok, %Notification{}} = + Notifications.delete_notification(scope, notification) assert_raise Ecto.NoResultsError, fn -> - Notifications.get_service_message!(scope, service_message.id) - end - end - - test "delete_service_message/2 with invalid scope raises" do - scope = user_scope_fixture() - other_scope = user_scope_fixture() - service_message = service_message_fixture(scope) - - assert_raise MatchError, fn -> - Notifications.delete_service_message(other_scope, service_message) + Notifications.get_notification!(scope, notification.id) end end - test "change_service_message/2 returns a service_message changeset" do + test "change_notification/2 returns a notification changeset" do scope = user_scope_fixture() - service_message = service_message_fixture(scope) - assert %Ecto.Changeset{} = Notifications.change_service_message(scope, service_message) + notification = notification_fixture(scope) + assert %Ecto.Changeset{} = Notifications.change_notification(scope, notification) end end end diff --git a/test/admin_web/live/notification_live_test.exs b/test/admin_web/live/notification_live_test.exs new file mode 100644 index 000000000..21e6be738 --- /dev/null +++ b/test/admin_web/live/notification_live_test.exs @@ -0,0 +1,87 @@ +defmodule AdminWeb.ServiceMessageLiveTest do + use AdminWeb.ConnCase + + import Phoenix.LiveViewTest + import Admin.NotificationsFixtures + + @create_attrs %{title: "some title", message: "some message"} + @invalid_attrs %{title: nil, message: nil} + + setup :register_and_log_in_user + + defp create_notification(%{scope: scope}) do + notification = notification_fixture(scope) + + %{notification: notification} + end + + describe "Index" do + setup [:create_notification] + + test "lists all notifications", %{conn: conn, notification: notification} do + {:ok, _index_live, html} = live(conn, ~p"/notifications") + + assert html =~ "Notifications" + assert html =~ notification.title + end + + test "saves new notification", %{conn: conn} do + {:ok, index_live, _html} = live(conn, ~p"/notifications") + + assert {:ok, form_live, _} = + index_live + |> element("a", "New Notification") + |> render_click() + |> follow_redirect(conn, ~p"/notifications/new") + + assert render(form_live) =~ "New Notification" + + assert form_live + |> form("#notification-form", notification: @invalid_attrs) + |> render_change() =~ "can't be blank" + + # The first dynamic email input uses the name "manual_email_0" + + form_live + |> element("input[name=manual_email_0]") + |> render_change(%{ + "_target" => ["manual_email_0"], + "manual_email_0" => "alice@example.com" + }) + + # After rows are set, validate the form fields + assert {:ok, index_live, _html} = + form_live + |> form("#notification-form", + notification: @create_attrs + ) + |> render_submit() + |> follow_redirect(conn, ~p"/notifications") + + html = render(index_live) + assert html =~ "Notification created" + assert html =~ "some title" + end + + test "deletes notification in listing", %{conn: conn, notification: notification} do + {:ok, index_live, _html} = live(conn, ~p"/notifications") + + assert index_live + |> element("#notifications-#{notification.id} a", "Delete") + |> render_click() + + refute has_element?(index_live, "#notifications-#{notification.id}") + end + end + + describe "Show" do + setup [:create_notification] + + test "displays notification", %{conn: conn, notification: notification} do + {:ok, _show_live, html} = live(conn, ~p"/notifications/#{notification}") + + assert html =~ "Show Notification" + assert html =~ notification.title + end + end +end diff --git a/test/admin_web/live/service_message_live_test.exs b/test/admin_web/live/service_message_live_test.exs deleted file mode 100644 index ac5116445..000000000 --- a/test/admin_web/live/service_message_live_test.exs +++ /dev/null @@ -1,125 +0,0 @@ -defmodule AdminWeb.ServiceMessageLiveTest do - use AdminWeb.ConnCase - - import Phoenix.LiveViewTest - import Admin.NotificationsFixtures - - @create_attrs %{message: "some message", subject: "some subject"} - @update_attrs %{message: "some updated message", subject: "some updated subject"} - @invalid_attrs %{message: nil, subject: nil} - - setup :register_and_log_in_user - - defp create_service_message(%{scope: scope}) do - service_message = service_message_fixture(scope) - - %{service_message: service_message} - end - - describe "Index" do - setup [:create_service_message] - - test "lists all service_messages", %{conn: conn, service_message: service_message} do - {:ok, _index_live, html} = live(conn, ~p"/service_messages") - - assert html =~ "Listing Service messages" - assert html =~ service_message.subject - end - - test "saves new service_message", %{conn: conn} do - {:ok, index_live, _html} = live(conn, ~p"/service_messages") - - assert {:ok, form_live, _} = - index_live - |> element("a", "New Service message") - |> render_click() - |> follow_redirect(conn, ~p"/service_messages/new") - - assert render(form_live) =~ "New Service message" - - assert form_live - |> form("#service_message-form", service_message: @invalid_attrs) - |> render_change() =~ "can't be blank" - - assert {:ok, index_live, _html} = - form_live - |> form("#service_message-form", service_message: @create_attrs) - |> render_submit() - |> follow_redirect(conn, ~p"/service_messages") - - html = render(index_live) - assert html =~ "Service message created successfully" - assert html =~ "some subject" - end - - test "updates service_message in listing", %{conn: conn, service_message: service_message} do - {:ok, index_live, _html} = live(conn, ~p"/service_messages") - - assert {:ok, form_live, _html} = - index_live - |> element("#service_messages-#{service_message.id} a", "Edit") - |> render_click() - |> follow_redirect(conn, ~p"/service_messages/#{service_message}/edit") - - assert render(form_live) =~ "Edit Service message" - - assert form_live - |> form("#service_message-form", service_message: @invalid_attrs) - |> render_change() =~ "can't be blank" - - assert {:ok, index_live, _html} = - form_live - |> form("#service_message-form", service_message: @update_attrs) - |> render_submit() - |> follow_redirect(conn, ~p"/service_messages") - - html = render(index_live) - assert html =~ "Service message updated successfully" - assert html =~ "some updated subject" - end - - test "deletes service_message in listing", %{conn: conn, service_message: service_message} do - {:ok, index_live, _html} = live(conn, ~p"/service_messages") - - assert index_live |> element("#service_messages-#{service_message.id} a", "Delete") |> render_click() - refute has_element?(index_live, "#service_messages-#{service_message.id}") - end - end - - describe "Show" do - setup [:create_service_message] - - test "displays service_message", %{conn: conn, service_message: service_message} do - {:ok, _show_live, html} = live(conn, ~p"/service_messages/#{service_message}") - - assert html =~ "Show Service message" - assert html =~ service_message.subject - end - - test "updates service_message and returns to show", %{conn: conn, service_message: service_message} do - {:ok, show_live, _html} = live(conn, ~p"/service_messages/#{service_message}") - - assert {:ok, form_live, _} = - show_live - |> element("a", "Edit") - |> render_click() - |> follow_redirect(conn, ~p"/service_messages/#{service_message}/edit?return_to=show") - - assert render(form_live) =~ "Edit Service message" - - assert form_live - |> form("#service_message-form", service_message: @invalid_attrs) - |> render_change() =~ "can't be blank" - - assert {:ok, show_live, _html} = - form_live - |> form("#service_message-form", service_message: @update_attrs) - |> render_submit() - |> follow_redirect(conn, ~p"/service_messages/#{service_message}") - - html = render(show_live) - assert html =~ "Service message updated successfully" - assert html =~ "some updated subject" - end - end -end diff --git a/test/support/fixtures/notifications_fixtures.ex b/test/support/fixtures/notifications_fixtures.ex index 8c63d270f..48a4d9829 100644 --- a/test/support/fixtures/notifications_fixtures.ex +++ b/test/support/fixtures/notifications_fixtures.ex @@ -5,16 +5,17 @@ defmodule Admin.NotificationsFixtures do """ @doc """ - Generate a service_message. + Generate a notification. """ - def service_message_fixture(scope, attrs \\ %{}) do + def notification_fixture(scope, attrs \\ %{}) do attrs = Enum.into(attrs, %{ message: "some message", - subject: "some subject" + title: "some title", + recipients: ["user1@example.com", "user2@example.com"] }) - {:ok, service_message} = Admin.Notifications.create_service_message(scope, attrs) - service_message + {:ok, notification} = Admin.Notifications.create_notification(scope, attrs) + notification end end From 25d640b9be1ce0cc164fa376e238347500092f5d Mon Sep 17 00:00:00 2001 From: spaenleh Date: Wed, 5 Nov 2025 12:18:40 +0100 Subject: [PATCH 5/9] fix: add tests --- lib/admin/accounts.ex | 10 +++- lib/admin/accounts/account.ex | 10 +++- lib/admin/mailer_worker.ex | 6 +- lib/admin/notifications.ex | 19 ++++++ lib/admin_web/live/notification_live/new.ex | 8 +-- lib/mix/tasks/webapp.ex | 26 -------- test/admin/accounts_test.exs | 26 ++++++++ test/admin/mailer_worker_test.exs | 66 +++++++++++++++++++++ test/support/fixtures/accounts_fixtures.ex | 15 +++++ 9 files changed, 150 insertions(+), 36 deletions(-) delete mode 100644 lib/mix/tasks/webapp.ex create mode 100644 test/admin/mailer_worker_test.exs diff --git a/lib/admin/accounts.ex b/lib/admin/accounts.ex index 4ad5a6a73..67b465c3e 100644 --- a/lib/admin/accounts.ex +++ b/lib/admin/accounts.ex @@ -363,11 +363,11 @@ defmodule Admin.Accounts do def get_member_by_email(email) do case Repo.get_by(Account, email: email) do %Account{} = user -> {:ok, user} - nil -> {:error, :not_found} + nil -> {:error, :member_not_found} end end - def get_active_users do + def get_active_members do Repo.all( from(m in Account, where: @@ -378,4 +378,10 @@ defmodule Admin.Accounts do ) ) end + + def create_member(attrs \\ %{}) do + %Account{} + |> Account.changeset(attrs) + |> Repo.insert() + end end diff --git a/lib/admin/accounts/account.ex b/lib/admin/accounts/account.ex index b52e18beb..8df33b89e 100644 --- a/lib/admin/accounts/account.ex +++ b/lib/admin/accounts/account.ex @@ -1,8 +1,9 @@ defmodule Admin.Accounts.Account do @moduledoc """ - This represents a graasp user. + This represents a graasp user. """ use Admin.Schema + import Ecto.Changeset schema "account" do field :name, :string @@ -11,4 +12,11 @@ defmodule Admin.Accounts.Account do timestamps(type: :utc_datetime) end + + @doc false + def changeset(account, attrs) do + account + |> cast(attrs, [:name, :email, :type]) + |> validate_required([:name, :email, :type]) + end end diff --git a/lib/admin/mailer_worker.ex b/lib/admin/mailer_worker.ex index d95771085..b42e6a35e 100644 --- a/lib/admin/mailer_worker.ex +++ b/lib/admin/mailer_worker.ex @@ -25,7 +25,7 @@ defmodule Admin.MailerWorker do scope = Scope.for_user(user) with {:ok, member} <- Accounts.get_member_by_email(member_email), - notification <- Notifications.get_notification!(scope, notification_id), + {:ok, notification} <- Notifications.get_notification(scope, notification_id), {:ok, _} <- UserNotifier.deliver_notification( member, @@ -43,7 +43,7 @@ defmodule Admin.MailerWorker do :ok else - {:error, :not_found} -> + {:error, reason} when reason in [:member_not_found, :notification_not_found] -> Notifications.save_log( scope, %{ @@ -53,7 +53,7 @@ defmodule Admin.MailerWorker do %Notification{id: notification_id} ) - {:cancel, "Member was not found"} + {:cancel, reason} {:error, _} -> {:error, "Failed to send notification"} diff --git a/lib/admin/notifications.ex b/lib/admin/notifications.ex index c42adc35a..e47e6fc60 100644 --- a/lib/admin/notifications.ex +++ b/lib/admin/notifications.ex @@ -88,6 +88,25 @@ defmodule Admin.Notifications do Repo.get_by!(Notification, id: id) |> Repo.preload(:logs) end + @doc """ + Gets a single notification. + + ## Examples + + iex> get_notification(scope, 123) + {:ok, %Notification{}} + + iex> get_notification(scope, 456) + {:error, :not_found} + + """ + def get_notification(%Scope{} = _scope, id) do + case Repo.get_by(Notification, id: id) |> Repo.preload(:logs) do + %Notification{} = notification -> {:ok, notification} + nil -> {:error, :notification_not_found} + end + end + @doc """ Updates a service_message. diff --git a/lib/admin_web/live/notification_live/new.ex b/lib/admin_web/live/notification_live/new.ex index fa37c8ffb..ce2ea967a 100644 --- a/lib/admin_web/live/notification_live/new.ex +++ b/lib/admin_web/live/notification_live/new.ex @@ -157,7 +157,7 @@ defmodule AdminWeb.NotificationLive.New do # Fetch active users and set recipients to that list # You can do this async if Accounts.get_active_users/0 is slow. active = - safe_get_active_users() + safe_get_active_members() # take only email |> Enum.map(& &1.email) @@ -276,9 +276,9 @@ defmodule AdminWeb.NotificationLive.New do end end - # Safely get active users; in a real app consider async if slow - defp safe_get_active_users do - Accounts.get_active_users() + # Safely get active members; in a real app consider async if slow + defp safe_get_active_members do + Accounts.get_active_members() rescue _ -> [] end diff --git a/lib/mix/tasks/webapp.ex b/lib/mix/tasks/webapp.ex deleted file mode 100644 index 6eaf5ee88..000000000 --- a/lib/mix/tasks/webapp.ex +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Mix.Tasks.Webapp do - @moduledoc """ - React frontend compilation and bundling for production. - """ - use Mix.Task - require Logger - # Path for the frontend static assets that are being served - # from our Phoenix router when accessing /app/* for the first time - @public_path "./priv/static/webapp" - - @shortdoc "Compile and bundle React frontend for production" - def run(_) do - Logger.info("📦 - Installing NPM packages") - System.cmd("pnpm", ["install", "--quiet"], cd: "./frontend") - - Logger.info("⚙️ - Compiling React frontend") - System.cmd("pnpm", ["run", "build"], cd: "./frontend") - - Logger.info("🚛 - Moving dist folder to Phoenix at #{@public_path}") - # First clean up any stale files from previous builds if any - System.cmd("rm", ["-rf", @public_path]) - System.cmd("cp", ["-R", "./frontend/dist", @public_path]) - - Logger.info("⚛️ - React frontend ready.") - end -end diff --git a/test/admin/accounts_test.exs b/test/admin/accounts_test.exs index 7affe7afa..4f8324ae4 100644 --- a/test/admin/accounts_test.exs +++ b/test/admin/accounts_test.exs @@ -402,4 +402,30 @@ defmodule Admin.AccountsTest do refute Enum.empty?(Accounts.list_users()) end end + + describe "create_member/0" do + test "without correct params" do + assert {:error, %Ecto.Changeset{} = changeset} = + Accounts.create_member(%{name: nil, email: nil, type: nil}) + + assert changeset.errors == [ + name: {"can't be blank", [validation: :required]}, + email: {"can't be blank", [validation: :required]}, + type: {"can't be blank", [validation: :required]} + ] + end + + test "with correct params" do + assert {:ok, member} = + Accounts.create_member(%{ + name: "John Doe", + email: "john@example.com", + type: "member" + }) + + assert member.name == "John Doe" + assert member.email == "john@example.com" + assert member.type == "member" + end + end end diff --git a/test/admin/mailer_worker_test.exs b/test/admin/mailer_worker_test.exs new file mode 100644 index 000000000..7c1accb64 --- /dev/null +++ b/test/admin/mailer_worker_test.exs @@ -0,0 +1,66 @@ +defmodule Admin.MailerWorkerTest do + use Admin.DataCase + use Oban.Testing, repo: Admin.Repo + + import Admin.AccountsFixtures + import Admin.NotificationsFixtures + + describe "mailer worker" do + test "correct inputs" do + scope = user_scope_fixture() + member = member_fixture() + notification = notification_fixture(scope) + + args = %{ + member_email: member.email, + user_id: scope.user.id, + notification_id: notification.id + } + + Oban.Testing.with_testing_mode(:manual, fn -> + assert {:ok, _} = + args + |> Admin.MailerWorker.new() + |> Oban.insert() + + assert :ok = perform_job(Admin.MailerWorker, args) + end) + end + + test "invalid member email" do + scope = user_scope_fixture() + notification = notification_fixture(scope) + + args = %{ + member_email: "toto@email.com", + user_id: scope.user.id, + notification_id: notification.id + } + + assert {:ok, _} = + args + |> Admin.MailerWorker.new() + |> Oban.insert() + + assert {:cancel, :member_not_found} = perform_job(Admin.MailerWorker, args) + end + + test "invalid notification id" do + scope = user_scope_fixture() + member = member_fixture() + + args = %{ + member_email: member.email, + user_id: scope.user.id, + notification_id: Ecto.UUID.generate() + } + + assert {:ok, _} = + args + |> Admin.MailerWorker.new() + |> Oban.insert() + + assert {:cancel, :notification_not_found} = perform_job(Admin.MailerWorker, args) + end + end +end diff --git a/test/support/fixtures/accounts_fixtures.ex b/test/support/fixtures/accounts_fixtures.ex index 8fea5550b..efbd65128 100644 --- a/test/support/fixtures/accounts_fixtures.ex +++ b/test/support/fixtures/accounts_fixtures.ex @@ -10,6 +10,7 @@ defmodule Admin.AccountsFixtures do alias Admin.Accounts.Scope def unique_user_email, do: "user#{System.unique_integer()}@example.com" + def unique_user_name, do: "user#{System.unique_integer()}" def valid_user_password, do: "hello world!" def valid_user_attributes(attrs \\ %{}) do @@ -86,4 +87,18 @@ defmodule Admin.AccountsFixtures do set: [created_at: dt, authenticated_at: dt] ) end + + defp valid_member_attributes(attrs) do + Enum.into(attrs, %{ + name: unique_user_name(), + email: unique_user_email(), + type: "individual" + }) + end + + def member_fixture(attrs \\ %{}) do + {:ok, member} = attrs |> valid_member_attributes() |> Accounts.create_member() + + member + end end From bf969b19dc1cbe3a8e9db7d6dd1b8653b1c4e4ec Mon Sep 17 00:00:00 2001 From: spaenleh Date: Wed, 5 Nov 2025 13:54:51 +0100 Subject: [PATCH 6/9] fix: add html email templates --- lib/admin/accounts/user_notifier.ex | 21 ++++++ lib/admin/notifications/mailing_templates.ex | 69 ++++++++++++++++++++ mix.exs | 5 +- mix.lock | 3 + 4 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 lib/admin/notifications/mailing_templates.ex diff --git a/lib/admin/accounts/user_notifier.ex b/lib/admin/accounts/user_notifier.ex index 3c3f98492..6407f75b4 100644 --- a/lib/admin/accounts/user_notifier.ex +++ b/lib/admin/accounts/user_notifier.ex @@ -10,6 +10,27 @@ defmodule Admin.Accounts.UserNotifier do @footer "Graasp.org is a learning experience platform." + def test_email(scope) do + html = + Admin.Notifications.MailingTemplates.simple_call_to_action(%{ + user: scope, + message: "This is a test email.", + button_text: "Click here", + button_url: "https://example.com" + }) + + email = + new() + |> to("basile@graasp.org") + |> from({"Admin", "admin@graasp.org"}) + |> subject("test email") + |> html_body(html) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + # Delivers the email using the application mailer. defp deliver(recipient, subject, body) do email = diff --git a/lib/admin/notifications/mailing_templates.ex b/lib/admin/notifications/mailing_templates.ex new file mode 100644 index 000000000..a4a2b64ce --- /dev/null +++ b/lib/admin/notifications/mailing_templates.ex @@ -0,0 +1,69 @@ +defmodule Admin.Notifications.MailingTemplates do + use Phoenix.Component + import Phoenix.Template, only: [render_to_string: 4] + + def call_to_action_email(assigns) do + ~H""" + + + + + + + + + + + + + + Graasp + + + + + + + + Hello {@user.name}, + + + {@message} + + + {@button_text} + + + In case you can not click the button above here is the full URL: + + + {@button_url} + + + + + + + + You are receiving this email because of your account on Graasp. + + + Graasp Association, Valais, Switzerland + + + + + + """ + end + + def simple_call_to_action(assigns) do + {:ok, html} = + render_to_string(__MODULE__, "call_to_action_email", "html", assigns) |> Mjml.to_html() + + html + end +end diff --git a/mix.exs b/mix.exs index a7ff835ae..29e54d671 100644 --- a/mix.exs +++ b/mix.exs @@ -114,7 +114,10 @@ defmodule Admin.MixProject do {:sweet_xml, "~> 0.7"}, # jobs with Oban {:oban, "~> 2.19"}, - {:oban_web, "~> 2.11"} + {:oban_web, "~> 2.11"}, + + # html templating for emails + {:mjml, "~> 5.0"} ] end diff --git a/mix.lock b/mix.lock index 5c3647f91..51683208d 100644 --- a/mix.lock +++ b/mix.lock @@ -3,6 +3,7 @@ "bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "castore": {:hex, :castore, "1.0.16", "8a4f9a7c8b81cda88231a08fe69e3254f16833053b23fa63274b05cbc61d2a1e", [:mix], [], "hexpm", "33689203a0eaaf02fcd0e86eadfbcf1bd636100455350592e7e2628564022aaf"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"}, @@ -43,6 +44,7 @@ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [: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", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mjml": {:hex, :mjml, "5.2.0", "f0ef9ae7028948fb4a9259a4d9f0fc50f6c16864694d60f3f715724fdaa5c0ba", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8.1", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "bf39d2e0041f1f08afd07694239be39a8c173b00649e3463c2bd959473092c2a"}, "mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"}, "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, @@ -78,6 +80,7 @@ "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [: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", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [: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", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.3", "4e741024b0b097fe783add06e53ae9a6f23ddc78df1010f215df0c02915ef5a8", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "c23f5f33cb6608542de4d04faf0f0291458c352a4648e4d28d17ee1098cddcc4"}, "sentry": {:hex, :sentry, "11.0.4", "60371c96cefd247e0fc98840bba2648f64f19aa0b8db8e938f5a98421f55b619", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 0.3.0 or ~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:opentelemetry, ">= 0.0.0", [hex: :opentelemetry, repo: "hexpm", optional: true]}, {:opentelemetry_api, ">= 0.0.0", [hex: :opentelemetry_api, repo: "hexpm", optional: true]}, {:opentelemetry_exporter, ">= 0.0.0", [hex: :opentelemetry_exporter, repo: "hexpm", optional: true]}, {:opentelemetry_semantic_conventions, ">= 0.0.0", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "feaafc284dc204c82aadaddc884227aeaa3480decb274d30e184b9d41a700c66"}, "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, From 3ceb7b9839ddb16cffa9dc5f64fa4007e4450858 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Wed, 5 Nov 2025 15:36:35 +0100 Subject: [PATCH 7/9] fix: credo issues --- lib/admin/accounts/user_notifier.ex | 3 ++- lib/admin/notifications/mailing_templates.ex | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/admin/accounts/user_notifier.ex b/lib/admin/accounts/user_notifier.ex index 6407f75b4..71c4c42f5 100644 --- a/lib/admin/accounts/user_notifier.ex +++ b/lib/admin/accounts/user_notifier.ex @@ -7,12 +7,13 @@ defmodule Admin.Accounts.UserNotifier do alias Admin.Accounts.Account alias Admin.Accounts.User alias Admin.Mailer + alias Admin.Notifications.MailingTemplates @footer "Graasp.org is a learning experience platform." def test_email(scope) do html = - Admin.Notifications.MailingTemplates.simple_call_to_action(%{ + MailingTemplates.simple_call_to_action(%{ user: scope, message: "This is a test email.", button_text: "Click here", diff --git a/lib/admin/notifications/mailing_templates.ex b/lib/admin/notifications/mailing_templates.ex index a4a2b64ce..71325b309 100644 --- a/lib/admin/notifications/mailing_templates.ex +++ b/lib/admin/notifications/mailing_templates.ex @@ -1,4 +1,8 @@ defmodule Admin.Notifications.MailingTemplates do + @moduledoc """ + Module for managing mailing templates. + """ + use Phoenix.Component import Phoenix.Template, only: [render_to_string: 4] From 2c0103ef16497cd426ed308f76c00dc716bc67e3 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Thu, 6 Nov 2025 14:58:15 +0100 Subject: [PATCH 8/9] fix: make requested changes --- lib/admin/accounts.ex | 4 +--- lib/admin/notifications/mailing_templates.ex | 2 +- lib/admin_web/components/layouts.ex | 4 ++-- lib/admin_web/live/notification_live/index.ex | 6 +++--- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/admin/accounts.ex b/lib/admin/accounts.ex index 67b465c3e..d100a002e 100644 --- a/lib/admin/accounts.ex +++ b/lib/admin/accounts.ex @@ -372,9 +372,7 @@ defmodule Admin.Accounts do from(m in Account, where: not is_nil(m.last_authenticated_at) and m.last_authenticated_at > ago(90, "day") and - m.type == "individual", - limit: 100, - order_by: [desc: m.created_at] + m.type == "individual" ) ) end diff --git a/lib/admin/notifications/mailing_templates.ex b/lib/admin/notifications/mailing_templates.ex index 71325b309..fb5f3cd6a 100644 --- a/lib/admin/notifications/mailing_templates.ex +++ b/lib/admin/notifications/mailing_templates.ex @@ -38,7 +38,7 @@ defmodule Admin.Notifications.MailingTemplates do {@button_text} - In case you can not click the button above here is the full URL: + In case you can not click the button above here is the link: {@button_url} diff --git a/lib/admin_web/components/layouts.ex b/lib/admin_web/components/layouts.ex index cf93a5947..5fe6998ff 100644 --- a/lib/admin_web/components/layouts.ex +++ b/lib/admin_web/components/layouts.ex @@ -169,7 +169,7 @@ defmodule AdminWeb.Layouts do
  • <.link navigate={~p"/publishers"}>Apps
  • -
  • <.link navigate={~p"/notifications"}>Notifications
  • +
  • <.link navigate={~p"/notifications"}>Mailing
  • <.link navigate={~p"/users/settings"}>Settings
  • <.link navigate={~p"/oban"}>Oban
  • @@ -205,7 +205,7 @@ defmodule AdminWeb.Layouts do
  • <.link navigate={~p"/publishers"}>Apps
  • -
  • <.link navigate={~p"/notifications"}>Notifications
  • +
  • <.link navigate={~p"/notifications"}>Mailing
  • <.link navigate={~p"/users/settings"}>Settings
  • <.link navigate={~p"/oban"}>Oban
  • <% end %> diff --git a/lib/admin_web/live/notification_live/index.ex b/lib/admin_web/live/notification_live/index.ex index ff32e4636..5bb9eea61 100644 --- a/lib/admin_web/live/notification_live/index.ex +++ b/lib/admin_web/live/notification_live/index.ex @@ -7,10 +7,10 @@ defmodule AdminWeb.NotificationLive.Index do ~H""" <.header> - Notifications + Mailing <:actions> <.button variant="primary" navigate={~p"/notifications/new"}> - <.icon name="hero-plus" /> New Notification + <.icon name="hero-plus" /> New mail @@ -30,10 +30,10 @@ defmodule AdminWeb.NotificationLive.Index do
    <.link navigate={~p"/notifications/#{notification}"}>Show
    - <%!-- <.link navigate={~p"/notifications/#{notification}/edit"}>Edit --%> <:action :let={{id, notification}}> <.link + class="btn btn-error" phx-click={JS.push("delete", value: %{id: notification.id}) |> hide("##{id}")} data-confirm="Are you sure?" > From 5e313bbbca3318f0d28b035d43f5386cd82d2e02 Mon Sep 17 00:00:00 2001 From: spaenleh Date: Thu, 6 Nov 2025 16:42:55 +0100 Subject: [PATCH 9/9] fix: add more changes and add tests --- lib/admin/accounts/account.ex | 9 +++ lib/admin/notifications/log.ex | 2 +- lib/admin/notifications/notification.ex | 73 ++++++++++++++++++- .../controllers/published_item_controller.ex | 1 + lib/admin_web/live/notification_live/index.ex | 7 +- lib/admin_web/live/notification_live/new.ex | 35 +++++---- lib/admin_web/live/notification_live/show.ex | 3 +- lib/admin_web/live/user_live/listing.ex | 1 + lib/admin_web/router.ex | 6 +- test/admin/notifications_test.exs | 13 +++- .../admin_web/live/notification_live_test.exs | 8 +- 11 files changed, 125 insertions(+), 33 deletions(-) diff --git a/lib/admin/accounts/account.ex b/lib/admin/accounts/account.ex index 8df33b89e..000208284 100644 --- a/lib/admin/accounts/account.ex +++ b/lib/admin/accounts/account.ex @@ -18,5 +18,14 @@ defmodule Admin.Accounts.Account do account |> cast(attrs, [:name, :email, :type]) |> validate_required([:name, :email, :type]) + |> validate_email() + end + + defp validate_email(changeset) do + changeset + |> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, + message: "must have the @ sign and no spaces" + ) + |> validate_length(:email, max: 160) end end diff --git a/lib/admin/notifications/log.ex b/lib/admin/notifications/log.ex index 13e049980..9ab39d2af 100644 --- a/lib/admin/notifications/log.ex +++ b/lib/admin/notifications/log.ex @@ -5,7 +5,7 @@ defmodule Admin.Notifications.Log do use Admin.Schema import Ecto.Changeset - @statuses ~w(active inactive banned)a + @statuses ~w(sent failed)a schema "notification_logs" do field :email, :string diff --git a/lib/admin/notifications/notification.ex b/lib/admin/notifications/notification.ex index b1b504449..d8a783f11 100644 --- a/lib/admin/notifications/notification.ex +++ b/lib/admin/notifications/notification.ex @@ -19,12 +19,83 @@ defmodule Admin.Notifications.Notification do def changeset(notification, attrs, _user_scope) do notification |> cast(attrs, [:title, :message, :recipients]) - |> validate_required([:title, :message]) + |> validate_required([:title, :message, :recipients]) + |> normalize_emails(:recipients) + |> validate_email_list(:recipients) end def update_recipients(notification, %{recipients: _} = attrs) do notification |> cast(attrs, [:recipients]) |> validate_required([:recipients]) + |> normalize_emails(:recipients) + |> validate_email_list(:recipients) + end + + # Normalize each email string: trim, downcase, drop empty values + defp normalize_emails(changeset, key) do + case get_change(changeset, key) do + nil -> + changeset + + emails when is_list(emails) -> + cleaned = + emails + |> Enum.map(&normalize_email_item/1) + |> Enum.reject(&(&1 == "")) + + put_change(changeset, key, cleaned) + + _other -> + # If a non-list sneaks in, leave as-is; validate_emails_list will add an error + changeset + end + end + + defp normalize_email_item(item) when is_binary(item) do + item + |> String.trim() + |> String.downcase() + end + + defp normalize_email_item(_), do: "" + + # Validate the list and each element + defp validate_email_list(changeset, key) do + # Ensure it's a list + changeset = + validate_change(changeset, key, fn key, value -> + if is_list(value) do + [] + else + [%{key => "must be a list of strings"}] + end + end) + + recipients = get_field(changeset, key) || [] + + # Validate each item is a binary and matches email format + changeset = + Enum.with_index(recipients) + |> Enum.reduce(changeset, fn {email, idx}, acc -> + cond do + not is_binary(email) -> + add_error(acc, key, "item at index #{idx} must be a string") + + not valid_email?(email) -> + add_error(acc, key, "invalid email at index #{idx}: #{email}") + + true -> + acc + end + end) + + changeset + end + + # Pragmatic email validator; replace with your preferred validator if available. + defp valid_email?(email) when is_binary(email) do + # Simple, commonly used pattern; not fully RFC-compliant but practical. + Regex.match?(~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, email) end end diff --git a/lib/admin_web/controllers/published_item_controller.ex b/lib/admin_web/controllers/published_item_controller.ex index f8d313174..714c2c412 100644 --- a/lib/admin_web/controllers/published_item_controller.ex +++ b/lib/admin_web/controllers/published_item_controller.ex @@ -11,6 +11,7 @@ defmodule AdminWeb.PublishedItemController do published_items = Publications.list_published_items(100) render(conn, :index, + page_title: "Published Items", published_items: published_items, changeset: PublishedItemSearchForm.changeset(%PublishedItemSearchForm{}, %{}) ) diff --git a/lib/admin_web/live/notification_live/index.ex b/lib/admin_web/live/notification_live/index.ex index 5bb9eea61..021f3f0ff 100644 --- a/lib/admin_web/live/notification_live/index.ex +++ b/lib/admin_web/live/notification_live/index.ex @@ -10,11 +10,12 @@ defmodule AdminWeb.NotificationLive.Index do Mailing <:actions> <.button variant="primary" navigate={~p"/notifications/new"}> - <.icon name="hero-plus" /> New mail + <.icon name="hero-plus" /> New Mail + <%!-- Idea: represent the mails as cards ? --%> <.table id="notifications" rows={@streams.notifications} @@ -33,7 +34,7 @@ defmodule AdminWeb.NotificationLive.Index do <:action :let={{id, notification}}> <.link - class="btn btn-error" + class="text-error" phx-click={JS.push("delete", value: %{id: notification.id}) |> hide("##{id}")} data-confirm="Are you sure?" > @@ -53,7 +54,7 @@ defmodule AdminWeb.NotificationLive.Index do {:ok, socket - |> assign(:page_title, "Listing Service messages") + |> assign(:page_title, "Mailing") |> stream(:notifications, Notifications.list_notifications(socket.assigns.current_scope))} end diff --git a/lib/admin_web/live/notification_live/new.ex b/lib/admin_web/live/notification_live/new.ex index ce2ea967a..80bdb502e 100644 --- a/lib/admin_web/live/notification_live/new.ex +++ b/lib/admin_web/live/notification_live/new.ex @@ -10,7 +10,7 @@ defmodule AdminWeb.NotificationLive.New do ~H""" <.header> - New Notification + New Mailing <.form @@ -24,7 +24,7 @@ defmodule AdminWeb.NotificationLive.New do <.input field={form[:message]} type="textarea" label="Message" rows="6" />
    - Recipients source + Recipients - + Choose how to populate recipient emails.
    @@ -87,20 +87,25 @@ defmodule AdminWeb.NotificationLive.New do <%= if @active_users == [] do %>

    No active users found.

    <% else %> -
      - <%= for email <- @active_users do %> -
    • {email}
    • - <% end %> -
    +
    +
    + {length(@active_users)} active users (click to show) +
    + +
    <% end %> -

    - Recipients will be set to the current active users list on submit. -

    <% end %>
    - <.button variant="primary">Create Notification + <.button variant="primary">Create Mail
    @@ -119,6 +124,7 @@ defmodule AdminWeb.NotificationLive.New do # UI state: recipient_method can be "manual" or "active_users" socket = socket + |> assign(:page_title, "New Mailing") |> assign(:form, notification) |> assign(:recipient_method, "manual") # start with one empty input @@ -157,7 +163,7 @@ defmodule AdminWeb.NotificationLive.New do # Fetch active users and set recipients to that list # You can do this async if Accounts.get_active_users/0 is slow. active = - safe_get_active_members() + get_active_members() # take only email |> Enum.map(& &1.email) @@ -276,8 +282,7 @@ defmodule AdminWeb.NotificationLive.New do end end - # Safely get active members; in a real app consider async if slow - defp safe_get_active_members do + defp get_active_members do Accounts.get_active_members() rescue _ -> [] diff --git a/lib/admin_web/live/notification_live/show.ex b/lib/admin_web/live/notification_live/show.ex index 8a863e552..102cce3b6 100644 --- a/lib/admin_web/live/notification_live/show.ex +++ b/lib/admin_web/live/notification_live/show.ex @@ -25,6 +25,7 @@ defmodule AdminWeb.NotificationLive.Show do <.table id="notification_logs" rows={@notification.logs}> <:col :let={message_log} label="Email">{message_log.email} <:col :let={message_log} label="Sent at">{message_log.created_at} + <:col :let={message_log} label="Status">{message_log.status} <% else %>

    No messages sent yet

    @@ -41,7 +42,7 @@ defmodule AdminWeb.NotificationLive.Show do {:ok, socket - |> assign(:page_title, "Show Notification") + |> assign(:page_title, "Show Mail") |> assign( :notification, Notifications.get_notification!(socket.assigns.current_scope, id) diff --git a/lib/admin_web/live/user_live/listing.ex b/lib/admin_web/live/user_live/listing.ex index 293aac470..0f140e45f 100644 --- a/lib/admin_web/live/user_live/listing.ex +++ b/lib/admin_web/live/user_live/listing.ex @@ -72,6 +72,7 @@ defmodule AdminWeb.UserLive.Listing do socket = socket + |> assign(:page_title, "Users") |> stream(:users, Accounts.list_users()) |> assign(:show_modal, false) |> assign(:user_to_delete, nil) diff --git a/lib/admin_web/router.ex b/lib/admin_web/router.ex index 487824d3b..6bb87cefa 100644 --- a/lib/admin_web/router.ex +++ b/lib/admin_web/router.ex @@ -101,11 +101,7 @@ defmodule AdminWeb.Router do scope "/notifications" do live "/", NotificationLive.Index, :index live "/new", NotificationLive.New, :new - - scope "/:id" do - live "/", NotificationLive.Show, :show - # live "/edit", ServiceMessageLive.Form, :edit - end + live "/:id", NotificationLive.Show, :show end end diff --git a/test/admin/notifications_test.exs b/test/admin/notifications_test.exs index f31dbac4c..666afa356 100644 --- a/test/admin/notifications_test.exs +++ b/test/admin/notifications_test.exs @@ -9,7 +9,8 @@ defmodule Admin.NotificationsTest do import Admin.AccountsFixtures, only: [user_scope_fixture: 0] import Admin.NotificationsFixtures - @invalid_attrs %{message: nil, title: nil, recipients: nil} + @empty_attrs %{message: nil, title: nil, recipients: nil} + @invalid_email_attrs %{message: "A message", title: "title", recipients: ["test", "other"]} test "list_notifications/1 returns all notifications" do scope = user_scope_fixture() @@ -49,7 +50,10 @@ defmodule Admin.NotificationsTest do scope = user_scope_fixture() assert {:error, %Ecto.Changeset{}} = - Notifications.create_notification(scope, @invalid_attrs) + Notifications.create_notification(scope, @empty_attrs) + + assert {:error, %Ecto.Changeset{}} = + Notifications.create_notification(scope, @invalid_email_attrs) end test "update_notification/3 with valid data updates the notification" do @@ -69,7 +73,10 @@ defmodule Admin.NotificationsTest do notification = notification_fixture(scope) assert {:error, %Ecto.Changeset{}} = - Notifications.update_notification(scope, notification, @invalid_attrs) + Notifications.update_notification(scope, notification, @empty_attrs) + + assert {:error, %Ecto.Changeset{}} = + Notifications.update_notification(scope, notification, @invalid_email_attrs) assert notification == Notifications.get_notification!(scope, notification.id) end diff --git a/test/admin_web/live/notification_live_test.exs b/test/admin_web/live/notification_live_test.exs index 21e6be738..c120fdb60 100644 --- a/test/admin_web/live/notification_live_test.exs +++ b/test/admin_web/live/notification_live_test.exs @@ -21,7 +21,7 @@ defmodule AdminWeb.ServiceMessageLiveTest do test "lists all notifications", %{conn: conn, notification: notification} do {:ok, _index_live, html} = live(conn, ~p"/notifications") - assert html =~ "Notifications" + assert html =~ "Mailing" assert html =~ notification.title end @@ -30,11 +30,11 @@ defmodule AdminWeb.ServiceMessageLiveTest do assert {:ok, form_live, _} = index_live - |> element("a", "New Notification") + |> element("a", "New Mail") |> render_click() |> follow_redirect(conn, ~p"/notifications/new") - assert render(form_live) =~ "New Notification" + assert render(form_live) =~ "New Mail" assert form_live |> form("#notification-form", notification: @invalid_attrs) @@ -80,7 +80,7 @@ defmodule AdminWeb.ServiceMessageLiveTest do test "displays notification", %{conn: conn, notification: notification} do {:ok, _show_live, html} = live(conn, ~p"/notifications/#{notification}") - assert html =~ "Show Notification" + assert html =~ "Show Mail" assert html =~ notification.title end end