From 0642c0b7e022ea7eedafc6431c94f800e26848c2 Mon Sep 17 00:00:00 2001 From: Kamaro Date: Mon, 7 Jul 2025 13:56:49 +0300 Subject: [PATCH 01/22] Started adding invitation --- lib/helpcenter/accounts.ex | 9 ++ lib/helpcenter/accounts/invitation.ex | 109 ++++++++++++++++++ .../invitation/changes/add_user_to_team.ex | 27 +++++ .../invitation/changes/send_declined_email.ex | 44 +++++++ .../changes/send_invitation_email.ex | 42 +++++++ .../invitation/changes/send_welcome_email.ex | 44 +++++++ .../changes/set_invitation_attributes.ex | 22 ++++ lib/helpcenter/accounts/user.ex | 3 +- lib/helpcenter/knowledge_base/article.ex | 1 - .../controllers/auth_controller.ex | 2 +- mix.lock | 70 +++++------ test/helpcenter/accounts/invitation_test.exs | 33 ++++++ 12 files changed, 368 insertions(+), 38 deletions(-) create mode 100644 lib/helpcenter/accounts/invitation.ex create mode 100644 lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex create mode 100644 lib/helpcenter/accounts/invitation/changes/send_declined_email.ex create mode 100644 lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex create mode 100644 lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex create mode 100644 lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex create mode 100644 test/helpcenter/accounts/invitation_test.exs diff --git a/lib/helpcenter/accounts.ex b/lib/helpcenter/accounts.ex index 13195e8..9a8b664 100644 --- a/lib/helpcenter/accounts.ex +++ b/lib/helpcenter/accounts.ex @@ -4,6 +4,15 @@ defmodule Helpcenter.Accounts do resources do # the rest of the domain resources + resource Helpcenter.Accounts.Team + resource Helpcenter.Accounts.User + resource Helpcenter.Accounts.Group + resource Helpcenter.Accounts.UserTeam + resource Helpcenter.Accounts.UserGroup + resource Helpcenter.Accounts.GroupPermission + + resource Helpcenter.Accounts.Token + resource Helpcenter.Accounts.Invitation resource Helpcenter.Accounts.UserNotification do define :notify, action: :create diff --git a/lib/helpcenter/accounts/invitation.ex b/lib/helpcenter/accounts/invitation.ex new file mode 100644 index 0000000..ad65877 --- /dev/null +++ b/lib/helpcenter/accounts/invitation.ex @@ -0,0 +1,109 @@ +# lib/helpcenter/accounts/invitation.ex +defmodule Helpcenter.Accounts.Invitation do + use Ash.Resource, + domain: Helpcenter.Accounts, + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] + + postgres do + table "invitations" + repo Helpcenter.Repo + end + + actions do + defaults [:read, :destroy] + + create :create do + description """ + This action assumes that inserting new data == inviting a new users. It will then: + 1. Set new invitation attributes such as: token, expires_at, team, etc + 2. Sends an invitation to the newly invited user via email + """ + + accept [:email] + change Helpcenter.Accounts.Invitation.Changes.SetInviteAttributes + change Helpcenter.Accounts.Invitation.Changes.SendInvitationEmail + end + + update :accept do + description """ + When an invitee accepts invitation, this action will be called: + 1. Add invitee to the team based on the invitation data + 2. Add invitee to the permission group based on the invitation data + 3. Send invitee a welcome email to the newly added user to the team + """ + + accept [] + change set_attribute(:status, :accepted) + change Helpcenter.Accounts.Invitation.Changes.AddUserToTeam + change Helpcenter.Accounts.Invitation.Changes.SendWelcomeEmail + end + + update :decline do + description """ + When an invitee declines invitation, this action will be called: + 1. Changes the status to the declined. + 2. Send a decline email. + """ + + accept [] + change set_attribute(:status, :declined) + change Helpcenter.Accounts.Invitation.Changes.SendDeclinedEmail + end + end + + attributes do + uuid_primary_key :id + + attribute :email, :string do + allow_nil? false + constraints match: ~r/^[^\s]+@[^\s]+\.[^\s]+$/ + description "Email address of the user to invite" + end + + attribute :status, :atom do + default :pending + allow_nil? false + constraints one_of: [:pending, :accepted, :declined] + description "The status of the invitation sent to the user" + end + + attribute :token, :string do + allow_nil? false + description "The token in the URL to identify this invitation" + end + + attribute :expires_at, :utc_datetime do + allow_nil? false + description "The time this invitation will expire. Default 30 days" + end + + timestamps() + end + + relationships do + belongs_to :team, Helpcenter.Accounts.Team do + allow_nil? false + source_attribute :team_id + description "The team user will be added to after accepting invitation" + end + + belongs_to :group, Helpcenter.Accounts.Group do + allow_nil? false + source_attribute :group_id + description "User permission group the invitee will be added to" + end + + belongs_to :inviter, Helpcenter.Accounts.User do + allow_nil? false + source_attribute :inviter_user_id + description "The user who sent this invitation to the new joiner" + end + + belongs_to :invitee, Helpcenter.Accounts.User do + allow_nil? true + source_attribute :invitee_user_id + description "The invited user. This will not be nil if the user already exists in the app" + end + end +end diff --git a/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex b/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex new file mode 100644 index 0000000..4e0326d --- /dev/null +++ b/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex @@ -0,0 +1,27 @@ +defmodule Helpcenter.Accounts.Invitation.Changes.AddUserToTeam do + alias Helpcenter.Accounts.Invitation + use Ash.Resource.Change + + @impl true + def change(changeset, _opts, _context) do + Ash.Changeset.after_action(changeset, &add_user_to_team/2) + end + + @impl true + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end + + defp add_user_to_team(_changeset, invite) do + # 1. get user + user = get_or_create_user(invite) + # 2. add user to the team + # 3. Add user to the permission group + + {:ok, invite} + end + + defp get_or_create_user(%Invitation{} = invitation) do + dbg(invitation) + end +end diff --git a/lib/helpcenter/accounts/invitation/changes/send_declined_email.ex b/lib/helpcenter/accounts/invitation/changes/send_declined_email.ex new file mode 100644 index 0000000..dc550f0 --- /dev/null +++ b/lib/helpcenter/accounts/invitation/changes/send_declined_email.ex @@ -0,0 +1,44 @@ +defmodule Helpcenter.Accounts.Invitation.Changes.SendDeclinedEmail do + use Ash.Resource.Change + use HelpcenterWeb, :verified_routes + + import Swoosh.Email + alias Helpcenter.Mailer + + @impl true + def change(changeset, _opts, _context) do + Ash.Changeset.after_action(changeset, &send_invitation_email/2) + end + + @impl true + def atomic?, do: true + + @impl true + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end + + defp send_invitation_email(_changeset, invitation) do + send(invitation.email, invitation.token) + {:ok, invitation} + end + + def send(email, token, _) do + new() + # TODO: Replace with your email + |> from({"noreply", "noreply@example.com"}) + |> to(to_string(email)) + |> subject("Your login link") + |> html_body(body(token: token, email: email)) + |> Mailer.deliver!() + end + + defp body(params) do + url = url(~p"/auth/user/magic_link/?token=#{params[:token]}") + + """ +

Hello, #{params[:email]}! Click this link to sign in:

+

#{url}

+ """ + end +end diff --git a/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex b/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex new file mode 100644 index 0000000..93534d5 --- /dev/null +++ b/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex @@ -0,0 +1,42 @@ +defmodule Helpcenter.Accounts.Invitation.Changes.SendInvitationEmail do + use Ash.Resource.Change + use HelpcenterWeb, :verified_routes + + import Swoosh.Email + + alias Helpcenter.Mailer + + @impl true + def change(changeset, _opts, _context) do + Ash.Changeset.after_action(changeset, &send_invitation_email/2) + end + + @impl true + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end + + defp send_invitation_email(_changeset, invitation) do + send(invitation.email, invitation.token) + {:ok, invitation} + end + + def send(email, token, _) do + new() + # TODO: Replace with your email + |> from({"noreply", "noreply@example.com"}) + |> to(to_string(email)) + |> subject("Your login link") + |> html_body(body(token: token, email: email)) + |> Mailer.deliver!() + end + + defp body(params) do + url = url(~p"/auth/user/magic_link/?token=#{params[:token]}") + + """ +

Hello, #{params[:email]}! Click this link to sign in:

+

#{url}

+ """ + end +end diff --git a/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex b/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex new file mode 100644 index 0000000..b5d8bfd --- /dev/null +++ b/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex @@ -0,0 +1,44 @@ +defmodule Helpcenter.Accounts.Invitation.Changes.SendWelcomeEmail do + use Ash.Resource.Change + use HelpcenterWeb, :verified_routes + + import Swoosh.Email + alias Helpcenter.Mailer + + @impl true + def change(changeset, _opts, _context) do + Ash.Changeset.after_action(changeset, &send_invitation_email/2) + end + + @impl true + def atomic?, do: true + + @impl true + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end + + defp send_invitation_email(_changeset, invitation) do + send(invitation.email, invitation.token) + {:ok, invitation} + end + + def send(email, token, _) do + new() + # TODO: Replace with your email + |> from({"noreply", "noreply@example.com"}) + |> to(to_string(email)) + |> subject("Your login link") + |> html_body(body(token: token, email: email)) + |> Mailer.deliver!() + end + + defp body(params) do + url = url(~p"/auth/user/magic_link/?token=#{params[:token]}") + + """ +

Hello, #{params[:email]}! Click this link to sign in:

+

#{url}

