feat: add join request resource
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
2a04fad4fe
commit
2515a679b8
9 changed files with 323 additions and 5 deletions
|
|
@ -85,6 +85,8 @@ lib/
|
||||||
├── membership/ # Membership domain
|
├── membership/ # Membership domain
|
||||||
│ ├── membership.ex # Domain definition
|
│ ├── membership.ex # Domain definition
|
||||||
│ ├── member.ex # Member resource
|
│ ├── 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.ex # Custom field (definition) resource
|
||||||
│ ├── custom_field_value.ex # Custom field value resource
|
│ ├── custom_field_value.ex # Custom field value resource
|
||||||
│ ├── setting.ex # Global settings (singleton resource)
|
│ ├── setting.ex # Global settings (singleton resource)
|
||||||
|
|
|
||||||
|
|
@ -792,6 +792,14 @@ defmodule MvWeb.Components.SearchBarTest do
|
||||||
end
|
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
|
### Test Data Management
|
||||||
|
|
||||||
**Seed Data:**
|
**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)
|
- `Setting` - Global application settings (singleton)
|
||||||
- `Group` - Groups that members can belong to
|
- `Group` - Groups that members can belong to
|
||||||
- `MemberGroup` - Join table for many-to-many relationship between Members and Groups
|
- `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
|
## Public API
|
||||||
The domain exposes these main actions:
|
The domain exposes these main actions:
|
||||||
|
|
@ -27,6 +28,8 @@ defmodule Mv.Membership do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
alias Ash.Error.Query.NotFound, as: NotFoundError
|
||||||
|
alias Mv.Membership.JoinRequest
|
||||||
|
|
||||||
admin do
|
admin do
|
||||||
show? true
|
show? true
|
||||||
|
|
@ -80,6 +83,10 @@ 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 :submit_join_request, action: :submit
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Singleton pattern: Get the single settings record
|
# Singleton pattern: Get the single settings record
|
||||||
|
|
@ -342,4 +349,49 @@ defmodule Mv.Membership do
|
||||||
|> Keyword.put_new(:domain, __MODULE__)
|
|> Keyword.put_new(:domain, __MODULE__)
|
||||||
|> then(&Ash.read_one(query, &1))
|
|> then(&Ash.read_one(query, &1))
|
||||||
end
|
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
|
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
|
describe "submit_join_request/2 (create with actor: nil)" do
|
||||||
test "creates JoinRequest in pending_confirmation with valid attributes and 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} =
|
assert {:ok, request} =
|
||||||
Membership.submit_join_request(attrs, actor: nil)
|
Membership.submit_join_request(attrs, actor: nil)
|
||||||
|
|
@ -98,10 +103,9 @@ defmodule Mv.Membership.JoinRequestTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "policies (actor: nil)" do
|
describe "policies (actor: nil)" do
|
||||||
@tag :skip
|
test "read with actor nil returns Forbidden" do
|
||||||
test "read with actor nil returns Forbidden (unskip and add: Ash.read(Mv.Membership.JoinRequest, actor: nil, domain: Mv.Membership) -> expect Forbidden)" do
|
assert {:error, %Ash.Error.Forbidden{}} =
|
||||||
# When JoinRequest resource exists: assert {:error, %Ash.Error.Forbidden{}} = Ash.read(Mv.Membership.JoinRequest, actor: nil, domain: Mv.Membership)
|
Ash.read(Mv.Membership.JoinRequest, actor: nil, domain: Mv.Membership)
|
||||||
flunk("Add JoinRequest resource, then unskip and replace this with the Ash.read assertion")
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue