This commit is contained in:
parent
883e7a3e62
commit
e7393e32d8
6 changed files with 344 additions and 2 deletions
|
|
@ -335,6 +335,15 @@ end
|
||||||
- show custom fields in member overview per default
|
- show custom fields in member overview per default
|
||||||
- can be set to false in the settings for the specific custom field
|
- 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
|
## Implementation Decisions
|
||||||
|
|
||||||
### Architecture Patterns
|
### 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)
|
### 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).
|
- **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`.
|
- **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”.
|
- **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
|
#### 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 :list_member_groups, action: :read
|
||||||
define :destroy_member_group, action: :destroy
|
define :destroy_member_group, action: :destroy
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
# Singleton pattern: Get the single settings record
|
# 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