8000 Email notifications: group membership (B1-B7) by mwoods79 · Pull Request #129 · huddlz-hq/huddlz · GitHub
[go: up one dir, main page]

Skip to content
5 changes: 5 additions & 0 deletions lib/huddlz/communities.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ defmodule Huddlz.Communities do
define :update_details,
action: :update_details,
args: [:name, :description, :location, :is_public, :slug]

define :transfer_group_ownership,
action: :transfer_ownership,
args: [:new_owner_id]
end

resource Huddlz.Communities.GroupImage do
Expand Down Expand Up @@ -69,6 +73,7 @@ defmodule Huddlz.Communities do
resource Huddlz.Communities.GroupMember do
define :add_member, action: :add_member, args: [:group_id, :user_id, :role]
define :remove_member, action: :remove_member, args: [:group_id, :user_id]
define :change_member_role, action: :change_role, args: [:role]
define :get_by_group, action: :get_by_group, args: [:group_id]
define :get_by_user, action: :get_by_user
end
Expand Down
40 changes: 39 additions & 1 deletion lib/huddlz/communities/group.ex
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,13 @@ defmodule Huddlz.Communities.Group do
end

actions do
defaults [:create, :read, :update, :destroy]
defaults [:create, :read, :update]

destroy :destroy do
primary? true
require_atomic? false
change Huddlz.Communities.Group.Changes.NotifyArchived
end

create :create_group do
description "Create a new group; the owner is always the current actor."
Expand Down Expand Up @@ -126,6 +132,33 @@ defmodule Huddlz.Communities.Group do
change Huddlz.Geocoding.ApplyProvidedCoordinates
change {Huddlz.Geocoding.GeocodeChange, field: :location}
end

update :transfer_ownership do
description "Transfer the group ownership to another user; demotes the previous owner to organizer."
accept []
require_atomic? false

argument :new_owner_id, :uuid do
allow_nil? false
end

validate fn changeset, _ctx ->
new_owner_id = Ash.Changeset.get_argument(changeset, :new_owner_id)

cond do
is_nil(new_owner_id) ->
{:error, field: :new_owner_id, message: "is required"}

new_owner_id == changeset.data.owner_id ->
{:error, field: :new_owner_id, message: "is already the group owner"}

true ->
:ok
end
end

change Huddlz.Communities.Group.Changes.TransferOwnership
end
end

policies do
Expand All @@ -144,6 +177,11 @@ defmodule Huddlz.Communities.Group do
authorize_if expr(owner_id == ^actor(:id))
end

# Only the current owner can transfer ownership.
policy action(:transfer_ownership) do
authorize_if expr(owner_id == ^actor(:id))
end

# Group owner can do anything with their groups (for non-create actions)
policy action_type([:update, :destroy]) do
# Explicitly forbid users that are not the owner
Expand Down
22 changes: 15 additions & 7 deletions lib/huddlz/communities/group/changes/add_owner_as_member.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,22 @@ defmodule Huddlz.Communities.Group.Changes.AddOwnerAsMember do

