diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 1dcf994..2f96345 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -335,6 +335,15 @@ end - show custom fields in member overview per default - 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 ### Architecture Patterns diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md index 9c09da7..1959c1b 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -168,12 +168,12 @@ The feature is split into a small number of well-bounded subtasks. **Resend conf ### 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). - **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”. -- **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 diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex new file mode 100644 index 0000000..466840b --- /dev/null +++ b/lib/membership/join_request.ex @@ -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 diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index 74735e4..69a8110 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -78,6 +78,52 @@ defmodule Mv.Membership do define :list_member_groups, action: :read define :destroy_member_group, action: :destroy 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 # Singleton pattern: Get the single settings record diff --git a/priv/repo/migrations/20260220120000_add_join_requests.exs b/priv/repo/migrations/20260220120000_add_join_requests.exs new file mode 100644 index 0000000..00f764d --- /dev/null +++ b/priv/repo/migrations/20260220120000_add_join_requests.exs @@ -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 diff --git a/test/mv/membership/join_request_test.exs b/test/mv/membership/join_request_test.exs new file mode 100644 index 0000000..fa81231 --- /dev/null +++ b/test/mv/membership/join_request_test.exs @@ -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