+ """ + end +end diff --git a/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex b/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex new file mode 100644 index 0000000..20c3aef --- /dev/null +++ b/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex @@ -0,0 +1,22 @@ +defmodule Helpcenter.Accounts.Invitation.Changes.SetInvitationAttributes do + use Ash.Resource.Change + + @impl true + def change(changeset, _opts, context) do + changeset + |> Ash.Changeset.before_transaction(fn changeset -> + expires_at = DateTime.add(DateTime.utc_now(), 7, :day) + + changeset + |> Ash.Changeset.force_change_attribute(:expires_at, expires_at) + |> Ash.Changeset.force_change_attribute(:token, Ash.UUIDv7.generate()) + |> Ash.Changeset.force_change_attribute(:inviter_user_id, context.actor.id) + |> Ash.Changeset.force_change_attribute(:team_id, context.actor.current_team_id) + end) + end + + @impl true + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end +end diff --git a/lib/helpcenter/accounts/user.ex b/lib/helpcenter/accounts/user.ex index 35cca33..f75b8ed 100644 --- a/lib/helpcenter/accounts/user.ex +++ b/lib/helpcenter/accounts/user.ex @@ -17,6 +17,7 @@ defmodule Helpcenter.Accounts.User do monitor_fields [:email] confirm_on_create? true confirm_on_update? false + require_interaction? true auto_confirm_actions [:sign_in_with_magic_link, :reset_password_with_token] sender Helpcenter.Accounts.User.Senders.SendNewUserConfirmationEmail end @@ -34,7 +35,7 @@ defmodule Helpcenter.Accounts.User do magic_link do identity_field :email registration_enabled? true - + require_interaction? true sender Helpcenter.Accounts.User.Senders.SendMagicLinkEmail end diff --git a/lib/helpcenter/knowledge_base/article.ex b/lib/helpcenter/knowledge_base/article.ex index 407abc2..61f3c43 100644 --- a/lib/helpcenter/knowledge_base/article.ex +++ b/lib/helpcenter/knowledge_base/article.ex @@ -111,7 +111,6 @@ defmodule Helpcenter.KnowledgeBase.Article do relationships do belongs_to :category, Helpcenter.KnowledgeBase.Category do source_attribute :category_id - # category_id can be null when there is no related category allow_nil? true end diff --git a/lib/helpcenter_web/controllers/auth_controller.ex b/lib/helpcenter_web/controllers/auth_controller.ex index 50939e5..a7fcd77 100644 --- a/lib/helpcenter_web/controllers/auth_controller.ex +++ b/lib/helpcenter_web/controllers/auth_controller.ex @@ -48,7 +48,7 @@ defmodule HelpcenterWeb.AuthController do return_to = get_session(conn, :return_to) || ~p"/" conn - |> clear_session() + |> clear_session(:helpcenter) |> put_flash(:info, "You are now signed out") |> redirect(to: return_to) end diff --git a/mix.lock b/mix.lock index 63d2fa2..8664951 100644 --- a/mix.lock +++ b/mix.lock @@ -1,81 +1,81 @@ %{ "artificery": {:hex, :artificery, "0.4.3", "0bc4260f988dcb9dda4b23f9fc3c6c8b99a6220a331534fdf5bf2fd0d4333b02", [:mix], [], "hexpm", "12e95333a30e20884e937abdbefa3e7f5e05609c2ba8cf37b33f000b9ffc0504"}, - "ash": {:hex, :ash, "3.4.71", "ce8fa3c38bb59d067647bdc87aa9198335fdeeab36660c869b72c47339fc9d69", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.24 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f255da731b5b3ec4d916b5282faecbbbe9beb64a3641b4a45ac91160ffea3cc9"}, - "ash_authentication": {:hex, :ash_authentication, "4.6.0", "f466524b89166b76ec9847dc89283085119420d20bccaca6b52724081cb63911", [:mix], [{:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, "~> 2.0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "~> 0.2.13", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "6c369d50fc8c702403ceedc329552ac631e3a83e5e0677b4901d1b77bbdbdb30"}, - "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.5.1", "aa11c8d58f26cd85e1de331d7e4423af2cbdfa9c35f1b5ec2c115a6badeb30d6", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.1", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "45980793197482cbcb3b76a5e60c87901ebc35f55bcc5c3399ec4cb44c605c88"}, + "ash": {:hex, :ash, "3.5.25", "99f7139e98b745a64312ae80e2420589205b2fec1799f00fc58da771d2c63373", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.4 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.2.65 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d45844ea30062b796d4adcad75b8d91e21081ac0f1bb6627d1a2663ca5ecf258"}, + "ash_authentication": {:hex, :ash_authentication, "4.9.5", "9a72fef7dc6912ef46bde34dc582eaa83c12e289c68b69456e8870857d122c85", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "~> 0.2.13", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "80e83db132c9b97fac739805f634f650cf8a30a904d6bb055be82c42b0506d29"}, + "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.10.2", "474b450ca85e4773d0eafd5c1c9dbcfdfc6e95f496aa049d3c20d4cb701829d9", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, ">= 4.9.1 and < 5.0.0-0", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d122d97b5269d8f2acba749312ea9c6574c03b647cac9ff501cecaf7f0af7592"}, "ash_oban": {:hex, :ash_oban, "0.4.9", "91873f1e2c9d462554ec4902e1a90378f4351277dd81f4371c0984aed4ee300e", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:oban, "~> 2.15", [hex: :oban, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.18", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "f2d2ad468e0983f7a330f1511530d244209389a28e10bd0a05a0e30c7da6eae1"}, - "ash_phoenix": {:hex, :ash_phoenix, "2.1.23", "75c5500142d44c07431fcf7473784e6eed8d32777b68616de30b2ee7c3909110", [:mix], [{:ash, ">= 3.4.31 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.4.3 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "2d640dbd57020102d1a8997ab4b42035418696ebacfa5f8cbb94466e67deb8cf"}, - "ash_postgres": {:hex, :ash_postgres, "2.5.12", "cca37fb0a72114ea899f9a80cf7385b1826872263c75a7735f01898f4ff68a23", [:mix], [{:ash, ">= 3.4.69 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.62 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, ">= 3.12.1 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "b69a116b3d5b57fe868da914e1ed15286c46a0865b45f14950a38a6af06a915f"}, - "ash_sql": {:hex, :ash_sql, "0.2.62", "fcf1dde5a453cb024799bd43ab25aee3a7cc4ce7a48f1456310a65aec9e7ea7a", [:mix], [{:ash, ">= 3.4.65 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "df8c72b9b1c7b2c3147334eb63e819bc8d15288e1c6f0ddcd7691530db272ce0"}, + "ash_phoenix": {:hex, :ash_phoenix, "2.3.9", "684645f02725ca71625fcade6a4cc7c3a881a150762cdc532d03a32bde5a366d", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "462ed487e62aa7de14d587c3fed64c9a3971e4132a9d0121033754c444c1400e"}, + "ash_postgres": {:hex, :ash_postgres, "2.6.9", "8312bbe1ec463036841f08861196595ea233eadc1cd4c8097a1701ff7b0e95ed", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.72 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "1eb0c258d7dbe594312b37697097745ebb396162f38cddde282e011c26eb834c"}, + "ash_sql": {:hex, :ash_sql, "0.2.84", "1187555609f4773aacb5cccdca82a78c2b3f7390e78b400a8f03c91b2e7cd82f", [:mix], [{:ash, ">= 3.5.25 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "5e6a4d3070e60a0653c572527276a8c034b9458e37b1aca8868b17fcf0a1d1c0"}, "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, - "bandit": {:hex, :bandit, "1.6.9", "cf4653d0490941629a4475381eda3b8d4d2653471a9efe0147b2195bef40ece5", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [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", "67ab91338f308da9fb10d5afde35899e15af653adf31d682dd3a0e7c1d34db23"}, - "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.2.1", "e361261a0401d82dadc1ab7b969f91d250bf7577283e933fe8c5b72f8f5b3c46", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "81170177d5c2e280d12141a0b9d9e299bf731535e2d959982bdcd4cfe3c82865"}, - "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, + "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [: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", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, + "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"}, + "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, - "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, + "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm", "bbc7008b0161a6f130d8d903b5b3232351fccc9c31a991f8fcbf2a12ace22995"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, - "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, - "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, + "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, "edeliver": {:hex, :edeliver, "1.9.2", "78caa5a261c265dc9c17226e890b330165612145da312012dbe35d0e8631291b", [:mix], [{:distillery, "~> 2.1.0", [hex: :distillery, repo: "hexpm", optional: true]}], "hexpm", "cf6600ec1326d8aa224410e2ee02133f88e4f71dedb70db7b98b6acaea7e771f"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, - "esbuild": {:hex, :esbuild, "0.9.0", "f043eeaca4932ca8e16e5429aebd90f7766f31ac160a25cbd9befe84f2bc068f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b415027f71d5ab57ef2be844b2a10d0c1b5a492d431727f43937adce22ba45ae"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, - "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, - "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, - "hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"}, - "igniter": {:hex, :igniter, "0.5.38", "436a6414abc9245e539d6c92a6f4854f62270fbf6547c4acf1e5a65c0e4f4d4b", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:inflex, "~> 2.0", [hex: :inflex, 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", "e5f1474a2a7ad186f3b71074d9d1ef25d306634e12af4ea01beae06ba958491d"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "igniter": {:hex, :igniter, "0.6.11", "bd7269949df4e668fba4219aad32fc922e48f5197e2e27e106cc44d972e50649", [: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", "8dc87860a66597dd93abfaf640a3ce747bfcdf53d7dcbb2ec51f5edfb741307d"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, - "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "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"}, - "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, + "mix_test_watch": {:hex, :mix_test_watch, "1.3.0", "2ffc9f72b0d1f4ecf0ce97b044e0e3c607c3b4dc21d6228365e8bc7c2856dc77", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f9e5edca976857ffac78632e635750d158df14ee2d6185a15013844af7570ffe"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "oban": {:hex, :oban, "2.19.4", "045adb10db1161dceb75c254782f97cdc6596e7044af456a59decb6d06da73c1", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fcc6219e6464525b808d97add17896e724131f498444a292071bf8991c99f97"}, "owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"}, - "phoenix": {:hex, :phoenix, "1.7.20", "6bababaf27d59f5628f9b608de902a021be2cecefb8231e1dbdc0a2e2e480e9b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "6be2ab98302e8784a31829e0d50d8bdfa81a23cd912c395bafd8b8bfb5a086c2"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, + "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, + "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"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, - "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.6", "7b1f0327f54c9eb69845fd09a77accf922f488c549a7e7b8618775eb603a62c7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1681ab813ec26ca6915beb3414aa138f298e17721dc6a2bde9e6eb8a62360ff6"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.7", "491c5fcccb9cee4978a25f0ec4c4b01975cd5f8d6d2366ca1bd5bf6f7f81a862", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a1758c5816f65c83af38dfeef35a6d491363e32c707c2e3bb6b8f6339e8f2cbf"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.17", "beeb16d83a7d3760f7ad463df94e83b087577665d2acc0bf2987cd7d9778068f", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a4ca05c1eb6922c4d07a508a75bfa12c45e5f4d8f77ae83283465f02c53741e1"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"}, - "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, + "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, - "reactor": {:hex, :reactor, "0.15.0", "556937d9310e1a6dd06083592b9eb9e0d212540b6d82faecba70823ee7a0747d", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "f634383a7760ba3106d31a3185f2e2c39e1485d899d884d94c22c62c9b5e7a4a"}, - "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, + "reactor": {:hex, :reactor, "0.15.6", "d717f9add549b25a089a94c90197718d2d838e35d81dd776b1d81587d4cf2aaa", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "74db98165e3644d86e0f723672d91ceca4339eaa935bcad7e78bf146a46d77b9"}, + "req": {:hex, :req, "0.5.14", "521b449fa0bf275e6d034c05f29bec21789a0d6cd6f7a1c326c7bee642bf6e07", [: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", "b7b15692071d556c73432c7797aa7e96b51d1a2db76f746b976edef95c930021"}, "rewrite": {:hex, :rewrite, "1.1.2", "f5a5d10f5fed1491a6ff48e078d4585882695962ccc9e6c779bae025d1f92eda", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "7f8b94b1e3528d0a47b3e8b7bfeca559d2948a65fa7418a9ad7d7712703d39d4"}, "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, - "sourceror": {:hex, :sourceror, "1.7.1", "599d78f4cc2be7d55c9c4fd0a8d772fd0478e3a50e726697c20d13d02aa056d4", [:mix], [], "hexpm", "cd6f268fe29fa00afbc535e215158680a0662b357dc784646d7dff28ac65a0fc"}, - "spark": {:hex, :spark, "2.2.48", "dd1005c26c7f98ea686a951f7ae58fffb54eff19c47830e6ff68b93f87433baa", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "379912647b9ddcc5265e91a82a235a264a727123d1f9e90052d91ad8cebbb2d0"}, - "spitfire": {:hex, :spitfire, "0.2.0", "0de1f519a23f65bde40d316adad53c07a9563f25cc68915d639d8a509a0aad8a", [:mix], [], "hexpm", "743daaee2d81a0d8095431729f478ce49b47ea8943c7d770de86704975cb7775"}, + "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, + "spark": {:hex, :spark, "2.2.67", "67626cb9f59ea4b1c5aa85d4afdd025e0740cbd49ed82665d0a40ff007d7fd4b", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "c8575402e3afc66871362e821bece890536d16319cdb758c5fb2d1250182e46f"}, + "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, "splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"}, - "stream_data": {:hex, :stream_data, "1.1.3", "15fdb14c64e84437901258bb56fc7d80aaf6ceaf85b9324f359e219241353bfb", [:mix], [], "hexpm", "859eb2be72d74be26c1c4f272905667672a52e44f743839c57c7ee73a1a66420"}, - "swoosh": {:hex, :swoosh, "1.18.3", "ca12197550bd7456654179055b1446168cc0f55067f784a3707e0e4462e269f5", [: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]}, {: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", "a533daccea84e887a061a919295212b37f4f2c7916436037eb8be7f1265bacba"}, + "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, + "swoosh": {:hex, :swoosh, "1.19.3", "02ad4455939f502386e4e1443d4de94c514995fd0e51b3cafffd6bd270ffe81c", [: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]}, {: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", "04a10f8496786b744b84130e3510eb53ca51e769c39511b65023bdf4136b732f"}, "tailwind": {:hex, :tailwind, "0.3.1", "a89d2835c580748c7a975ad7dd3f2ea5e63216dc16d44f9df492fbd12c094bed", [:mix], [], "hexpm", "98a45febdf4a87bc26682e1171acdedd6317d0919953c353fcd1b4f9f4b676a2"}, "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.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, - "thousand_island": {:hex, :thousand_island, "1.3.12", "590ff651a6d2a59ed7eabea398021749bdc664e2da33e0355e6c64e7e1a2ef93", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "55d0b1c868b513a7225892b8a8af0234d7c8981a51b0740369f3125f7c99a549"}, + "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, diff --git a/test/helpcenter/accounts/invitation_test.exs b/test/helpcenter/accounts/invitation_test.exs new file mode 100644 index 0000000..dbe6e47 --- /dev/null +++ b/test/helpcenter/accounts/invitation_test.exs @@ -0,0 +1,33 @@ +# test/helpcenter/accounts/invitation_test.exs +defmodule Helpcenter.Accounts.InvitationTest do + use HelpcenterWeb.ConnCase, async: false + require Ash.Query + + defp create_group() do + user = create_user() + group_attrs = %{name: "Accountants", description: "Can manage billing in the system"} + + {:ok, _group} = + Ash.create( + Helpcenter.Accounts.Group, + group_attrs, + actor: user, + load: [:permissions, :users], + authorize?: false + ) + end + + describe "Invitations test" do + test "Team owner can invite a new user" do + actor = create_user() + group = create_group() + + invite_attributes = %{email: "john@example.com", group_id: group.id} + + Helpcenter.Accounts.Invitation + |> Ash.Changeset.for_create(:create, invite_attributes, actor: actor) + |> Ash.create!() + |> dbg() + end + end +end From 34cef06468d1887e7aeae69ec12cbc7b8f0e54eb Mon Sep 17 00:00:00 2001 From: Kamaro Date: Thu, 14 Aug 2025 22:40:28 +0300 Subject: [PATCH 02/22] Added tests along sides --- lib/helpcenter/accounts/invitation.ex | 37 +++- .../invitation/changes/add_user_to_team.ex | 57 ++++- .../invitation/changes/send_welcome_email.ex | 7 +- .../changes/set_invitation_attributes.ex | 36 ++- lib/helpcenter/accounts/invitation_test.exs | 42 ++++ lib/helpcenter/accounts/team.ex | 31 ++- lib/helpcenter/accounts/user.ex | 4 + lib/helpcenter/preparations/set_tenant.ex | 9 +- lib/test_helper.exs | 2 + mix.exs | 1 + ...20250814182210_add_team_to_invitations.exs | 17 ++ ...20250814182208_add_team_to_invitations.exs | 84 +++++++ .../repo/teams/20250814182210.json | 137 ++++++++++++ .../tenants/invitations/20250814182209.json | 208 ++++++++++++++++++ .../user_notifications/20250814182209.json | 158 +++++++++++++ test/helpcenter/accounts/group_test.exs | 2 +- test/helpcenter/accounts/invitation_test.exs | 33 --- 17 files changed, 793 insertions(+), 72 deletions(-) create mode 100644 lib/helpcenter/accounts/invitation_test.exs create mode 100644 lib/test_helper.exs create mode 100644 priv/repo/migrations/20250814182210_add_team_to_invitations.exs create mode 100644 priv/repo/tenant_migrations/20250814182208_add_team_to_invitations.exs create mode 100644 priv/resource_snapshots/repo/teams/20250814182210.json create mode 100644 priv/resource_snapshots/repo/tenants/invitations/20250814182209.json create mode 100644 priv/resource_snapshots/repo/tenants/user_notifications/20250814182209.json delete mode 100644 test/helpcenter/accounts/invitation_test.exs diff --git a/lib/helpcenter/accounts/invitation.ex b/lib/helpcenter/accounts/invitation.ex index ad65877..42493ba 100644 --- a/lib/helpcenter/accounts/invitation.ex +++ b/lib/helpcenter/accounts/invitation.ex @@ -2,8 +2,7 @@ defmodule Helpcenter.Accounts.Invitation do use Ash.Resource, domain: Helpcenter.Accounts, - data_layer: AshPostgres.DataLayer, - authorizers: [Ash.Policy.Authorizer] + data_layer: AshPostgres.DataLayer postgres do table "invitations" @@ -20,9 +19,14 @@ defmodule Helpcenter.Accounts.Invitation do 2. Sends an invitation to the newly invited user via email """ - accept [:email] - change Helpcenter.Accounts.Invitation.Changes.SetInviteAttributes - change Helpcenter.Accounts.Invitation.Changes.SendInvitationEmail + accept [:email, :group_id] + change Helpcenter.Accounts.Invitation.Changes.SetInvitationAttributes + # change Helpcenter.Accounts.Invitation.Changes.SendInvitationEmail + end + + read :by_token do + description "This action is used to read an invitation by its token" + filter expr(token == ^arg(:token)) end update :accept do @@ -73,6 +77,11 @@ defmodule Helpcenter.Accounts.Invitation do description "The token in the URL to identify this invitation" end + attribute :team, :string do + allow_nil? false + description "The team the user will be added to after accepting invitation" + end + attribute :expires_at, :utc_datetime do allow_nil? false description "The time this invitation will expire. Default 30 days" @@ -81,13 +90,19 @@ defmodule Helpcenter.Accounts.Invitation do timestamps() end - relationships do - belongs_to :team, Helpcenter.Accounts.Team do - allow_nil? false - source_attribute :team_id - description "The team user will be added to after accepting invitation" - end + multitenancy do + strategy :context + end + + changes do + change Helpcenter.Changes.SetTenant + end + preparations do + prepare Helpcenter.Preparations.SetTenant + end + + relationships do belongs_to :group, Helpcenter.Accounts.Group do allow_nil? false source_attribute :group_id diff --git a/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex b/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex index 4e0326d..7c6a663 100644 --- a/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex +++ b/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex @@ -1,10 +1,10 @@ defmodule Helpcenter.Accounts.Invitation.Changes.AddUserToTeam do - alias Helpcenter.Accounts.Invitation use Ash.Resource.Change + require Ash.Query @impl true def change(changeset, _opts, _context) do - Ash.Changeset.after_action(changeset, &add_user_to_team/2) + Ash.Changeset.after_action(changeset, &link_user_team/2) end @impl true @@ -12,16 +12,59 @@ defmodule Helpcenter.Accounts.Invitation.Changes.AddUserToTeam do {:ok, change(changeset, opts, context)} end - defp add_user_to_team(_changeset, invite) do + defp link_user_team(_changeset, invite) do + tenant = invite.team # 1. get user user = get_or_create_user(invite) - # 2. add user to the team - # 3. Add user to the permission group + # 2. Link user to the team + _user_team = add_user_to_team(user.id, tenant) + _user_current_team = set_user_current_team(user, tenant) + + # 3. Add user to the permission group + _user_group = add_user_to_group(user.id, invite.group_id, tenant) {:ok, invite} end - defp get_or_create_user(%Invitation{} = invitation) do - dbg(invitation) + defp get_team(team) do + Helpcenter.Accounts.Team + |> Ash.Query.filter(domain == ^team) + |> Ash.read_first!(authorize?: false) + end + + defp add_user_to_team(user_id, tenant) do + team = get_team(tenant) + + Ash.Seed.seed!( + Helpcenter.Accounts.UserTeam, + %{user_id: user_id, team_id: team.id}, + tenant: tenant + ) + end + + defp set_user_current_team(user, tenant) do + Ash.Seed.update!(user, %{current_team: tenant}, tenant: tenant) + end + + defp add_user_to_group(user_id, group_id, tenant) do + Ash.Seed.seed!( + Helpcenter.Accounts.UserGroup, + %{user_id: user_id, group_id: group_id}, + tenant: tenant + ) + end + + defp get_or_create_user(%{email: email} = invitation) do + Helpcenter.Accounts.User + |> Ash.Query.filter(email == ^email) + |> Ash.read_first(authorize?: false) + |> case do + {:ok, nil} -> create_user(email, invitation.team) + {:ok, user} -> user + end + end + + defp create_user(email, tenant) do + Ash.Seed.seed!(Helpcenter.Accounts.User, %{email: email}, tenant: tenant) end end diff --git a/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex b/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex index b5d8bfd..2b5d575 100644 --- a/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex +++ b/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex @@ -19,11 +19,8 @@ defmodule Helpcenter.Accounts.Invitation.Changes.SendWelcomeEmail do end defp send_invitation_email(_changeset, invitation) do - send(invitation.email, invitation.token) - {:ok, invitation} - end + %{token: token, email: email} = invitation - def send(email, token, _) do new() # TODO: Replace with your email |> from({"noreply", "noreply@example.com"}) @@ -31,6 +28,8 @@ defmodule Helpcenter.Accounts.Invitation.Changes.SendWelcomeEmail do |> subject("Your login link") |> html_body(body(token: token, email: email)) |> Mailer.deliver!() + + {:ok, invitation} end defp body(params) do diff --git a/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex b/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex index 20c3aef..b3d0bf0 100644 --- a/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex +++ b/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex @@ -1,22 +1,38 @@ defmodule Helpcenter.Accounts.Invitation.Changes.SetInvitationAttributes do use Ash.Resource.Change + @alphabet String.split("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", "", + trim: true + ) + @alphabet_length length(@alphabet) + @impl true def change(changeset, _opts, context) do - changeset - |> Ash.Changeset.before_transaction(fn changeset -> - expires_at = DateTime.add(DateTime.utc_now(), 7, :day) - - changeset - |> Ash.Changeset.force_change_attribute(:expires_at, expires_at) - |> Ash.Changeset.force_change_attribute(:token, Ash.UUIDv7.generate()) - |> Ash.Changeset.force_change_attribute(:inviter_user_id, context.actor.id) - |> Ash.Changeset.force_change_attribute(:team_id, context.actor.current_team_id) - end) + Ash.Changeset.before_transaction(changeset, &set_attributes(&1, context)) end @impl true def atomic(changeset, opts, context) do {:ok, change(changeset, opts, context)} end + + defp set_attributes(changeset, context) do + expires_at = DateTime.add(DateTime.utc_now(), 30, :day) + + changeset + |> Ash.Changeset.force_change_attribute(:token, generate()) + |> Ash.Changeset.force_change_attribute(:expires_at, expires_at) + |> Ash.Changeset.force_change_attribute(:team, changeset.tenant) + |> Ash.Changeset.force_change_attribute(:inviter_user_id, context.actor.id) + |> Ash.Changeset.force_change_attribute(:team, context.actor.current_team) + end + + @doc """ + Generates a YouTube-like ID by randomly selecting characters from a custom alphabet. + """ + def generate(length \\ 11) do + Stream.repeatedly(fn -> Enum.at(@alphabet, :rand.uniform(@alphabet_length) - 1) end) + |> Enum.take(length) + |> Enum.join() + end end diff --git a/lib/helpcenter/accounts/invitation_test.exs b/lib/helpcenter/accounts/invitation_test.exs new file mode 100644 index 0000000..513d0bb --- /dev/null +++ b/lib/helpcenter/accounts/invitation_test.exs @@ -0,0 +1,42 @@ +defmodule Helpcenter.Accounts.InvitationTest do + use HelpcenterWeb.ConnCase, async: false + require Ash.Query + + defp create_group!(actor) do + group_attrs = %{name: "Accountants", description: "Can manage billing in the system"} + + Ash.create!( + Helpcenter.Accounts.Group, + group_attrs, + actor: actor, + authorize?: false + ) + end + + describe "Invitations test" do + test "Team owner can invite a new user" do + actor = create_user() + group = create_group!(actor) + invite_attributes = %{email: "john@example.com", group_id: group.id} + + invitation = + Helpcenter.Accounts.Invitation + |> Ash.Changeset.for_create(:create, invite_attributes, actor: actor) + |> Ash.create!() + + assert invitation.status == :pending + assert invitation.email == invite_attributes.email + assert invitation.group_id == group.id + + # Accept invitation + accepted_invitation = + invitation + |> Ash.Changeset.for_update(:accept, %{}, actor: actor) + |> Ash.update!() + + assert accepted_invitation.status == :accepted + assert accepted_invitation.email == invite_attributes.email + assert accepted_invitation.group_id == group.id + end + end +end diff --git a/lib/helpcenter/accounts/team.ex b/lib/helpcenter/accounts/team.ex index 534be8a..95c1e37 100644 --- a/lib/helpcenter/accounts/team.ex +++ b/lib/helpcenter/accounts/team.ex @@ -20,6 +20,11 @@ defmodule Helpcenter.Accounts.Team do end end + code_interface do + # the action open can be omitted because it matches the function name + define :by_domain, args: [:domain], action: :by_domain + end + postgres do table "teams" repo Helpcenter.Repo @@ -40,17 +45,39 @@ defmodule Helpcenter.Accounts.Team do change Helpcenter.Accounts.Team.Changes.AssociateUserToTeam change Helpcenter.Accounts.Team.Changes.SetOwnerCurrentTeamAfterCreate end + + read :by_domain do + description "This action is used to read a team by its domain" + filter expr(domain == ^arg(:domain)) + end end attributes do uuid_v7_primary_key :id - attribute :name, :string, allow_nil?: false, public?: true - attribute :domain, :string, allow_nil?: false, public?: true + + attribute :name, :string do + allow_nil? false + public? true + description "Team or organisation name" + end + + attribute :domain, :string do + allow_nil? false + public? true + description "Domain name of the team or organisation" + end + attribute :description, :string, allow_nil?: true, public?: true timestamps() end + identities do + identity :unique_domain, [:domain] do + description "Identity to find a team by its domain" + end + end + relationships do belongs_to :owner, Helpcenter.Accounts.User do source_attribute :owner_user_id diff --git a/lib/helpcenter/accounts/user.ex b/lib/helpcenter/accounts/user.ex index f75b8ed..733ee25 100644 --- a/lib/helpcenter/accounts/user.ex +++ b/lib/helpcenter/accounts/user.ex @@ -269,6 +269,10 @@ defmodule Helpcenter.Accounts.User do end end + code_interface do + define :get_by_email, args: [:email], action: :get_by_email + end + policies do bypass AshAuthentication.Checks.AshAuthenticationInteraction do authorize_if always() diff --git a/lib/helpcenter/preparations/set_tenant.ex b/lib/helpcenter/preparations/set_tenant.ex index 9682d94..b748bdc 100644 --- a/lib/helpcenter/preparations/set_tenant.ex +++ b/lib/helpcenter/preparations/set_tenant.ex @@ -11,11 +11,12 @@ defmodule Helpcenter.Preparations.SetTenant do 2. If tenant is not provided, but actor is provided, the use current_team user 3. If none of the above conditions are met, ignore and contineu """ - def prepare(query, _opts, %{tenant: nil, actor: nil} = _context), do: query - def prepare(query, _opts, %{tenant: nil, actor: actor} = _context) do - Ash.Query.set_tenant(query, actor.current_team) + def prepare(query, _opts, %{actor: nil} = _context) do + query end - def prepare(query, _opts, _context), do: query + def prepare(query, _opts, context) do + Ash.Query.set_tenant(query, context.actor.current_team) + end end diff --git a/lib/test_helper.exs b/lib/test_helper.exs new file mode 100644 index 0000000..92102e8 --- /dev/null +++ b/lib/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(Helpcenter.Repo, :manual) diff --git a/mix.exs b/mix.exs index 1ddd017..d34ae27 100644 --- a/mix.exs +++ b/mix.exs @@ -9,6 +9,7 @@ defmodule Helpcenter.MixProject do elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, consolidate_protocols: Mix.env() != :dev, + test_paths: ["test", "lib"], aliases: aliases(), deps: deps() ] diff --git a/priv/repo/migrations/20250814182210_add_team_to_invitations.exs b/priv/repo/migrations/20250814182210_add_team_to_invitations.exs new file mode 100644 index 0000000..ca1b4c5 --- /dev/null +++ b/priv/repo/migrations/20250814182210_add_team_to_invitations.exs @@ -0,0 +1,17 @@ +defmodule Helpcenter.Repo.Migrations.AddTeamToInvitations do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create unique_index(:teams, [:domain], name: "teams_unique_domain_index") + end + + def down do + drop_if_exists unique_index(:teams, [:domain], name: "teams_unique_domain_index") + end +end diff --git a/priv/repo/tenant_migrations/20250814182208_add_team_to_invitations.exs b/priv/repo/tenant_migrations/20250814182208_add_team_to_invitations.exs new file mode 100644 index 0000000..1576757 --- /dev/null +++ b/priv/repo/tenant_migrations/20250814182208_add_team_to_invitations.exs @@ -0,0 +1,84 @@ +defmodule Helpcenter.Repo.TenantMigrations.AddTeamToInvitations do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:user_notifications, prefix: prefix()) do + modify :recipient_user_id, + references(:users, + column: :id, + name: "user_notifications_recipient_user_id_fkey", + type: :uuid, + prefix: "public" + ) + end + + create index(:user_notifications, [:recipient_user_id]) + + create table(:invitations, primary_key: false, prefix: prefix()) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :email, :text, null: false + add :status, :text, null: false, default: "pending" + add :token, :text, null: false + add :team, :text, null: false + add :expires_at, :utc_datetime, null: false + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :group_id, + references(:groups, + column: :id, + name: "invitations_group_id_fkey", + type: :uuid, + prefix: prefix() + ), + null: false + + add :inviter_user_id, + references(:users, + column: :id, + name: "invitations_inviter_user_id_fkey", + type: :uuid, + prefix: "public" + ), + null: false + + add :invitee_user_id, + references(:users, + column: :id, + name: "invitations_invitee_user_id_fkey", + type: :uuid, + prefix: "public" + ) + end + end + + def down do + drop constraint(:invitations, "invitations_group_id_fkey") + + drop constraint(:invitations, "invitations_inviter_user_id_fkey") + + drop constraint(:invitations, "invitations_invitee_user_id_fkey") + + drop table(:invitations, prefix: prefix()) + + drop_if_exists index(:user_notifications, [:recipient_user_id]) + + drop constraint(:user_notifications, "user_notifications_recipient_user_id_fkey") + + alter table(:user_notifications, prefix: prefix()) do + modify :recipient_user_id, :uuid + end + end +end diff --git a/priv/resource_snapshots/repo/teams/20250814182210.json b/priv/resource_snapshots/repo/teams/20250814182210.json new file mode 100644 index 0000000..3a97d4f --- /dev/null +++ b/priv/resource_snapshots/repo/teams/20250814182210.json @@ -0,0 +1,137 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"uuid_generate_v7()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "domain", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "teams_owner_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "owner_user_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "276437960B05C0DA3BCAFD936B7AA75EB9958B99F4353F40E141B81832E6B671", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "teams_unique_domain_index", + "keys": [ + { + "type": "atom", + "value": "domain" + } + ], + "name": "unique_domain", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Helpcenter.Repo", + "schema": null, + "table": "teams" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/tenants/invitations/20250814182209.json b/priv/resource_snapshots/repo/tenants/invitations/20250814182209.json new file mode 100644 index 0000000..10e6cb2 --- /dev/null +++ b/priv/resource_snapshots/repo/tenants/invitations/20250814182209.json @@ -0,0 +1,208 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": false, + "default": "\"pending\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "token", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "team", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "expires_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "name": "invitations_group_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "groups" + }, + "scale": null, + "size": null, + "source": "group_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "invitations_inviter_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "inviter_user_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "invitations_invitee_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "invitee_user_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "C943D079A2BEE09A33514945F44D9DF5DBA8DB94C5BC2C180E3A6DC9644C7ADE", + "identities": [], + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "repo": "Elixir.Helpcenter.Repo", + "schema": null, + "table": "invitations" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/tenants/user_notifications/20250814182209.json b/priv/resource_snapshots/repo/tenants/user_notifications/20250814182209.json new file mode 100644 index 0000000..98b3a8c --- /dev/null +++ b/priv/resource_snapshots/repo/tenants/user_notifications/20250814182209.json @@ -0,0 +1,158 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "sender_user_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "user_notifications_recipient_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "recipient_user_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "subject", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "body", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "read_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "\"unread\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": false, + "default": "false", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "processed", + "type": "boolean" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "6723A2BAC63EA63C7BD5D5F03417BD29E5E305FBE7F8238456A3CDD66C0D64CB", + "identities": [], + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "repo": "Elixir.Helpcenter.Repo", + "schema": null, + "table": "user_notifications" +} \ No newline at end of file diff --git a/test/helpcenter/accounts/group_test.exs b/test/helpcenter/accounts/group_test.exs index 54d0689..47a73e2 100644 --- a/test/helpcenter/accounts/group_test.exs +++ b/test/helpcenter/accounts/group_test.exs @@ -18,7 +18,7 @@ defmodule Helpcenter.Accounts.GroupTest do Helpcenter.Accounts.Group, group_attrs, actor: user, - load: [:permissions, :users], + load: [:permissions], authorize?: false ) diff --git a/test/helpcenter/accounts/invitation_test.exs b/test/helpcenter/accounts/invitation_test.exs deleted file mode 100644 index dbe6e47..0000000 --- a/test/helpcenter/accounts/invitation_test.exs +++ /dev/null @@ -1,33 +0,0 @@ -# test/helpcenter/accounts/invitation_test.exs -defmodule Helpcenter.Accounts.InvitationTest do - use HelpcenterWeb.ConnCase, async: false - require Ash.Query - - defp create_group() do - user = create_user() - group_attrs = %{name: "Accountants", description: "Can manage billing in the system"} - - {:ok, _group} = - Ash.create( - Helpcenter.Accounts.Group, - group_attrs, - actor: user, - load: [:permissions, :users], - authorize?: false - ) - end - - describe "Invitations test" do - test "Team owner can invite a new user" do - actor = create_user() - group = create_group() - - invite_attributes = %{email: "john@example.com", group_id: group.id} - - Helpcenter.Accounts.Invitation - |> Ash.Changeset.for_create(:create, invite_attributes, actor: actor) - |> Ash.create!() - |> dbg() - end - end -end From 300db3f8f1e760af4908285ee99c09d58770e87e Mon Sep 17 00:00:00 2001 From: Kamaro Date: Fri, 15 Aug 2025 12:25:36 +0300 Subject: [PATCH 03/22] Added tests file --- .formatter.exs | 1 + assets/tailwind.config.js | 1 + config/config.exs | 1 + lib/helpcenter/accounts/invitation.ex | 24 ++++++++--------- lib/helpcenter/accounts/team.ex | 22 +++++++-------- lib/helpcenter/accounts/user.ex | 8 +++--- .../components/layouts/app.html.heex | 8 ++++++ .../live/accounts/users/users_live.ex | 27 +++++++++++++++++++ .../live/accounts/users/users_live_test.exs | 12 +++++++++ lib/helpcenter_web/router.ex | 13 ++++++--- mix.exs | 1 + mix.lock | 1 + 12 files changed, 89 insertions(+), 30 deletions(-) create mode 100644 lib/helpcenter_web/live/accounts/users/users_live.ex create mode 100644 lib/helpcenter_web/live/accounts/users/users_live_test.exs diff --git a/.formatter.exs b/.formatter.exs index 8690b3d..c404978 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,6 @@ [ import_deps: [ + :cinder, :ash_oban, :oban, :ash_authentication_phoenix, diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index 4ed8383..e454c94 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -7,6 +7,7 @@ const path = require("path") module.exports = { content: [ + "../deps/cinder/lib/**/*.*ex", "../deps/ash_authentication_phoenix/**/*.*ex", "./js/**/*.js", "../lib/helpcenter_web.ex", diff --git a/config/config.exs b/config/config.exs index 28e4c6d..ea775ab 100644 --- a/config/config.exs +++ b/config/config.exs @@ -7,6 +7,7 @@ # General application configuration import Config +config :cinder, default_theme: "modern" config :ash_oban, pro?: false config :helpcenter, Oban, diff --git a/lib/helpcenter/accounts/invitation.ex b/lib/helpcenter/accounts/invitation.ex index 42493ba..4022f30 100644 --- a/lib/helpcenter/accounts/invitation.ex +++ b/lib/helpcenter/accounts/invitation.ex @@ -56,6 +56,18 @@ defmodule Helpcenter.Accounts.Invitation do end end + preparations do + prepare Helpcenter.Preparations.SetTenant + end + + changes do + change Helpcenter.Changes.SetTenant + end + + multitenancy do + strategy :context + end + attributes do uuid_primary_key :id @@ -90,18 +102,6 @@ defmodule Helpcenter.Accounts.Invitation do timestamps() end - multitenancy do - strategy :context - end - - changes do - change Helpcenter.Changes.SetTenant - end - - preparations do - prepare Helpcenter.Preparations.SetTenant - end - relationships do belongs_to :group, Helpcenter.Accounts.Group do allow_nil? false diff --git a/lib/helpcenter/accounts/team.ex b/lib/helpcenter/accounts/team.ex index 95c1e37..d2a746c 100644 --- a/lib/helpcenter/accounts/team.ex +++ b/lib/helpcenter/accounts/team.ex @@ -20,11 +20,6 @@ defmodule Helpcenter.Accounts.Team do end end - code_interface do - # the action open can be omitted because it matches the function name - define :by_domain, args: [:domain], action: :by_domain - end - postgres do table "teams" repo Helpcenter.Repo @@ -36,6 +31,11 @@ defmodule Helpcenter.Accounts.Team do end end + code_interface do + # the action open can be omitted because it matches the function name + define :by_domain, args: [:domain], action: :by_domain + end + actions do default_accept [:name, :domain, :description, :owner_user_id] defaults [:read] @@ -72,12 +72,6 @@ defmodule Helpcenter.Accounts.Team do timestamps() end - identities do - identity :unique_domain, [:domain] do - description "Identity to find a team by its domain" - end - end - relationships do belongs_to :owner, Helpcenter.Accounts.User do source_attribute :owner_user_id @@ -89,4 +83,10 @@ defmodule Helpcenter.Accounts.Team do destination_attribute_on_join_resource :user_id end end + + identities do + identity :unique_domain, [:domain] do + description "Identity to find a team by its domain" + end + end end diff --git a/lib/helpcenter/accounts/user.ex b/lib/helpcenter/accounts/user.ex index 733ee25..355ed04 100644 --- a/lib/helpcenter/accounts/user.ex +++ b/lib/helpcenter/accounts/user.ex @@ -57,6 +57,10 @@ defmodule Helpcenter.Accounts.User do repo Helpcenter.Repo end + code_interface do + define :get_by_email, args: [:email], action: :get_by_email + end + actions do default_accept [:email] defaults [:create, :read] @@ -269,10 +273,6 @@ defmodule Helpcenter.Accounts.User do end end - code_interface do - define :get_by_email, args: [:email], action: :get_by_email - end - policies do bypass AshAuthentication.Checks.AshAuthenticationInteraction do authorize_if always() diff --git a/lib/helpcenter_web/components/layouts/app.html.heex b/lib/helpcenter_web/components/layouts/app.html.heex index 3ac7977..d3d1825 100644 --- a/lib/helpcenter_web/components/layouts/app.html.heex +++ b/lib/helpcenter_web/components/layouts/app.html.heex @@ -20,6 +20,14 @@
<%!-- Only show this div if the user is logged in --%>
+ <.link + id="categories" + navigate={~p"/accounts/users"} + class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700 hover:underline" + > + <.icon name="hero-users" class="h-4 w-4" /> + {gettext("Users")} + <.link id="categories" navigate={~p"/categories"} diff --git a/lib/helpcenter_web/live/accounts/users/users_live.ex b/lib/helpcenter_web/live/accounts/users/users_live.ex new file mode 100644 index 0000000..2c302f8 --- /dev/null +++ b/lib/helpcenter_web/live/accounts/users/users_live.ex @@ -0,0 +1,27 @@ +defmodule HelpcenterWeb.Accounts.Users.UsersLive do + use HelpcenterWeb, :live_view + + @impl true + def render(assigns) do + ~H""" + + <:col :let={row} label="Email" field="email" filter sort>{row.email} + <:col :let={row} label="Team">{Phoenix.Naming.humanize(row.current_team)} + + """ + end + + @impl true + def mount(_params, _session, socket) do + socket + |> assign(:page_title, "Users") + |> ok() + end + + defp get_query do + require Ash.Query + + Helpcenter.Accounts.User + |> Ash.Query.for_read(:read, %{}, authorize?: false) + end +end diff --git a/lib/helpcenter_web/live/accounts/users/users_live_test.exs b/lib/helpcenter_web/live/accounts/users/users_live_test.exs new file mode 100644 index 0000000..831d778 --- /dev/null +++ b/lib/helpcenter_web/live/accounts/users/users_live_test.exs @@ -0,0 +1,12 @@ +defmodule HelpcenterWeb.Accounts.Users.UsersLiveTest do + use HelpcenterWeb.ConnCase + + describe "UsersLive" do + test "renders the users page", %{conn: conn} do + conn = get(conn, ~p"/accounts/users") + assert html_response(conn, 200) =~ "Users" + assert html_response(conn, 200) =~ "Email" + assert html_response(conn, 200) =~ "Team" + end + end +end diff --git a/lib/helpcenter_web/router.ex b/lib/helpcenter_web/router.ex index 7be5a1b..5b7e37c 100644 --- a/lib/helpcenter_web/router.ex +++ b/lib/helpcenter_web/router.ex @@ -44,9 +44,16 @@ defmodule HelpcenterWeb.Router do end # lib/helpcenter_web/router.ex - scope "/accounts/groups", Accounts.Groups do - live "/", GroupsLive - live "/:group_id/permissions", GroupPermissionsLive + scope "/accounts", Accounts do + scope "/users", Users do + live "/", UsersLive + live "/:user_id", UserLive + end + + scope "/groups", Groups do + live "/", GroupsLive + live "/:group_id/permissions", GroupPermissionsLive + end end end end diff --git a/mix.exs b/mix.exs index d34ae27..9f3ccb6 100644 --- a/mix.exs +++ b/mix.exs @@ -34,6 +34,7 @@ defmodule Helpcenter.MixProject do # Type `mix help deps` for examples and options. defp deps do [ + {:cinder, "~> 0.5"}, {:gen_smtp, "~> 1.0"}, {:oban, "~> 2.0"}, {:ash_oban, "~> 0.4"}, diff --git a/mix.lock b/mix.lock index 8664951..5eb67b5 100644 --- a/mix.lock +++ b/mix.lock @@ -11,6 +11,7 @@ "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [: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", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, "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"}, "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, + "cinder": {:hex, :cinder, "0.5.5", "745c7085694114777bf20a811d8213f103fab06388c9b8fa378479af238aaa36", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "c6dd26cb2e770b5742523defb15e1f8fec0e5261be9248e531f7f59f1e67ba06"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, From 20850041d23cf81b5b7e47b91cb61b6624cf98c2 Mon Sep 17 00:00:00 2001 From: Kamaro Date: Fri, 15 Aug 2025 18:05:44 +0300 Subject: [PATCH 04/22] Added full invitation for the new users to join a team --- lib/helpcenter/accounts/group.ex | 5 +- lib/helpcenter/accounts/invitation.ex | 46 +++++++++---- .../invitation/changes/add_user_to_team.ex | 6 +- .../changes/send_invitation_email.ex | 27 +++++--- .../invitation/changes/send_welcome_email.ex | 30 ++++---- .../preparations/for_current_team.ex | 8 +++ lib/helpcenter/knowledge_base/category.ex | 1 - lib/helpcenter/workers/email_sender.ex | 20 ++++++ .../components/layouts/app.html.heex | 8 +++ .../accounts/users/user_invitations_live.ex | 61 ++++++++++++++++ .../accounts/users/user_invitations_live.exs | 0 .../live/accounts/users/users_live.ex | 69 ++++++++++++++++++- lib/helpcenter_web/router.ex | 2 +- 13 files changed, 238 insertions(+), 45 deletions(-) create mode 100644 lib/helpcenter/accounts/invitation/preparations/for_current_team.ex create mode 100644 lib/helpcenter/workers/email_sender.ex create mode 100644 lib/helpcenter_web/live/accounts/users/user_invitations_live.ex create mode 100644 lib/helpcenter_web/live/accounts/users/user_invitations_live.exs diff --git a/lib/helpcenter/accounts/group.ex b/lib/helpcenter/accounts/group.ex index 741e0c2..ab46ab7 100644 --- a/lib/helpcenter/accounts/group.ex +++ b/lib/helpcenter/accounts/group.ex @@ -15,7 +15,10 @@ defmodule Helpcenter.Accounts.Group do defaults [:create, :read, :update, :destroy] end - # Confirm how Ash will wor + code_interface do + define :list_groups, action: :read + end + pub_sub do module HelpcenterWeb.Endpoint diff --git a/lib/helpcenter/accounts/invitation.ex b/lib/helpcenter/accounts/invitation.ex index 4022f30..d6e6c58 100644 --- a/lib/helpcenter/accounts/invitation.ex +++ b/lib/helpcenter/accounts/invitation.ex @@ -2,13 +2,41 @@ defmodule Helpcenter.Accounts.Invitation do use Ash.Resource, domain: Helpcenter.Accounts, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + notifiers: Ash.Notifier.PubSub postgres do table "invitations" repo Helpcenter.Repo end + preparations do + prepare Helpcenter.Preparations.SetTenant + prepare Helpcenter.Accounts.Invitation.Preparations.ForCurrentTeam + end + + changes do + change Helpcenter.Changes.SetTenant + end + + multitenancy do + strategy :context + end + + # Confirm how Ash will wor + pub_sub do + module HelpcenterWeb.Endpoint + prefix "invitations" + publish_all :update, [[:id, :team, nil]] + publish_all :create, [[:id, :team, nil]] + publish_all :destroy, [[:id, :team, nil]] + end + + code_interface do + define :accept, action: :accept + define :get_by_token, args: [:token], action: :by_token + end + actions do defaults [:read, :destroy] @@ -21,12 +49,14 @@ defmodule Helpcenter.Accounts.Invitation do accept [:email, :group_id] change Helpcenter.Accounts.Invitation.Changes.SetInvitationAttributes - # change Helpcenter.Accounts.Invitation.Changes.SendInvitationEmail + change Helpcenter.Accounts.Invitation.Changes.SendInvitationEmail end read :by_token do description "This action is used to read an invitation by its token" + argument :token, :string filter expr(token == ^arg(:token)) + get? true end update :accept do @@ -56,18 +86,6 @@ defmodule Helpcenter.Accounts.Invitation do end end - preparations do - prepare Helpcenter.Preparations.SetTenant - end - - changes do - change Helpcenter.Changes.SetTenant - end - - multitenancy do - strategy :context - end - attributes do uuid_primary_key :id diff --git a/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex b/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex index 7c6a663..82c5ece 100644 --- a/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex +++ b/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex @@ -65,6 +65,10 @@ defmodule Helpcenter.Accounts.Invitation.Changes.AddUserToTeam do end defp create_user(email, tenant) do - Ash.Seed.seed!(Helpcenter.Accounts.User, %{email: email}, tenant: tenant) + Ash.Seed.seed!( + Helpcenter.Accounts.User, + %{email: email, current_team: tenant}, + tenant: tenant + ) end end diff --git a/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex b/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex index 93534d5..1bd4c5a 100644 --- a/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex +++ b/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex @@ -17,18 +17,23 @@ defmodule Helpcenter.Accounts.Invitation.Changes.SendInvitationEmail do end defp send_invitation_email(_changeset, invitation) do - send(invitation.email, invitation.token) - {:ok, invitation} - end + %{email: email, token: token} = invitation + message = body(token: token, email: email) + + %{ + id: token, + params: %{ + html_message: message, + text_message: message, + subject: "You are invited", + from: nil, + to: email + } + } + |> Helpcenter.Workers.EmailSender.new() + |> Oban.insert() - def send(email, token, _) do - new() - # TODO: Replace with your email - |> from({"noreply", "noreply@example.com"}) - |> to(to_string(email)) - |> subject("Your login link") - |> html_body(body(token: token, email: email)) - |> Mailer.deliver!() + {:ok, invitation} end defp body(params) do diff --git a/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex b/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex index 2b5d575..fc371c5 100644 --- a/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex +++ b/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex @@ -2,32 +2,32 @@ defmodule Helpcenter.Accounts.Invitation.Changes.SendWelcomeEmail do use Ash.Resource.Change use HelpcenterWeb, :verified_routes - import Swoosh.Email - alias Helpcenter.Mailer - @impl true def change(changeset, _opts, _context) do Ash.Changeset.after_action(changeset, &send_invitation_email/2) end - @impl true - def atomic?, do: true - @impl true def atomic(changeset, opts, context) do {:ok, change(changeset, opts, context)} end defp send_invitation_email(_changeset, invitation) do - %{token: token, email: email} = invitation - - new() - # TODO: Replace with your email - |> from({"noreply", "noreply@example.com"}) - |> to(to_string(email)) - |> subject("Your login link") - |> html_body(body(token: token, email: email)) - |> Mailer.deliver!() + %{email: email, token: token, team: team} = invitation + message = body(token: token, email: email) + + %{ + id: token, + params: %{ + html_message: message, + text_message: message, + subject: "Welcome to #{invitation.team}", + from: nil, + to: email + } + } + |> Helpcenter.Workers.EmailSender.new() + |> Oban.insert() {:ok, invitation} end diff --git a/lib/helpcenter/accounts/invitation/preparations/for_current_team.ex b/lib/helpcenter/accounts/invitation/preparations/for_current_team.ex new file mode 100644 index 0000000..9dd6757 --- /dev/null +++ b/lib/helpcenter/accounts/invitation/preparations/for_current_team.ex @@ -0,0 +1,8 @@ +defmodule Helpcenter.Accounts.Invitation.Preparations.ForCurrentTeam do + use Ash.Resource.Preparation + + def prepare(query, _opts, context) do + %{current_team: team} = context.actor + Ash.Query.filter(query, team == ^team) + end +end diff --git a/lib/helpcenter/knowledge_base/category.ex b/lib/helpcenter/knowledge_base/category.ex index 061e6ee..f843aae 100644 --- a/lib/helpcenter/knowledge_base/category.ex +++ b/lib/helpcenter/knowledge_base/category.ex @@ -4,7 +4,6 @@ defmodule Helpcenter.KnowledgeBase.Category do domain: Helpcenter.KnowledgeBase, data_layer: AshPostgres.DataLayer, notifiers: Ash.Notifier.PubSub, - # Tell Ash that this resource require authorization authorizers: Ash.Policy.Authorizer postgres do diff --git a/lib/helpcenter/workers/email_sender.ex b/lib/helpcenter/workers/email_sender.ex new file mode 100644 index 0000000..48385af --- /dev/null +++ b/lib/helpcenter/workers/email_sender.ex @@ -0,0 +1,20 @@ +defmodule Helpcenter.Workers.EmailSender do + use Oban.Worker, queue: :mailers + import Swoosh.Email + import Phoenix.Component + + @impl Oban.Worker + def perform(job) do + params = job.args["params"] + + new() + |> from({"Zippiker", "no-reply@zippiker.com"}) + |> to(params["to"] |> to_string()) + |> subject(params["subject"]) + |> html_body(params["body"]) + |> text_body(params["text"]) + |> Helpcenter.Mailer.deliver!() + + :ok + end +end diff --git a/lib/helpcenter_web/components/layouts/app.html.heex b/lib/helpcenter_web/components/layouts/app.html.heex index d3d1825..d96f73d 100644 --- a/lib/helpcenter_web/components/layouts/app.html.heex +++ b/lib/helpcenter_web/components/layouts/app.html.heex @@ -27,6 +27,14 @@ > <.icon name="hero-users" class="h-4 w-4" /> {gettext("Users")} + + <.link + id="categories" + navigate={~p"/accounts/users/invitations"} + class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700 hover:underline" + > + <.icon name="hero-message" class="h-4 w-4" /> + {gettext("Invitations")} <.link id="categories" diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex new file mode 100644 index 0000000..0b42803 --- /dev/null +++ b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex @@ -0,0 +1,61 @@ +defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive do + use HelpcenterWeb, :live_view + + def render(assigns) do + ~H""" + + <:col label="Email" :let={row} field="email" filter sort>{Phoenix.Naming.humanize(row.email)} + <:col label="Status" :let={row} field="status" fitler sort>{Phoenix.Naming.humanize(row.status)} + <:col label="Team" :let={row}>{Phoenix.Naming.humanize(row.team)} + <:col :let={row}> + <.button phx-click={"accept-invite-#{row.token}"}>{gettext("Accept")} + + + """ + end + + @imple true + def mount(_params, _sessions, socket) do + if connected?(socket) do + HelpcenterWeb.Endpoint.subscribe("invitations") + end + + socket + |> assign(:page_title, gettext("User Invitations")) + |> ok() + end + + def handle_event("accept-invite-" <> token, _params, socket) do + %{current_user: actor} = socket.assigns + {:ok, invitation} = Helpcenter.Accounts.Invitation.get_by_token(token, actor: actor) + + case Helpcenter.Accounts.Invitation.accept(invitation, actor: actor) do + {:ok, invitation} -> + dbg(invitation) + + socket + |> put_flash(:info, "Invitation accepted") + |> noreply() + + {:error, error} -> + dbg(error) + + socket + |> put_flash(:error, "Unable to accept invitation") + |> noreply() + end + end + + def handle_info(_event, socket) do + socket + |> Cinder.Table.refresh_table("user-invitations-table") + |> noreply() + end + + defp get_query() do + require Ash.Query + + Helpcenter.Accounts.Invitation + |> Ash.Query.filter(status == :pending) + end +end diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live.exs b/lib/helpcenter_web/live/accounts/users/user_invitations_live.exs new file mode 100644 index 0000000..e69de29 diff --git a/lib/helpcenter_web/live/accounts/users/users_live.ex b/lib/helpcenter_web/live/accounts/users/users_live.ex index 2c302f8..9ce17dd 100644 --- a/lib/helpcenter_web/live/accounts/users/users_live.ex +++ b/lib/helpcenter_web/live/accounts/users/users_live.ex @@ -4,10 +4,25 @@ defmodule HelpcenterWeb.Accounts.Users.UsersLive do @impl true def render(assigns) do ~H""" + <.button phx-click={show_modal("invite-user-modal")}> + {gettext("Invite a user")} + <:col :let={row} label="Email" field="email" filter sort>{row.email} <:col :let={row} label="Team">{Phoenix.Naming.humanize(row.current_team)} + + <.modal id="invite-user-modal"> +

