diff --git a/CHANGELOG.md b/CHANGELOG.md
index edb53f9..74d015d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,11 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- **GDPR/DSGVO join-form description** – Custom fields can carry a "join form description" that is shown as the field's label on the public join form, with clickable external links (whole URLs and Markdown `[text](url)`). Useful for presenting a GDPR confirmation with a link to an externally hosted privacy declaration before sign-up.
+- **Join-form description tooltip in member details** – Custom fields that have a join-form description show an info tooltip (prefixed "Beitrittsformular:") on their label in the member detail view.
+- **Editable join-form description** – Admins can set a field's join-form description in the custom-field settings, with an inline hint about the supported link syntax.
- **CSV import – groups column** – Members can be assigned to groups during CSV import via a `Groups`/`Gruppen` column; group names that do not exist yet are created automatically, and re-importing the same file does not create duplicate groups.
- **CSV import – membership fee type column** – A `Fee Type`/`Beitragsart` column assigns each member's membership fee type; an unknown name falls back to the default fee type and is flagged in the preview with a link to create it.
- **CSV import – mapping preview** – After uploading a file, a preview shows how every column maps (with sample rows and warnings for ignored or unknown columns) and the import only starts once you confirm.
- **Dynamic CSV import templates** – The EN and DE import-template downloads now include the association's current custom fields instead of a fixed column set.
+### Changed
+- **Default GDPR custom field** – The seeded GDPR field was shortened from "Datenschutzerklärung akzeptiert" to "DSGVO" and now ships with a default join-form description (with a placeholder link to replace).
+
### Fixed
- **CSV date round-trip** – Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors.
- **CSV import – fee-status columns ignored** – Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists.
diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex
index ef6c79a..5f4dd0e 100644
--- a/lib/membership/custom_field.ex
+++ b/lib/membership/custom_field.ex
@@ -12,6 +12,8 @@ defmodule Mv.Membership.CustomField do
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation.
- `description` - Optional human-readable description
+ - `join_description` - Optional label shown for this field on the public join form
+ (e.g., a GDPR confirmation text); supports inline external links. Falls back to `name` when nil.
- `required` - If true, all members must have this custom field (future feature)
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
@@ -61,7 +63,14 @@ defmodule Mv.Membership.CustomField do
end
actions do
- default_accept [:name, :value_type, :description, :required, :show_in_overview]
+ default_accept [
+ :name,
+ :value_type,
+ :description,
+ :join_description,
+ :required,
+ :show_in_overview
+ ]
read :read do
primary? true
@@ -69,13 +78,13 @@ defmodule Mv.Membership.CustomField do
end
create :create do
- accept [:name, :value_type, :description, :required, :show_in_overview]
+ accept [:name, :value_type, :description, :join_description, :required, :show_in_overview]
change Mv.Membership.Changes.GenerateSlug
validate string_length(:slug, min: 1)
end
update :update do
- accept [:name, :description, :required, :show_in_overview]
+ accept [:name, :description, :join_description, :required, :show_in_overview]
require_atomic? false
validate fn changeset, _context ->
@@ -139,6 +148,15 @@ defmodule Mv.Membership.CustomField do
trim?: true
]
+ attribute :join_description, :string,
+ allow_nil?: true,
+ public?: true,
+ description: "Label shown for this field on the public join form; supports external links",
+ constraints: [
+ max_length: 1000,
+ trim?: true
+ ]
+
attribute :required, :boolean,
default: false,
allow_nil?: false
diff --git a/lib/mv_web/helpers/join_description_renderer.ex b/lib/mv_web/helpers/join_description_renderer.ex
new file mode 100644
index 0000000..4c4705f
--- /dev/null
+++ b/lib/mv_web/helpers/join_description_renderer.ex
@@ -0,0 +1,70 @@
+defmodule MvWeb.Helpers.JoinDescriptionRenderer do
+ @moduledoc """
+ Renders a custom field's `join_description` into Phoenix-safe HTML for the
+ public join form.
+
+ The renderer auto-links two patterns into `` tags:
+
+ - Markdown links of the form `[text](url)` (processed first)
+ - bare `http(s)://` URLs in the remaining text
+
+ All other content is HTML-escaped: only `` tags are ever
+ emitted, so arbitrary HTML in the input is rendered as inert text. This is a
+ defense-in-depth measure — `join_description` is admin-set content, never
+ end-user input — but the renderer must not become a vector for injecting
+ arbitrary markup.
+
+ Markdown links are matched before bare URLs and their matched region is
+ consumed, so a Markdown link whose URL also looks like a bare URL is linked
+ exactly once (no nested anchors).
+ """
+
+ @markdown_link ~r/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/
+ @bare_url ~r/(https?:\/\/[^\s<]+)/
+ @bare_url_anchored ~r/\A(https?:\/\/[^\s<]+)\z/
+
+ @doc """
+ Converts `value` to a Phoenix-safe HTML iolist.
+
+ Returns `{:safe, ""}` for `nil`. For a string, returns `{:safe, iolist}` with
+ links rendered and all other text HTML-escaped.
+ """
+ @spec render(String.t() | nil) :: Phoenix.HTML.safe()
+ def render(nil), do: {:safe, ""}
+
+ def render(value) when is_binary(value) do
+ {:safe, render_segments(value)}
+ end
+
+ # Split on Markdown links first; for each non-Markdown segment, link bare URLs;
+ # everything that is not a link is HTML-escaped.
+ defp render_segments(text) do
+ Regex.split(@markdown_link, text, include_captures: true)
+ |> Enum.map(&render_markdown_or_plain/1)
+ end
+
+ defp render_markdown_or_plain(segment) do
+ case Regex.run(@markdown_link, segment) do
+ [^segment, label, url] -> anchor(url, label)
+ _ -> render_plain(segment)
+ end
+ end
+
+ # Auto-link bare URLs in a plain-text segment, escaping all surrounding text.
+ defp render_plain(segment) do
+ Regex.split(@bare_url, segment, include_captures: true)
+ |> Enum.map(fn part ->
+ if Regex.match?(@bare_url_anchored, part) do
+ anchor(part, part)
+ else
+ escape(part)
+ end
+ end)
+ end
+
+ defp anchor(url, label) do
+ ["", escape(label), ""]
+ end
+
+ defp escape(text), do: Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string()
+end
diff --git a/lib/mv_web/live/custom_field_live/form_component.ex b/lib/mv_web/live/custom_field_live/form_component.ex
index 2e98aeb..1663b4e 100644
--- a/lib/mv_web/live/custom_field_live/form_component.ex
+++ b/lib/mv_web/live/custom_field_live/form_component.ex
@@ -91,6 +91,45 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
<% end %>
<.input field={@form[:description]} type="text" label={gettext("Description")} />
+
+
+
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.input
field={@form[:show_in_overview]}
diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex
index b679127..2c37f3a 100644
--- a/lib/mv_web/live/join_live.ex
+++ b/lib/mv_web/live/join_live.ex
@@ -8,6 +8,7 @@ defmodule MvWeb.JoinLive do
alias Ash.Resource.Info
alias Mv.Membership
alias Mv.Membership.CustomFieldLookup
+ alias MvWeb.Helpers.JoinDescriptionRenderer
alias MvWeb.JoinRateLimit
alias MvWeb.Translations.MemberFields
@@ -96,14 +97,20 @@ defmodule MvWeb.JoinLive do
class="checkbox checkbox-sm"
/>
- {field.label} *
+ {render_field_label(field)} *
<% else %>
assign(:form, to_form(params, as: "join"))}
end
+ # Renders a join field's label. When a custom field has a join_description it is
+ # rendered with auto-linked URLs/Markdown; otherwise the plain field label is used.
+ # Safe: join_description is admin-set settings content, never end-user input, and
+ # JoinDescriptionRenderer escapes all non-link text (only emits
tags).
+ defp render_field_label(%{join_description: join_description})
+ when is_binary(join_description) do
+ JoinDescriptionRenderer.render(join_description)
+ end
+
+ defp render_field_label(%{label: label}), do: label
+
defp build_join_fields_with_labels(allowlist) do
member_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
custom_field_by_id = custom_field_map(allowlist, member_field_strings)
@@ -249,20 +267,36 @@ defmodule MvWeb.JoinLive do
defp build_join_field(id, required, member_field_strings, custom_field_by_id) do
if id in member_field_strings do
label = MemberFields.label(String.to_existing_atom(id))
- %{id: id, label: label, required: required, input_type: member_field_input_type(id)}
+
+ %{
+ id: id,
+ label: label,
+ required: required,
+ input_type: member_field_input_type(id),
+ join_description: nil
+ }
else
custom_field = Map.get(custom_field_by_id, id)
label = if custom_field, do: custom_field.name, else: gettext("Field")
input_type = custom_field_input_type(custom_field && custom_field.value_type)
- %{id: id, label: label, required: required, input_type: input_type}
+ %{
+ id: id,
+ label: label,
+ required: required,
+ input_type: input_type,
+ join_description: custom_field && custom_field.join_description
+ }
end
end
defp custom_field_map(allowlist, _member_field_strings) do
allowlist
|> Enum.map(& &1.id)
- |> CustomFieldLookup.fetch_map_by_ids(authorize?: false, select: [:id, :name, :value_type])
+ |> CustomFieldLookup.fetch_map_by_ids(
+ authorize?: false,
+ select: [:id, :name, :value_type, :join_description]
+ )
end
defp initial_form_params(join_fields) do
diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex
index 52b8250..5ac4fee 100644
--- a/lib/mv_web/live/member_live/show.ex
+++ b/lib/mv_web/live/member_live/show.ex
@@ -235,6 +235,19 @@ defmodule MvWeb.MemberLive.Show do
<%= for custom_field <- @custom_fields do %>
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
<.data_field label={custom_field.name}>
+ <:label_suffix :if={custom_field.join_description}>
+ <.tooltip
+ content={"#{gettext("Join form:")} #{custom_field.join_description}"}
+ wrap_class="ml-1 inline-flex items-center"
+ >
+
+ <.icon
+ name="hero-information-circle"
+ class="size-3.5 text-base-content/50"
+ />
+
+
+
{format_custom_field_value(cfv, custom_field.value_type)}
<% end %>
@@ -605,11 +618,14 @@ defmodule MvWeb.MemberLive.Show do
attr :value, :string, default: nil
attr :class, :string, default: ""
slot :inner_block
+ slot :label_suffix
defp data_field(assigns) do
~H"""
- - {@label}
+ -
+ {@label}{render_slot(@label_suffix)}
+
-
<%= if @inner_block != [] do %>
{render_slot(@inner_block)}
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index 343ede8..ca8edaf 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -3967,6 +3967,21 @@ msgstr "Zeitraum"
msgid "To"
msgstr "Bis"
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Join form:"
+msgstr "Beitrittsformular:"
+
+#: lib/mv_web/live/custom_field_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Description for join form"
+msgstr "Beschreibung für das Beitrittsformular"
+
+#: lib/mv_web/live/custom_field_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "You can add links: full addresses (https://…) or as [link text](https://…)."
+msgstr "Du kannst Links einfügen: ganze Adressen (https://…) oder als [Linktext](https://…)."
+
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Fee type '%{name}' not found; using the default fee type."
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index f14f7a1..f301985 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -3967,6 +3967,21 @@ msgstr ""
msgid "To"
msgstr ""
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format
+msgid "Join form:"
+msgstr ""
+
+#: lib/mv_web/live/custom_field_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Description for join form"
+msgstr ""
+
+#: lib/mv_web/live/custom_field_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "You can add links: full addresses (https://…) or as [link text](https://…)."
+msgstr ""
+
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Fee type '%{name}' not found; using the default fee type."
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index 18d1e30..09c586c 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -3967,6 +3967,21 @@ msgstr ""
msgid "To"
msgstr ""
+#: lib/mv_web/live/member_live/show.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Join form:"
+msgstr ""
+
+#: lib/mv_web/live/custom_field_live/form_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Description for join form"
+msgstr ""
+
+#: lib/mv_web/live/custom_field_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "You can add links: full addresses (https://…) or as [link text](https://…)."
+msgstr ""
+
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Fee type '%{name}' not found; using the default fee type."
diff --git a/priv/repo/migrations/20260603000531_add_join_description_to_custom_fields.exs b/priv/repo/migrations/20260603000531_add_join_description_to_custom_fields.exs
new file mode 100644
index 0000000..b1d9a05
--- /dev/null
+++ b/priv/repo/migrations/20260603000531_add_join_description_to_custom_fields.exs
@@ -0,0 +1,21 @@
+defmodule Mv.Repo.Migrations.AddJoinDescriptionToCustomFields do
+ @moduledoc """
+ Updates resources based on their most recent snapshots.
+
+ This file was autogenerated with `mix ash_postgres.generate_migrations`
+ """
+
+ use Ecto.Migration
+
+ def up do
+ alter table(:custom_fields) do
+ add :join_description, :text
+ end
+ end
+
+ def down do
+ alter table(:custom_fields) do
+ remove :join_description
+ end
+ end
+end
diff --git a/priv/repo/seeds_bootstrap.exs b/priv/repo/seeds_bootstrap.exs
index 9947704..e3430ec 100644
--- a/priv/repo/seeds_bootstrap.exs
+++ b/priv/repo/seeds_bootstrap.exs
@@ -81,9 +81,11 @@ custom_field_configs = [
show_in_overview: true
},
%{
- name: "Datenschutzerklärung akzeptiert",
+ name: "DSGVO",
value_type: :boolean,
- description: "Angabe, ob Datenschutzerklärung akzeptiert wurde",
+ description: "Angabe, ob die Datenschutzerklärung akzeptiert wurde",
+ join_description:
+ "Ich habe die [Datenschutzerklärung](https://example.org/datenschutz) gelesen und akzeptiere sie.",
required: false,
show_in_overview: false
},
@@ -302,11 +304,15 @@ case Membership.get_settings() do
ArgumentError -> Map.has_key?(vis, k)
end
end
+
merged =
Enum.reduce(default_hidden_in_overview, visibility_config, fn {key, val}, vis ->
if has_key.(vis, key), do: vis, else: Map.put(vis, key, val)
end)
- if merged != visibility_config, do: Map.put(acc, :member_field_visibility, merged), else: acc
+
+ if merged != visibility_config,
+ do: Map.put(acc, :member_field_visibility, merged),
+ else: acc
end)
if map_size(updates) > 0 do
@@ -332,9 +338,7 @@ IO.puts(
" - Fee types: 5 (Standard, Ermäßigt, Unterstützer, Fördermitglied, Probemitgliedschaft)"
)
-IO.puts(
- " - Custom fields: 6 (Geburtsdatum shown in overview; others hidden by default)"
-)
+IO.puts(" - Custom fields: 6 (Geburtsdatum shown in overview; others hidden by default)")
IO.puts(" - Roles: 5 (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)")
IO.puts(" - Default fee type: Standard (120€ yearly)")
diff --git a/priv/repo/seeds_dev.exs b/priv/repo/seeds_dev.exs
index 5b3de9f..72474a3 100644
--- a/priv/repo/seeds_dev.exs
+++ b/priv/repo/seeds_dev.exs
@@ -431,15 +431,16 @@ end)
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
-# 16 members with 4–6 custom field values each (Geburtsdatum, Datenschutz, SEPA, Rechnungs-E-Mail, IBAN, Stunden)
+# 16 members with 4–6 custom field values each (Geburtsdatum, DSGVO, SEPA, Rechnungs-E-Mail, IBAN, Stunden)
custom_value_assignments =
Enum.map(1..16, fn n ->
email = "mitglied#{n}@example.de"
# Vary birth dates and values per index
base_date = Date.add(~D[1970-01-01], 365 * (n + 10) + rem(n * 31, 365))
+
values = [
{"Geburtsdatum", %{"_union_type" => "date", "_union_value" => base_date}},
- {"Datenschutzerklärung akzeptiert",
+ {"DSGVO",
%{"_union_type" => "boolean", "_union_value" => n in [1, 2, 3, 5, 7, 9, 11, 13, 15]}},
{"SEPA-Mandat", %{"_union_type" => "boolean", "_union_value" => rem(n, 3) != 0}},
{"Rechnungs-E-Mail",
@@ -448,10 +449,12 @@ custom_value_assignments =
%{
"_union_type" => "string",
"_union_value" =>
- "DE8937040044#{String.pad_leading(to_string(rem(532013000 + n, 1_000_000_000)), 10, "0")}"
+ "DE8937040044#{String.pad_leading(to_string(rem(532_013_000 + n, 1_000_000_000)), 10, "0")}"
}},
- {"Stunden ehrenamtlich", %{"_union_type" => "integer", "_union_value" => 5 + rem(n * 3, 25)}}
+ {"Stunden ehrenamtlich",
+ %{"_union_type" => "integer", "_union_value" => 5 + rem(n * 3, 25)}}
]
+
# Drop 0–2 fields per member so not all have 6 (still ~80% overall filled)
drop_count = rem(n, 3)
{email, Enum.take(values, 6 - drop_count)}
@@ -502,19 +505,36 @@ case Membership.get_settings() do
Membership.update_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: settings.join_form_field_ids || default_join_form_field_ids,
- join_form_field_required: settings.join_form_field_required || default_join_form_field_required
+ join_form_field_required:
+ settings.join_form_field_required || default_join_form_field_required
})
end
+
_ ->
:ok
end
# Membership applications (join requests) for UI testing: 4 submitted, 1 with extra form_data
join_request_configs = [
- %{email: "antrag1@example.de", first_name: "Sandra", last_name: "Meier", form_data: %{"city" => "Berlin"}},
+ %{
+ email: "antrag1@example.de",
+ first_name: "Sandra",
+ last_name: "Meier",
+ form_data: %{"city" => "Berlin"}
+ },
%{email: "antrag2@example.de", first_name: "Thomas", last_name: "Bauer", form_data: %{}},
- %{email: "antrag3@example.de", first_name: "Julia", last_name: "Krause", form_data: %{"city" => "Hamburg", "notes" => "Interesse an Jugendgruppe"}},
- %{email: "antrag4@example.de", first_name: "Michael", last_name: "Schmitt", form_data: %{"city" => "München"}}
+ %{
+ email: "antrag3@example.de",
+ first_name: "Julia",
+ last_name: "Krause",
+ form_data: %{"city" => "Hamburg", "notes" => "Interesse an Jugendgruppe"}
+ },
+ %{
+ email: "antrag4@example.de",
+ first_name: "Michael",
+ last_name: "Schmitt",
+ form_data: %{"city" => "München"}
+ }
]
for config <- join_request_configs do
@@ -532,8 +552,15 @@ for config <- join_request_configs do
end
IO.puts("✅ Dev seeds completed.")
-IO.puts(" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz), fee types distributed, 5 with exit date")
-IO.puts(" - Test users: 4 linked to mitglied1–4 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung")
+
+IO.puts(
+ " - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz), fee types distributed, 5 with exit date"
+)
+
+IO.puts(
+ " - Test users: 4 linked to mitglied1–4 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung"
+)
+
IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)")
IO.puts(" - Custom field values: ~80% filled (16 members, 4–6 fields each)")
IO.puts(" - Join form enabled; 4 membership applications (join requests) for UI testing")
diff --git a/priv/resource_snapshots/repo/custom_fields/20260603000204.json b/priv/resource_snapshots/repo/custom_fields/20260603000204.json
new file mode 100644
index 0000000..aa0b0ed
--- /dev/null
+++ b/priv/resource_snapshots/repo/custom_fields/20260603000204.json
@@ -0,0 +1,145 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"gen_random_uuid()\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": true,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "slug",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "value_type",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "description",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "join_description",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "false",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "required",
+ "type": "boolean"
+ },
+ {
+ "allow_nil?": false,
+ "default": "true",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "show_in_overview",
+ "type": "boolean"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "create_table_options": null,
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "2600667D140A2A846F9A848ACEFCADA1F1206950B38EF407B0BB13816E508A2A",
+ "identities": [
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "custom_fields_unique_name_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "name"
+ }
+ ],
+ "name": "unique_name",
+ "nils_distinct?": true,
+ "where": null
+ },
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "custom_fields_unique_slug_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "slug"
+ }
+ ],
+ "name": "unique_slug",
+ "nils_distinct?": true,
+ "where": null
+ }
+ ],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "custom_fields"
+}
\ No newline at end of file
diff --git a/test/membership/custom_field_validation_test.exs b/test/membership/custom_field_validation_test.exs
index e642d82..9b1faf5 100644
--- a/test/membership/custom_field_validation_test.exs
+++ b/test/membership/custom_field_validation_test.exs
@@ -159,6 +159,61 @@ defmodule Mv.Membership.CustomFieldValidationTest do
end
end
+ describe "join_description" do
+ test "persists join_description when set", %{actor: actor} do
+ assert {:ok, custom_field} =
+ CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "dsgvo_field",
+ value_type: :boolean,
+ join_description: "hereby I confirm the GDPR"
+ })
+ |> Ash.create(actor: actor)
+
+ assert custom_field.join_description == "hereby I confirm the GDPR"
+ end
+
+ test "defaults to nil when not given", %{actor: actor} do
+ assert {:ok, custom_field} =
+ CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "no_join_desc",
+ value_type: :boolean
+ })
+ |> Ash.create(actor: actor)
+
+ assert custom_field.join_description == nil
+ end
+
+ test "rejects join_description longer than 1000 characters", %{actor: actor} do
+ assert {:error, changeset} =
+ CustomField
+ |> Ash.Changeset.for_create(:create, %{
+ name: "too_long_join_desc",
+ value_type: :boolean,
+ join_description: String.duplicate("a", 1001)
+ })
+ |> Ash.create(actor: actor)
+
+ assert [%{field: :join_description, message: message}] = changeset.errors
+ assert message =~ "max" or message =~ "length" or message =~ "1000"
+ end
+
+ test "is writable via the update action", %{actor: actor} do
+ {:ok, custom_field} =
+ CustomField
+ |> Ash.Changeset.for_create(:create, %{name: "updatable", value_type: :boolean})
+ |> Ash.create(actor: actor)
+
+ assert {:ok, updated} =
+ custom_field
+ |> Ash.Changeset.for_update(:update, %{join_description: "Accept the GDPR"})
+ |> Ash.update(actor: actor)
+
+ assert updated.join_description == "Accept the GDPR"
+ end
+ end
+
describe "name uniqueness" do
test "rejects duplicate names", %{actor: actor} do
assert {:ok, _} =
diff --git a/test/mv_web/helpers/join_description_renderer_test.exs b/test/mv_web/helpers/join_description_renderer_test.exs
new file mode 100644
index 0000000..39a3351
--- /dev/null
+++ b/test/mv_web/helpers/join_description_renderer_test.exs
@@ -0,0 +1,85 @@
+defmodule MvWeb.Helpers.JoinDescriptionRendererTest do
+ @moduledoc """
+ Tests for the join-description renderer that auto-links raw URLs and Markdown
+ links while escaping all other content.
+ """
+ use ExUnit.Case, async: true
+ use ExUnitProperties
+
+ alias MvWeb.Helpers.JoinDescriptionRenderer
+
+ defp html(value) do
+ value
+ |> JoinDescriptionRenderer.render()
+ |> Phoenix.HTML.safe_to_string()
+ end
+
+ describe "render/1" do
+ test "converts a raw URL to an anchor tag with the standard link class" do
+ result = html("Akzeptiere https://example.com/dsgvo")
+
+ assert result =~ ~s("
+ assert result =~ "Akzeptiere "
+ end
+
+ test "converts Markdown [text](url) to an anchor tag with the standard link class" do
+ result = html("[Datenschutzerklärung](https://example.com/dsgvo)")
+
+ assert result =~ ~s(Datenschutzerklärung"
+ end
+
+ test "returns an empty safe string for nil input" do
+ assert JoinDescriptionRenderer.render(nil) == {:safe, ""}
+ end
+
+ test "escapes arbitrary HTML in non-link text" do
+ result = html("")
+
+ refute result =~ "