add backend for join form #308 #438
6 changed files with 344 additions and 2 deletions
|
|
@ -335,6 +335,15 @@ end
|
|||
- show custom fields in member overview per default
|
||||
- can be set to false in the settings for the specific custom field
|
||||
|
||||
---
|
||||
|
||||
**Onboarding / Public Join (Issue #308) – Subtask 1: JoinRequest resource and public policies**
|
||||
- JoinRequest Ash resource (`lib/membership/join_request.ex`) per concept §2.3.2: email, confirmation_token_hash, status, submitted_at, source, schema_version, payload, approved_at, rejected_at, reviewed_by_user_id
|
||||
- Migration `20260220120000_add_join_requests.exs` with unique index on `confirmation_token_hash` for idempotency
|
||||
- Public policies: `:confirm` and `:read` allowed with `actor: nil`; generic `:create` requires HasPermission
|
||||
- Domain interface: `confirm_join_request/2`, `list_join_requests/1`, `get_join_request/2`, `update_join_request/2`, `destroy_join_request/1`
|
||||
- Tests: `test/mv/membership/join_request_test.exs` – public create/read with nil, idempotency, validations (no UI/email yet)
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### Architecture Patterns
|
||||
|
|
|
|||
|
|
@ -168,12 +168,12 @@ The feature is split into a small number of well-bounded subtasks. **Resend conf
|
|||
|
||||
### Prio 1 – Public Join (4 subtasks)
|
||||
|
||||
#### 1. JoinRequest resource and public policies
|
||||
#### 1. JoinRequest resource and public policies ✅
|
||||
|
||||
- **Scope:** Ash resource `JoinRequest` per §2.3.2 (email, payload/schema_version, status, submitted_at, approved_at, rejected_at, reviewed_by_user_id, source, optional abuse metadata); migration; idempotency key (e.g. unique_index on confirmation_token_hash).
|
||||
- **Policies:** Explicit public actions (e.g. `confirm`) allowed with `actor: nil`; no system-actor fallback, no undocumented `authorize?: false`.
|
||||
- **Boundary:** No UI, no emails, no pre-confirmation logic – only resource, persistence, and “creatable with nil actor”.
|
||||
- **Done:** Resource and migration in place; tests for create/read with `actor: nil` and for idempotency (same token twice → no second record).
|
||||
- **Done:** Resource and migration in place; tests in `test/mv/membership/join_request_test.exs` for create/read with `actor: nil` and for idempotency (same token twice → no second record).
|
||||
|
||||
#### 2. Pre-confirmation store and confirm flow
|
||||
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
42
priv/repo/migrations/20260220120000_add_join_requests.exs
Normal file
42
priv/repo/migrations/20260220120000_add_join_requests.exs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
defmodule Mv.Repo.Migrations.AddJoinRequests do
|
||||
@moduledoc """
|
||||
Adds join_requests table for public join flow (onboarding concept §2.3.2).
|
||||
"""
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
create table(:join_requests, primary_key: false) do
|
||||
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
|
||||
add :email, :string, null: false
|
||||
add :confirmation_token_hash, :string, null: false
|
||||
add :status, :string, null: false
|
||||
add :submitted_at, :utc_datetime_usec, null: false
|
||||
add :source, :string, null: false
|
||||
add :schema_version, :bigint, null: false
|
||||
add :payload, :map, null: true
|
||||
add :approved_at, :utc_datetime_usec, null: true
|
||||
add :rejected_at, :utc_datetime_usec, null: true
|
||||
add :reviewed_by_user_id, :uuid, null: true
|
||||
|
||||
add :inserted_at, :utc_datetime_usec,
|
||||
null: false,
|
||||
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||
|
||||
add :updated_at, :utc_datetime_usec,
|
||||
null: false,
|
||||
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||
end
|
||||
|
||||
create unique_index(:join_requests, [:confirmation_token_hash],
|
||||
name: "join_requests_unique_confirmation_token_hash_index"
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop_if_exists unique_index(:join_requests, [:confirmation_token_hash],
|
||||
name: "join_requests_unique_confirmation_token_hash_index"
|
||||
)
|
||||
|
||||
drop table(:join_requests)
|
||||
end
|
||||
end
|
||||
104
test/mv/membership/join_request_test.exs
Normal file
104
test/mv/membership/join_request_test.exs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
defmodule Mv.Membership.JoinRequestTest do
|
||||
@moduledoc """
|
||||
Tests for JoinRequest resource and public policies (Subtask 1: onboarding join concept).
|
||||
|
||||
Covers: public create/read with actor nil, idempotency of confirm (confirmation_token_hash),
|
||||
and minimal required attributes. No framework behaviour is tested; only our policies and constraints.
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.JoinRequest
|
||||
|
||||
require Ash.Query
|
||||
|
||||
# Minimal valid attributes for the public :confirm action (per concept §2.3.2)
|
||||
defp valid_confirm_attrs(opts \\ []) do
|
||||
token = Keyword.get(opts, :confirmation_token_hash, "hash_#{System.unique_integer([:positive])}")
|
||||
[
|
||||
email: "join_#{System.unique_integer([:positive])}@example.com",
|
||||
confirmation_token_hash: token,
|
||||
status: "submitted",
|
||||
submitted_at: DateTime.utc_now(),
|
||||
source: "public_join",
|
||||
schema_version: 1,
|
||||
payload: %{}
|
||||
]
|
||||
|> Enum.into(%{})
|
||||
end
|
||||
|
||||
describe "Public policies (actor: nil)" do
|
||||
test "confirm with actor nil succeeds" do
|
||||
attrs = valid_confirm_attrs()
|
||||
|
||||
assert {:ok, %JoinRequest{} = request} =
|
||||
Membership.confirm_join_request(attrs, actor: nil)
|
||||
|
||||
assert request.email == attrs.email
|
||||
assert request.status == "submitted"
|
||||
assert request.source == "public_join"
|
||||
end
|
||||
|
||||
test "read with actor nil succeeds for created join request" do
|
||||
attrs = valid_confirm_attrs()
|
||||
{:ok, created} = Membership.confirm_join_request(attrs, actor: nil)
|
||||
|
||||
assert {:ok, %JoinRequest{} = read} =
|
||||
Ash.get(JoinRequest, created.id, actor: nil, domain: Mv.Membership)
|
||||
|
||||
assert read.id == created.id
|
||||
assert read.email == created.email
|
||||
end
|
||||
|
||||
test "generic create with actor nil is forbidden" do
|
||||
attrs = valid_confirm_attrs()
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{errors: [%Ash.Error.Forbidden.Policy{}]}} =
|
||||
JoinRequest
|
||||
|> Ash.Changeset.for_create(:create, attrs)
|
||||
|> Ash.create(actor: nil, domain: Mv.Membership)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Idempotency (confirmation_token_hash)" do
|
||||
test "second create with same confirmation_token_hash does not create duplicate" do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
token = "idempotent_token_#{System.unique_integer([:positive])}"
|
||||
attrs1 = valid_confirm_attrs(confirmation_token_hash: token)
|
||||
attrs2 = valid_confirm_attrs(confirmation_token_hash: token)
|
||||
attrs2 = %{attrs2 | email: "other_#{System.unique_integer([:positive])}@example.com"}
|
||||
|
||||
assert {:ok, first} = Membership.confirm_join_request(attrs1, actor: nil)
|
||||
|
||||
# Second call with same token: idempotent return {:ok, existing} (concept §2.3.2)
|
||||
assert {:ok, second} = Membership.confirm_join_request(attrs2, actor: nil)
|
||||
assert second.id == first.id, "idempotent confirm must return the existing record"
|
||||
|
||||
count =
|
||||
JoinRequest
|
||||
|> Ash.Query.filter(confirmation_token_hash == ^token)
|
||||
|> Ash.read!(actor: system_actor, domain: Mv.Membership, authorize?: false)
|
||||
|> length()
|
||||
|
||||
assert count == 1, "expected exactly one JoinRequest with this confirmation_token_hash, got #{count}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Resource and validations" do
|
||||
test "create with minimal required attributes succeeds" do
|
||||
attrs = valid_confirm_attrs()
|
||||
|
||||
assert {:ok, %JoinRequest{}} = Membership.confirm_join_request(attrs, actor: nil)
|
||||
end
|
||||
|
||||
test "email is required" do
|
||||
attrs = valid_confirm_attrs() |> Map.delete(:email)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Membership.confirm_join_request(attrs, actor: nil)
|
||||
|
||||
assert Enum.any?(errors, fn e -> Map.get(e, :field) == :email end),
|
||||
"expected an error for field :email, got: #{inspect(errors)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue