feat: add approval ui for join requests
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Simon 2026-03-11 02:04:03 +01:00
parent 50433e607f
commit 86d9242d83
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
22 changed files with 1624 additions and 12 deletions

View file

@ -40,6 +40,13 @@ defmodule Mv.Membership.JoinRequest do
change Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist
end
# Internal/seeding only: create with status submitted (no policy allows; use authorize?: false).
create :create_submitted do
description "Create a join request with status submitted (seeds, internal use only)"
accept [:email, :first_name, :last_name, :form_data, :schema_version]
change Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding
end
read :get_by_confirmation_token_hash do
description "Find a join request by confirmation token hash (for confirm flow only)"
argument :confirmation_token_hash, :string, allow_nil?: false
@ -56,25 +63,64 @@ defmodule Mv.Membership.JoinRequest do
change Mv.Membership.JoinRequest.Changes.ConfirmRequest
end
update :approve do
description "Approve a submitted join request and promote to Member"
require_atomic? false
change Mv.Membership.JoinRequest.Changes.ApproveRequest
end
update :reject do
description "Reject a submitted join request"
require_atomic? false
change Mv.Membership.JoinRequest.Changes.RejectRequest
end
end
policies do
policy action(:submit) do
# Use :strict so unauthorized access returns Forbidden (not empty list).
# Default :filter would silently return [] for unauthorized reads instead of Forbidden.
default_access_type :strict
# Public actions: bypass so nil actor is immediately authorized (skips all remaining policies).
# Using bypass (not policy) avoids AND-combination with the read policy below.
bypass action(:submit) do
description "Allow unauthenticated submit (public join form)"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
policy action(:get_by_confirmation_token_hash) do
bypass action(:get_by_confirmation_token_hash) do
description "Allow unauthenticated lookup by token hash for confirm"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
policy action(:confirm) do
bypass action(:confirm) do
description "Allow unauthenticated confirm (confirmation link click)"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
# Default read/destroy: no policy for actor nil → Forbidden
# READ: bypass for authorized roles (normal_user, admin).
# Uses a SimpleCheck (HasJoinRequestAccess) to avoid HasPermission.auto_filter returning
# expr(false), which would silently produce an empty list instead of Forbidden for
# unauthorized actors. See docs/policy-bypass-vs-haspermission.md.
# Unauthorized actors fall through to no matching policy → Ash default deny (Forbidden).
bypass action_type(:read) do
description "Allow normal_user and admin to read join requests (SimpleCheck bypass)"
authorize_if Mv.Authorization.Checks.HasJoinRequestAccess
end
# Approve/Reject: only actors with JoinRequest update permission
policy action(:approve) do
description "Allow authenticated users with JoinRequest update permission to approve"
authorize_if Mv.Authorization.Checks.HasPermission
end
policy action(:reject) do
description "Allow authenticated users with JoinRequest update permission to reject"
authorize_if Mv.Authorization.Checks.HasPermission
end
end
validations do
@ -135,6 +181,13 @@ defmodule Mv.Membership.JoinRequest do
update_timestamp :updated_at
end
relationships do
belongs_to :reviewed_by_user, Mv.Accounts.User do
define_attribute? false
source_attribute :reviewed_by_user_id
end
end
# Public helpers (used by SetConfirmationToken change and domain confirm_join_request)
@doc """

View file

@ -0,0 +1,37 @@
defmodule Mv.Membership.JoinRequest.Changes.ApproveRequest do
@moduledoc """
Sets the join request to approved and records the reviewer.
Only transitions from :submitted status. If already approved, returns error
(idempotency guard via status validation). Promotion to Member is handled
by the domain function approve_join_request/2 after calling this action.
"""
use Ash.Resource.Change
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, context) do
current_status = Ash.Changeset.get_data(changeset, :status)
if current_status == :submitted do
reviewed_by_id = actor_id(context.actor)
changeset
|> Ash.Changeset.force_change_attribute(:status, :approved)
|> Ash.Changeset.force_change_attribute(:approved_at, DateTime.utc_now())
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
else
Ash.Changeset.add_error(changeset,
field: :status,
message: "can only approve a submitted join request (current status: #{current_status})"
)
end
end
defp actor_id(nil), do: nil
defp actor_id(actor) when is_map(actor) do
Map.get(actor, :id) || Map.get(actor, "id")
end
defp actor_id(_), do: nil
end

View file

@ -0,0 +1,36 @@
defmodule Mv.Membership.JoinRequest.Changes.RejectRequest do
@moduledoc """
Sets the join request to rejected and records the reviewer.
Only transitions from :submitted status. Returns an error for any other status.
No reason field in MVP; audit fields (rejected_at, reviewed_by_user_id) are set.
"""
use Ash.Resource.Change
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, context) do
current_status = Ash.Changeset.get_data(changeset, :status)
if current_status == :submitted do
reviewed_by_id = actor_id(context.actor)
changeset
|> Ash.Changeset.force_change_attribute(:status, :rejected)
|> Ash.Changeset.force_change_attribute(:rejected_at, DateTime.utc_now())
|> Ash.Changeset.force_change_attribute(:reviewed_by_user_id, reviewed_by_id)
else
Ash.Changeset.add_error(changeset,
field: :status,
message: "can only reject a submitted join request (current status: #{current_status})"
)
end
end
defp actor_id(nil), do: nil
defp actor_id(actor) when is_map(actor) do
Map.get(actor, :id) || Map.get(actor, "id")
end
defp actor_id(_), do: nil
end

View file

@ -0,0 +1,15 @@
defmodule Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding do
@moduledoc """
Sets status to :submitted and submitted_at for seed/internal creation.
Used only by the :create_submitted action (e.g. seeds, no policy allows it for normal actors).
"""
use Ash.Resource.Change
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
changeset
|> Ash.Changeset.force_change_attribute(:status, :submitted)
|> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now())
end
end

View file

@ -87,7 +87,8 @@ defmodule Mv.Membership do
end
resource Mv.Membership.JoinRequest do
# submit_join_request/2 implemented as custom function below (create + send email)
# Public submit/confirm and approval domain functions are implemented as custom
# functions below to handle cross-resource operations (Member promotion on approve).
end
end
@ -507,4 +508,202 @@ defmodule Mv.Membership do
defp expired?(nil), do: true
defp expired?(expires_at), do: DateTime.compare(expires_at, DateTime.utc_now()) == :lt
# ---------------------------------------------------------------------------
# Step 2: Approval domain functions
# ---------------------------------------------------------------------------
@doc """
Lists join requests, optionally filtered by status.
## Options
- `:actor` - Required. The actor for authorization (normal_user or admin).
- `:status` - Optional atom to filter by status (default: `:submitted`).
Pass `:all` to return requests of all statuses.
## Returns
- `{:ok, list}` - List of JoinRequests
- `{:error, error}` - Authorization or query error
"""
@spec list_join_requests(keyword()) :: {:ok, [JoinRequest.t()]} | {:error, term()}
def list_join_requests(opts \\ []) do
actor = Keyword.get(opts, :actor)
status = Keyword.get(opts, :status, :submitted)
query =
if status == :all do
JoinRequest
|> Ash.Query.sort(inserted_at: :desc)
else
JoinRequest
|> Ash.Query.filter(expr(status == ^status))
|> Ash.Query.sort(inserted_at: :desc)
end
Ash.read(query, actor: actor, domain: __MODULE__)
end
@doc """
Lists join requests with status `:approved` or `:rejected` (history), sorted by most recent first.
Loads `:reviewed_by_user` for displaying the reviewer. Same authorization as `list_join_requests/1`.
## Options
- `:actor` - Required. The actor for authorization (normal_user or admin).
## Returns
- `{:ok, list}` - List of JoinRequests (approved/rejected only)
- `{:error, error}` - Authorization or query error
"""
@spec list_join_requests_history(keyword()) :: {:ok, [JoinRequest.t()]} | {:error, term()}
def list_join_requests_history(opts \\ []) do
actor = Keyword.get(opts, :actor)
query =
JoinRequest
|> Ash.Query.filter(expr(status in [:approved, :rejected]))
|> Ash.Query.sort(updated_at: :desc)
|> Ash.Query.load(:reviewed_by_user)
Ash.read(query, actor: actor, domain: __MODULE__)
end
@doc """
Returns the count of join requests with status `:submitted` (unprocessed).
Used e.g. for sidebar indicator. Same authorization as `list_join_requests/1`.
## Options
- `:actor` - Required. The actor for authorization (normal_user or admin).
## Returns
- Non-negative integer (0 on error or when unauthorized).
"""
@spec count_submitted_join_requests(keyword()) :: non_neg_integer()
def count_submitted_join_requests(opts \\ []) do
actor = Keyword.get(opts, :actor)
query = JoinRequest |> Ash.Query.filter(expr(status == :submitted))
case Ash.count(query, actor: actor, domain: __MODULE__) do
{:ok, count} when is_integer(count) and count >= 0 -> count
_ -> 0
end
end
@doc """
Gets a single JoinRequest by id.
## Options
- `:actor` - Required. The actor for authorization.
## Returns
- `{:ok, request}` - The JoinRequest
- `{:ok, nil}` - Not found
- `{:error, error}` - Authorization or query error
"""
@spec get_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t() | nil} | {:error, term()}
def get_join_request(id, opts \\ []) do
actor = Keyword.get(opts, :actor)
Ash.get(JoinRequest, id, actor: actor, load: [:reviewed_by_user], domain: __MODULE__)
end
@doc """
Approves a join request and promotes it to a Member.
Finds the JoinRequest by id, calls the :approve action (which sets status to
:approved and records the reviewer), then creates a Member from the typed fields
and form_data. Idempotency: if the request is already approved, returns an error.
## Options
- `:actor` - Required. The reviewer (normal_user or admin).
## Returns
- `{:ok, approved_request}` - Approved JoinRequest
- `{:error, error}` - Status error, authorization error, or Member creation error
"""
@spec approve_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()}
def approve_join_request(id, opts \\ []) do
actor = Keyword.get(opts, :actor)
with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__),
{:ok, approved} <-
request
|> Ash.Changeset.for_update(:approve, %{}, actor: actor, domain: __MODULE__)
|> Ash.update(actor: actor, domain: __MODULE__),
{:ok, _member} <- promote_to_member(approved, actor) do
{:ok, approved}
end
end
@doc """
Rejects a join request.
Finds the JoinRequest by id and calls the :reject action (status :rejected,
records reviewer). No Member is created. Returns error if not in :submitted status.
## Options
- `:actor` - Required. The reviewer (normal_user or admin).
## Returns
- `{:ok, rejected_request}` - Rejected JoinRequest
- `{:error, error}` - Status error or authorization error
"""
@spec reject_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()}
def reject_join_request(id, opts \\ []) do
actor = Keyword.get(opts, :actor)
with {:ok, request} <- Ash.get(JoinRequest, id, actor: actor, domain: __MODULE__) do
request
|> Ash.Changeset.for_update(:reject, %{}, actor: actor, domain: __MODULE__)
|> Ash.update(actor: actor, domain: __MODULE__)
end
end
# Builds Member attrs + custom_field_values from a JoinRequest and creates the Member.
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
defp promote_to_member(%JoinRequest{} = request, actor) do
{member_attrs, custom_field_values} = build_member_attrs(request)
attrs =
if Enum.empty?(custom_field_values) do
member_attrs
else
Map.put(member_attrs, :custom_field_values, custom_field_values)
end
Ash.create(Mv.Membership.Member, attrs,
action: :create_member,
actor: actor,
domain: __MODULE__
)
end
defp build_member_attrs(%JoinRequest{} = request) do
# join_date defaults to today so membership fee cycles can be generated.
base_attrs = %{
email: request.email,
first_name: request.first_name,
last_name: request.last_name,
join_date: Date.utc_today()
}
form_data = request.form_data || %{}
Enum.reduce(form_data, {base_attrs, []}, fn {key, value}, {attrs, cfvs} ->
cond do
key in @member_field_strings ->
atom_key = String.to_existing_atom(key)
{Map.put(attrs, atom_key, value), cfvs}
Regex.match?(@uuid_pattern, key) ->
cfv = %{custom_field_id: key, value: to_string(value)}
{attrs, [cfv | cfvs]}
true ->
{attrs, cfvs}
end
end)
end
end