add join request resource #463
9 changed files with 323 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
|
|
|||
133
lib/membership/join_request.ex
Normal file
133
lib/membership/join_request.ex
Normal 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
|
||||
17
lib/membership/join_request/changes/confirm_request.ex
Normal file
17
lib/membership/join_request/changes/confirm_request.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
17
lib/mv/authorization/checks/actor_is_nil.ex
Normal file
17
lib/mv/authorization/checks/actor_is_nil.ex
Normal 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
|
||||
53
priv/repo/migrations/20260309141437_add_join_requests.exs
Normal file
53
priv/repo/migrations/20260309141437_add_join_requests.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue