746 lines
25 KiB
Elixir
746 lines
25 KiB
Elixir
defmodule Mv.Membership do
|
|
@moduledoc """
|
|
Ash Domain for membership management.
|
|
|
|
## Resources
|
|
- `Member` - Club members with personal information and custom field values
|
|
- `CustomFieldValue` - Dynamic custom field values attached to members
|
|
- `CustomField` - Schema definitions for custom fields
|
|
- `Setting` - Global application settings (singleton)
|
|
- `Group` - Groups that members can belong to
|
|
- `MemberGroup` - Join table for many-to-many relationship between Members and Groups
|
|
- `JoinRequest` - Public join form submissions (pending_confirmation → submitted after email confirm)
|
|
|
|
## Public API
|
|
The domain exposes these main actions:
|
|
- Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1`
|
|
- Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc.
|
|
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, `list_required_custom_fields/1`, etc.
|
|
- Settings management: `get_settings/0`, `update_settings/2`, `update_member_field_visibility/2`, `update_single_member_field_visibility/3`
|
|
- Group management: `create_group/1`, `list_groups/0`, `update_group/2`, `destroy_group/1`
|
|
- Member-group associations: `create_member_group/1`, `list_member_groups/0`, `destroy_member_group/1`
|
|
|
|
## Admin Interface
|
|
The domain is configured with AshAdmin for management UI.
|
|
"""
|
|
use Ash.Domain,
|
|
extensions: [AshAdmin.Domain, AshPhoenix]
|
|
|
|
require Ash.Query
|
|
import Ash.Expr
|
|
alias Ash.Error.Query.NotFound, as: NotFoundError
|
|
alias Mv.Membership.JoinRequest
|
|
alias MvWeb.Emails.JoinConfirmationEmail
|
|
require Logger
|
|
|
|
admin do
|
|
show? true
|
|
end
|
|
|
|
resources do
|
|
resource Mv.Membership.Member do
|
|
define :create_member, action: :create_member
|
|
define :list_members, action: :read
|
|
define :update_member, action: :update_member
|
|
define :destroy_member, action: :destroy
|
|
end
|
|
|
|
resource Mv.Membership.CustomFieldValue do
|
|
define :create_custom_field_value, action: :create
|
|
define :list_custom_field_values, action: :read
|
|
define :update_custom_field_value, action: :update
|
|
define :destroy_custom_field_value, action: :destroy
|
|
end
|
|
|
|
resource Mv.Membership.CustomField do
|
|
define :create_custom_field, action: :create
|
|
define :list_custom_fields, action: :read
|
|
define :update_custom_field, action: :update
|
|
define :destroy_custom_field, action: :destroy_with_values
|
|
define :prepare_custom_field_deletion, action: :prepare_deletion, args: [:id]
|
|
end
|
|
|
|
resource Mv.Membership.Setting do
|
|
# Note: create action exists but is not exposed via code interface
|
|
# It's only used internally as fallback in get_settings/0
|
|
# Settings should be created via seed script
|
|
define :update_settings, action: :update
|
|
define :update_member_field_visibility, action: :update_member_field_visibility
|
|
|
|
define :update_single_member_field_visibility,
|
|
action: :update_single_member_field_visibility
|
|
|
|
define :update_single_member_field, action: :update_single_member_field
|
|
end
|
|
|
|
resource Mv.Membership.Group do
|
|
define :create_group, action: :create
|
|
define :list_groups, action: :read
|
|
define :update_group, action: :update
|
|
define :destroy_group, action: :destroy
|
|
end
|
|
|
|
resource Mv.Membership.MemberGroup do
|
|
define :create_member_group, action: :create
|
|
define :list_member_groups, action: :read
|
|
define :destroy_member_group, action: :destroy
|
|
end
|
|
|
|
resource Mv.Membership.JoinRequest do
|
|
# Public submit/confirm and approval domain functions are implemented as custom
|
|
# functions below to handle cross-resource operations (Member promotion on approve).
|
|
end
|
|
end
|
|
|
|
# Singleton pattern: Get the single settings record
|
|
@doc """
|
|
Gets the global settings.
|
|
|
|
Settings should normally be created via the seed script (`priv/repo/seeds.exs`).
|
|
If no settings exist, this function will create them as a fallback using the
|
|
`ASSOCIATION_NAME` environment variable or "Club Name" as default.
|
|
|
|
## Returns
|
|
|
|
- `{:ok, settings}` - The settings record
|
|
- `{:ok, nil}` - No settings exist (should not happen if seeds were run)
|
|
- `{:error, error}` - Error reading settings
|
|
|
|
## Examples
|
|
|
|
iex> {:ok, settings} = Mv.Membership.get_settings()
|
|
iex> settings.club_name
|
|
"My Club"
|
|
|
|
"""
|
|
def get_settings do
|
|
# Try to get the first (and only) settings record
|
|
case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do
|
|
{:ok, nil} ->
|
|
# No settings exist - create as fallback (should normally be created via seed script)
|
|
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
|
|
|
|
Mv.Membership.Setting
|
|
|> Ash.Changeset.for_create(:create, %{
|
|
club_name: default_club_name,
|
|
member_field_visibility: %{"exit_date" => false}
|
|
})
|
|
|> Ash.create!(domain: __MODULE__)
|
|
|> then(fn settings -> {:ok, settings} end)
|
|
|
|
{:ok, settings} ->
|
|
{:ok, settings}
|
|
|
|
{:error, error} ->
|
|
{:error, error}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Updates the global settings.
|
|
|
|
## Parameters
|
|
|
|
- `settings` - The settings record to update
|
|
- `attrs` - A map of attributes to update (e.g., `%{club_name: "New Name"}`)
|
|
|
|
## Returns
|
|
|
|
- `{:ok, updated_settings}` - Successfully updated settings
|
|
- `{:error, error}` - Validation or update error
|
|
|
|
## Examples
|
|
|
|
iex> {:ok, settings} = Mv.Membership.get_settings()
|
|
iex> {:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Club"})
|
|
iex> updated.club_name
|
|
"New Club"
|
|
|
|
"""
|
|
def update_settings(settings, attrs) do
|
|
settings
|
|
|> Ash.Changeset.for_update(:update, attrs)
|
|
|> Ash.update(domain: __MODULE__)
|
|
end
|
|
|
|
@doc """
|
|
Lists only required custom fields.
|
|
|
|
This is an optimized version that filters at the database level instead of
|
|
loading all custom fields and filtering in memory. Requires an actor for
|
|
authorization (CustomField read policy). Callers must pass `actor:`; no default.
|
|
|
|
## Options
|
|
|
|
- `:actor` - Required. The actor for authorization (e.g. current user).
|
|
All roles can read CustomField; actor must have a valid role.
|
|
|
|
## Returns
|
|
|
|
- `{:ok, required_custom_fields}` - List of required custom fields
|
|
- `{:error, :missing_actor}` - When actor is nil (caller must pass actor)
|
|
- `{:error, error}` - Error reading custom fields (e.g. Forbidden)
|
|
|
|
## Examples
|
|
|
|
iex> {:ok, required_fields} = Mv.Membership.list_required_custom_fields(actor: actor)
|
|
iex> Enum.all?(required_fields, & &1.required)
|
|
true
|
|
|
|
iex> Mv.Membership.list_required_custom_fields(actor: nil)
|
|
{:error, :missing_actor}
|
|
"""
|
|
def list_required_custom_fields(actor: actor) when not is_nil(actor) do
|
|
Mv.Membership.CustomField
|
|
|> Ash.Query.filter(expr(required == true))
|
|
|> Ash.read(domain: __MODULE__, actor: actor)
|
|
end
|
|
|
|
def list_required_custom_fields(actor: nil), do: {:error, :missing_actor}
|
|
|
|
@doc """
|
|
Updates the member field visibility configuration.
|
|
|
|
This is a specialized action for updating only the member field visibility settings.
|
|
It validates that all keys are valid member fields and all values are booleans.
|
|
|
|
## Parameters
|
|
|
|
- `settings` - The settings record to update
|
|
- `visibility_config` - A map of member field names (strings) to boolean visibility values
|
|
(e.g., `%{"street" => false, "house_number" => false}`)
|
|
|
|
## Returns
|
|
|
|
- `{:ok, updated_settings}` - Successfully updated settings
|
|
- `{:error, error}` - Validation or update error
|
|
|
|
## Examples
|
|
|
|
iex> {:ok, settings} = Mv.Membership.get_settings()
|
|
iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
|
iex> updated.member_field_visibility
|
|
%{"street" => false, "house_number" => false}
|
|
|
|
"""
|
|
def update_member_field_visibility(settings, visibility_config) do
|
|
settings
|
|
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
|
|
member_field_visibility: visibility_config
|
|
})
|
|
|> Ash.update(domain: __MODULE__)
|
|
end
|
|
|
|
@doc """
|
|
Atomically updates a single field in the member field visibility configuration.
|
|
|
|
This action uses PostgreSQL's jsonb_set function to atomically update a single key
|
|
in the JSONB map, preventing lost updates in concurrent scenarios. This is the
|
|
preferred method for updating individual field visibility settings.
|
|
|
|
## Parameters
|
|
|
|
- `settings` - The settings record to update
|
|
- `field` - The member field name as a string (e.g., "street", "house_number")
|
|
- `show_in_overview` - Boolean value indicating visibility
|
|
|
|
## Returns
|
|
|
|
- `{:ok, updated_settings}` - Successfully updated settings
|
|
- `{:error, error}` - Validation or update error
|
|
|
|
## Examples
|
|
|
|
iex> {:ok, settings} = Mv.Membership.get_settings()
|
|
iex> {:ok, updated} = Mv.Membership.update_single_member_field_visibility(settings, field: "street", show_in_overview: false)
|
|
iex> updated.member_field_visibility["street"]
|
|
false
|
|
|
|
"""
|
|
def update_single_member_field_visibility(settings,
|
|
field: field,
|
|
show_in_overview: show_in_overview
|
|
) do
|
|
settings
|
|
|> Ash.Changeset.new()
|
|
|> Ash.Changeset.set_argument(:field, field)
|
|
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
|
|> Ash.Changeset.for_update(:update_single_member_field_visibility, %{})
|
|
|> Ash.update(domain: __MODULE__)
|
|
end
|
|
|
|
@doc """
|
|
Atomically updates visibility and required for a single member field.
|
|
|
|
Updates both `member_field_visibility` and `member_field_required` in one
|
|
operation. Use this when saving from the member field settings form.
|
|
|
|
## Parameters
|
|
|
|
- `settings` - The settings record to update
|
|
- `field` - The member field name as a string (e.g., "first_name", "street")
|
|
- `show_in_overview` - Boolean value indicating visibility in member overview
|
|
- `required` - Boolean value indicating whether the field is required in member forms
|
|
|
|
## Returns
|
|
|
|
- `{:ok, updated_settings}` - Successfully updated settings
|
|
- `{:error, error}` - Validation or update error
|
|
|
|
## Examples
|
|
|
|
iex> {:ok, settings} = Mv.Membership.get_settings()
|
|
iex> {:ok, updated} = Mv.Membership.update_single_member_field(settings, field: "first_name", show_in_overview: true, required: true)
|
|
iex> updated.member_field_required["first_name"]
|
|
true
|
|
|
|
"""
|
|
def update_single_member_field(settings,
|
|
field: field,
|
|
show_in_overview: show_in_overview,
|
|
required: required
|
|
) do
|
|
settings
|
|
|> Ash.Changeset.new()
|
|
|> Ash.Changeset.set_argument(:field, field)
|
|
|> Ash.Changeset.set_argument(:show_in_overview, show_in_overview)
|
|
|> Ash.Changeset.set_argument(:required, required)
|
|
|> Ash.Changeset.for_update(:update_single_member_field, %{})
|
|
|> Ash.update(domain: __MODULE__)
|
|
end
|
|
|
|
@doc """
|
|
Gets a group by its slug.
|
|
|
|
Uses `Ash.Query.filter` to efficiently find a group by its slug.
|
|
The unique index on `slug` ensures efficient lookup performance.
|
|
The slug lookup is case-sensitive (exact match required).
|
|
|
|
## Parameters
|
|
|
|
- `slug` - The slug to search for (case-sensitive)
|
|
- `opts` - Options including `:actor` for authorization
|
|
|
|
## Returns
|
|
|
|
- `{:ok, group}` - Found group (with members and member_count loaded)
|
|
- `{:ok, nil}` - Group not found
|
|
- `{:error, error}` - Error reading group
|
|
|
|
## Examples
|
|
|
|
iex> {:ok, group} = Mv.Membership.get_group_by_slug("board-members", actor: actor)
|
|
iex> group.name
|
|
"Board Members"
|
|
|
|
iex> {:ok, nil} = Mv.Membership.get_group_by_slug("non-existent", actor: actor)
|
|
{:ok, nil}
|
|
|
|
"""
|
|
def get_group_by_slug(slug, opts \\ []) do
|
|
load = Keyword.get(opts, :load, [])
|
|
|
|
require Ash.Query
|
|
|
|
query =
|
|
Mv.Membership.Group
|
|
|> Ash.Query.filter(slug == ^slug)
|
|
|> Ash.Query.load(load)
|
|
|
|
opts
|
|
|> Keyword.delete(:load)
|
|
|> Keyword.put_new(:domain, __MODULE__)
|
|
|> then(&Ash.read_one(query, &1))
|
|
end
|
|
|
|
@doc """
|
|
Creates a join request (submit flow) and sends the confirmation email.
|
|
|
|
Generates a confirmation token if not provided in attrs (e.g. for tests, pass
|
|
`:confirmation_token` to get a known token). On success, sends one email with
|
|
the confirm link to the request email.
|
|
|
|
## Options
|
|
- `:actor` - Must be nil for public submit (policy allows only unauthenticated).
|
|
|
|
## Returns
|
|
- `{:ok, request}` - Created JoinRequest in status pending_confirmation, email sent
|
|
- `{:error, :email_delivery_failed}` - Request created but confirmation email could not be sent (logged)
|
|
- `{:error, error}` - Validation or authorization error
|
|
"""
|
|
def submit_join_request(attrs, opts \\ []) do
|
|
actor = Keyword.get(opts, :actor)
|
|
token = Map.get(attrs, :confirmation_token) || generate_confirmation_token()
|
|
|
|
# Raw token is passed to the submit action; JoinRequest.Changes.SetConfirmationToken
|
|
# hashes it before persist. Only the hash is stored; the raw token is sent in the email link.
|
|
attrs_with_token = Map.put(attrs, :confirmation_token, token)
|
|
|
|
case Ash.create(JoinRequest, attrs_with_token,
|
|
action: :submit,
|
|
actor: actor,
|
|
domain: __MODULE__
|
|
) do
|
|
{:ok, request} ->
|
|
case JoinConfirmationEmail.send(request.email, token) do
|
|
{:ok, _email} ->
|
|
{:ok, request}
|
|
|
|
{:error, reason} ->
|
|
Logger.error(
|
|
"Join confirmation email failed for #{request.email}: #{inspect(reason)}"
|
|
)
|
|
|
|
{:error, :email_delivery_failed}
|
|
end
|
|
|
|
error ->
|
|
error
|
|
end
|
|
end
|
|
|
|
defp generate_confirmation_token do
|
|
32
|
|
|> :crypto.strong_rand_bytes()
|
|
|> Base.url_encode64(padding: false)
|
|
end
|
|
|
|
@doc """
|
|
Confirms a join request by token (public confirmation link).
|
|
|
|
Hashes the token, finds the JoinRequest by confirmation_token_hash, checks that
|
|
the token has not expired, then updates to status :submitted. Idempotent: if
|
|
already submitted, approved, or rejected, returns the existing record without changing it.
|
|
|
|
## Options
|
|
- `:actor` - Must be nil for public confirm (policy allows only unauthenticated).
|
|
|
|
## Returns
|
|
- `{:ok, request}` - Updated or already-processed JoinRequest
|
|
- `{:error, :token_expired}` - Token was found but confirmation_token_expires_at is in the past
|
|
- `{:error, error}` - Token unknown/invalid or authorization error
|
|
"""
|
|
def confirm_join_request(token, opts \\ []) when is_binary(token) do
|
|
hash = JoinRequest.hash_confirmation_token(token)
|
|
actor = Keyword.get(opts, :actor)
|
|
|
|
query =
|
|
Ash.Query.for_read(JoinRequest, :get_by_confirmation_token_hash, %{
|
|
confirmation_token_hash: hash
|
|
})
|
|
|
|
case Ash.read_one(query, actor: actor, domain: __MODULE__) do
|
|
{:ok, nil} ->
|
|
{:error, NotFoundError.exception(resource: JoinRequest)}
|
|
|
|
{:ok, request} ->
|
|
do_confirm_request(request, actor)
|
|
|
|
{:error, error} ->
|
|
{:error, error}
|
|
end
|
|
end
|
|
|
|
defp do_confirm_request(request, _actor)
|
|
when request.status in [:submitted, :approved, :rejected] do
|
|
{:ok, request}
|
|
end
|
|
|
|
defp do_confirm_request(request, actor) do
|
|
if expired?(request.confirmation_token_expires_at) do
|
|
{:error, :token_expired}
|
|
else
|
|
request
|
|
|> Ash.Changeset.for_update(:confirm, %{}, domain: __MODULE__)
|
|
|> Ash.update(domain: __MODULE__, actor: actor)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Returns whether the public join form is enabled in global settings.
|
|
|
|
Used by the web layer (JoinRequest LiveViews, Layouts, plugs) to decide whether
|
|
to show join-related UI and to gate access to join request pages.
|
|
"""
|
|
@spec join_form_enabled?() :: boolean()
|
|
def join_form_enabled? do
|
|
case get_settings() do
|
|
{:ok, %{join_form_enabled: true}} -> true
|
|
_ -> false
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Returns the allowlist of fields configured for the public join form.
|
|
|
|
Reads the current settings. When the join form is disabled (or no settings exist),
|
|
returns an empty list. When enabled, returns each configured field as a map with:
|
|
- `:id` - field identifier string (member field name or custom field UUID)
|
|
- `:required` - boolean; email is always true
|
|
- `:type` - `:member_field` or `:custom_field`
|
|
|
|
This is the server-side allowlist used by the join form submit action (Subtask 4)
|
|
to enforce which fields are accepted from user input.
|
|
|
|
## Returns
|
|
|
|
- `[%{id: String.t(), required: boolean(), type: :member_field | :custom_field}]`
|
|
- `[]` when join form is disabled or settings are missing
|
|
|
|
## Examples
|
|
|
|
iex> Mv.Membership.get_join_form_allowlist()
|
|
[%{id: "email", required: true, type: :member_field},
|
|
%{id: "first_name", required: false, type: :member_field}]
|
|
|
|
"""
|
|
def get_join_form_allowlist do
|
|
case get_settings() do
|
|
{:ok, settings} ->
|
|
if settings.join_form_enabled do
|
|
build_join_form_allowlist(settings)
|
|
else
|
|
[]
|
|
end
|
|
|
|
{:error, _} ->
|
|
[]
|
|
end
|
|
end
|
|
|
|
defp build_join_form_allowlist(settings) do
|
|
field_ids = settings.join_form_field_ids || []
|
|
required_config = settings.join_form_field_required || %{}
|
|
member_field_names = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
|
|
|
Enum.map(field_ids, fn id ->
|
|
type = if id in member_field_names, do: :member_field, else: :custom_field
|
|
required = Map.get(required_config, id, false)
|
|
%{id: id, required: required, type: type}
|
|
end)
|
|
end
|
|
|
|
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
|
|
|
|
{:error, error} ->
|
|
Logger.debug("count_submitted_join_requests failed: #{inspect(error)}")
|
|
0
|
|
|
|
_ ->
|
|
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],
|
|
not_found_error?: false,
|
|
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)
|
|
|
|
result =
|
|
Ash.transact(JoinRequest, fn ->
|
|
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)
|
|
|
|
# Ash.transact returns {:ok, callback_result}; flatten so callers get {:ok, request} | {:error, term()}
|
|
case result do
|
|
{:ok, inner} -> inner
|
|
{:error, _} = err -> err
|
|
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
|
|
# Evaluated at compile time so we do not resolve member_fields() on every reduce step.
|
|
@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
|