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 # Internal/seeding only: create with status submitted (no policy allows; use authorize?: false). create :create_submitted do description "Create a join request with status submitted (seeds, internal use only)" accept [:email, :first_name, :last_name, :form_data, :schema_version] change Mv.Membership.JoinRequest.Changes.SetSubmittedForSeeding 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 update :approve do description "Approve a submitted join request and promote to Member" require_atomic? false change Mv.Membership.JoinRequest.Changes.ApproveRequest end update :reject do description "Reject a submitted join request" require_atomic? false change Mv.Membership.JoinRequest.Changes.RejectRequest end # Internal: resend confirmation (new token) when user submits form again with same email. # Called from domain with authorize?: false; not exposed to public. update :regenerate_confirmation_token do description "Set new confirmation token and expiry (resend flow)" require_atomic? false argument :confirmation_token, :string, allow_nil?: false change Mv.Membership.JoinRequest.Changes.RegenerateConfirmationToken end end policies do # Use :strict so unauthorized access returns Forbidden (not empty list). # Default :filter would silently return [] for unauthorized reads instead of Forbidden. default_access_type :strict # Public actions: bypass so nil actor is immediately authorized (skips all remaining policies). # Using bypass (not policy) avoids AND-combination with the read policy below. bypass action(:submit) do description "Allow unauthenticated submit (public join form)" authorize_if Mv.Authorization.Checks.ActorIsNil end bypass action(:get_by_confirmation_token_hash) do description "Allow unauthenticated lookup by token hash for confirm" authorize_if Mv.Authorization.Checks.ActorIsNil end bypass action(:confirm) do description "Allow unauthenticated confirm (confirmation link click)" authorize_if Mv.Authorization.Checks.ActorIsNil end # READ: bypass for authorized roles (normal_user, admin). # Uses a SimpleCheck (HasJoinRequestAccess) to avoid HasPermission.auto_filter returning # expr(false), which would silently produce an empty list instead of Forbidden for # unauthorized actors. See docs/policy-bypass-vs-haspermission.md. # Unauthorized actors fall through to no matching policy → Ash default deny (Forbidden). bypass action_type(:read) do description "Allow normal_user and admin to read join requests (SimpleCheck bypass)" authorize_if Mv.Authorization.Checks.HasJoinRequestAccess end # Approve/Reject: only actors with JoinRequest update permission policy action(:approve) do description "Allow authenticated users with JoinRequest update permission to approve" authorize_if Mv.Authorization.Checks.HasPermission end policy action(:reject) do description "Allow authenticated users with JoinRequest update permission to reject" authorize_if Mv.Authorization.Checks.HasPermission end 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 :reviewed_by_display, :string do description "Denormalized reviewer display (e.g. email) for UI without loading User" end attribute :source, :string create_timestamp :inserted_at update_timestamp :updated_at end relationships do belongs_to :reviewed_by_user, Mv.Accounts.User do define_attribute? false source_attribute :reviewed_by_user_id end 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