@impl true
def change(changeset, _opts, _context) do
Ash.Changeset.after_action(changeset, fn _changeset, group ->
# Automatically add the owner as a member with owner role
Ash.Changeset.after_action(changeset, fn cs, group ->
# Automatically add the owner as a member with owner role.
# Pass the actor (the new owner) through so any add-member side
# effects can recognise this as a self-add and skip notifications.
actor = cs.context[:private][:actor]

Huddlz.Communities.GroupMember
|> Ash.Changeset.for_create(:add_member, %{
group_id: group.id,
user_id: group.owner_id,
role: "owner"
})
|> Ash.Changeset.for_create(
:add_member,
%{
group_id: group.id,
user_id: group.owner_id,
role: "owner"
},
actor: actor
)
|> Ash.create!(authorize?: false)

{:ok, group}
Expand Down
63 changes: 63 additions & 0 deletions lib/huddlz/communities/group/changes/notify_archived.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
defmodule Huddlz.Communities.Group.Changes.NotifyArchived do
@moduledoc """
Enqueues B6 (group_archived) notifications when a group is destroyed.

Captures member user_ids in `before_action` because the GroupMember
rows cascade-delete with the group. The actor (the user destroying
the group, typically the owner) is excluded from the recipients.
Fans out emails in `after_action` once the destroy commits.
"""

use Ash.Resource.Change

require Ash.Query

alias Huddlz.Accounts.User
alias Huddlz.Communities.GroupMember
alias Huddlz.Notifications

@impl true
def change(changeset, _opts, _context) do
changeset
|> Ash.Changeset.before_action(&capture_members/1)
|> Ash.Changeset.after_action(&notify/2)
end

defp capture_members(cs) do
user_ids =
GroupMember
|> Ash.Query.filter(group_id == ^cs.data.id)
|> Ash.Query.select([:user_id])
|> Ash.read!(authorize?: false)
|> Enum.map(& &1.user_id)
|> Enum.uniq()

actor_id =
case cs.context[:private][:actor] do
%{id: id} -> id
_ -> nil
end

recipients = Enum.reject(user_ids, &(&1 == actor_id))

Ash.Changeset.put_context(cs, :group_archived_recipients, recipients)
end

defp notify(cs, group) do
recipients = cs.context[:group_archived_recipients] || []

payload = %{
"group_id" => group.id,
"group_name" => to_string(group.name)
}

for user_id <- recipients do
case Ash.get(User, user_id, authorize?: false) do
{:ok, user} -> Notifications.deliver_async(user, :group_archived, payload)
_ -> :noop
end
end

{:ok, group}
end
end
126 changes: 126 additions & 0 deletions lib/huddlz/communities/group/changes/transfer_ownership.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
defmodule Huddlz.Communities.Group.Changes.TransferOwnership do
@moduledoc """
Implements the side-effects of `Group.:transfer_ownership`:

* Sets `owner_id` to the new owner.
* Demotes the previous owner's GroupMember row to `:organizer`.
* Promotes the new owner's GroupMember row to `:owner`, creating the
membership if the new owner is not already in the group.
* Enqueues B7 notifications to both the previous and new owners.

Runs as a single `after_action` hook so it lands inside the same Ash
transaction as the owner_id update.
"""

use Ash.Resource.Change

require Ash.Query

alias Huddlz.Accounts.User
alias Huddlz.Communities.GroupMember
alias Huddlz.Notifications

@impl true
def change(changeset, _opts, _context) do
new_owner_id = Ash.Changeset.get_argument(changeset, :new_owner_id)
previous_owner_id = changeset.data.owner_id

changeset
|> Ash.Changeset.force_change_attribute(:owner_id, new_owner_id)
|> Ash.Changeset.after_action(fn _cs, group ->
with :ok <- demote(group.id, previous_owner_id),
:ok <- promote(group.id, new_owner_id) do
notify(group, previous_owner_id, new_owner_id)
{:ok, group}
end
end)
end

defp notify(group, previous_owner_id, new_owner_id) do
previous_owner =
case Ash.get(User, previous_owner_id, authorize?: false) do
{:ok, %User{} = u} -> u
_ -> nil
end

new_owner =
case Ash.get(User, new_owner_id, authorize?: false) do
{:ok, %User{} = u} -> u
_ -> nil
end

base = %{
"group_id" => group.id,
"group_name" => to_string(group.name),
"group_slug" => group.slug,
"previous_owner_display_name" => previous_owner && previous_owner.display_name,
"new_owner_display_name" => new_owner && new_owner.display_name
}

if previous_owner do
Notifications.deliver_async(
previous_owner,
:group_ownership_transferred,
Map.put(base, "role", "previous_owner")
)
end

if new_owner do
Notifications.deliver_async(
new_owner,
:group_ownership_transferred,
Map.put(base, "role", "new_owner")
)
end
end

defp demote(_group_id, nil), do: :ok

defp demote(group_id, user_id) do
case fetch_membership(group_id, user_id) do
nil ->
:ok

membership ->
membership
|> Ash.Changeset.for_update(:set_role, %{role: :organizer})
|> Ash.update(authorize?: false)
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
end

defp promote(group_id, user_id) do
case fetch_membership(group_id, user_id) do
nil ->
GroupMember
|> Ash.Changeset.for_create(:add_member, %{
group_id: group_id,
user_id: user_id,
role: "owner"
})
|> Ash.create(authorize?: false)
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end

membership ->
membership
|> Ash.Changeset.for_update(:set_role, %{role: :owner})
|> Ash.update(authorize?: false)
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
end

defp fetch_membership(group_id, user_id) do
GroupMember
|> Ash.Query.filter(group_id == ^group_id and user_id == ^user_id)
|> Ash.read_one!(authorize?: false)
end
end
41 changes: 41 additions & 0 deletions lib/huddlz/communities/group_member.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ defmodule Huddlz.Communities.GroupMember do
destroy :leave_group, :leave_group
create :add_member, :add_member
destroy :remove_member, :remove_member
update :change_member_role, :change_role
end
end

Expand All @@ -38,6 +39,7 @@ defmodule Huddlz.Communities.GroupMember do
delete :leave_group
post :add_member, route: "/add"
route :delete, "/remove", :remove_member_by_ids
patch :change_role, route: "/role"
end
end

Expand Down Expand Up @@ -81,10 +83,12 @@ defmodule Huddlz.Communities.GroupMember do
change manage_relationship(:group_id, :group, type: :append)
change manage_relationship(:user_id, :user, type: :append)
change set_attribute(:role, arg(:role))
change Huddlz.Communities.GroupMember.Changes.NotifyAdded
end

destroy :remove_member do
description "Remove a user from a group"
require_atomic? false

argument :group_id, :uuid do
allow_nil? false
Expand All @@ -96,6 +100,8 @@ defmodule Huddlz.Communities.GroupMember do

filter expr(group_id == ^arg(:group_id))
filter expr(user_id == ^arg(:user_id))

change Huddlz.Communities.GroupMember.Changes.NotifyRemoved
end

action :remove_member_by_ids, :struct do
Expand All @@ -113,6 +119,29 @@ defmodule Huddlz.Communities.GroupMember do
run Huddlz.Communities.GroupMember.Actions.RemoveMemberByIds
end

update :change_role do
description "Change a member's rol DCFC e within a group (owner only). Cannot promote to :owner — use Group.:transfer_ownership."
accept [:role]
require_atomic? false

validate attribute_in(:role, [:member, :organizer]) do
message "must be :member or :organizer (use transfer_ownership to change the owner)"
end

change Huddlz.Communities.GroupMember.Changes.NotifyRoleChanged
end

update :set_role do
description """
Internal helper used by Group.:transfer_ownership to swap roles
without the :change_role validation. Always forbidden by policy —
callers must pass `authorize?: false`.
"""

accept [:role]
require_atomic? false
end

create :join_group do
description "Join a group as a regular member as the current actor"

Expand All @@ -123,6 +152,7 @@ defmodule Huddlz.Communities.GroupMember do
change manage_relationship(:group_id, :group, type: :append)
change relate_actor(:user)
change set_attribute(:role, :member)
change Huddlz.Communities.GroupMember.Changes.NotifyJoined
end

destroy :leave_group do
Expand Down Expand Up @@ -163,6 +193,17 @@ defmodule Huddlz.Communities.GroupMember do
authorize_if GroupOwner
end

# Group owners can change a member's role, but never their own.
policy action(:change_role) do
forbid_if expr(user_id == ^actor(:id))
authorize_if expr(group.owner_id == ^actor(:id))
end

# Internal-only — must be called with `authorize?: false`.
policy action(:set_role) do
forbid_if always()
end

# The remove-by-ids wrapper authorizes by delegating to :remove_member
# internally. Allow any authenticated actor through this gate; the inner
# destroy is the real check.
Expand Down
Loading
Loading
0