From 2515a679b87068e2fe42050ca8ec3c1f12521fb5 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 9 Mar 2026 14:44:45 +0100 Subject: [PATCH] feat: add join request resource --- CODE_GUIDELINES.md | 2 + docs/development-progress-log.md | 8 ++ lib/membership/join_request.ex | 133 ++++++++++++++++++ .../join_request/changes/confirm_request.ex | 17 +++ .../changes/set_confirmation_token.ex | 32 +++++ lib/membership/membership.ex | 52 +++++++ lib/mv/authorization/checks/actor_is_nil.ex | 17 +++ .../20260309141437_add_join_requests.exs | 53 +++++++ test/membership/join_request_test.exs | 14 +- 9 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 lib/membership/join_request.ex create mode 100644 lib/membership/join_request/changes/confirm_request.ex create mode 100644 lib/membership/join_request/changes/set_confirmation_token.ex create mode 100644 lib/mv/authorization/checks/actor_is_nil.ex create mode 100644 priv/repo/migrations/20260309141437_add_join_requests.exs diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index c3de14b..18036db 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -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) diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 97c586b..84687d1 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -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:** diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex new file mode 100644 index 0000000..d42aa1e --- /dev/null +++ b/lib/membership/join_request.ex @@ -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 diff --git a/lib/membership/join_request/changes/confirm_request.ex b/lib/membership/join_request/changes/confirm_request.ex new file mode 100644 index 0000000..477561f --- /dev/null +++ b/lib/membership/join_request/changes/confirm_request.ex @@ -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 diff --git a/lib/membership/join_request/changes/set_confirmation_token.ex b/lib/membership/join_request/changes/set_confirmation_token.ex new file mode 100644 index 0000000..b052799 --- /dev/null +++ b/lib/membership/join_request/changes/set_confirmation_token.ex @@ -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 diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 2583718..66e2f9b 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -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 diff --git a/lib/mv/authorization/checks/actor_is_nil.ex b/lib/mv/authorization/checks/actor_is_nil.ex new file mode 100644 index 0000000..ed8474e --- /dev/null +++ b/lib/mv/authorization/checks/actor_is_nil.ex @@ -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 diff --git a/priv/repo/migrations/20260309141437_add_join_requests.exs b/priv/repo/migrations/20260309141437_add_join_requests.exs new file mode 100644 index 0000000..e4921ca --- /dev/null +++ b/priv/repo/migrations/20260309141437_add_join_requests.exs @@ -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 diff --git a/test/membership/join_request_test.exs b/test/membership/join_request_test.exs index 1d8ff95..2123730 100644 --- a/test/membership/join_request_test.exs +++ b/test/membership/join_request_test.exs @@ -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