mitgliederverwaltung/lib/membership/membership.ex
Simon 5deb102e45
All checks were successful
continuous-integration/drone/push Build is passing
refactor: adress review comments
2026-03-09 18:54:40 +01:00

460 lines
15 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
# submit_join_request/2 implemented as custom function below (create + send email)
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
- `{: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)}"
)
# Request was created; return success so the user sees the confirmation message
{:ok, request}
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
defp expired?(nil), do: true
defp expired?(expires_at), do: DateTime.compare(expires_at, DateTime.utc_now()) == :lt
end