feat: add join request resource
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Simon 2026-03-09 14:44:45 +01:00
parent 2a04fad4fe
commit 2515a679b8
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
9 changed files with 323 additions and 5 deletions

View file

@ -0,0 +1,133 @@
defmodule Mv.Membership.JoinRequest do
@moduledoc """
Ash resource for public join requests (onboarding, double opt-in).
A JoinRequest is created on form submit with status `pending_confirmation`, then
updated to `submitted` when the user clicks the confirmation link. No User or
Member is created in this flow; promotion happens in a later approval step.
## Public actions (actor: nil)
- `submit` (create) create with token hash and expiry
- `get_by_confirmation_token_hash` (read) lookup by token hash for confirm flow
- `confirm` (update) set status to submitted and invalidate token
## Schema
Typed: email (required), first_name, last_name. Remaining form data in form_data (jsonb).
Confirmation: confirmation_token_hash, confirmation_token_expires_at. Audit: submitted_at, etc.
"""
use Ash.Resource,
domain: Mv.Membership,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "join_requests"
repo Mv.Repo
end
actions do
defaults [:read, :destroy]
create :submit do
description "Create a join request (public form submit); stores token hash and expiry"
primary? true
argument :confirmation_token, :string, allow_nil?: false
accept [:email, :first_name, :last_name, :form_data, :schema_version]
change Mv.Membership.JoinRequest.Changes.SetConfirmationToken
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
filter expr(confirmation_token_hash == ^arg(:confirmation_token_hash))
prepare build(sort: [inserted_at: :desc], limit: 1)
end
update :confirm do
description "Mark join request as submitted and invalidate token (after link click)"
primary? true
require_atomic? false
change Mv.Membership.JoinRequest.Changes.ConfirmRequest
end
end
policies do
policy 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
description "Allow unauthenticated lookup by token hash for confirm"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
policy 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
end
validations do
validate present(:email), on: [:create]
end
attributes do
uuid_primary_key :id
attribute :status, :atom do
description "pending_confirmation | submitted | approved | rejected"
default :pending_confirmation
constraints one_of: [:pending_confirmation, :submitted, :approved, :rejected]
allow_nil? false
end
attribute :email, :string do
description "Email address (required for join form)"
allow_nil? false
end
attribute :first_name, :string
attribute :last_name, :string
attribute :form_data, :map do
description "Additional form fields (jsonb)"
end
attribute :schema_version, :integer do
description "Version of join form / member_fields for form_data"
end
attribute :confirmation_token_hash, :string do
description "SHA256 hash of confirmation token; raw token only in email link"
end
attribute :confirmation_token_expires_at, :utc_datetime_usec do
description "When the confirmation link expires (e.g. 24h)"
end
attribute :confirmation_sent_at, :utc_datetime_usec do
description "When the confirmation email was sent"
end
attribute :submitted_at, :utc_datetime_usec do
description "When the user confirmed (clicked the link)"
end
attribute :approved_at, :utc_datetime_usec
attribute :rejected_at, :utc_datetime_usec
attribute :reviewed_by_user_id, :uuid
attribute :source, :string
create_timestamp :inserted_at
update_timestamp :updated_at
end
end

View file

@ -0,0 +1,17 @@
defmodule Mv.Membership.JoinRequest.Changes.ConfirmRequest do
@moduledoc """
Sets the join request to submitted (confirmation link clicked).
Used by the confirm action after the user clicks the confirmation link.
Token hash is kept so that a second click (idempotent) can still find the record
and return success without changing it.
"""
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

@ -0,0 +1,32 @@
defmodule Mv.Membership.JoinRequest.Changes.SetConfirmationToken do
@moduledoc """
Hashes the confirmation token and sets expiry for the join request (submit flow).
Reads the :confirmation_token argument, stores only its SHA256 hash and sets
confirmation_token_expires_at (e.g. 24h). Raw token is never persisted.
"""
use Ash.Resource.Change
@confirmation_validity_hours 24
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
token = Ash.Changeset.get_argument(changeset, :confirmation_token)
if is_binary(token) and token != "" do
hash = token_hash(token)
expires_at = DateTime.utc_now() |> DateTime.add(@confirmation_validity_hours, :hour)
changeset
|> Ash.Changeset.force_change_attribute(:confirmation_token_hash, hash)
|> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at)
|> Ash.Changeset.force_change_attribute(:status, :pending_confirmation)
else
changeset
end
end
defp token_hash(token) do
:crypto.hash(:sha256, token) |> Base.encode16(case: :lower)
end
end

View file

@ -9,6 +9,7 @@ defmodule Mv.Membership do
- `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:
@ -27,6 +28,8 @@ defmodule Mv.Membership do
require Ash.Query
import Ash.Expr
alias Ash.Error.Query.NotFound, as: NotFoundError
alias Mv.Membership.JoinRequest
admin do
show? true
@ -80,6 +83,10 @@ defmodule Mv.Membership do
define :list_member_groups, action: :read
define :destroy_member_group, action: :destroy
end
resource Mv.Membership.JoinRequest do
define :submit_join_request, action: :submit
end
end
# Singleton pattern: Get the single settings record
@ -342,4 +349,49 @@ defmodule Mv.Membership do
|> Keyword.put_new(:domain, __MODULE__)
|> then(&Ash.read_one(query, &1))
end
@doc """
Confirms a join request by token (public confirmation link).
Hashes the token, finds the JoinRequest by confirmation_token_hash, then updates
to status :submitted and invalidates the token. Idempotent: if already submitted,
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-submitted JoinRequest
- `{:error, error}` - Token unknown/invalid or authorization error
"""
def confirm_join_request(token, opts \\ []) when is_binary(token) do
hash = confirmation_token_hash(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} ->
if request.status == :submitted do
{:ok, request}
else
request
|> Ash.Changeset.for_update(:confirm, %{}, domain: __MODULE__)
|> Ash.update(domain: __MODULE__, actor: actor)
end
{:error, error} ->
{:error, error}
end
end
defp confirmation_token_hash(token) do
:crypto.hash(:sha256, token) |> Base.encode16(case: :lower)
end
end