{gettext("Invite a new user to ")} {Phoenix.Naming.humanize(@current_user.current_team)}

+ + <.simple_form for={@form} phx-change="validate" phx-submit="save"> + <.input field={@form[:email]} type="email" label={"New user Email"}/> + <.input field={@form[:group_id]} type="select" options={@access_groups} label={gettext("Select Access Group")} /> + <.button> + {gettext("Submit")} + + + """ end @@ -15,13 +30,65 @@ defmodule HelpcenterWeb.Accounts.Users.UsersLive do def mount(_params, _session, socket) do socket |> assign(:page_title, "Users") + |> assign_access_groups() + |> assign_form() |> ok() end + @impl true + def handle_event("validate", %{"form" => params}, socket) do + form = AshPhoenix.Form.validate(socket.assigns.form, params) + + socket + |> assign(:form, form) + |> noreply() + end + + @impl true + def handle_event("save", %{"form" => params}, socket) do + case AshPhoenix.Form.submit(socket.assigns.form, params: params) do + {:ok, _invitation} -> + socket + |> put_flash(:info, "Invitation sent successfully") + |> push_event("js-exec", %{to: "#invite-user-modal", attr: "data-cancel"}) + |> noreply() + + {:error, form} -> + socket + |> assign(:form, form) + |> noreply() + end + end + + defp assign_access_groups(socket) do + groups = + Helpcenter.Accounts.Group.list_groups!( + tenant: socket.assigns.current_user.current_team, + authorize?: false + ) + |> Enum.map(&{&1.name, &1.id}) + + assign(socket, :access_groups, groups) + end + + defp assign_form(socket) do + assign(socket, :form, get_form(socket.assigns)) + end + + defp get_form(%{current_user: user}) do + Helpcenter.Accounts.Invitation + |> AshPhoenix.Form.for_create(:create, actor: user) + |> to_form() + end + defp get_query do require Ash.Query Helpcenter.Accounts.User - |> Ash.Query.for_read(:read, %{}, authorize?: false) + |> Ash.Query.for_read( + :read, + %{}, + authorize?: false + ) end end diff --git a/lib/helpcenter_web/router.ex b/lib/helpcenter_web/router.ex index 5b7e37c..30a564e 100644 --- a/lib/helpcenter_web/router.ex +++ b/lib/helpcenter_web/router.ex @@ -47,7 +47,7 @@ defmodule HelpcenterWeb.Router do scope "/accounts", Accounts do scope "/users", Users do live "/", UsersLive - live "/:user_id", UserLive + live "/invitations", UserInvitationsLive end scope "/groups", Groups do From 65d57718b6efe77cdbc72f0e7ebc35cf6f22263a Mon Sep 17 00:00:00 2001 From: Kamaro Date: Sun, 17 Aug 2025 18:11:16 +0300 Subject: [PATCH 05/22] Made all tests pass --- .../changes/send_invitation_email.ex | 4 - .../invitation/changes/send_welcome_email.ex | 2 +- lib/helpcenter/workers/email_sender.ex | 1 - .../components/layouts/app.html.heex | 4 +- .../accounts/users/user_invitations_live.ex | 3 +- .../live/accounts/users/users_live_test.exs | 8 +- .../knowledge_base/category_test.exs | 313 +++++++++--------- 7 files changed, 168 insertions(+), 167 deletions(-) diff --git a/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex b/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex index 1bd4c5a..9eb520e 100644 --- a/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex +++ b/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex @@ -2,10 +2,6 @@ defmodule Helpcenter.Accounts.Invitation.Changes.SendInvitationEmail do use Ash.Resource.Change use HelpcenterWeb, :verified_routes - import Swoosh.Email - - alias Helpcenter.Mailer - @impl true def change(changeset, _opts, _context) do Ash.Changeset.after_action(changeset, &send_invitation_email/2) diff --git a/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex b/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex index fc371c5..27fc3e1 100644 --- a/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex +++ b/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex @@ -21,7 +21,7 @@ defmodule Helpcenter.Accounts.Invitation.Changes.SendWelcomeEmail do params: %{ html_message: message, text_message: message, - subject: "Welcome to #{invitation.team}", + subject: "Welcome to #{team}", from: nil, to: email } diff --git a/lib/helpcenter/workers/email_sender.ex b/lib/helpcenter/workers/email_sender.ex index 48385af..f4b0962 100644 --- a/lib/helpcenter/workers/email_sender.ex +++ b/lib/helpcenter/workers/email_sender.ex @@ -1,7 +1,6 @@ defmodule Helpcenter.Workers.EmailSender do use Oban.Worker, queue: :mailers import Swoosh.Email - import Phoenix.Component @impl Oban.Worker def perform(job) do diff --git a/lib/helpcenter_web/components/layouts/app.html.heex b/lib/helpcenter_web/components/layouts/app.html.heex index d96f73d..559d5df 100644 --- a/lib/helpcenter_web/components/layouts/app.html.heex +++ b/lib/helpcenter_web/components/layouts/app.html.heex @@ -21,7 +21,7 @@ <%!-- Only show this div if the user is logged in --%>
<.link - id="categories" + id="users" navigate={~p"/accounts/users"} class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700 hover:underline" > @@ -29,7 +29,7 @@ {gettext("Users")} <.link - id="categories" + id="user-invitations" navigate={~p"/accounts/users/invitations"} class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700 hover:underline" > diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex index 0b42803..3287b32 100644 --- a/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex +++ b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex @@ -5,7 +5,7 @@ defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive do ~H""" <:col label="Email" :let={row} field="email" filter sort>{Phoenix.Naming.humanize(row.email)} - <:col label="Status" :let={row} field="status" fitler sort>{Phoenix.Naming.humanize(row.status)} + <:col label="Status" :let={row} field="status" filter sort>{Phoenix.Naming.humanize(row.status)} <:col label="Team" :let={row}>{Phoenix.Naming.humanize(row.team)} <:col :let={row}> <.button phx-click={"accept-invite-#{row.token}"}>{gettext("Accept")} @@ -14,7 +14,6 @@ defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive do """ end - @imple true def mount(_params, _sessions, socket) do if connected?(socket) do HelpcenterWeb.Endpoint.subscribe("invitations") diff --git a/lib/helpcenter_web/live/accounts/users/users_live_test.exs b/lib/helpcenter_web/live/accounts/users/users_live_test.exs index 831d778..535a748 100644 --- a/lib/helpcenter_web/live/accounts/users/users_live_test.exs +++ b/lib/helpcenter_web/live/accounts/users/users_live_test.exs @@ -3,7 +3,13 @@ defmodule HelpcenterWeb.Accounts.Users.UsersLiveTest do describe "UsersLive" do test "renders the users page", %{conn: conn} do - conn = get(conn, ~p"/accounts/users") + user = create_user() + + conn = + conn + |> login(user) + |> get(~p"/accounts/users") + assert html_response(conn, 200) =~ "Users" assert html_response(conn, 200) =~ "Email" assert html_response(conn, 200) =~ "Team" diff --git a/test/helpcenter/knowledge_base/category_test.exs b/test/helpcenter/knowledge_base/category_test.exs index 89f0587..669c226 100644 --- a/test/helpcenter/knowledge_base/category_test.exs +++ b/test/helpcenter/knowledge_base/category_test.exs @@ -1,5 +1,6 @@ defmodule Helpcenter.KnowledgeBase.CategoryTest do - use HelpcenterWeb.ConnCase, async: false + use HelpcenterWeb.ConnCase + import ArticleCase import CategoryCase require Ash.Query @@ -67,160 +68,160 @@ defmodule Helpcenter.KnowledgeBase.CategoryTest do |> Ash.exists?(actor: user) end - # test "Can destroy an existing Category" do - # user = create_user() - - # create_categories(user.current_team) - - # require Ash.Query - - # # First identify category to destroy - # category_to_delete = - # Helpcenter.KnowledgeBase.Category - # |> Ash.Query.filter(name == "Approvals and Workflows") - # |> Ash.read_first!(actor: user) - - # # Tell Ash to destroy it - # Ash.destroy(category_to_delete) - - # refute Helpcenter.KnowledgeBase.Category - # |> Ash.Query.filter(name == "Approvals and Workflows") - # |> Ash.exists?(actor: user) - # end - - # test "Category can be created with an article" do - # user = create_user() - - # # Define category and related article attributes - # attrs = %{ - # name: "Features", - # slug: "features", - # description: "Category for features", - # article_attrs: %{ - # title: "Compliance Features in Zippiker", - # slug: "compliance-features-zippiker", - # content: "Overview of compliance management features built into Zippiker." - # } - # } - - # # Create category and its article at the same time - # Helpcenter.KnowledgeBase.Category - # |> Ash.Changeset.for_create(:create_with_article, attrs, actor: user) - # |> Ash.create() - - # assert Helpcenter.KnowledgeBase.Category - # |> Ash.Query.filter(name == ^attrs.name) - # |> Ash.exists?(actor: user) - - # assert Helpcenter.KnowledgeBase.Article - # |> Ash.Query.filter(title == ^attrs.article_attrs.title) - # |> Ash.exists?(actor: user) - # end - - # test "An article can be added to an existing category" do - # # 1. Get category to create an article under - # user = create_user() - # category = get_category(user.current_team) - - # # 2. Prepare new article data - # attrs = %{ - # title: "Getting Started with Zippiker", - # slug: "getting-started-zippiker", - # content: "Learn how to set up your Zippiker account and configure basic settings.", - # views_count: 1452, - # published: true - # } - - # # 3 Create an article under this category - # {:ok, _category} = - # category - # |> Ash.Changeset.for_update(:add_article, %{article_attrs: attrs}, actor: user) - # |> Ash.update() - - # # Confirm that the article has been create - # assert Helpcenter.KnowledgeBase.Article - # |> Ash.Query.filter(title == ^attrs.title) - # |> Ash.Query.filter(content == ^attrs.content) - # |> Ash.Query.filter(category_id == ^category.id) - # |> Ash.read(actor: user) - # end - - # test "Category can be retrieved with related articles" do - # # First create articles for the category - # user = create_user() - - # category = get_category(user.current_team) - # articles = create_articles(category, user.current_team) - - # category_with_articles = - # Helpcenter.KnowledgeBase.Category - # |> Ash.Query.filter(id == ^category.id) - # # Tell Ash to load related articles - # |> Ash.Query.load(:articles) - # |> Ash.read_first!(actor: user) - - # # This category might have added article else where in concurrency writing. Thus, use <= - # assert Enum.count(category_with_articles.articles) <= Enum.count(articles) - # end - - # test "articles_count aggregate can be loaded on the category" do - # # Create categories and seed articles - # user = create_user() - - # category = get_category(user.current_team) - # create_articles(category, user.current_team) - - # loaded_category = - # Helpcenter.KnowledgeBase.Category - # |> Ash.Query.filter(id == ^category.id) - # |> Ash.Query.load([:article_count, :articles]) - # |> Ash.read_first!(actor: user) - - # assert loaded_category.article_count == Enum.count(loaded_category.articles) - # end - - # test "'categories' pubsub event is published on create" do - # # Subscribe to the event so we can test whether it is being fired - # user = create_user() - # HelpcenterWeb.Endpoint.subscribe("categories") - - # attributes = %{name: "Art 1", slug: "art-1", description: "descrpt-1"} - - # Helpcenter.KnowledgeBase.Category - # |> Ash.Changeset.for_create(:create, attributes, actor: user) - # |> Ash.create() - - # # Confirm that the event is being recieved and its data - # assert_receive %Phoenix.Socket.Broadcast{topic: "categories", payload: category} - # assert category.data.name == attributes.name - # assert category.data.slug == attributes.slug - # assert category.data.description == attributes.description - # end - - # test "Global preparations works as expected" do - # user = create_user() - - # create_categories(user.current_team) - - # assert Helpcenter.KnowledgeBase.Category - # |> Helpcenter.Preparations.LimitTo5.prepare([], []) - # |> Helpcenter.Preparations.MonthToDate.prepare([], []) - # |> Helpcenter.Preparations.OrderByMostRecent.prepare([], []) - # |> Ash.count!(actor: user) == 5 - # end - - # test "Slug change generates slug successfully" do - # user = create_user() - - # params = %{ - # name: "Home appliances you cannot find elsewhere", - # description: "Home appliances description" - # } - - # {:ok, category} = - # Ash.create(Helpcenter.KnowledgeBase.Category, params, tenant: user.current_team) - - # refute category.slug |> is_nil() - # end + test "Can destroy an existing Category" do + user = create_user() + + create_categories(user.current_team) + + require Ash.Query + + # First identify category to destroy + category_to_delete = + Helpcenter.KnowledgeBase.Category + |> Ash.Query.filter(name == "Approvals and Workflows") + |> Ash.read_first!(actor: user) + + # Tell Ash to destroy it + Ash.destroy(category_to_delete, actor: user) + + refute Helpcenter.KnowledgeBase.Category + |> Ash.Query.filter(name == "Approvals and Workflows") + |> Ash.exists?(actor: user) + end + + test "Category can be created with an article" do + user = create_user() + + # Define category and related article attributes + attrs = %{ + name: "Features", + slug: "features", + description: "Category for features", + article_attrs: %{ + title: "Compliance Features in Zippiker", + slug: "compliance-features-zippiker", + content: "Overview of compliance management features built into Zippiker." + } + } + + # Create category and its article at the same time + Helpcenter.KnowledgeBase.Category + |> Ash.Changeset.for_create(:create_with_article, attrs, actor: user) + |> Ash.create() + + assert Helpcenter.KnowledgeBase.Category + |> Ash.Query.filter(name == ^attrs.name) + |> Ash.exists?(actor: user) + + assert Helpcenter.KnowledgeBase.Article + |> Ash.Query.filter(title == ^attrs.article_attrs.title) + |> Ash.exists?(actor: user) + end + + test "An article can be added to an existing category" do + # 1. Get category to create an article under + user = create_user() + category = get_category(user.current_team) + + # 2. Prepare new article data + attrs = %{ + title: "Getting Started with Zippiker", + slug: "getting-started-zippiker", + content: "Learn how to set up your Zippiker account and configure basic settings.", + views_count: 1452, + published: true + } + + # 3 Create an article under this category + {:ok, _category} = + category + |> Ash.Changeset.for_update(:add_article, %{article_attrs: attrs}, actor: user) + |> Ash.update() + + # Confirm that the article has been create + assert Helpcenter.KnowledgeBase.Article + |> Ash.Query.filter(title == ^attrs.title) + |> Ash.Query.filter(content == ^attrs.content) + |> Ash.Query.filter(category_id == ^category.id) + |> Ash.read(actor: user) + end + + test "Category can be retrieved with related articles" do + # First create articles for the category + user = create_user() + + category = get_category(user.current_team) + articles = create_articles(category, user.current_team) + + category_with_articles = + Helpcenter.KnowledgeBase.Category + |> Ash.Query.filter(id == ^category.id) + # Tell Ash to load related articles + |> Ash.Query.load(:articles) + |> Ash.read_first!(actor: user) + + # This category might have added article else where in concurrency writing. Thus, use <= + assert Enum.count(category_with_articles.articles) <= Enum.count(articles) + end + + test "articles_count aggregate can be loaded on the category" do + # Create categories and seed articles + user = create_user() + + category = get_category(user.current_team) + create_articles(category, user.current_team) + + loaded_category = + Helpcenter.KnowledgeBase.Category + |> Ash.Query.filter(id == ^category.id) + |> Ash.Query.load([:article_count, :articles]) + |> Ash.read_first!(actor: user) + + assert loaded_category.article_count == Enum.count(loaded_category.articles) + end + + test "'categories' pubsub event is published on create" do + # Subscribe to the event so we can test whether it is being fired + user = create_user() + HelpcenterWeb.Endpoint.subscribe("categories") + + attributes = %{name: "Art 1", slug: "art-1", description: "descrpt-1"} + + Helpcenter.KnowledgeBase.Category + |> Ash.Changeset.for_create(:create, attributes, actor: user) + |> Ash.create() + + # Confirm that the event is being recieved and its data + assert_receive %Phoenix.Socket.Broadcast{topic: "categories", payload: category} + assert category.data.name == attributes.name + assert category.data.slug == attributes.slug + assert category.data.description == attributes.description + end + + test "Global preparations works as expected" do + user = create_user() + + create_categories(user.current_team) + + assert Helpcenter.KnowledgeBase.Category + |> Helpcenter.Preparations.LimitTo5.prepare([], []) + |> Helpcenter.Preparations.MonthToDate.prepare([], []) + |> Helpcenter.Preparations.OrderByMostRecent.prepare([], []) + |> Ash.count!(actor: user) == 5 + end + + test "Slug change generates slug successfully" do + user = create_user() + + params = %{ + name: "Home appliances you cannot find elsewhere", + description: "Home appliances description" + } + + {:ok, category} = + Ash.create(Helpcenter.KnowledgeBase.Category, params, actor: user) + + refute category.slug |> is_nil() + end end end From c2413a409932be5c006787855c28bedb04a5e231 Mon Sep 17 00:00:00 2001 From: Kamaro Date: Sun, 17 Aug 2025 18:21:16 +0300 Subject: [PATCH 06/22] Fixed all tests --- .../users/user_invitations_live._tests.exs | 17 +++++++++++++++++ .../accounts/users/user_invitations_live.ex | 2 +- .../accounts/users/user_invitations_live.exs | 0 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 lib/helpcenter_web/live/accounts/users/user_invitations_live._tests.exs delete mode 100644 lib/helpcenter_web/live/accounts/users/user_invitations_live.exs diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live._tests.exs b/lib/helpcenter_web/live/accounts/users/user_invitations_live._tests.exs new file mode 100644 index 0000000..ea509c8 --- /dev/null +++ b/lib/helpcenter_web/live/accounts/users/user_invitations_live._tests.exs @@ -0,0 +1,17 @@ +defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLiveTest do + use HelpcenterWeb.ConnCase + + describe "User Invitations Live test" do + test "User can list and accept invitation", %{conn: conn} do + user = create_user() + + conn = + conn + |> login(user) + |> get(~p"/accounts/users/invitations") + + assert html_response(conn, 200) =~ "Email" + assert html_response(conn, 200) =~ "Team" + end + end +end diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex index 3287b32..e9f2ecd 100644 --- a/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex +++ b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex @@ -47,7 +47,7 @@ defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive do def handle_info(_event, socket) do socket - |> Cinder.Table.refresh_table("user-invitations-table") + |> Cinder.Table.Refresh.refresh_table("user-invitations-table") |> noreply() end diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live.exs b/lib/helpcenter_web/live/accounts/users/user_invitations_live.exs deleted file mode 100644 index e69de29..0000000 From af490042e88b06610b09d7427b1ef22781a2f534 Mon Sep 17 00:00:00 2001 From: Kamaro Date: Mon, 18 Aug 2025 23:03:55 +0300 Subject: [PATCH 07/22] Added invitation accept --- lib/helpcenter/accounts/invitation.ex | 3 +- .../changes/send_invitation_email.ex | 8 ++-- .../preparations/for_current_team.ex | 2 + .../team_invitation_acceptance_controller.ex | 25 ++++++++++ ..._invitation_acceptance_controller_test.exs | 46 +++++++++++++++++++ .../accounts/groups/group_permission_form.ex | 2 - .../accounts/users/user_invitations_live.ex | 2 - lib/helpcenter_web/router.ex | 4 ++ 8 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex create mode 100644 lib/helpcenter_web/controllers/team_invitation_acceptance_controller_test.exs diff --git a/lib/helpcenter/accounts/invitation.ex b/lib/helpcenter/accounts/invitation.ex index d6e6c58..9690196 100644 --- a/lib/helpcenter/accounts/invitation.ex +++ b/lib/helpcenter/accounts/invitation.ex @@ -21,6 +21,7 @@ defmodule Helpcenter.Accounts.Invitation do multitenancy do strategy :context + global? true end # Confirm how Ash will wor @@ -68,7 +69,7 @@ defmodule Helpcenter.Accounts.Invitation do """ accept [] - change set_attribute(:status, :accepted) + change atomic_update(:status, :accepted) change Helpcenter.Accounts.Invitation.Changes.AddUserToTeam change Helpcenter.Accounts.Invitation.Changes.SendWelcomeEmail end diff --git a/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex b/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex index 9eb520e..36893ca 100644 --- a/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex +++ b/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex @@ -13,8 +13,8 @@ defmodule Helpcenter.Accounts.Invitation.Changes.SendInvitationEmail do end defp send_invitation_email(_changeset, invitation) do - %{email: email, token: token} = invitation - message = body(token: token, email: email) + %{email: email, token: token, team: team} = invitation + message = body(token: token, email: email, tenant: team) %{ id: token, @@ -33,10 +33,10 @@ defmodule Helpcenter.Accounts.Invitation.Changes.SendInvitationEmail do end defp body(params) do - url = url(~p"/auth/user/magic_link/?token=#{params[:token]}") + url = url(~p"/accounts/users/invitations/#{params[:tenant]}/#{params[:token]}/accept") """ -

Hello, #{params[:email]}! Click this link to sign in:

+

Hello, #{params[:email]}! Click this link to accept the invitation to #{params[:tenant]}.

#{url}

""" end diff --git a/lib/helpcenter/accounts/invitation/preparations/for_current_team.ex b/lib/helpcenter/accounts/invitation/preparations/for_current_team.ex index 9dd6757..a4aba38 100644 --- a/lib/helpcenter/accounts/invitation/preparations/for_current_team.ex +++ b/lib/helpcenter/accounts/invitation/preparations/for_current_team.ex @@ -1,6 +1,8 @@ defmodule Helpcenter.Accounts.Invitation.Preparations.ForCurrentTeam do use Ash.Resource.Preparation + def prepare(query, _options, %{actor: nil}), do: query + def prepare(query, _opts, context) do %{current_team: team} = context.actor Ash.Query.filter(query, team == ^team) diff --git a/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex new file mode 100644 index 0000000..5f20ddf --- /dev/null +++ b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex @@ -0,0 +1,25 @@ +defmodule HelpcenterWeb.TeamInvitationAcceptanceController do + use HelpcenterWeb, :controller + + def accept(conn, %{"tenant" => tenant, "token" => token}) do + require Ash.Query + + options = [tenant: tenant, authorize?: false] + + Helpcenter.Accounts.Invitation + |> Ash.Query.filter(token == ^token) + |> Ash.read_first!(options) + |> Ash.Changeset.for_update(:accept, %{}, options) + |> Ash.update() + |> case do + {:ok, _inv} -> + conn + |> put_flash(:info, gettext("Invitation accepted")) + |> redirect(to: ~p"/") + + {:error, error} -> + dbg(error) + put_flash(conn, :error, gettext("Unable to accept invitation")) + end + end +end diff --git a/lib/helpcenter_web/controllers/team_invitation_acceptance_controller_test.exs b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller_test.exs new file mode 100644 index 0000000..cf16439 --- /dev/null +++ b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller_test.exs @@ -0,0 +1,46 @@ +defmodule HelpcenterWeb.TeamInvitationAcceptanceControllerTEst do + use HelpcenterWeb.ConnCase + + defp create_group!(actor) do + group_attrs = %{name: "Accountants", description: "Can manage billing in the system"} + + Ash.create!( + Helpcenter.Accounts.Group, + group_attrs, + actor: actor, + authorize?: false + ) + end + + describe "Team invitation acceptance controller" do + test "User can accept an invitation", %{conn: conn} do + # 1. Create auser + # 2. Add user to the team + # 3. Get the invitation token and send it in the URL + # 4. Confirm the notifiaction flash + # 5. Ensure that the user has been added to the team + actor = create_user() + group = create_group!(actor) + invite_attributes = %{email: "john@example.com", group_id: group.id} + + invitation = + Helpcenter.Accounts.Invitation + |> Ash.Changeset.for_create(:create, invite_attributes, actor: actor) + |> Ash.create!() + + conn = + conn + |> get(~p"/accounts/users/invitations/#{invitation.team}/#{invitation.token}/accept") + + assert redirected_to(conn) == ~p"/" + + # Confirm the invitation has been accepted + require Ash.Query + + assert Helpcenter.Accounts.Invitation + |> Ash.Query.filter(token == ^invitation.token) + |> Ash.Query.filter(status == :accepted) + |> Ash.exists?(actor: actor) + end + end +end diff --git a/lib/helpcenter_web/live/accounts/groups/group_permission_form.ex b/lib/helpcenter_web/live/accounts/groups/group_permission_form.ex index 427a3ce..eb830d6 100644 --- a/lib/helpcenter_web/live/accounts/groups/group_permission_form.ex +++ b/lib/helpcenter_web/live/accounts/groups/group_permission_form.ex @@ -90,8 +90,6 @@ defmodule HelpcenterWeb.Accounts.Groups.GroupPermissionForm do |> noreply() error -> - dbg(error) - socket |> put_flash(:error, "Unable to update permissions") |> noreply() diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex index e9f2ecd..b91cd10 100644 --- a/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex +++ b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex @@ -30,8 +30,6 @@ defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive do case Helpcenter.Accounts.Invitation.accept(invitation, actor: actor) do {:ok, invitation} -> - dbg(invitation) - socket |> put_flash(:info, "Invitation accepted") |> noreply() diff --git a/lib/helpcenter_web/router.ex b/lib/helpcenter_web/router.ex index 30a564e..1bbb4aa 100644 --- a/lib/helpcenter_web/router.ex +++ b/lib/helpcenter_web/router.ex @@ -86,6 +86,10 @@ defmodule HelpcenterWeb.Router do pipe_through :browser get "/", PageController, :home + + get "/accounts/users/invitations/:tenant/:token/accept", + TeamInvitationAcceptanceController, + :accept end # Other scopes may use custom stacks. From 85111ff7019e37dc8e47c56d620bb302d41a1cd8 Mon Sep 17 00:00:00 2001 From: Kamaro Date: Fri, 22 Aug 2025 16:47:44 +0300 Subject: [PATCH 08/22] Add validation error --- lib/helpcenter/accounts/invitation.ex | 9 ++-- .../changes/set_invitation_attributes.ex | 18 +------- .../validations/validate_pending_status.ex | 42 +++++++++++++++++++ .../team_invitation_acceptance_controller.ex | 16 +++++-- .../accounts/groups/group_permission_form.ex | 2 + .../accounts/users/user_invitations_live.ex | 12 +++--- .../live/accounts/users/users_live.ex | 1 + 7 files changed, 73 insertions(+), 27 deletions(-) create mode 100644 lib/helpcenter/accounts/invitation/validations/validate_pending_status.ex diff --git a/lib/helpcenter/accounts/invitation.ex b/lib/helpcenter/accounts/invitation.ex index 9690196..87c5827 100644 --- a/lib/helpcenter/accounts/invitation.ex +++ b/lib/helpcenter/accounts/invitation.ex @@ -44,8 +44,9 @@ defmodule Helpcenter.Accounts.Invitation do create :create do description """ This action assumes that inserting new data == inviting a new users. It will then: - 1. Set new invitation attributes such as: token, expires_at, team, etc - 2. Sends an invitation to the newly invited user via email + 1. Generate a unique token for the invitation and add it to the changeset + 2. Set new invitation attributes such as: expires_at, team, etc. + 3. Sends an invitation to the newly invited user via email """ accept [:email, :group_id] @@ -54,7 +55,7 @@ defmodule Helpcenter.Accounts.Invitation do end read :by_token do - description "This action is used to read an invitation by its token" + description "Get one invitation by its token" argument :token, :string filter expr(token == ^arg(:token)) get? true @@ -72,6 +73,7 @@ defmodule Helpcenter.Accounts.Invitation do change atomic_update(:status, :accepted) change Helpcenter.Accounts.Invitation.Changes.AddUserToTeam change Helpcenter.Accounts.Invitation.Changes.SendWelcomeEmail + validate Helpcenter.Accounts.Invitation.Validations.ValidatePendingStatus end update :decline do @@ -84,6 +86,7 @@ defmodule Helpcenter.Accounts.Invitation do accept [] change set_attribute(:status, :declined) change Helpcenter.Accounts.Invitation.Changes.SendDeclinedEmail + validate Helpcenter.Accounts.Invitation.Validations.ValidatePendingStatus end end diff --git a/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex b/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex index b3d0bf0..ff12729 100644 --- a/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex +++ b/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex @@ -1,11 +1,6 @@ defmodule Helpcenter.Accounts.Invitation.Changes.SetInvitationAttributes do use Ash.Resource.Change - @alphabet String.split("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", "", - trim: true - ) - @alphabet_length length(@alphabet) - @impl true def change(changeset, _opts, context) do Ash.Changeset.before_transaction(changeset, &set_attributes(&1, context)) @@ -20,19 +15,10 @@ defmodule Helpcenter.Accounts.Invitation.Changes.SetInvitationAttributes do expires_at = DateTime.add(DateTime.utc_now(), 30, :day) changeset - |> Ash.Changeset.force_change_attribute(:token, generate()) |> Ash.Changeset.force_change_attribute(:expires_at, expires_at) |> Ash.Changeset.force_change_attribute(:team, changeset.tenant) - |> Ash.Changeset.force_change_attribute(:inviter_user_id, context.actor.id) + |> Ash.Changeset.force_change_attribute(:token, Ash.UUIDv7.generate()) |> Ash.Changeset.force_change_attribute(:team, context.actor.current_team) - end - - @doc """ - Generates a YouTube-like ID by randomly selecting characters from a custom alphabet. - """ - def generate(length \\ 11) do - Stream.repeatedly(fn -> Enum.at(@alphabet, :rand.uniform(@alphabet_length) - 1) end) - |> Enum.take(length) - |> Enum.join() + |> Ash.Changeset.force_change_attribute(:inviter_user_id, context.actor.id) end end diff --git a/lib/helpcenter/accounts/invitation/validations/validate_pending_status.ex b/lib/helpcenter/accounts/invitation/validations/validate_pending_status.ex new file mode 100644 index 0000000..5642ddc --- /dev/null +++ b/lib/helpcenter/accounts/invitation/validations/validate_pending_status.ex @@ -0,0 +1,42 @@ +defmodule Helpcenter.Accounts.Invitation.Validations.ValidatePendingStatus do + use Ash.Resource.Validation + + def validate(changeset, _opts, context) do + changeset + |> get_invitation(context) + |> validate_invitation() + end + + @doc """ + Atomic function's return must be the same as the main behaviour callback function. + that's why we are not returning {:ok, validate(changeset, opts, context)} + like in a change {:ok, change(changeset, opts, context)} + """ + def atomic(changeset, opts, context) do + validate(changeset, opts, context) + end + + defp get_invitation(changeset, context) do + require Ash.Query + token = Ash.Changeset.get_attribute(changeset, :token) + + Helpcenter.Accounts.Invitation + |> Ash.Query.filter(token == ^token) + |> Ash.read_first(Ash.Context.to_opts(context)) + end + + # Validate that the invitation is still pending + defp validate_invitation({:ok, %{status: :pending}}), do: :ok + + defp validate_invitation({:ok, %{status: :accepted}}) do + {:error, field: :token, message: "This invitation has already been accepted."} + end + + defp validate_invitation({:ok, %{status: :declined}}) do + {:error, field: :token, message: "This invitation has already been declined."} + end + + defp validate_invitation(_others) do + {:error, field: :token, message: "Invalid invitation."} + end +end diff --git a/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex index 5f20ddf..1535bac 100644 --- a/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex +++ b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex @@ -17,9 +17,19 @@ defmodule HelpcenterWeb.TeamInvitationAcceptanceController do |> put_flash(:info, gettext("Invitation accepted")) |> redirect(to: ~p"/") - {:error, error} -> - dbg(error) - put_flash(conn, :error, gettext("Unable to accept invitation")) + {:error, %Ash.Error.Invalid{errors: errors}} -> + error_message = + Enum.map(errors, & &1.message) + |> Enum.join(", ") + + conn + |> put_flash(:error, error_message) + |> redirect(to: ~p"/") + + {:error, _other} -> + conn + |> put_flash(:error, gettext("Unable to accept invitation")) + |> redirect(to: ~p"/") end end end diff --git a/lib/helpcenter_web/live/accounts/groups/group_permission_form.ex b/lib/helpcenter_web/live/accounts/groups/group_permission_form.ex index eb830d6..427a3ce 100644 --- a/lib/helpcenter_web/live/accounts/groups/group_permission_form.ex +++ b/lib/helpcenter_web/live/accounts/groups/group_permission_form.ex @@ -90,6 +90,8 @@ defmodule HelpcenterWeb.Accounts.Groups.GroupPermissionForm do |> noreply() error -> + dbg(error) + socket |> put_flash(:error, "Unable to update permissions") |> noreply() diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex index b91cd10..8b2f702 100644 --- a/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex +++ b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex @@ -1,9 +1,10 @@ defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive do use HelpcenterWeb, :live_view + @impl true def render(assigns) do ~H""" - + <:col label="Email" :let={row} field="email" filter sort>{Phoenix.Naming.humanize(row.email)} <:col label="Status" :let={row} field="status" filter sort>{Phoenix.Naming.humanize(row.status)} <:col label="Team" :let={row}>{Phoenix.Naming.humanize(row.team)} @@ -14,6 +15,7 @@ defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive do """ end + @impl true def mount(_params, _sessions, socket) do if connected?(socket) do HelpcenterWeb.Endpoint.subscribe("invitations") @@ -24,12 +26,13 @@ defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive do |> ok() end + @impl true def handle_event("accept-invite-" <> token, _params, socket) do %{current_user: actor} = socket.assigns {:ok, invitation} = Helpcenter.Accounts.Invitation.get_by_token(token, actor: actor) case Helpcenter.Accounts.Invitation.accept(invitation, actor: actor) do - {:ok, invitation} -> + {:ok, _invitation} -> socket |> put_flash(:info, "Invitation accepted") |> noreply() @@ -43,6 +46,7 @@ defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive do end end + @impl true def handle_info(_event, socket) do socket |> Cinder.Table.Refresh.refresh_table("user-invitations-table") @@ -51,8 +55,6 @@ defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive do defp get_query() do require Ash.Query - - Helpcenter.Accounts.Invitation - |> Ash.Query.filter(status == :pending) + Ash.Query.filter(Helpcenter.Accounts.Invitation, status == :pending) end end diff --git a/lib/helpcenter_web/live/accounts/users/users_live.ex b/lib/helpcenter_web/live/accounts/users/users_live.ex index 9ce17dd..8657734 100644 --- a/lib/helpcenter_web/live/accounts/users/users_live.ex +++ b/lib/helpcenter_web/live/accounts/users/users_live.ex @@ -51,6 +51,7 @@ defmodule HelpcenterWeb.Accounts.Users.UsersLive do socket |> put_flash(:info, "Invitation sent successfully") |> push_event("js-exec", %{to: "#invite-user-modal", attr: "data-cancel"}) + |> redirect(to: ~p"/accounts/users/invitations") |> noreply() {:error, form} -> From 20a3d318cb742dcb8079861c5f2e6a1148e44844 Mon Sep 17 00:00:00 2001 From: Kamaro Date: Sat, 23 Aug 2025 08:48:31 +0300 Subject: [PATCH 09/22] Added tests for errors --- .../validations/validate_pending_status.ex | 10 ++-- lib/helpcenter/accounts/invitation_test.exs | 10 ++++ .../team_invitation_acceptance_controller.ex | 60 +++++++++++++------ 3 files changed, 57 insertions(+), 23 deletions(-) diff --git a/lib/helpcenter/accounts/invitation/validations/validate_pending_status.ex b/lib/helpcenter/accounts/invitation/validations/validate_pending_status.ex index 5642ddc..5122ace 100644 --- a/lib/helpcenter/accounts/invitation/validations/validate_pending_status.ex +++ b/lib/helpcenter/accounts/invitation/validations/validate_pending_status.ex @@ -4,7 +4,7 @@ defmodule Helpcenter.Accounts.Invitation.Validations.ValidatePendingStatus do def validate(changeset, _opts, context) do changeset |> get_invitation(context) - |> validate_invitation() + |> validate_pending_status() end @doc """ @@ -26,17 +26,17 @@ defmodule Helpcenter.Accounts.Invitation.Validations.ValidatePendingStatus do end # Validate that the invitation is still pending - defp validate_invitation({:ok, %{status: :pending}}), do: :ok + defp validate_pending_status({:ok, %{status: :pending}}), do: :ok - defp validate_invitation({:ok, %{status: :accepted}}) do + defp validate_pending_status({:ok, %{status: :accepted}}) do {:error, field: :token, message: "This invitation has already been accepted."} end - defp validate_invitation({:ok, %{status: :declined}}) do + defp validate_pending_status({:ok, %{status: :declined}}) do {:error, field: :token, message: "This invitation has already been declined."} end - defp validate_invitation(_others) do + defp validate_pending_status(_others) do {:error, field: :token, message: "Invalid invitation."} end end diff --git a/lib/helpcenter/accounts/invitation_test.exs b/lib/helpcenter/accounts/invitation_test.exs index 513d0bb..09024bb 100644 --- a/lib/helpcenter/accounts/invitation_test.exs +++ b/lib/helpcenter/accounts/invitation_test.exs @@ -37,6 +37,16 @@ defmodule Helpcenter.Accounts.InvitationTest do assert accepted_invitation.status == :accepted assert accepted_invitation.email == invite_attributes.email assert accepted_invitation.group_id == group.id + + # Confirm that user cannot decline an already accepted invitation + {:error, %Ash.Error.Invalid{errors: errors}} = + accepted_invitation + |> Ash.Changeset.for_update(:decline, %{}, actor: actor) + |> Ash.update() + + error = List.first(errors) + assert error.field == :token + assert error.message == "This invitation has already been accepted." end end end diff --git a/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex index 1535bac..d5b632f 100644 --- a/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex +++ b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex @@ -1,35 +1,59 @@ defmodule HelpcenterWeb.TeamInvitationAcceptanceController do use HelpcenterWeb, :controller + alias Helpcenter.Accounts.Invitation + + @doc """ + Accepts a team invitation using a tenant and token, updating the invitation status. + Redirects to the homepage with appropriate flash messages based on the outcome. + """ def accept(conn, %{"tenant" => tenant, "token" => token}) do + with {:ok, invitation} <- fetch_invitation(tenant, token), + {:ok, _updated_invitation} <- accept_invitation(invitation, tenant) do + conn + |> put_flash(:info, gettext("Invitation accepted")) + |> redirect(to: ~p"/") + else + {:error, error} -> + conn + |> put_flash(:error, format_error_message(error)) + |> redirect(to: ~p"/") + end + end + + defp fetch_invitation(tenant, token) do require Ash.Query options = [tenant: tenant, authorize?: false] - Helpcenter.Accounts.Invitation + Invitation |> Ash.Query.filter(token == ^token) |> Ash.read_first!(options) + |> case do + nil -> {:error, :invitation_not_found} + invitation -> {:ok, invitation} + end + end + + defp accept_invitation(invitation, tenant) do + options = [tenant: tenant, authorize?: false] + + invitation |> Ash.Changeset.for_update(:accept, %{}, options) |> Ash.update() - |> case do - {:ok, _inv} -> - conn - |> put_flash(:info, gettext("Invitation accepted")) - |> redirect(to: ~p"/") + end - {:error, %Ash.Error.Invalid{errors: errors}} -> - error_message = - Enum.map(errors, & &1.message) - |> Enum.join(", ") + defp format_error_message(:invitation_not_found) do + gettext("Invitation not found") + end - conn - |> put_flash(:error, error_message) - |> redirect(to: ~p"/") + defp format_error_message(%Ash.Error.Invalid{errors: errors}) do + errors + |> Enum.map(& &1.message) + |> Enum.join(", ") + end - {:error, _other} -> - conn - |> put_flash(:error, gettext("Unable to accept invitation")) - |> redirect(to: ~p"/") - end + defp format_error_message(_other) do + gettext("Unable to accept invitation") end end From 6486de4224e5922818124d2003e337e9b0ffa653 Mon Sep 17 00:00:00 2001 From: Kamaro Date: Mon, 25 Aug 2025 16:38:33 +0300 Subject: [PATCH 10/22] Extracted invitiation form in its own module --- lib/helpcenter/accounts.ex | 1 - lib/helpcenter/accounts/group.ex | 8 +- lib/helpcenter/accounts/invitation.ex | 54 ++++---- .../invitation/changes/add_user_to_team.ex | 98 +++++++++----- .../invitation/changes/send_declined_email.ex | 69 ++++++---- .../changes/send_invitation_email.ex | 58 ++++++-- .../invitation/changes/send_welcome_email.ex | 71 ++++++++-- .../changes/set_invitation_attributes.ex | 1 + ...ing_status.ex => ensure_pending_status.ex} | 8 +- lib/helpcenter/accounts/invitation_test.exs | 25 +++- lib/helpcenter/workers/email_sender.ex | 5 +- .../components/layouts/app.html.heex | 2 +- .../accounts/users/user_invitations_live.ex | 23 +++- .../invite_new_user_form.ex | 124 ++++++++++++++++++ .../invite_new_user_form_test.exs | 57 ++++++++ .../live/accounts/users/users_live.ex | 70 +--------- mix.exs | 2 + 17 files changed, 486 insertions(+), 190 deletions(-) rename lib/helpcenter/accounts/invitation/validations/{validate_pending_status.ex => ensure_pending_status.ex} (75%) create mode 100644 lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form.ex create mode 100644 lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form_test.exs diff --git a/lib/helpcenter/accounts.ex b/lib/helpcenter/accounts.ex index 9a8b664..92eaf24 100644 --- a/lib/helpcenter/accounts.ex +++ b/lib/helpcenter/accounts.ex @@ -3,7 +3,6 @@ defmodule Helpcenter.Accounts do use Ash.Domain, otp_app: :helpcenter resources do - # the rest of the domain resources resource Helpcenter.Accounts.Team resource Helpcenter.Accounts.User resource Helpcenter.Accounts.Group diff --git a/lib/helpcenter/accounts/group.ex b/lib/helpcenter/accounts/group.ex index ab46ab7..a1e7301 100644 --- a/lib/helpcenter/accounts/group.ex +++ b/lib/helpcenter/accounts/group.ex @@ -10,15 +10,15 @@ defmodule Helpcenter.Accounts.Group do repo Helpcenter.Repo end + code_interface do + define :list_groups, action: :read + end + actions do default_accept [:name, :description] defaults [:create, :read, :update, :destroy] end - code_interface do - define :list_groups, action: :read - end - pub_sub do module HelpcenterWeb.Endpoint diff --git a/lib/helpcenter/accounts/invitation.ex b/lib/helpcenter/accounts/invitation.ex index 87c5827..a111a9b 100644 --- a/lib/helpcenter/accounts/invitation.ex +++ b/lib/helpcenter/accounts/invitation.ex @@ -10,29 +10,6 @@ defmodule Helpcenter.Accounts.Invitation do repo Helpcenter.Repo end - preparations do - prepare Helpcenter.Preparations.SetTenant - prepare Helpcenter.Accounts.Invitation.Preparations.ForCurrentTeam - end - - changes do - change Helpcenter.Changes.SetTenant - end - - multitenancy do - strategy :context - global? true - end - - # Confirm how Ash will wor - pub_sub do - module HelpcenterWeb.Endpoint - prefix "invitations" - publish_all :update, [[:id, :team, nil]] - publish_all :create, [[:id, :team, nil]] - publish_all :destroy, [[:id, :team, nil]] - end - code_interface do define :accept, action: :accept define :get_by_token, args: [:token], action: :by_token @@ -70,10 +47,12 @@ defmodule Helpcenter.Accounts.Invitation do """ accept [] + + validate Helpcenter.Accounts.Invitation.Validations.EnsurePendingStatus + change atomic_update(:status, :accepted) change Helpcenter.Accounts.Invitation.Changes.AddUserToTeam change Helpcenter.Accounts.Invitation.Changes.SendWelcomeEmail - validate Helpcenter.Accounts.Invitation.Validations.ValidatePendingStatus end update :decline do @@ -84,12 +63,37 @@ defmodule Helpcenter.Accounts.Invitation do """ accept [] + + validate Helpcenter.Accounts.Invitation.Validations.EnsurePendingStatus + change set_attribute(:status, :declined) change Helpcenter.Accounts.Invitation.Changes.SendDeclinedEmail - validate Helpcenter.Accounts.Invitation.Validations.ValidatePendingStatus end end + # Confirm how Ash will wor + pub_sub do + module HelpcenterWeb.Endpoint + prefix "invitations" + publish_all :update, [[:id, :team, nil]] + publish_all :create, [[:id, :team, nil]] + publish_all :destroy, [[:id, :team, nil]] + end + + preparations do + prepare Helpcenter.Preparations.SetTenant + prepare Helpcenter.Accounts.Invitation.Preparations.ForCurrentTeam + end + + changes do + change Helpcenter.Changes.SetTenant + end + + multitenancy do + strategy :context + global? true + end + attributes do uuid_primary_key :id diff --git a/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex b/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex index 82c5ece..af563be 100644 --- a/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex +++ b/lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex @@ -1,10 +1,23 @@ +# lib/helpcenter/accounts/invitation/changes/add_user_to_team.ex defmodule Helpcenter.Accounts.Invitation.Changes.AddUserToTeam do + @moduledoc """ + An Ash Resource Change that adds a user to a team and permission group. + + This module handles the process of linking a user to a team, setting their + current team, and adding them to a permission group after accepting an invitation. + It uses Ash's seeding and querying capabilities for reliable data operations. + """ + use Ash.Resource.Change require Ash.Query + @doc """ + Registers an after_action callback to link the user to the team after + the changeset is processed. + """ @impl true def change(changeset, _opts, _context) do - Ash.Changeset.after_action(changeset, &link_user_team/2) + Ash.Changeset.after_action(changeset, &link_user_to_team/2) end @impl true @@ -12,63 +25,84 @@ defmodule Helpcenter.Accounts.Invitation.Changes.AddUserToTeam do {:ok, change(changeset, opts, context)} end - defp link_user_team(_changeset, invite) do - tenant = invite.team - # 1. get user - user = get_or_create_user(invite) + @doc """ + Links a user to a team and permission group based on the invitation. + + Retrieves or creates the user, associates them with the team, sets the + current team, and adds them to the specified permission group. - # 2. Link user to the team - _user_team = add_user_to_team(user.id, tenant) - _user_current_team = set_user_current_team(user, tenant) + ## Parameters + - _changeset: The Ash changeset (unused, kept for hook compatibility) + - invitation: The invitation struct containing email, team, and group_id - # 3. Add user to the permission group - _user_group = add_user_to_group(user.id, invite.group_id, tenant) - {:ok, invite} + ## Returns + - {:ok, invitation} on successful linking + - {:error, reason} if any operation fails + """ + def link_user_to_team(_changeset, invitation) do + with {:ok, user} <- get_or_create_user(invitation), + {:ok, _user_team} <- add_user_to_team(user.id, invitation.team), + {:ok, _user_updated} <- set_user_current_team(user, invitation.team), + {:ok, _user_group} <- add_user_to_group(user.id, invitation.group_id, invitation.team) do + {:ok, invitation} + else + {:error, reason} -> + {:error, "Failed to link user to team: #{inspect(reason)}"} + end end - defp get_team(team) do + defp get_team(team_name) do Helpcenter.Accounts.Team - |> Ash.Query.filter(domain == ^team) - |> Ash.read_first!(authorize?: false) + |> Ash.Query.filter(domain == ^team_name) + |> Ash.read_first(authorize?: false) + |> case do + {:ok, team} -> {:ok, team} + {:error, reason} -> {:error, reason} + end end - defp add_user_to_team(user_id, tenant) do - team = get_team(tenant) - - Ash.Seed.seed!( - Helpcenter.Accounts.UserTeam, - %{user_id: user_id, team_id: team.id}, - tenant: tenant - ) + defp add_user_to_team(user_id, team_name) do + with {:ok, team} <- get_team(team_name) do + Ash.Seed.seed!( + Helpcenter.Accounts.UserTeam, + %{user_id: user_id, team_id: team.id}, + tenant: team_name + ) + |> then(&{:ok, &1}) + end end - defp set_user_current_team(user, tenant) do - Ash.Seed.update!(user, %{current_team: tenant}, tenant: tenant) + defp set_user_current_team(user, team_name) do + Ash.Seed.update!(user, %{current_team: team_name}, tenant: team_name) + |> then(&{:ok, &1}) end - defp add_user_to_group(user_id, group_id, tenant) do + defp add_user_to_group(user_id, group_id, team_name) do Ash.Seed.seed!( Helpcenter.Accounts.UserGroup, %{user_id: user_id, group_id: group_id}, - tenant: tenant + tenant: team_name ) + |> then(&{:ok, &1}) end - defp get_or_create_user(%{email: email} = invitation) do + defp get_or_create_user(%{email: email, team: team_name}) do Helpcenter.Accounts.User |> Ash.Query.filter(email == ^email) |> Ash.read_first(authorize?: false) |> case do - {:ok, nil} -> create_user(email, invitation.team) - {:ok, user} -> user + {:ok, nil} -> create_user(email, team_name) + {:ok, user} -> {:ok, user} + {:error, reason} -> {:error, reason} end end - defp create_user(email, tenant) do + defp create_user(email, team_name) do Ash.Seed.seed!( Helpcenter.Accounts.User, - %{email: email, current_team: tenant}, - tenant: tenant + %{email: email, current_team: team_name}, + tenant: team_name ) + |> then(&{:ok, &1}) end end diff --git a/lib/helpcenter/accounts/invitation/changes/send_declined_email.ex b/lib/helpcenter/accounts/invitation/changes/send_declined_email.ex index dc550f0..9352b30 100644 --- a/lib/helpcenter/accounts/invitation/changes/send_declined_email.ex +++ b/lib/helpcenter/accounts/invitation/changes/send_declined_email.ex @@ -1,44 +1,67 @@ +# lib/helpcenter/accounts/invitation/changes/send_declined_email.ex defmodule Helpcenter.Accounts.Invitation.Changes.SendDeclinedEmail do + @moduledoc """ + An Ash Resource Change that sends a declination email after an invitation is declined. + + This module handles the process of sending an email notification to the user + who declined a team invitation. It integrates with Oban for async email delivery. + """ + use Ash.Resource.Change use HelpcenterWeb, :verified_routes - import Swoosh.Email - alias Helpcenter.Mailer - + @doc """ + Implements the change hook for Ash, registering an after_action callback + to send the declination email. + """ @impl true def change(changeset, _opts, _context) do - Ash.Changeset.after_action(changeset, &send_invitation_email/2) + Ash.Changeset.after_action(changeset, &send_email/2) end - @impl true - def atomic?, do: true - @impl true def atomic(changeset, opts, context) do {:ok, change(changeset, opts, context)} end - defp send_invitation_email(_changeset, invitation) do - send(invitation.email, invitation.token) - {:ok, invitation} + defp send_email(_changeset, invitation) do + with {:ok, email_data} <- build_email_data(invitation), + {:ok, _job} <- schedule_email(email_data) do + {:ok, invitation} + else + {:error, reason} -> + {:error, "Failed to send declination email: #{inspect(reason)}"} + end end - def send(email, token, _) do - new() - # TODO: Replace with your email - |> from({"noreply", "noreply@example.com"}) - |> to(to_string(email)) - |> subject("Your login link") - |> html_body(body(token: token, email: email)) - |> Mailer.deliver!() - end + defp build_email_data(%{email: email, token: token, team: team}) do + message = build_email_body(team) + + email_data = %{ + id: token, + params: %{ + html_message: message, + text_message: message, + subject: "You declined an invitation to join #{team}", + from: nil, + to: email + } + } - defp body(params) do - url = url(~p"/auth/user/magic_link/?token=#{params[:token]}") + {:ok, email_data} + end + defp build_email_body(team) do """ -

Hello, #{params[:email]}! Click this link to sign in:

-

#{url}

+

Hello,

+

You have declined the invitation to join the #{team} team.

+

If this was a mistake, please contact the team administrator.

""" end + + defp schedule_email(email_data) do + email_data + |> Helpcenter.Workers.EmailSender.new() + |> Oban.insert() + end end diff --git a/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex b/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex index 36893ca..ac9bf47 100644 --- a/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex +++ b/lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex @@ -1,43 +1,75 @@ +# lib/helpcenter/accounts/invitation/changes/send_invitation_email.ex defmodule Helpcenter.Accounts.Invitation.Changes.SendInvitationEmail do + @moduledoc """ + An Ash Resource Change that sends an invitation email to a user. + + This module handles sending an email to invite a user to join a team, + including a link to accept the invitation. It uses Oban for asynchronous + email delivery. + """ + use Ash.Resource.Change use HelpcenterWeb, :verified_routes + @doc """ + Registers an after_action callback to send the invitation email after + the changeset is processed. + """ @impl true def change(changeset, _opts, _context) do - Ash.Changeset.after_action(changeset, &send_invitation_email/2) + Ash.Changeset.after_action(changeset, &send_email/2) end + @doc """ + Implements the atomic change hook for Ash to ensure atomic operations. + """ @impl true def atomic(changeset, opts, context) do {:ok, change(changeset, opts, context)} end - defp send_invitation_email(_changeset, invitation) do - %{email: email, token: token, team: team} = invitation - message = body(token: token, email: email, tenant: team) + defp send_email(_changeset, invitation) do + with {:ok, email_data} <- build_email_data(invitation), + {:ok, _job} <- schedule_email(email_data) do + {:ok, invitation} + else + {:error, reason} -> + {:error, "Failed to send invitation email: #{inspect(reason)}"} + end + end - %{ + defp build_email_data(%{email: email, token: token, team: team}) do + message = build_email_body(email, token, team) + + email_data = %{ id: token, params: %{ html_message: message, text_message: message, - subject: "You are invited", + subject: "You're Invited to Join #{team}", from: nil, to: email } } - |> Helpcenter.Workers.EmailSender.new() - |> Oban.insert() - {:ok, invitation} + {:ok, email_data} end - defp body(params) do - url = url(~p"/accounts/users/invitations/#{params[:tenant]}/#{params[:token]}/accept") + defp build_email_body(email, token, team) do + accept_url = url(~p"/accounts/users/invitations/#{team}/#{token}/accept") """ -

Hello, #{params[:email]}! Click this link to accept the invitation to #{params[:tenant]}.

-

#{url}

+

Hello, #{email}!

+

You've been invited to join the #{team} team.

+

Please click the link below to accept the invitation:

+

Accept Invitation

+

If you believe this invitation was sent in error, please contact the team administrator.

""" end + + defp schedule_email(email_data) do + email_data + |> Helpcenter.Workers.EmailSender.new() + |> Oban.insert() + end end diff --git a/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex b/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex index 27fc3e1..5b1feb9 100644 --- a/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex +++ b/lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex @@ -1,43 +1,88 @@ +# lib/helpcenter/accounts/invitation/changes/send_welcome_email.ex defmodule Helpcenter.Accounts.Invitation.Changes.SendWelcomeEmail do + @moduledoc """ + An Ash Resource Change that sends a welcome email to a user. + + This module handles sending a welcome email with a magic link for signing in + after a user accepts an invitation. It uses Oban for asynchronous email delivery. + """ + use Ash.Resource.Change use HelpcenterWeb, :verified_routes + @doc """ + Registers an after_action callback to send the welcome email after + the changeset is processed. + """ @impl true def change(changeset, _opts, _context) do - Ash.Changeset.after_action(changeset, &send_invitation_email/2) + Ash.Changeset.after_action(changeset, &send_email/2) end + @doc """ + Implements the atomic change hook for Ash to ensure atomic operations. + """ @impl true def atomic(changeset, opts, context) do {:ok, change(changeset, opts, context)} end - defp send_invitation_email(_changeset, invitation) do - %{email: email, token: token, team: team} = invitation - message = body(token: token, email: email) + @doc """ + Sends a welcome email with a magic link for signing in. + + Builds the email content with a sign-in link and schedules it via the + EmailSender worker. + + ## Parameters + - _changeset: The Ash changeset (unused, kept for hook compatibility) + - invitation: The invitation struct containing email, token, and team + + ## Returns + - {:ok, invitation} on successful email scheduling + - {:error, reason} if email scheduling fails + """ + def send_email(_changeset, invitation) do + with {:ok, email_data} <- build_email_data(invitation), + {:ok, _job} <- schedule_email(email_data) do + {:ok, invitation} + else + {:error, reason} -> + {:error, "Failed to send welcome email: #{inspect(reason)}"} + end + end + + defp build_email_data(%{email: email, token: token, team: team}) do + message = build_email_body(email, token, team) - %{ + email_data = %{ id: token, params: %{ html_message: message, text_message: message, - subject: "Welcome to #{team}", + subject: "Welcome to #{team}!", from: nil, to: email } } - |> Helpcenter.Workers.EmailSender.new() - |> Oban.insert() - {:ok, invitation} + {:ok, email_data} end - defp body(params) do - url = url(~p"/auth/user/magic_link/?token=#{params[:token]}") + defp build_email_body(email, token, team) do + sign_in_url = url(~p"/auth/user/magic_link/?token=#{token}") """ -

Hello, #{params[:email]}! Click this link to sign in:

-

#{url}

+

Hello, #{email}!

+

Welcome to the #{team} team!

+

Please click the link below to sign in and get started:

+

Sign In

+

If you have any issues, please contact the team administrator.

""" end + + defp schedule_email(email_data) do + email_data + |> Helpcenter.Workers.EmailSender.new() + |> Oban.insert() + end end diff --git a/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex b/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex index ff12729..7caa7ce 100644 --- a/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex +++ b/lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex @@ -1,3 +1,4 @@ +# lib/helpcenter/accounts/invitation/changes/set_invitation_attributes.ex defmodule Helpcenter.Accounts.Invitation.Changes.SetInvitationAttributes do use Ash.Resource.Change diff --git a/lib/helpcenter/accounts/invitation/validations/validate_pending_status.ex b/lib/helpcenter/accounts/invitation/validations/ensure_pending_status.ex similarity index 75% rename from lib/helpcenter/accounts/invitation/validations/validate_pending_status.ex rename to lib/helpcenter/accounts/invitation/validations/ensure_pending_status.ex index 5122ace..870029f 100644 --- a/lib/helpcenter/accounts/invitation/validations/validate_pending_status.ex +++ b/lib/helpcenter/accounts/invitation/validations/ensure_pending_status.ex @@ -1,4 +1,5 @@ -defmodule Helpcenter.Accounts.Invitation.Validations.ValidatePendingStatus do +# lib/helpcenter/accounts/invitation/validations/ensure_pending_status.ex +defmodule Helpcenter.Accounts.Invitation.Validations.EnsurePendingStatus do use Ash.Resource.Validation def validate(changeset, _opts, context) do @@ -10,7 +11,8 @@ defmodule Helpcenter.Accounts.Invitation.Validations.ValidatePendingStatus do @doc """ Atomic function's return must be the same as the main behaviour callback function. that's why we are not returning {:ok, validate(changeset, opts, context)} - like in a change {:ok, change(changeset, opts, context)} + like in a change {:ok, change(changeset, opts, context)} instead we + retruning :ok or {:error, ...} directly as per validate definition """ def atomic(changeset, opts, context) do validate(changeset, opts, context) @@ -37,6 +39,6 @@ defmodule Helpcenter.Accounts.Invitation.Validations.ValidatePendingStatus do end defp validate_pending_status(_others) do - {:error, field: :token, message: "Invalid invitation."} + {:error, field: :token, message: "This invitation token is invalid or has expired."} end end diff --git a/lib/helpcenter/accounts/invitation_test.exs b/lib/helpcenter/accounts/invitation_test.exs index 09024bb..85b985b 100644 --- a/lib/helpcenter/accounts/invitation_test.exs +++ b/lib/helpcenter/accounts/invitation_test.exs @@ -1,5 +1,5 @@ defmodule Helpcenter.Accounts.InvitationTest do - use HelpcenterWeb.ConnCase, async: false + use HelpcenterWeb.ConnCase require Ash.Query defp create_group!(actor) do @@ -48,5 +48,28 @@ defmodule Helpcenter.Accounts.InvitationTest do assert error.field == :token assert error.message == "This invitation has already been accepted." end + + test "Invitation can be decline an invitation" do + actor = create_user() + group = create_group!(actor) + invite_attributes = %{email: "john@example.com", group_id: group.id} + + invitation = + Helpcenter.Accounts.Invitation + |> Ash.Changeset.for_create(:create, invite_attributes, actor: actor) + |> Ash.create!() + + assert invitation.status == :pending + assert invitation.email == invite_attributes.email + assert invitation.group_id == group.id + + # Decline invitation + declined_invitation = + invitation + |> Ash.Changeset.for_update(:decline, %{}, actor: actor) + |> Ash.update!() + + assert declined_invitation.status == :declined + end end end diff --git a/lib/helpcenter/workers/email_sender.ex b/lib/helpcenter/workers/email_sender.ex index f4b0962..287e159 100644 --- a/lib/helpcenter/workers/email_sender.ex +++ b/lib/helpcenter/workers/email_sender.ex @@ -1,5 +1,6 @@ +# lib/helpcenter/workers/email_sender.ex defmodule Helpcenter.Workers.EmailSender do - use Oban.Worker, queue: :mailers + use Oban.Worker, queue: :default import Swoosh.Email @impl Oban.Worker @@ -7,7 +8,7 @@ defmodule Helpcenter.Workers.EmailSender do params = job.args["params"] new() - |> from({"Zippiker", "no-reply@zippiker.com"}) + |> from({"Helpcenter", "no-reply@helpcenter.com"}) |> to(params["to"] |> to_string()) |> subject(params["subject"]) |> html_body(params["body"]) diff --git a/lib/helpcenter_web/components/layouts/app.html.heex b/lib/helpcenter_web/components/layouts/app.html.heex index 559d5df..f621fe1 100644 --- a/lib/helpcenter_web/components/layouts/app.html.heex +++ b/lib/helpcenter_web/components/layouts/app.html.heex @@ -28,7 +28,7 @@ <.icon name="hero-users" class="h-4 w-4" /> {gettext("Users")} - <.link + <.link id="user-invitations" navigate={~p"/accounts/users/invitations"} class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700 hover:underline" diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex index 8b2f702..92fd38c 100644 --- a/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex +++ b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex @@ -4,10 +4,21 @@ defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive do @impl true def render(assigns) do ~H""" - - <:col label="Email" :let={row} field="email" filter sort>{Phoenix.Naming.humanize(row.email)} - <:col label="Status" :let={row} field="status" filter sort>{Phoenix.Naming.humanize(row.status)} - <:col label="Team" :let={row}>{Phoenix.Naming.humanize(row.team)} + + + + <:col :let={row} label="Email" field="email" filter sort> + {Phoenix.Naming.humanize(row.email)} + + <:col :let={row} label="Status" field="status" filter sort> + {Phoenix.Naming.humanize(row.status)} + + <:col :let={row} label="Team">{Phoenix.Naming.humanize(row.team)} <:col :let={row}> <.button phx-click={"accept-invite-#{row.token}"}>{gettext("Accept")} @@ -17,9 +28,7 @@ defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive do @impl true def mount(_params, _sessions, socket) do - if connected?(socket) do - HelpcenterWeb.Endpoint.subscribe("invitations") - end + if connected?(socket), do: HelpcenterWeb.Endpoint.subscribe("invitations") socket |> assign(:page_title, gettext("User Invitations")) diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form.ex b/lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form.ex new file mode 100644 index 0000000..b208150 --- /dev/null +++ b/lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form.ex @@ -0,0 +1,124 @@ +# lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form.ex +defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive.InviteNewUserForm do + @moduledoc """ + LiveComponent that renders a form to invite a new user to the current user's team. + The form includes fields for the user's email and the access group they should be assigned to. + When the form is submitted, an invitation is created in the system, and an invitation email + is sent to the provided email address. + + This component is designed to be used within a modal dialog, which can be triggered by a button + elsewhere in the application. The modal button is this compoent + """ + use HelpcenterWeb, :live_component + + @doc """ + This a wrapper used to access this component like a static component + in the template. + """ + attr :actor, Helpcenter.Accounts.User, required: true + + def form(assigns) do + ~H""" + <.live_component id={Ash.UUIDv7.generate()} actor={@actor} module={__MODULE__} /> + """ + end + + attr :id, :string, default: Ash.UUIDv7.generate() + attr :actor, Helpcenter.Accounts.User, required: true + + def render(assigns) do + ~H""" +
+ <.button phx-click={show_modal("invite-user-modal")} id="invite-user-modal-button"> + {gettext("Invite a user")} + + <.modal id="invite-user-modal"> +
+

+ {gettext("Invite a new member to your team")} +

+

+ {gettext( + "Invite a new user to your team by entering their email address below. They will receive an invitation email with instructions on how to join your team." + )} +

+
+ + <.simple_form for={@form} phx-change="validate" phx-submit="save" phx-target={@myself}> + <.input field={@form[:email]} type="email" label="New user Email" /> + <.input + field={@form[:group_id]} + type="select" + options={@access_groups} + label={gettext("Select Access Group")} + /> +
+ <.button> + {gettext("Invite User")} <.icon name="hero-paper-airplane-solid" class="ml-2 h-5 w-5" /> + +
+ + +
+ """ + end + + @impl true + def update(assigns, socket) do + socket + |> assign(assigns) + |> assign_access_groups() + |> assign_form() + |> ok() + end + + @impl true + def handle_event("validate", %{"form" => params}, socket) do + form = AshPhoenix.Form.validate(socket.assigns.form, params) + + socket + |> assign(:form, form) + |> noreply() + end + + @impl true + def handle_event("save", %{"form" => params}, socket) do + case AshPhoenix.Form.submit(socket.assigns.form, params: params) do + {:ok, _invitation} -> + socket + |> put_flash(:info, "Invitation sent successfully") + |> push_event("js-exec", %{to: "#invite-user-modal", attr: "data-cancel"}) + |> redirect(to: ~p"/accounts/users/invitations") + |> noreply() + + {:error, form} -> + socket + |> assign(:form, form) + |> noreply() + end + end + + defp assign_form(socket) do + assign(socket, :form, get_form(socket.assigns)) + end + + # Prevents the form from being re-created on every update + defp get_form(%{form: form}) when is_map(form), do: form + + defp get_form(%{actor: actor}) do + Helpcenter.Accounts.Invitation + |> AshPhoenix.Form.for_create(:create, actor: actor) + |> to_form() + end + + defp assign_access_groups(socket) do + %{actor: actor} = socket.assigns + + groups = + Helpcenter.Accounts.Group + |> Ash.read!(tenant: actor.current_team, authorize?: false) + |> Enum.map(&{&1.name, &1.id}) + + assign(socket, :access_groups, groups) + end +end diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form_test.exs b/lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form_test.exs new file mode 100644 index 0000000..e48070e --- /dev/null +++ b/lib/helpcenter_web/live/accounts/users/user_invitations_live/invite_new_user_form_test.exs @@ -0,0 +1,57 @@ +defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive.InviteNewUserFormTest do + use HelpcenterWeb.ConnCase + alias HelpcenterWeb.Accounts.Users.UserInvitationsLive.InviteNewUserForm + + defp create_group!(actor) do + group_attrs = %{name: "Accountants", description: "Can manage billing in the system"} + + Ash.create!( + Helpcenter.Accounts.Group, + group_attrs, + actor: actor, + authorize?: false + ) + end + + describe "Invite New User Form" do + test "Invite user form renders successfully" do + assigns = %{id: Ash.UUIDv7.generate(), actor: create_user()} + html = render_component(InviteNewUserForm, assigns) + + assert html =~ "email" + assert html =~ "group_id" + assert html =~ "Invite User" + assert html =~ "Invite a new member to your team" + end + + test "New user can be invited successfully", %{conn: conn} do + user = create_user() + group = create_group!(user) + + {:ok, view, html} = + conn + |> login(user) + |> live("/accounts/users/invitations") + + # Confirm invite user button is present + assert html =~ "Invite a user" + assert has_element?(view, "#invite-user-modal-button") + + # Confirm submitting the form works + params = %{email: "tester@example.com", group_id: group.id} + + view + |> form("#invite-user-modal form", form: params) + |> render_submit() + + # Confirm that the invitation was created in the database + require Ash.Query + + assert Helpcenter.Accounts.Invitation + |> Ash.Query.filter(email == ^params.email) + |> Ash.Query.filter(group_id == ^group.id) + |> Ash.Query.filter(status == :pending) + |> Ash.exists?(actor: user, authorize?: false) + end + end +end diff --git a/lib/helpcenter_web/live/accounts/users/users_live.ex b/lib/helpcenter_web/live/accounts/users/users_live.ex index 8657734..54e8474 100644 --- a/lib/helpcenter_web/live/accounts/users/users_live.ex +++ b/lib/helpcenter_web/live/accounts/users/users_live.ex @@ -1,28 +1,17 @@ +# lib/helpcenter_web/live/accounts/users/users_live.ex defmodule HelpcenterWeb.Accounts.Users.UsersLive do use HelpcenterWeb, :live_view @impl true def render(assigns) do ~H""" - <.button phx-click={show_modal("invite-user-modal")}> - {gettext("Invite a user")} - - + + + <%!-- User list --%> + <:col :let={row} label="Email" field="email" filter sort>{row.email} <:col :let={row} label="Team">{Phoenix.Naming.humanize(row.current_team)} - - <.modal id="invite-user-modal"> -

{gettext("Invite a new user to ")} {Phoenix.Naming.humanize(@current_user.current_team)}

- - <.simple_form for={@form} phx-change="validate" phx-submit="save"> - <.input field={@form[:email]} type="email" label={"New user Email"}/> - <.input field={@form[:group_id]} type="select" options={@access_groups} label={gettext("Select Access Group")} /> - <.button> - {gettext("Submit")} - - - """ end @@ -30,58 +19,9 @@ defmodule HelpcenterWeb.Accounts.Users.UsersLive do def mount(_params, _session, socket) do socket |> assign(:page_title, "Users") - |> assign_access_groups() - |> assign_form() |> ok() end - @impl true - def handle_event("validate", %{"form" => params}, socket) do - form = AshPhoenix.Form.validate(socket.assigns.form, params) - - socket - |> assign(:form, form) - |> noreply() - end - - @impl true - def handle_event("save", %{"form" => params}, socket) do - case AshPhoenix.Form.submit(socket.assigns.form, params: params) do - {:ok, _invitation} -> - socket - |> put_flash(:info, "Invitation sent successfully") - |> push_event("js-exec", %{to: "#invite-user-modal", attr: "data-cancel"}) - |> redirect(to: ~p"/accounts/users/invitations") - |> noreply() - - {:error, form} -> - socket - |> assign(:form, form) - |> noreply() - end - end - - defp assign_access_groups(socket) do - groups = - Helpcenter.Accounts.Group.list_groups!( - tenant: socket.assigns.current_user.current_team, - authorize?: false - ) - |> Enum.map(&{&1.name, &1.id}) - - assign(socket, :access_groups, groups) - end - - defp assign_form(socket) do - assign(socket, :form, get_form(socket.assigns)) - end - - defp get_form(%{current_user: user}) do - Helpcenter.Accounts.Invitation - |> AshPhoenix.Form.for_create(:create, actor: user) - |> to_form() - end - defp get_query do require Ash.Query diff --git a/mix.exs b/mix.exs index 9f3ccb6..64832b2 100644 --- a/mix.exs +++ b/mix.exs @@ -1,3 +1,4 @@ +# mix.exs defmodule Helpcenter.MixProject do use Mix.Project @@ -9,6 +10,7 @@ defmodule Helpcenter.MixProject do elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, consolidate_protocols: Mix.env() != :dev, + # Include "lib" for integration tests test_paths: ["test", "lib"], aliases: aliases(), deps: deps() From e4aa410388a936a5c730a5e9b2f9e5681725ee9f Mon Sep 17 00:00:00 2001 From: Kamaro Date: Mon, 25 Aug 2025 17:03:40 +0300 Subject: [PATCH 11/22] Added action to accept or reject invitation --- .../team_invitation_acceptance_controller.ex | 23 +++++++++++ .../accounts/users/user_invitations_live.ex | 40 +------------------ .../live/accounts/users/users_live.ex | 11 ++--- .../live/accounts/users/users_live_test.exs | 1 + lib/helpcenter_web/router.ex | 4 ++ 5 files changed, 34 insertions(+), 45 deletions(-) diff --git a/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex index d5b632f..f284a3a 100644 --- a/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex +++ b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex @@ -1,3 +1,4 @@ +# lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex defmodule HelpcenterWeb.TeamInvitationAcceptanceController do use HelpcenterWeb, :controller @@ -21,6 +22,20 @@ defmodule HelpcenterWeb.TeamInvitationAcceptanceController do end end + def reject(conn, %{"tenant" => tenant, "token" => token}) do + with {:ok, invitation} <- fetch_invitation(tenant, token), + {:ok, _updated_invitation} <- reject_invitation(invitation, tenant) do + conn + |> put_flash(:info, gettext("Invitation rejected")) + |> redirect(to: ~p"/") + else + {:error, error} -> + conn + |> put_flash(:error, format_error_message(error)) + |> redirect(to: ~p"/") + end + end + defp fetch_invitation(tenant, token) do require Ash.Query @@ -43,6 +58,14 @@ defmodule HelpcenterWeb.TeamInvitationAcceptanceController do |> Ash.update() end + defp reject_invitation(invitation, tenant) do + options = [tenant: tenant, authorize?: false] + + invitation + |> Ash.Changeset.for_update(:accept, %{}, options) + |> Ash.update() + end + defp format_error_message(:invitation_not_found) do gettext("Invitation not found") end diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex index 92fd38c..18b21d1 100644 --- a/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex +++ b/lib/helpcenter_web/live/accounts/users/user_invitations_live.ex @@ -1,3 +1,4 @@ +# lib/helpcenter_web/live/accounts/users/user_invitations_live.ex defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive do use HelpcenterWeb, :live_view @@ -7,7 +8,7 @@ defmodule HelpcenterWeb.Accounts.Users.UserInvitationsLive do <:col :let={row} label="Team">{Phoenix.Naming.humanize(row.team)} - <:col :let={row}> - <.button phx-click={"accept-invite-#{row.token}"}>{gettext("Accept")} - """ end @impl true def mount(_params, _sessions, socket) do - if connected?(socket), do: HelpcenterWeb.Endpoint.subscribe("invitations") - socket |> assign(:page_title, gettext("User Invitations")) |> ok() end - - @impl true - def handle_event("accept-invite-" <> token, _params, socket) do - %{current_user: actor} = socket.assigns - {:ok, invitation} = Helpcenter.Accounts.Invitation.get_by_token(token, actor: actor) - - case Helpcenter.Accounts.Invitation.accept(invitation, actor: actor) do - {:ok, _invitation} -> - socket - |> put_flash(:info, "Invitation accepted") - |> noreply() - - {:error, error} -> - dbg(error) - - socket - |> put_flash(:error, "Unable to accept invitation") - |> noreply() - end - end - - @impl true - def handle_info(_event, socket) do - socket - |> Cinder.Table.Refresh.refresh_table("user-invitations-table") - |> noreply() - end - - defp get_query() do - require Ash.Query - Ash.Query.filter(Helpcenter.Accounts.Invitation, status == :pending) - end end diff --git a/lib/helpcenter_web/live/accounts/users/users_live.ex b/lib/helpcenter_web/live/accounts/users/users_live.ex index 54e8474..581ec85 100644 --- a/lib/helpcenter_web/live/accounts/users/users_live.ex +++ b/lib/helpcenter_web/live/accounts/users/users_live.ex @@ -8,7 +8,7 @@ defmodule HelpcenterWeb.Accounts.Users.UsersLive do <%!-- User list --%> - + <:col :let={row} label="Email" field="email" filter sort>{row.email} <:col :let={row} label="Team">{Phoenix.Naming.humanize(row.current_team)} @@ -22,14 +22,11 @@ defmodule HelpcenterWeb.Accounts.Users.UsersLive do |> ok() end - defp get_query do + defp get_query(current_user) do require Ash.Query Helpcenter.Accounts.User - |> Ash.Query.for_read( - :read, - %{}, - authorize?: false - ) + |> Ash.Query.filter(teams.domain == ^current_user.current_team) + |> Ash.Query.for_read(:read, %{}, authorize?: false) end end diff --git a/lib/helpcenter_web/live/accounts/users/users_live_test.exs b/lib/helpcenter_web/live/accounts/users/users_live_test.exs index 535a748..28f6c52 100644 --- a/lib/helpcenter_web/live/accounts/users/users_live_test.exs +++ b/lib/helpcenter_web/live/accounts/users/users_live_test.exs @@ -1,3 +1,4 @@ +# lib/helpcenter_web/live/accounts/users/users_live_test.exs defmodule HelpcenterWeb.Accounts.Users.UsersLiveTest do use HelpcenterWeb.ConnCase diff --git a/lib/helpcenter_web/router.ex b/lib/helpcenter_web/router.ex index 1bbb4aa..11339d7 100644 --- a/lib/helpcenter_web/router.ex +++ b/lib/helpcenter_web/router.ex @@ -90,6 +90,10 @@ defmodule HelpcenterWeb.Router do get "/accounts/users/invitations/:tenant/:token/accept", TeamInvitationAcceptanceController, :accept + + get "/accounts/users/invitations/:tenant/:token/reject", + TeamInvitationAcceptanceController, + :reject end # Other scopes may use custom stacks. From 6c7aa0094d5de9e3ad9c29689c9fcc81d70085e0 Mon Sep 17 00:00:00 2001 From: Kamaro Date: Thu, 28 Aug 2025 18:28:16 +0300 Subject: [PATCH 12/22] Started to work on Ash extension tutorial --- lib/helpcenter/extensions/parental/readme.md | 20 +++++++++++++++++++ .../components/layouts/app.html.heex | 1 - .../team_invitation_acceptance_controller.ex | 17 ++++++++++------ ..._invitation_acceptance_controller_test.exs | 1 + ...sts.exs => user_invitations_live_test.exs} | 0 lib/helpcenter_web/router.ex | 2 ++ 6 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 lib/helpcenter/extensions/parental/readme.md rename lib/helpcenter_web/live/accounts/users/{user_invitations_live._tests.exs => user_invitations_live_test.exs} (100%) diff --git a/lib/helpcenter/extensions/parental/readme.md b/lib/helpcenter/extensions/parental/readme.md new file mode 100644 index 0000000..cf768de --- /dev/null +++ b/lib/helpcenter/extensions/parental/readme.md @@ -0,0 +1,20 @@ +# Ash Parental Resource + +Ash Parental is an extension that brings STI(Single Table Inheritance) capability to a resource + +## What is single table inheritance (STI)? + +It's a fancy name for a simple concept: Extending a resource (usually to add specific behavior), but referencing the same table. + +```ex +defmodule MyApp.Comment do + use Ash.Resource, extensions: [AshParental] + .... +end +``` + +The above should do the following: +1.Add `parent_id` attributes that is nullable +2.Add `parent` belongs to relationship +3.Add `children` has_many relationship +4.Add `children_count` aggregates diff --git a/lib/helpcenter_web/components/layouts/app.html.heex b/lib/helpcenter_web/components/layouts/app.html.heex index f621fe1..b9679c5 100644 --- a/lib/helpcenter_web/components/layouts/app.html.heex +++ b/lib/helpcenter_web/components/layouts/app.html.heex @@ -3,7 +3,6 @@

Helpcenter diff --git a/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex index f284a3a..6a64717 100644 --- a/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex +++ b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller.ex @@ -2,13 +2,14 @@ defmodule HelpcenterWeb.TeamInvitationAcceptanceController do use HelpcenterWeb, :controller - alias Helpcenter.Accounts.Invitation - @doc """ Accepts a team invitation using a tenant and token, updating the invitation status. Redirects to the homepage with appropriate flash messages based on the outcome. """ def accept(conn, %{"tenant" => tenant, "token" => token}) do + # 1. Check if the invitation exists and is valid + # 2. If valid, accept inviation(creates account behind the scenes) + # 3. Redirect to homepage with success message with {:ok, invitation} <- fetch_invitation(tenant, token), {:ok, _updated_invitation} <- accept_invitation(invitation, tenant) do conn @@ -23,6 +24,10 @@ defmodule HelpcenterWeb.TeamInvitationAcceptanceController do end def reject(conn, %{"tenant" => tenant, "token" => token}) do + # 1. Check if the invitation exists and is valid + # 2. If valid, reject inviation + # 3. Redirect to homepage with success message + with {:ok, invitation} <- fetch_invitation(tenant, token), {:ok, _updated_invitation} <- reject_invitation(invitation, tenant) do conn @@ -41,12 +46,12 @@ defmodule HelpcenterWeb.TeamInvitationAcceptanceController do options = [tenant: tenant, authorize?: false] - Invitation + Helpcenter.Accounts.Invitation |> Ash.Query.filter(token == ^token) - |> Ash.read_first!(options) + |> Ash.read_first(options) |> case do - nil -> {:error, :invitation_not_found} - invitation -> {:ok, invitation} + {:ok, nil} -> {:error, :invitation_not_found} + other -> other end end diff --git a/lib/helpcenter_web/controllers/team_invitation_acceptance_controller_test.exs b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller_test.exs index cf16439..ea751e5 100644 --- a/lib/helpcenter_web/controllers/team_invitation_acceptance_controller_test.exs +++ b/lib/helpcenter_web/controllers/team_invitation_acceptance_controller_test.exs @@ -1,3 +1,4 @@ +# lib/helpcenter_web/controllers/team_invitation_acceptance_controller_test.exs defmodule HelpcenterWeb.TeamInvitationAcceptanceControllerTEst do use HelpcenterWeb.ConnCase diff --git a/lib/helpcenter_web/live/accounts/users/user_invitations_live._tests.exs b/lib/helpcenter_web/live/accounts/users/user_invitations_live_test.exs similarity index 100% rename from lib/helpcenter_web/live/accounts/users/user_invitations_live._tests.exs rename to lib/helpcenter_web/live/accounts/users/user_invitations_live_test.exs diff --git a/lib/helpcenter_web/router.ex b/lib/helpcenter_web/router.ex index 11339d7..c671f8a 100644 --- a/lib/helpcenter_web/router.ex +++ b/lib/helpcenter_web/router.ex @@ -37,6 +37,8 @@ defmodule HelpcenterWeb.Router do # If an authenticated user must *not* be present: # on_mount {HelpcenterWeb.LiveUserAuth, :live_no_user} + live "/grok", GrokLayoutLive + scope "/categories" do live "/", CategoriesLive live "/create", CreateCategoryLive From e5009a5912404899abc29747608544c724a6e1a7 Mon Sep 17 00:00:00 2001 From: Kamaro Date: Thu, 28 Aug 2025 21:08:10 +0300 Subject: [PATCH 13/22] Added AshParental Extensions --- .../extensions/ash_parental/ash_parental.ex | 4 ++++ .../ash_parental/ash_parental_test.exs | 11 +++++++++ .../{parental => ash_parental}/readme.md | 0 .../transformers/add_parent_id_field.ex | 23 +++++++++++++++++++ lib/helpcenter/knowledge_base/category.ex | 5 ++-- lib/helpcenter/knowledge_base/comment.ex | 3 ++- mix.exs | 1 - 7 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 lib/helpcenter/extensions/ash_parental/ash_parental.ex create mode 100644 lib/helpcenter/extensions/ash_parental/ash_parental_test.exs rename lib/helpcenter/extensions/{parental => ash_parental}/readme.md (100%) create mode 100644 lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_field.ex diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental.ex b/lib/helpcenter/extensions/ash_parental/ash_parental.ex new file mode 100644 index 0000000..6271129 --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/ash_parental.ex @@ -0,0 +1,4 @@ +defmodule Helpcenter.Extensions.AshParental do + use Spark.Dsl.Extension, + transformers: [Helpcenter.Extensions.AshParental.Transformers.AddParentIdField] +end diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs new file mode 100644 index 0000000..d179c6a --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs @@ -0,0 +1,11 @@ +defmodule Helpcenter.Extensions.AshParental do + defmodule Comment do + use Ash.Resource, extensions: [Helpcenter.Extensions.AshParental] + end + + describe "AshParental" do + test "AshParental adds parent_id to the resource" do + dbg(Ash.Resource.Info.attribute_names(Comment)) + end + end +end diff --git a/lib/helpcenter/extensions/parental/readme.md b/lib/helpcenter/extensions/ash_parental/readme.md similarity index 100% rename from lib/helpcenter/extensions/parental/readme.md rename to lib/helpcenter/extensions/ash_parental/readme.md diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_field.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_field.ex new file mode 100644 index 0000000..3713502 --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_field.ex @@ -0,0 +1,23 @@ +# lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_field.ex +defmodule Helpcenter.Extensions.AshParental.Transformers.AddParentIdField do + use Spark.Dsl.Transformer + + def transform(dsl_state) do + primary_key_type = get_primary_key_type(dsl_state) || :uuid + + dsl_state + |> Ash.Resource.Builder.add_new_attribute( + :parent_id, + primary_key_type, + allow_nil?: false + ) + end + + defp get_primary_key_type(dsl_state) do + dsl_state + |> Ash.Resource.Info.primary_key() + |> Enum.map(&Ash.Resource.Info.attribute(dsl_state, &1)) + |> List.first() + |> Map.get(:type) + end +end diff --git a/lib/helpcenter/knowledge_base/category.ex b/lib/helpcenter/knowledge_base/category.ex index f843aae..bc0bb0a 100644 --- a/lib/helpcenter/knowledge_base/category.ex +++ b/lib/helpcenter/knowledge_base/category.ex @@ -4,12 +4,11 @@ defmodule Helpcenter.KnowledgeBase.Category do domain: Helpcenter.KnowledgeBase, data_layer: AshPostgres.DataLayer, notifiers: Ash.Notifier.PubSub, - authorizers: Ash.Policy.Authorizer + authorizers: Ash.Policy.Authorizer, + extensions: [Helpcenter.Extensions.AshParental] postgres do - # <-- Tell Ash that this resource data is stored in a table named "categories" table "categories" - # <-- Tell Ash that this resource access data storage via Helpcenter.Repo repo Helpcenter.Repo end diff --git a/lib/helpcenter/knowledge_base/comment.ex b/lib/helpcenter/knowledge_base/comment.ex index d42da55..f01aa8a 100644 --- a/lib/helpcenter/knowledge_base/comment.ex +++ b/lib/helpcenter/knowledge_base/comment.ex @@ -1,7 +1,8 @@ defmodule Helpcenter.KnowledgeBase.Comment do use Ash.Resource, domain: Helpcenter.KnowledgeBase, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + extensions: [Helpcenter.Extensions.AshParental] postgres do table "comments" diff --git a/mix.exs b/mix.exs index 64832b2..1733be1 100644 --- a/mix.exs +++ b/mix.exs @@ -44,7 +44,6 @@ defmodule Helpcenter.MixProject do {:ash_authentication, "~> 4.0"}, {:ash_authentication_phoenix, "~> 2.0"}, {:picosat_elixir, "~> 0.2"}, - {:mix_test_watch, "~> 1.0"}, {:distillery, "~> 2.0"}, {:edeliver, "~> 1.0"}, {:ash_phoenix, "~> 2.0"}, From 8475d3556efa510370ba7a2fc4453a6435b117ed Mon Sep 17 00:00:00 2001 From: Kamaro Date: Thu, 28 Aug 2025 21:18:40 +0300 Subject: [PATCH 14/22] Added tests --- .../ash_parental/ash_parental_test.exs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs index d179c6a..f9e5beb 100644 --- a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs +++ b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs @@ -1,6 +1,29 @@ -defmodule Helpcenter.Extensions.AshParental do +defmodule Helpcenter.Extensions.AshParentalTest do + use ExUnit.Case + + defmodule Domain do + use Ash.Domain + + resources do + resource Comment + end + end + defmodule Comment do - use Ash.Resource, extensions: [Helpcenter.Extensions.AshParental] + use Ash.Resource, + domain: Domain, + data_layer: Ash.DataLayer.Ets, + extensions: [Helpcenter.Extensions.AshParental] + + ets do + table :comments + + private? true + end + + attributes do + uuid_primary_key :id + end end describe "AshParental" do From 232bbf87807b520c7b776eaceaa00ec3e84c7450 Mon Sep 17 00:00:00 2001 From: Kamaro Date: Thu, 28 Aug 2025 21:21:11 +0300 Subject: [PATCH 15/22] Fixed tests --- .../ash_parental/ash_parental_test.exs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs index f9e5beb..92f8de2 100644 --- a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs +++ b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs @@ -1,17 +1,9 @@ defmodule Helpcenter.Extensions.AshParentalTest do use ExUnit.Case - defmodule Domain do - use Ash.Domain - - resources do - resource Comment - end - end - defmodule Comment do use Ash.Resource, - domain: Domain, + domain: Helpcenter.Extensions.AshParentalTest.Domain, data_layer: Ash.DataLayer.Ets, extensions: [Helpcenter.Extensions.AshParental] @@ -26,9 +18,17 @@ defmodule Helpcenter.Extensions.AshParentalTest do end end + defmodule Domain do + use Ash.Domain + + resources do + resource Helpcenter.Extensions.AshParentalTest.Comment + end + end + describe "AshParental" do test "AshParental adds parent_id to the resource" do - dbg(Ash.Resource.Info.attribute_names(Comment)) + assert :parent_id in Ash.Resource.Info.attribute_names(Comment) end end end From 20b6adcbeb338bceb08a072978d30cac5e3e8ae1 Mon Sep 17 00:00:00 2001 From: Kamaro Date: Fri, 29 Aug 2025 17:49:03 +0300 Subject: [PATCH 16/22] added relationships, next is aggregations --- .../extensions/ash_parental/ash_parental.ex | 11 +++++-- .../ash_parental/ash_parental_test.exs | 27 +++++++++++++-- .../add_belongs_to_parent_relationship.ex | 33 +++++++++++++++++++ .../add_children_count_aggregate.ex | 18 ++++++++++ .../add_has_many_children_relationship.ex | 29 ++++++++++++++++ .../transformers/add_parent_id_field.ex | 4 +-- lib/helpcenter/knowledge_base/category.ex | 3 +- lib/helpcenter/knowledge_base/comment.ex | 3 +- 8 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 lib/helpcenter/extensions/ash_parental/transformers/add_belongs_to_parent_relationship.ex create mode 100644 lib/helpcenter/extensions/ash_parental/transformers/add_children_count_aggregate.ex create mode 100644 lib/helpcenter/extensions/ash_parental/transformers/add_has_many_children_relationship.ex diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental.ex b/lib/helpcenter/extensions/ash_parental/ash_parental.ex index 6271129..599e481 100644 --- a/lib/helpcenter/extensions/ash_parental/ash_parental.ex +++ b/lib/helpcenter/extensions/ash_parental/ash_parental.ex @@ -1,4 +1,11 @@ +# lib/helpcenter/extensions/ash_parental/ash_parental.ex defmodule Helpcenter.Extensions.AshParental do - use Spark.Dsl.Extension, - transformers: [Helpcenter.Extensions.AshParental.Transformers.AddParentIdField] + @transformers [ + Helpcenter.Extensions.AshParental.Transformers.AddParentIdField, + Helpcenter.Extensions.AshParental.Transformers.AddChildrenCountAggregate, + Helpcenter.Extensions.AshParental.Transformers.AddBelongsToParentRelationship, + Helpcenter.Extensions.AshParental.Transformers.AddHasManyChildrenRelationship + ] + + use Spark.Dsl.Extension, transformers: @transformers end diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs index 92f8de2..b7b2f9c 100644 --- a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs +++ b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs @@ -1,3 +1,4 @@ +# lib/helpcenter/extensions/ash_parental/ash_parental_test.exs defmodule Helpcenter.Extensions.AshParentalTest do use ExUnit.Case @@ -9,12 +10,12 @@ defmodule Helpcenter.Extensions.AshParentalTest do ets do table :comments - - private? true end attributes do uuid_primary_key :id + attribute :content, :string, allow_nil?: false + timestamps() end end @@ -26,9 +27,29 @@ defmodule Helpcenter.Extensions.AshParentalTest do end end + defp relationships(resource) do + Ash.Resource.Info.relationships(resource) + |> Enum.map(& &1.name) + end + + alias Helpcenter.Extensions.AshParentalTest.Comment + describe "AshParental" do - test "AshParental adds parent_id to the resource" do + test "Adds parent_id to the resource" do assert :parent_id in Ash.Resource.Info.attribute_names(Comment) end + + test "Adds a belongs_to relationship to the resource" do + assert :parent in relationships(Comment) + end + + test "Adds a children relationships" do + assert :children in relationships(Comment) + end + + test "Adds children count aggregate" do + dbg(Ash.Resource.Info.aggregates(Comment)) + assert :children_count in Ash.Resource.Info.aggregates(Comment) + end end end diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_belongs_to_parent_relationship.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_belongs_to_parent_relationship.ex new file mode 100644 index 0000000..a186d28 --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_belongs_to_parent_relationship.ex @@ -0,0 +1,33 @@ +defmodule Helpcenter.Extensions.AshParental.Transformers.AddBelongsToParentRelationship do + use Spark.Dsl.Transformer + + @doc """ + Ensure that this transformer runs after the AddParentIdField transformer. This will prevent + errors where the relationship is added before the parent_id field exists. + """ + def after?(Helpcenter.Extensions.AshParental.Transformers.AddParentIdField), do: true + def after?(_), do: false + + def transform(dsl_state) do + Ash.Resource.Builder.add_new_relationship( + dsl_state, + :belongs_to, + :parent, + get_current_resource_name(dsl_state), + source_attribute: :parent_id, + destination_attribute: get_primary_key_name(dsl_state) + ) + end + + defp get_current_resource_name(dsl_state) do + Spark.Dsl.Transformer.get_persisted(dsl_state, :module) + end + + defp get_primary_key_name(dsl_state) do + dsl_state + |> Ash.Resource.Info.primary_key() + |> Enum.map(&Ash.Resource.Info.attribute(dsl_state, &1)) + |> List.first() + |> Map.get(:name) + end +end diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_children_count_aggregate.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_children_count_aggregate.ex new file mode 100644 index 0000000..327999f --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_children_count_aggregate.ex @@ -0,0 +1,18 @@ +defmodule Helpcenter.Extensions.AshParental.Transformers.AddChildrenCountAggregate do + use Spark.Dsl.Transformer + + def after?(Helpcenter.Extensions.AshParental.Transformers.AddHasManyChildrenRelationship) do + true + end + + def after?(_), do: false + + def transform(dsl_state) do + Ash.Resource.Builder.add_new_aggregate( + dsl_state, + :count_of_children, + :count, + [:children] + ) + end +end diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_has_many_children_relationship.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_has_many_children_relationship.ex new file mode 100644 index 0000000..1ce764e --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_has_many_children_relationship.ex @@ -0,0 +1,29 @@ +defmodule Helpcenter.Extensions.AshParental.Transformers.AddHasManyChildrenRelationship do + use Spark.Dsl.Transformer + + def after?(Helpcenter.Extensions.AshParental.Transformers.AddParentIdField), do: true + def after?(_), do: false + + def transform(dsl_state) do + Ash.Resource.Builder.add_new_relationship( + dsl_state, + :has_many, + :children, + get_current_resource_name(dsl_state), + source_attribute: get_primary_key_name(dsl_state), + destination_attribute: :parent_id + ) + end + + defp get_current_resource_name(dsl_state) do + Spark.Dsl.Transformer.get_persisted(dsl_state, :module) + end + + defp get_primary_key_name(dsl_state) do + dsl_state + |> Ash.Resource.Info.primary_key() + |> Enum.map(&Ash.Resource.Info.attribute(dsl_state, &1)) + |> List.first() + |> Map.get(:name) + end +end diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_field.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_field.ex index 3713502..bce10af 100644 --- a/lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_field.ex +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_field.ex @@ -5,8 +5,8 @@ defmodule Helpcenter.Extensions.AshParental.Transformers.AddParentIdField do def transform(dsl_state) do primary_key_type = get_primary_key_type(dsl_state) || :uuid - dsl_state - |> Ash.Resource.Builder.add_new_attribute( + Ash.Resource.Builder.add_new_attribute( + dsl_state, :parent_id, primary_key_type, allow_nil?: false diff --git a/lib/helpcenter/knowledge_base/category.ex b/lib/helpcenter/knowledge_base/category.ex index bc0bb0a..d6fa5d4 100644 --- a/lib/helpcenter/knowledge_base/category.ex +++ b/lib/helpcenter/knowledge_base/category.ex @@ -4,8 +4,7 @@ defmodule Helpcenter.KnowledgeBase.Category do domain: Helpcenter.KnowledgeBase, data_layer: AshPostgres.DataLayer, notifiers: Ash.Notifier.PubSub, - authorizers: Ash.Policy.Authorizer, - extensions: [Helpcenter.Extensions.AshParental] + authorizers: Ash.Policy.Authorizer postgres do table "categories" diff --git a/lib/helpcenter/knowledge_base/comment.ex b/lib/helpcenter/knowledge_base/comment.ex index f01aa8a..d42da55 100644 --- a/lib/helpcenter/knowledge_base/comment.ex +++ b/lib/helpcenter/knowledge_base/comment.ex @@ -1,8 +1,7 @@ defmodule Helpcenter.KnowledgeBase.Comment do use Ash.Resource, domain: Helpcenter.KnowledgeBase, - data_layer: AshPostgres.DataLayer, - extensions: [Helpcenter.Extensions.AshParental] + data_layer: AshPostgres.DataLayer postgres do table "comments" From 4684a049e4a65ec329cbaf6da3ecb86b8caa3b68 Mon Sep 17 00:00:00 2001 From: Kamaro Date: Fri, 29 Aug 2025 23:17:48 +0300 Subject: [PATCH 17/22] Added count_of_children aggregates --- .../extensions/ash_parental/ash_parental_test.exs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs index b7b2f9c..9f6be1a 100644 --- a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs +++ b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs @@ -48,8 +48,12 @@ defmodule Helpcenter.Extensions.AshParentalTest do end test "Adds children count aggregate" do - dbg(Ash.Resource.Info.aggregates(Comment)) - assert :children_count in Ash.Resource.Info.aggregates(Comment) + %{name: aggregate_name, kind: kind} = + Ash.Resource.Info.aggregates(Comment) + |> List.first() + + assert :count_of_children == aggregate_name + assert :count == kind end end end From 7773a1e6b49de2a629b6e312109a23a6499d1462 Mon Sep 17 00:00:00 2001 From: Kamaro Date: Fri, 29 Aug 2025 23:26:15 +0300 Subject: [PATCH 18/22] Added test for Ashparental --- .../ash_parental/ash_parental_test.exs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs index 9f6be1a..0b3c090 100644 --- a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs +++ b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs @@ -12,6 +12,10 @@ defmodule Helpcenter.Extensions.AshParentalTest do table :comments end + actions do + defaults [:create, :read, :update, :destroy] + end + attributes do uuid_primary_key :id attribute :content, :string, allow_nil?: false @@ -55,5 +59,20 @@ defmodule Helpcenter.Extensions.AshParentalTest do assert :count_of_children == aggregate_name assert :count == kind end + + test "Parents - Child and versa relationships records" do + parent = Ash.Seed.seed!(Comment, %{content: "parent"}) + child_1 = Ash.Seed.seed!(Comment, %{content: "child 1", parent_id: parent.id}) + child_2 = Ash.Seed.seed!(Comment, %{content: "child 2", parent_id: parent.id}) + + parent_record = Ash.get!(Comment, parent.id, load: [:children, :count_of_children]) + + assert 2 == parent_record.count_of_children + assert Enum.count(parent_record.children) == parent_record.count_of_children + + child_1_record = Ash.get!(Comment, child_1.id, load: [:parent]) + + assert child_1_record.parent_id == parent.id + end end end From 25273557f859600364623d84dbe023f3bd5b3d3d Mon Sep 17 00:00:00 2001 From: Kamaro Date: Sun, 31 Aug 2025 21:14:43 +0300 Subject: [PATCH 19/22] Adding change to destroy children on parent destruction --- .../extensions/ash_parental/ash_parental.ex | 43 +++- .../ash_parental/ash_parental_test.exs | 38 +++- .../ash_parental/changes/destroy_children.ex | 24 ++ .../extensions/ash_parental/info.ex | 4 + .../extensions/ash_parental/readme.md | 11 +- .../add_belongs_to_parent_relationship.ex | 7 +- .../add_children_count_aggregate.ex | 9 +- .../add_delete_destroy_children_change.ex | 24 ++ .../add_has_many_children_relationship.ex | 12 +- ...id_field.ex => add_parent_id_attribute.ex} | 8 +- lib/helpcenter/knowledge_base/category.ex | 2 + lib/helpcenter/knowledge_base/comment.ex | 7 +- .../20250829202959_add_ash_parental.exs | 57 +++++ .../20250829202957_add_ash_parental.exs | 30 +++ .../repo/invitations/20250829202959.json | 208 ++++++++++++++++++ .../repo/tenants/comments/20250829202957.json | 129 +++++++++++ 16 files changed, 599 insertions(+), 14 deletions(-) create mode 100644 lib/helpcenter/extensions/ash_parental/changes/destroy_children.ex create mode 100644 lib/helpcenter/extensions/ash_parental/info.ex create mode 100644 lib/helpcenter/extensions/ash_parental/transformers/add_delete_destroy_children_change.ex rename lib/helpcenter/extensions/ash_parental/transformers/{add_parent_id_field.ex => add_parent_id_attribute.ex} (70%) create mode 100644 priv/repo/migrations/20250829202959_add_ash_parental.exs create mode 100644 priv/repo/tenant_migrations/20250829202957_add_ash_parental.exs create mode 100644 priv/resource_snapshots/repo/invitations/20250829202959.json create mode 100644 priv/resource_snapshots/repo/tenants/comments/20250829202957.json diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental.ex b/lib/helpcenter/extensions/ash_parental/ash_parental.ex index 599e481..be3d4a8 100644 --- a/lib/helpcenter/extensions/ash_parental/ash_parental.ex +++ b/lib/helpcenter/extensions/ash_parental/ash_parental.ex @@ -1,11 +1,50 @@ # lib/helpcenter/extensions/ash_parental/ash_parental.ex defmodule Helpcenter.Extensions.AshParental do + @moduledoc """ + An Ash extension that adds parental relationships to a resource. + When added to a resource, it will automatically add: + - A `parent_id` attribute (of the same type as the resource's primary key) + - A `belongs_to :parent` relationship to the same resource + - A `has_many :children` relationship to the same resource + - A `count_of_children` aggregate to count the number of children + """ + @transformers [ - Helpcenter.Extensions.AshParental.Transformers.AddParentIdField, + Helpcenter.Extensions.AshParental.Transformers.AddParentIdAttribute, Helpcenter.Extensions.AshParental.Transformers.AddChildrenCountAggregate, Helpcenter.Extensions.AshParental.Transformers.AddBelongsToParentRelationship, Helpcenter.Extensions.AshParental.Transformers.AddHasManyChildrenRelationship ] - use Spark.Dsl.Extension, transformers: @transformers + # Define the DSL section and its schema for configuration + # <-- This part is added + @section %Spark.Dsl.Section{ + name: :ash_parental, + describe: "Configurations for the AshParental extension", + examples: [ + """ + ash_parental do + children_relationship_name :sub_items + on_parent_delete_destroy_children true + end + """ + ], + schema: [ + children_relationship_name: [ + type: :atom, + doc: "The name of the children relationship to be added", + default: :children + ], + on_parent_delete_destroy_children: [ + type: :boolean, + doc: "If true, deleting a parent will also delete its children", + default: false + ] + ] + } + + use Spark.Dsl.Extension, + transformers: @transformers, + # <-- Add the DSL section to the extension + sections: [@section] end diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs index 0b3c090..825fdd3 100644 --- a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs +++ b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs @@ -2,6 +2,7 @@ defmodule Helpcenter.Extensions.AshParentalTest do use ExUnit.Case + # Define a simple Ash resource for testing purposes defmodule Comment do use Ash.Resource, domain: Helpcenter.Extensions.AshParentalTest.Domain, @@ -23,23 +24,52 @@ defmodule Helpcenter.Extensions.AshParentalTest do end end + # lib/helpcenter/extensions/ash_parental/ash_parental_test.exs + defmodule Category do + use Ash.Resource, + domain: Helpcenter.Extensions.AshParentalTest.Domain, + data_layer: Ash.DataLayer.Ets, + extensions: [Helpcenter.Extensions.AshParental] + + ets do + table :categories + end + + ash_parental do + children_relationship_name(:subcategories) + end + + actions do + defaults [:create, :read, :update, :destroy] + end + + attributes do + uuid_primary_key :id + attribute :name, :string, allow_nil?: false + timestamps() + end + end + + # Define a domain to hold the resource for testing defmodule Domain do use Ash.Domain resources do resource Helpcenter.Extensions.AshParentalTest.Comment + resource Helpcenter.Extensions.AshParentalTest.Category end end defp relationships(resource) do - Ash.Resource.Info.relationships(resource) - |> Enum.map(& &1.name) + Ash.Resource.Info.relationships(resource) |> Enum.map(& &1.name) end alias Helpcenter.Extensions.AshParentalTest.Comment describe "AshParental" do test "Adds parent_id to the resource" do + # Confirm that the parent_id attribute has been added + # to the reource's attributes after applying the extension assert :parent_id in Ash.Resource.Info.attribute_names(Comment) end @@ -74,5 +104,9 @@ defmodule Helpcenter.Extensions.AshParentalTest do assert child_1_record.parent_id == parent.id end + + test "Children relationship name is configurable" do + assert :subcategories in relationships(Category) + end end end diff --git a/lib/helpcenter/extensions/ash_parental/changes/destroy_children.ex b/lib/helpcenter/extensions/ash_parental/changes/destroy_children.ex new file mode 100644 index 0000000..3de3ad6 --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/changes/destroy_children.ex @@ -0,0 +1,24 @@ +defmodule Helpcenter.Extensions.AshParental.Changes.DestroyChildren do + use Ash.Resource.Change + + def change(%Ash.Changeset{action_type: :destroy} = changeset, _opts, _context) do + dbg("DestroyChildren change called") + Ash.Changeset.before_action(changeset, &delete_children/1) + end + + def change(changeset, _opts, _context), do: changeset + + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end + + defp delete_children(changeset) do + require Ash.Query + opts = Ash.Context.to_opts(changeset.context) + + Helpcenter.KnowledgeBase.Comment + |> Ash.Query.filter(parent_id == ^changeset.data.id) + |> Ash.read!(opts) + |> Ash.bulk_destroy(:destroy, _condition = %{}, opts) + end +end diff --git a/lib/helpcenter/extensions/ash_parental/info.ex b/lib/helpcenter/extensions/ash_parental/info.ex new file mode 100644 index 0000000..37118ae --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/info.ex @@ -0,0 +1,4 @@ +# lib/helpcenter/extensions/ash_parental/info.ex +defmodule Helpcenter.Extensions.AshParental.Info do + use Spark.InfoGenerator, extension: Helpcenter.Extensions.AshParental, sections: [:ash_parental] +end diff --git a/lib/helpcenter/extensions/ash_parental/readme.md b/lib/helpcenter/extensions/ash_parental/readme.md index cf768de..b504c55 100644 --- a/lib/helpcenter/extensions/ash_parental/readme.md +++ b/lib/helpcenter/extensions/ash_parental/readme.md @@ -1,5 +1,4 @@ # Ash Parental Resource - Ash Parental is an extension that brings STI(Single Table Inheritance) capability to a resource ## What is single table inheritance (STI)? @@ -18,3 +17,13 @@ The above should do the following: 2.Add `parent` belongs to relationship 3.Add `children` has_many relationship 4.Add `children_count` aggregates + +Configurations + +```ex + +ash_parental do + children_relationship_name :subcategories + +end +``` diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_belongs_to_parent_relationship.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_belongs_to_parent_relationship.ex index a186d28..4daf0c5 100644 --- a/lib/helpcenter/extensions/ash_parental/transformers/add_belongs_to_parent_relationship.ex +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_belongs_to_parent_relationship.ex @@ -1,11 +1,12 @@ +# lib/helpcenter/extensions/ash_parental/transformers/add_belongs_to_parent_relationship.ex defmodule Helpcenter.Extensions.AshParental.Transformers.AddBelongsToParentRelationship do use Spark.Dsl.Transformer @doc """ - Ensure that this transformer runs after the AddParentIdField transformer. This will prevent + Ensure that this transformer runs after the AddParentIdAttribute transformer. This will prevent errors where the relationship is added before the parent_id field exists. """ - def after?(Helpcenter.Extensions.AshParental.Transformers.AddParentIdField), do: true + def after?(Helpcenter.Extensions.AshParental.Transformers.AddParentIdAttribute), do: true def after?(_), do: false def transform(dsl_state) do @@ -19,10 +20,12 @@ defmodule Helpcenter.Extensions.AshParental.Transformers.AddBelongsToParentRelat ) end + # Get the current resource name defp get_current_resource_name(dsl_state) do Spark.Dsl.Transformer.get_persisted(dsl_state, :module) end + # Get the current primary key name defp get_primary_key_name(dsl_state) do dsl_state |> Ash.Resource.Info.primary_key() diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_children_count_aggregate.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_children_count_aggregate.ex index 327999f..849ac3c 100644 --- a/lib/helpcenter/extensions/ash_parental/transformers/add_children_count_aggregate.ex +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_children_count_aggregate.ex @@ -1,3 +1,4 @@ +# lib/helpcenter/extensions/ash_parental/transformers/add_children_count_aggregate.ex defmodule Helpcenter.Extensions.AshParental.Transformers.AddChildrenCountAggregate do use Spark.Dsl.Transformer @@ -12,7 +13,13 @@ defmodule Helpcenter.Extensions.AshParental.Transformers.AddChildrenCountAggrega dsl_state, :count_of_children, :count, - [:children] + # <-- Use the configured children relationship name + [get_children_relationship_name(dsl_state)] ) end + + defp get_children_relationship_name(dsl_state) do + dsl_state + |> Helpcenter.Extensions.AshParental.Info.ash_parental_children_relationship_name!() + end end diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_delete_destroy_children_change.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_delete_destroy_children_change.ex new file mode 100644 index 0000000..a91939b --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_delete_destroy_children_change.ex @@ -0,0 +1,24 @@ +# # lib/helpcenter/extensions/ash_parental/transformers/add_delete_destroy_children_change.ex +defmodule Helpcenter.Extensions.AshParental.Transformers.AddDeleteDestroyChildrenChange do + use Spark.Dsl.Transformer + + def after?(Helpcenter.Extensions.AshParental.Transformers.AddHasManyChildrenRelationship), + do: true + + def after?(_), do: false + + def transform(dsl_state) do + if Helpcenter.Extensions.AshParental.Info.ash_parental_on_parent_delete_destroy_children?( + dsl_state + ) do + Ash.Resource.Builder.add_change( + dsl_state, + Helpcenter.Extensions.AshParental.Changes.DestroyChildren + ) + else + dsl_state + end + end +end + +# # lib/helpcenter/extensions/ash_parental/transformers/add_delete_destroy_children_change.ex diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_has_many_children_relationship.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_has_many_children_relationship.ex index 1ce764e..d59fcdd 100644 --- a/lib/helpcenter/extensions/ash_parental/transformers/add_has_many_children_relationship.ex +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_has_many_children_relationship.ex @@ -1,20 +1,28 @@ +# lib/helpcenter/extensions/ash_parental/transformers/add_has_many_children_relationship.ex defmodule Helpcenter.Extensions.AshParental.Transformers.AddHasManyChildrenRelationship do use Spark.Dsl.Transformer - def after?(Helpcenter.Extensions.AshParental.Transformers.AddParentIdField), do: true + def after?(Helpcenter.Extensions.AshParental.Transformers.AddParentIdAttribute), do: true def after?(_), do: false def transform(dsl_state) do Ash.Resource.Builder.add_new_relationship( dsl_state, :has_many, - :children, + # <-- Use the configured children relationship name + get_children_relationship_name(dsl_state), get_current_resource_name(dsl_state), source_attribute: get_primary_key_name(dsl_state), destination_attribute: :parent_id ) end + # <-- Retrieve the children relationship name from the resource configuration + defp get_children_relationship_name(dsl_state) do + dsl_state + |> Helpcenter.Extensions.AshParental.Info.ash_parental_children_relationship_name!() + end + defp get_current_resource_name(dsl_state) do Spark.Dsl.Transformer.get_persisted(dsl_state, :module) end diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_field.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_attribute.ex similarity index 70% rename from lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_field.ex rename to lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_attribute.ex index bce10af..815b7ab 100644 --- a/lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_field.ex +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_attribute.ex @@ -1,9 +1,10 @@ -# lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_field.ex -defmodule Helpcenter.Extensions.AshParental.Transformers.AddParentIdField do +# lib/helpcenter/extensions/ash_parental/transformers/add_parent_id_attribute.ex +defmodule Helpcenter.Extensions.AshParental.Transformers.AddParentIdAttribute do use Spark.Dsl.Transformer def transform(dsl_state) do - primary_key_type = get_primary_key_type(dsl_state) || :uuid + # Get the type of the primary key to use for the parent_id attribute + primary_key_type = get_primary_key_type(dsl_state) Ash.Resource.Builder.add_new_attribute( dsl_state, @@ -13,6 +14,7 @@ defmodule Helpcenter.Extensions.AshParental.Transformers.AddParentIdField do ) end + # Helper function to get the current resource primary key type defp get_primary_key_type(dsl_state) do dsl_state |> Ash.Resource.Info.primary_key() diff --git a/lib/helpcenter/knowledge_base/category.ex b/lib/helpcenter/knowledge_base/category.ex index d6fa5d4..91addc6 100644 --- a/lib/helpcenter/knowledge_base/category.ex +++ b/lib/helpcenter/knowledge_base/category.ex @@ -87,6 +87,8 @@ defmodule Helpcenter.KnowledgeBase.Category do attribute :slug, :string attribute :description, :string, allow_nil?: true + attribute :parent_id, :uuid, allow_nil?: true + timestamps() end diff --git a/lib/helpcenter/knowledge_base/comment.ex b/lib/helpcenter/knowledge_base/comment.ex index d42da55..ccd282c 100644 --- a/lib/helpcenter/knowledge_base/comment.ex +++ b/lib/helpcenter/knowledge_base/comment.ex @@ -1,7 +1,8 @@ defmodule Helpcenter.KnowledgeBase.Comment do use Ash.Resource, domain: Helpcenter.KnowledgeBase, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + extensions: [Helpcenter.Extensions.AshParental] postgres do table "comments" @@ -13,6 +14,10 @@ defmodule Helpcenter.KnowledgeBase.Comment do defaults [:create, :read, :update, :destroy] end + ash_parental do + children_relationship_name(:replies) + end + preparations do prepare Helpcenter.Preparations.SetTenant end diff --git a/priv/repo/migrations/20250829202959_add_ash_parental.exs b/priv/repo/migrations/20250829202959_add_ash_parental.exs new file mode 100644 index 0000000..2ce5af5 --- /dev/null +++ b/priv/repo/migrations/20250829202959_add_ash_parental.exs @@ -0,0 +1,57 @@ +defmodule Helpcenter.Repo.Migrations.AddAshParental do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create table(:invitations, primary_key: false) do + add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true + add :email, :text, null: false + add :status, :text, null: false, default: "pending" + add :token, :text, null: false + add :team, :text, null: false + add :expires_at, :utc_datetime, null: false + + add :inserted_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :updated_at, :utc_datetime_usec, + null: false, + default: fragment("(now() AT TIME ZONE 'utc')") + + add :group_id, :uuid, null: false + + add :inviter_user_id, + references(:users, + column: :id, + name: "invitations_inviter_user_id_fkey", + type: :uuid, + prefix: "public" + ), + null: false + + add :invitee_user_id, + references(:users, + column: :id, + name: "invitations_invitee_user_id_fkey", + type: :uuid, + prefix: "public" + ) + end + end + + def down do + drop constraint(:invitations, "invitations_group_id_fkey") + + drop constraint(:invitations, "invitations_inviter_user_id_fkey") + + drop constraint(:invitations, "invitations_invitee_user_id_fkey") + + drop table(:invitations) + end +end diff --git a/priv/repo/tenant_migrations/20250829202957_add_ash_parental.exs b/priv/repo/tenant_migrations/20250829202957_add_ash_parental.exs new file mode 100644 index 0000000..7ce6851 --- /dev/null +++ b/priv/repo/tenant_migrations/20250829202957_add_ash_parental.exs @@ -0,0 +1,30 @@ +defmodule Helpcenter.Repo.TenantMigrations.AddAshParental do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:comments, prefix: prefix()) do + add :parent_id, + references(:comments, + column: :id, + name: "comments_parent_id_fkey", + type: :uuid, + prefix: prefix() + ), + null: false + end + end + + def down do + drop constraint(:comments, "comments_parent_id_fkey") + + alter table(:comments, prefix: prefix()) do + remove :parent_id + end + end +end diff --git a/priv/resource_snapshots/repo/invitations/20250829202959.json b/priv/resource_snapshots/repo/invitations/20250829202959.json new file mode 100644 index 0000000..cb42c19 --- /dev/null +++ b/priv/resource_snapshots/repo/invitations/20250829202959.json @@ -0,0 +1,208 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "email", + "type": "text" + }, + { + "allow_nil?": false, + "default": "\"pending\"", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "status", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "token", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "team", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "expires_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "name": "invitations_group_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "groups" + }, + "scale": null, + "size": null, + "source": "group_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "invitations_inviter_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "inviter_user_id", + "type": "uuid" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "invitations_invitee_user_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "users" + }, + "scale": null, + "size": null, + "source": "invitee_user_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "62A4CE516E55B26E46A1694CB732E668884347ECD19B5EF2777D47F62DF6BC71", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Helpcenter.Repo", + "schema": null, + "table": "invitations" +} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/tenants/comments/20250829202957.json b/priv/resource_snapshots/repo/tenants/comments/20250829202957.json new file mode 100644 index 0000000..5643bc0 --- /dev/null +++ b/priv/resource_snapshots/repo/tenants/comments/20250829202957.json @@ -0,0 +1,129 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "content", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "name": "comments_article_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "articles" + }, + "scale": null, + "size": null, + "source": "article_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "name": "comments_parent_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "comments" + }, + "scale": null, + "size": null, + "source": "parent_id", + "type": "uuid" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "963C70D2E954AA318FC603E9284CD0B91FFA68AE2AA4277FEC18E623F9904FFB", + "identities": [], + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "repo": "Elixir.Helpcenter.Repo", + "schema": null, + "table": "comments" +} \ No newline at end of file From fd92f2d4bbce954d36599fdeffde96800c49cc2a Mon Sep 17 00:00:00 2001 From: Kamaro Date: Mon, 1 Sep 2025 17:56:05 +0300 Subject: [PATCH 20/22] Added changeset to delete children on delete --- .../extensions/ash_parental/ash_parental.ex | 15 ++- .../ash_parental/ash_parental_test.exs | 21 +++- .../ash_parental/changes/destroy_children.ex | 20 ++-- .../add_delete_destroy_children_change.ex | 24 ---- .../add_destroy_with_children_change.ex | 22 ++++ lib/helpcenter/knowledge_base/category.ex | 1 - lib/helpcenter/knowledge_base/comment.ex | 2 +- ...20250814182208_add_team_to_invitations.exs | 17 --- ...50901141147_add_children_to_categories.exs | 21 ++++ .../tenants/categories/20250901141147.json | 103 ++++++++++++++++++ 10 files changed, 186 insertions(+), 60 deletions(-) delete mode 100644 lib/helpcenter/extensions/ash_parental/transformers/add_delete_destroy_children_change.ex create mode 100644 lib/helpcenter/extensions/ash_parental/transformers/add_destroy_with_children_change.ex create mode 100644 priv/repo/tenant_migrations/20250901141147_add_children_to_categories.exs create mode 100644 priv/resource_snapshots/repo/tenants/categories/20250901141147.json diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental.ex b/lib/helpcenter/extensions/ash_parental/ash_parental.ex index be3d4a8..ca9b4c2 100644 --- a/lib/helpcenter/extensions/ash_parental/ash_parental.ex +++ b/lib/helpcenter/extensions/ash_parental/ash_parental.ex @@ -11,9 +11,16 @@ defmodule Helpcenter.Extensions.AshParental do @transformers [ Helpcenter.Extensions.AshParental.Transformers.AddParentIdAttribute, - Helpcenter.Extensions.AshParental.Transformers.AddChildrenCountAggregate, + + # Add relationships after the attribute and aggregate have been added Helpcenter.Extensions.AshParental.Transformers.AddBelongsToParentRelationship, - Helpcenter.Extensions.AshParental.Transformers.AddHasManyChildrenRelationship + Helpcenter.Extensions.AshParental.Transformers.AddHasManyChildrenRelationship, + + # Aggregates + Helpcenter.Extensions.AshParental.Transformers.AddChildrenCountAggregate, + + # Add the destroy children change last + Helpcenter.Extensions.AshParental.Transformers.AddDestroyWithChildrenChange ] # Define the DSL section and its schema for configuration @@ -35,10 +42,10 @@ defmodule Helpcenter.Extensions.AshParental do doc: "The name of the children relationship to be added", default: :children ], - on_parent_delete_destroy_children: [ + distroy_with_children?: [ type: :boolean, doc: "If true, deleting a parent will also delete its children", - default: false + default: true ] ] } diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs index 825fdd3..b846cf9 100644 --- a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs +++ b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs @@ -22,9 +22,12 @@ defmodule Helpcenter.Extensions.AshParentalTest do attribute :content, :string, allow_nil?: false timestamps() end + + changes do + change Helpcenter.Extensions.AshParental.Changes.DestroyChildren + end end - # lib/helpcenter/extensions/ash_parental/ash_parental_test.exs defmodule Category do use Ash.Resource, domain: Helpcenter.Extensions.AshParentalTest.Domain, @@ -93,7 +96,7 @@ defmodule Helpcenter.Extensions.AshParentalTest do test "Parents - Child and versa relationships records" do parent = Ash.Seed.seed!(Comment, %{content: "parent"}) child_1 = Ash.Seed.seed!(Comment, %{content: "child 1", parent_id: parent.id}) - child_2 = Ash.Seed.seed!(Comment, %{content: "child 2", parent_id: parent.id}) + Ash.Seed.seed!(Comment, %{content: "child 2", parent_id: parent.id}) parent_record = Ash.get!(Comment, parent.id, load: [:children, :count_of_children]) @@ -108,5 +111,19 @@ defmodule Helpcenter.Extensions.AshParentalTest do test "Children relationship name is configurable" do assert :subcategories in relationships(Category) end + + test "Destroying a parent also destroys its children" do + parent = Ash.Seed.seed!(Comment, %{content: "parent"}) + Ash.Seed.seed!(Comment, %{content: "child 1", parent_id: parent.id}) + Ash.Seed.seed!(Comment, %{content: "child 2", parent_id: parent.id}) + + require Ash.Query + + # assert %{status: :success} = + Comment + |> Ash.Query.filter(parent_id == ^parent.id) + |> Ash.read_first!() + |> Ash.destroy() + end end end diff --git a/lib/helpcenter/extensions/ash_parental/changes/destroy_children.ex b/lib/helpcenter/extensions/ash_parental/changes/destroy_children.ex index 3de3ad6..fc5b35c 100644 --- a/lib/helpcenter/extensions/ash_parental/changes/destroy_children.ex +++ b/lib/helpcenter/extensions/ash_parental/changes/destroy_children.ex @@ -1,24 +1,22 @@ defmodule Helpcenter.Extensions.AshParental.Changes.DestroyChildren do use Ash.Resource.Change - def change(%Ash.Changeset{action_type: :destroy} = changeset, _opts, _context) do - dbg("DestroyChildren change called") - Ash.Changeset.before_action(changeset, &delete_children/1) + def change(changeset, _opts, context) do + Ash.Changeset.before_action(changeset, &delete_children(&1, context)) end - def change(changeset, _opts, _context), do: changeset - def atomic(changeset, opts, context) do {:ok, change(changeset, opts, context)} end - defp delete_children(changeset) do + defp delete_children(changeset, context) do require Ash.Query - opts = Ash.Context.to_opts(changeset.context) - Helpcenter.KnowledgeBase.Comment - |> Ash.Query.filter(parent_id == ^changeset.data.id) - |> Ash.read!(opts) - |> Ash.bulk_destroy(:destroy, _condition = %{}, opts) + %{status: :success} = + changeset.data.__struct__ + |> Ash.Query.filter(parent_id == ^changeset.data.id) + |> Ash.bulk_destroy(:destroy, %{}, Ash.Context.to_opts(context)) + + changeset end end diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_delete_destroy_children_change.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_delete_destroy_children_change.ex deleted file mode 100644 index a91939b..0000000 --- a/lib/helpcenter/extensions/ash_parental/transformers/add_delete_destroy_children_change.ex +++ /dev/null @@ -1,24 +0,0 @@ -# # lib/helpcenter/extensions/ash_parental/transformers/add_delete_destroy_children_change.ex -defmodule Helpcenter.Extensions.AshParental.Transformers.AddDeleteDestroyChildrenChange do - use Spark.Dsl.Transformer - - def after?(Helpcenter.Extensions.AshParental.Transformers.AddHasManyChildrenRelationship), - do: true - - def after?(_), do: false - - def transform(dsl_state) do - if Helpcenter.Extensions.AshParental.Info.ash_parental_on_parent_delete_destroy_children?( - dsl_state - ) do - Ash.Resource.Builder.add_change( - dsl_state, - Helpcenter.Extensions.AshParental.Changes.DestroyChildren - ) - else - dsl_state - end - end -end - -# # lib/helpcenter/extensions/ash_parental/transformers/add_delete_destroy_children_change.ex diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_destroy_with_children_change.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_destroy_with_children_change.ex new file mode 100644 index 0000000..da465ae --- /dev/null +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_destroy_with_children_change.ex @@ -0,0 +1,22 @@ +# # lib/helpcenter/extensions/ash_parental/transformers/add_delete_destroy_children_change.ex +defmodule Helpcenter.Extensions.AshParental.Transformers.AddDestroyWithChildrenChange do + use Spark.Dsl.Transformer + alias Helpcenter.Extensions.AshParental.Info + alias Helpcenter.Extensions.AshParental.Changes.DestroyChildren + alias Helpcenter.Extensions.AshParental.Transformers.AddHasManyChildrenRelationship + + @doc """ + Ensures this transformer runs after the AddHasManyChildrenRelationship transformer + """ + def after?(AddHasManyChildrenRelationship), do: true + def after?(_), do: false + + def transform(dsl_state) do + if Info.ash_parental_distroy_with_children?(dsl_state) do + # Specify only on: :destroy to avoid adding it to updates or create changes + Ash.Resource.Builder.add_change(dsl_state, DestroyChildren, on: :destroy) + else + dsl_state + end + end +end diff --git a/lib/helpcenter/knowledge_base/category.ex b/lib/helpcenter/knowledge_base/category.ex index 91addc6..075d3ba 100644 --- a/lib/helpcenter/knowledge_base/category.ex +++ b/lib/helpcenter/knowledge_base/category.ex @@ -71,7 +71,6 @@ defmodule Helpcenter.KnowledgeBase.Category do changes do change Helpcenter.Changes.Slugify - # Automatically set tenant based on the user/ actor change Helpcenter.Changes.SetTenant end diff --git a/lib/helpcenter/knowledge_base/comment.ex b/lib/helpcenter/knowledge_base/comment.ex index ccd282c..8f8da73 100644 --- a/lib/helpcenter/knowledge_base/comment.ex +++ b/lib/helpcenter/knowledge_base/comment.ex @@ -15,7 +15,7 @@ defmodule Helpcenter.KnowledgeBase.Comment do end ash_parental do - children_relationship_name(:replies) + distroy_with_children?(true) end preparations do diff --git a/priv/repo/tenant_migrations/20250814182208_add_team_to_invitations.exs b/priv/repo/tenant_migrations/20250814182208_add_team_to_invitations.exs index 1576757..57d2ab4 100644 --- a/priv/repo/tenant_migrations/20250814182208_add_team_to_invitations.exs +++ b/priv/repo/tenant_migrations/20250814182208_add_team_to_invitations.exs @@ -8,17 +8,6 @@ defmodule Helpcenter.Repo.TenantMigrations.AddTeamToInvitations do use Ecto.Migration def up do - alter table(:user_notifications, prefix: prefix()) do - modify :recipient_user_id, - references(:users, - column: :id, - name: "user_notifications_recipient_user_id_fkey", - type: :uuid, - prefix: "public" - ) - end - - create index(:user_notifications, [:recipient_user_id]) create table(:invitations, primary_key: false, prefix: prefix()) do add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true @@ -74,11 +63,5 @@ defmodule Helpcenter.Repo.TenantMigrations.AddTeamToInvitations do drop table(:invitations, prefix: prefix()) drop_if_exists index(:user_notifications, [:recipient_user_id]) - - drop constraint(:user_notifications, "user_notifications_recipient_user_id_fkey") - - alter table(:user_notifications, prefix: prefix()) do - modify :recipient_user_id, :uuid - end end end diff --git a/priv/repo/tenant_migrations/20250901141147_add_children_to_categories.exs b/priv/repo/tenant_migrations/20250901141147_add_children_to_categories.exs new file mode 100644 index 0000000..a82f8fb --- /dev/null +++ b/priv/repo/tenant_migrations/20250901141147_add_children_to_categories.exs @@ -0,0 +1,21 @@ +defmodule Helpcenter.Repo.TenantMigrations.AddChildrenToCategories do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + alter table(:categories, prefix: prefix()) do + add :parent_id, :uuid + end + end + + def down do + alter table(:categories, prefix: prefix()) do + remove :parent_id + end + end +end diff --git a/priv/resource_snapshots/repo/tenants/categories/20250901141147.json b/priv/resource_snapshots/repo/tenants/categories/20250901141147.json new file mode 100644 index 0000000..76aa59c --- /dev/null +++ b/priv/resource_snapshots/repo/tenants/categories/20250901141147.json @@ -0,0 +1,103 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"gen_random_uuid()\")", + "generated?": false, + "precision": null, + "primary_key?": true, + "references": null, + "scale": null, + "size": null, + "source": "id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "slug", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "description", + "type": "text" + }, + { + "allow_nil?": true, + "default": "nil", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "parent_id", + "type": "uuid" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "precision": null, + "primary_key?": false, + "references": null, + "scale": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "637C2B6D0D87B21171304593DB5D971408BE94E3AAE2EA361A8E21E2BE35F355", + "identities": [], + "multitenancy": { + "attribute": null, + "global": false, + "strategy": "context" + }, + "repo": "Elixir.Helpcenter.Repo", + "schema": null, + "table": "categories" +} \ No newline at end of file From 7e865f137ad19da692f5daa5d6218b59a263961c Mon Sep 17 00:00:00 2001 From: Kamaro Date: Mon, 1 Sep 2025 19:13:25 +0300 Subject: [PATCH 21/22] Added destroy on delete --- .../extensions/ash_parental/ash_parental_test.exs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs index b846cf9..31d70df 100644 --- a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs +++ b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs @@ -121,9 +121,13 @@ defmodule Helpcenter.Extensions.AshParentalTest do # assert %{status: :success} = Comment - |> Ash.Query.filter(parent_id == ^parent.id) - |> Ash.read_first!() + |> Ash.get!(parent.id) |> Ash.destroy() + + # Confirm that no children remain + refute Comment + |> Ash.Query.filter(parent_id == ^parent.id) + |> Ash.exists?() end end end From ba90789ea7f16091b5599424daeb670468360a5d Mon Sep 17 00:00:00 2001 From: Kamaro Date: Mon, 1 Sep 2025 19:19:21 +0300 Subject: [PATCH 22/22] Fixed the configuration issue --- lib/helpcenter/extensions/ash_parental/ash_parental.ex | 2 +- .../extensions/ash_parental/ash_parental_test.exs | 8 ++++---- .../transformers/add_destroy_with_children_change.ex | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental.ex b/lib/helpcenter/extensions/ash_parental/ash_parental.ex index ca9b4c2..0bef57c 100644 --- a/lib/helpcenter/extensions/ash_parental/ash_parental.ex +++ b/lib/helpcenter/extensions/ash_parental/ash_parental.ex @@ -45,7 +45,7 @@ defmodule Helpcenter.Extensions.AshParental do distroy_with_children?: [ type: :boolean, doc: "If true, deleting a parent will also delete its children", - default: true + default: false ] ] } diff --git a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs index 31d70df..937567a 100644 --- a/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs +++ b/lib/helpcenter/extensions/ash_parental/ash_parental_test.exs @@ -13,6 +13,10 @@ defmodule Helpcenter.Extensions.AshParentalTest do table :comments end + ash_parental do + distroy_with_children?(true) + end + actions do defaults [:create, :read, :update, :destroy] end @@ -22,10 +26,6 @@ defmodule Helpcenter.Extensions.AshParentalTest do attribute :content, :string, allow_nil?: false timestamps() end - - changes do - change Helpcenter.Extensions.AshParental.Changes.DestroyChildren - end end defmodule Category do diff --git a/lib/helpcenter/extensions/ash_parental/transformers/add_destroy_with_children_change.ex b/lib/helpcenter/extensions/ash_parental/transformers/add_destroy_with_children_change.ex index da465ae..8f9c8fe 100644 --- a/lib/helpcenter/extensions/ash_parental/transformers/add_destroy_with_children_change.ex +++ b/lib/helpcenter/extensions/ash_parental/transformers/add_destroy_with_children_change.ex @@ -16,7 +16,7 @@ defmodule Helpcenter.Extensions.AshParental.Transformers.AddDestroyWithChildrenC # Specify only on: :destroy to avoid adding it to updates or create changes Ash.Resource.Builder.add_change(dsl_state, DestroyChildren, on: :destroy) else - dsl_state + {:ok, dsl_state} end end end