This commit is contained in:
parent
883e7a3e62
commit
e7393e32d8
6 changed files with 344 additions and 2 deletions
141
lib/membership/join_request.ex
Normal file
141
lib/membership/join_request.ex
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
defmodule Mv.Membership.JoinRequest do
|
||||
@moduledoc """
|
||||
Ash resource for public join requests (onboarding flow).
|
||||
|
||||
Created only after email confirmation (double opt-in). Per concept §2.3.2:
|
||||
- email (dedicated field), payload, schema_version, status, submitted_at, source
|
||||
- approved_at, rejected_at, reviewed_by_user_id for audit (Step 2)
|
||||
- confirmation_token_hash for idempotency (unique constraint)
|
||||
"""
|
||||
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 :create do
|
||||
primary? true
|
||||
accept [
|
||||
:email,
|
||||
:confirmation_token_hash,
|
||||
:status,
|
||||
:submitted_at,
|
||||
:source,
|
||||
:schema_version,
|
||||
:payload,
|
||||
:approved_at,
|
||||
:rejected_at,
|
||||
:reviewed_by_user_id
|
||||
]
|
||||
end
|
||||
|
||||
create :confirm do
|
||||
description "Public action: create JoinRequest after confirmation link click (actor: nil)"
|
||||
accept [
|
||||
:email,
|
||||
:confirmation_token_hash,
|
||||
:status,
|
||||
:submitted_at,
|
||||
:source,
|
||||
:schema_version,
|
||||
:payload
|
||||
]
|
||||
end
|
||||
|
||||
update :update do
|
||||
accept [:status, :approved_at, :rejected_at, :reviewed_by_user_id]
|
||||
require_atomic? false
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action(:confirm) do
|
||||
description "Allow public confirmation (actor nil) for join flow"
|
||||
authorize_if Ash.Policy.Check.Builtins.actor_absent()
|
||||
end
|
||||
|
||||
policy action_type(:read) do
|
||||
description "Allow read when actor nil (success page) or when user has permission"
|
||||
authorize_if Ash.Policy.Check.Builtins.actor_absent()
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
policy action(:create) do
|
||||
description "Generic create only for authorized users; public uses :confirm"
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
|
||||
policy action_type([:update, :destroy]) do
|
||||
authorize_if Mv.Authorization.Checks.HasPermission
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_v7_primary_key :id
|
||||
|
||||
attribute :email, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :confirmation_token_hash, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :status, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
default "submitted"
|
||||
end
|
||||
|
||||
attribute :submitted_at, :utc_datetime_usec do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :source, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :schema_version, :integer do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :payload, :map do
|
||||
allow_nil? true
|
||||
public? true
|
||||
default %{}
|
||||
end
|
||||
|
||||
attribute :approved_at, :utc_datetime_usec do
|
||||
allow_nil? true
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :rejected_at, :utc_datetime_usec do
|
||||
allow_nil? true
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :reviewed_by_user_id, :uuid do
|
||||
allow_nil? true
|
||||
public? true
|
||||
end
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_confirmation_token_hash, [:confirmation_token_hash]
|
||||
end
|
||||
end
|
||||
|
|
@ -78,6 +78,52 @@ defmodule Mv.Membership do
|
|||
define :list_member_groups, action: :read
|
||||
define :destroy_member_group, action: :destroy
|
||||
end
|
||||
|
||||
resource Mv.Membership.JoinRequest do
|
||||
define :list_join_requests, action: :read
|
||||
define :get_join_request, action: :read, get_by: [:id]
|
||||
define :update_join_request, action: :update
|
||||
define :destroy_join_request, action: :destroy
|
||||
end
|
||||
end
|
||||
|
||||
# Idempotent confirm: implemented in code so duplicate token returns {:ok, existing} (concept §2.3.2)
|
||||
@doc """
|
||||
Creates a JoinRequest after confirmation link click (public action with actor: nil).
|
||||
|
||||
Idempotent: if a JoinRequest with the same `confirmation_token_hash` already exists,
|
||||
returns `{:ok, existing}` instead of creating a duplicate (per concept §2.3.2).
|
||||
"""
|
||||
def confirm_join_request(attrs, opts \\ []) do
|
||||
hash = attrs[:confirmation_token_hash] || attrs["confirmation_token_hash"]
|
||||
|
||||
if hash do
|
||||
case get_join_request_by_confirmation_token_hash!(hash, opts) do
|
||||
nil -> do_confirm_join_request(attrs, opts)
|
||||
existing -> {:ok, existing}
|
||||
end
|
||||
else
|
||||
do_confirm_join_request(attrs, opts)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_confirm_join_request(attrs, opts) do
|
||||
Mv.Membership.JoinRequest
|
||||
|> Ash.Changeset.for_create(:confirm, attrs)
|
||||
|> Ash.create(Keyword.put(opts, :domain, __MODULE__))
|
||||
end
|
||||
|
||||
defp get_join_request_by_confirmation_token_hash!(hash, opts) do
|
||||
opts = Keyword.put(opts, :domain, __MODULE__)
|
||||
|
||||
Mv.Membership.JoinRequest
|
||||
|> Ash.Query.filter(confirmation_token_hash == ^hash)
|
||||
|> Ash.read_one(opts)
|
||||
|> case do
|
||||
{:ok, %Mv.Membership.JoinRequest{} = existing} -> existing
|
||||
{:ok, nil} -> nil
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Singleton pattern: Get the single settings record
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue