147 lines
4.7 KiB
Elixir
147 lines
4.7 KiB
Elixir
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 are backend-internal for now; set public? true when exposing via AshJsonApi/AshGraphql
|
||
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
|
||
|
||
# Public helpers (used by SetConfirmationToken change and domain confirm_join_request)
|
||
|
||
@doc """
|
||
Returns the SHA256 hash of the confirmation token (lowercase hex).
|
||
|
||
Used when creating a join request (submit) and when confirming by token.
|
||
Only one implementation ensures algorithm changes stay in sync.
|
||
"""
|
||
@spec hash_confirmation_token(String.t()) :: String.t()
|
||
def hash_confirmation_token(token) when is_binary(token) do
|
||
:crypto.hash(:sha256, token) |> Base.encode16(case: :lower)
|
||
end
|
||
end
|