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 change Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist 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 # Format/formatting of email is not validated here; invalid addresses may fail at send time # or can be enforced via an Ash change if needed. 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