add join request resource #463

Merged
simon merged 9 commits from feature/308-web-form into main 2026-03-10 10:11:55 +01:00
9 changed files with 323 additions and 5 deletions
Showing only changes of commit 2515a679b8 - Show all commits

View file

@ -85,6 +85,8 @@ lib/
├── membership/ # Membership domain
│ ├── membership.ex # Domain definition
│ ├── member.ex # Member resource
│ ├── join_request.ex # JoinRequest (public join form, double opt-in)
│ ├── join_request/ # JoinRequest changes (SetConfirmationToken, ConfirmRequest)
│ ├── custom_field.ex # Custom field (definition) resource
│ ├── custom_field_value.ex # Custom field value resource
│ ├── setting.ex # Global settings (singleton resource)

View file

@ -792,6 +792,14 @@ defmodule MvWeb.Components.SearchBarTest do
end
```
### Onboarding / Join (Issue #308, TDD)
**Subtask 1 JoinRequest resource and public policies (done):**
- Resource: `Mv.Membership.JoinRequest` with attributes (status, email, first_name, last_name, form_data, schema_version, confirmation_token_hash, confirmation_token_expires_at, submitted_at, etc.), actions `submit` (create), `get_by_confirmation_token_hash` (read), `confirm` (update). Migration: `20260309141437_add_join_requests.exs`.
- Policies: Public actions allowed with `actor: nil` via `Mv.Authorization.Checks.ActorIsNil` (submit, get_by_confirmation_token_hash, confirm); default read remains Forbidden for unauthenticated.
- Domain: `Mv.Membership.submit_join_request/2`, `Mv.Membership.confirm_join_request/2` (token hashing and lookup in domain).
- Test file: `test/membership/join_request_test.exs` all tests pass; policy test (read with actor nil → Forbidden) unskipped. Expired-token test still skipped (fixture for expired token to be added in Subtask 2 or later).
### Test Data Management
**Seed Data:**

View file

@ -0,0 +1,133 @@
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
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
end
policies do
policy action(:submit) do
description "Allow unauthenticated submit (public join form)"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
policy action(:get_by_confirmation_token_hash) do
description "Allow unauthenticated lookup by token hash for confirm"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
policy action(:confirm) do
description "Allow unauthenticated confirm (confirmation link click)"
authorize_if Mv.Authorization.Checks.ActorIsNil
end
# Default read/destroy: no policy for actor nil → Forbidden
end
validations do
validate present(:email), on: [:create]
end
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 :source, :string
create_timestamp :inserted_at
update_timestamp :updated_at
end
end

View file

@ -0,0 +1,17 @@
defmodule Mv.Membership.JoinRequest.Changes.ConfirmRequest do
@moduledoc """
Sets the join request to submitted (confirmation link clicked).
Used by the confirm action after the user clicks the confirmation link.
Token hash is kept so that a second click (idempotent) can still find the record
and return success without changing it.
"""
use Ash.Resource.Change
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
changeset
|> Ash.Changeset.force_change_attribute(:status, :submitted)
|> Ash.Changeset.force_change_attribute(:submitted_at, DateTime.utc_now())
end
end

View file

@ -0,0 +1,32 @@
defmodule Mv.Membership.JoinRequest.Changes.SetConfirmationToken do
@moduledoc """
Hashes the confirmation token and sets expiry for the join request (submit flow).
Reads the :confirmation_token argument, stores only its SHA256 hash and sets
confirmation_token_expires_at (e.g. 24h). Raw token is never persisted.
"""
use Ash.Resource.Change
@confirmation_validity_hours 24
@spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t()
def change(changeset, _opts, _context) do
token = Ash.Changeset.get_argument(changeset, :confirmation_token)
if is_binary(token) and token != "" do
hash = token_hash(token)
expires_at = DateTime.utc_now() |> DateTime.add(@confirmation_validity_hours, :hour)
changeset
|> Ash.Changeset.force_change_attribute(:confirmation_token_hash, hash)
|> Ash.Changeset.force_change_attribute(:confirmation_token_expires_at, expires_at)
|> Ash.Changeset.force_change_attribute(:status, :pending_confirmation)
else
changeset
end
end
defp token_hash(token) do
:crypto.hash(:sha256, token) |> Base.encode16(case: :lower)
end
end

View file

@ -9,6 +9,7 @@ defmodule Mv.Membership do
- `Setting` - Global application settings (singleton)
- `Group` - Groups that members can belong to
- `MemberGroup` - Join table for many-to-many relationship between Members and Groups
- `JoinRequest` - Public join form submissions (pending_confirmation submitted after email confirm)
## Public API
The domain exposes these main actions:
@ -27,6 +28,8 @@ defmodule Mv.Membership do
require Ash.Query
import Ash.Expr
alias Ash.Error.Query.NotFound, as: NotFoundError
alias Mv.Membership.JoinRequest
admin do
show? true
@ -80,6 +83,10 @@ defmodule Mv.Membership do
define :list_member_groups, action: :read
define :destroy_member_group, action: :destroy
end
resource Mv.Membership.JoinRequest do
define :submit_join_request, action: :submit
end
end
# Singleton pattern: Get the single settings record
@ -342,4 +349,49 @@ defmodule Mv.Membership do
|> Keyword.put_new(:domain, __MODULE__)
|> then(&Ash.read_one(query, &1))
end
@doc """
Confirms a join request by token (public confirmation link).
Hashes the token, finds the JoinRequest by confirmation_token_hash, then updates
to status :submitted and invalidates the token. Idempotent: if already submitted,
returns the existing record without changing it.
## Options
- `:actor` - Must be nil for public confirm (policy allows only unauthenticated).
## Returns
- `{:ok, request}` - Updated or already-submitted JoinRequest
- `{:error, error}` - Token unknown/invalid or authorization error
"""
def confirm_join_request(token, opts \\ []) when is_binary(token) do
hash = confirmation_token_hash(token)
actor = Keyword.get(opts, :actor)
query =
Ash.Query.for_read(JoinRequest, :get_by_confirmation_token_hash, %{
confirmation_token_hash: hash
})
case Ash.read_one(query, actor: actor, domain: __MODULE__) do
{:ok, nil} ->
{:error, NotFoundError.exception(resource: JoinRequest)}
{:ok, request} ->
if request.status == :submitted do
{:ok, request}
else
request
|> Ash.Changeset.for_update(:confirm, %{}, domain: __MODULE__)
|> Ash.update(domain: __MODULE__, actor: actor)
end
{:error, error} ->
{:error, error}
end
end
defp confirmation_token_hash(token) do
:crypto.hash(:sha256, token) |> Base.encode16(case: :lower)
end
end

View file

@ -0,0 +1,17 @@
defmodule Mv.Authorization.Checks.ActorIsNil do
@moduledoc """
Policy check: true only when the actor is nil (unauthenticated).
Used for the public join flow so that submit and confirm actions are allowed
only when called without an authenticated user (e.g. from the public /join form
and confirmation link). See docs/onboarding-join-concept.md.
"""
use Ash.Policy.SimpleCheck
@impl true
def describe(_opts), do: "actor is nil (unauthenticated)"
@impl true
def match?(nil, _context, _opts), do: true
def match?(_actor, _context, _opts), do: false
end

View file

@ -0,0 +1,53 @@
defmodule Mv.Repo.Migrations.AddJoinRequests do
@moduledoc """
Creates join_requests table for the public join flow (onboarding, double opt-in).
Stores join form submissions with status pending_confirmation submitted (after email confirm).
Token stored as hash only; 24h retention for unconfirmed records (cleanup via scheduled job).
"""
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 :status, :text, null: false, default: "pending_confirmation"
add :email, :text, null: false
add :first_name, :text
add :last_name, :text
add :form_data, :map
add :schema_version, :integer
add :confirmation_token_hash, :text
add :confirmation_token_expires_at, :utc_datetime_usec
add :confirmation_sent_at, :utc_datetime_usec
add :submitted_at, :utc_datetime_usec
add :approved_at, :utc_datetime_usec
add :rejected_at, :utc_datetime_usec
add :reviewed_by_user_id, :uuid
add :source, :text
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_confirmation_token_hash_unique",
where: "confirmation_token_hash IS NOT NULL"
)
create index(:join_requests, [:email])
create index(:join_requests, [:status])
end
def down do
drop table(:join_requests)
end
end

View file

@ -21,7 +21,12 @@ defmodule Mv.Membership.JoinRequestTest do
describe "submit_join_request/2 (create with actor: nil)" do
test "creates JoinRequest in pending_confirmation with valid attributes and actor nil" do
attrs = Map.put(@valid_submit_attrs, :confirmation_token, "test-token-#{System.unique_integer([:positive])}")
attrs =
Map.put(
@valid_submit_attrs,
:confirmation_token,
"test-token-#{System.unique_integer([:positive])}"
)
assert {:ok, request} =
Membership.submit_join_request(attrs, actor: nil)
@ -98,10 +103,9 @@ defmodule Mv.Membership.JoinRequestTest do
end
describe "policies (actor: nil)" do
@tag :skip
test "read with actor nil returns Forbidden (unskip and add: Ash.read(Mv.Membership.JoinRequest, actor: nil, domain: Mv.Membership) -> expect Forbidden)" do
# When JoinRequest resource exists: assert {:error, %Ash.Error.Forbidden{}} = Ash.read(Mv.Membership.JoinRequest, actor: nil, domain: Mv.Membership)
flunk("Add JoinRequest resource, then unskip and replace this with the Ash.read assertion")
test "read with actor nil returns Forbidden" do
assert {:error, %Ash.Error.Forbidden{}} =
Ash.read(Mv.Membership.JoinRequest, actor: nil, domain: Mv.Membership)
end
end