feat: join request backend
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-02-20 17:37:51 +01:00
parent 883e7a3e62
commit e7393e32d8
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
6 changed files with 344 additions and 2 deletions

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View 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

View 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