diff --git a/.drone.yml b/.drone.yml index 427ecfc..623114f 100644 --- a/.drone.yml +++ b/.drone.yml @@ -53,8 +53,6 @@ steps: - mix hex.audit # Provide hints for improving code quality - mix credo - # Check that translations are up to date - - mix gettext.extract --check-up-to-date - name: wait_for_postgres image: docker.io/library/postgres:17.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 71d9147..74df997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,14 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - PostgreSQL trigram-based member search with typo tolerance - WCAG 2.1 AA compliant autocomplete dropdown with ARIA support - Bilingual UI (German/English) for member linking workflow -- **Bulk email copy feature** - Copy email addresses of selected members to clipboard (#230) - - Email format: "First Last " with semicolon separator (compatible with email clients) - - CopyToClipboard JavaScript hook with fallback for older browsers - - Button shows count of visible selected members (respects search/filter) - - German/English translations ### Fixed - Email validation false positive when linking user and member with identical emails (#168 Problem #4) - Relationship data extraction from Ash manage_relationship during validation -- Copy button count now shows only visible selected members when filtering diff --git a/Justfile b/Justfile index 907283f..b28dbdc 100644 --- a/Justfile +++ b/Justfile @@ -29,7 +29,6 @@ lint: mix format --check-formatted mix compile --warnings-as-errors mix credo - mix gettext.extract --check-up-to-date audit: mix sobelow --config diff --git a/assets/js/app.js b/assets/js/app.js index 883ca30..e55a06d 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -27,33 +27,6 @@ let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute(" // Hooks for LiveView components let Hooks = {} -// CopyToClipboard hook: Copies text to clipboard when triggered by server event -Hooks.CopyToClipboard = { - mounted() { - this.handleEvent("copy_to_clipboard", ({text}) => { - if (navigator.clipboard) { - navigator.clipboard.writeText(text).catch(err => { - console.error("Clipboard write failed:", err) - }) - } else { - // Fallback for older browsers - const textArea = document.createElement("textarea") - textArea.value = text - textArea.style.position = "fixed" - textArea.style.left = "-999999px" - document.body.appendChild(textArea) - textArea.select() - try { - document.execCommand("copy") - } catch (err) { - console.error("Fallback clipboard copy failed:", err) - } - document.body.removeChild(textArea) - } - }) - } -} - // ComboBox hook: Prevents form submission when Enter is pressed in dropdown Hooks.ComboBox = { mounted() { diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index 1644f2a..d548b82 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -115,6 +115,7 @@ Member (1) → (N) Properties ### Member Constraints - First name and last name required (min 1 char) - Email unique, validated format (5-254 chars) +- Birth date cannot be in future - Join date cannot be in future - Exit date must be after join date - Phone: `+?[0-9\- ]{6,20}` @@ -168,7 +169,7 @@ Member (1) → (N) Properties ### Weighted Fields - **Weight A (highest):** first_name, last_name - **Weight B:** email, notes -- **Weight C:** phone_number, city, street, house_number, postal_code +- **Weight C:** birth_date, phone_number, city, street, house_number, postal_code - **Weight D (lowest):** join_date, exit_date ### Usage Example @@ -380,7 +381,7 @@ Install "DBML Language" extension to view/edit DBML files with: - tokens (jti, purpose, extra_data) **Personal Data (GDPR):** -- All member fields (name, email, address) +- All member fields (name, email, birth_date, address) - User email - Token subject diff --git a/docs/database_schema.dbml b/docs/database_schema.dbml index b620830..33c0647 100644 --- a/docs/database_schema.dbml +++ b/docs/database_schema.dbml @@ -122,6 +122,7 @@ Table members { first_name text [not null, note: 'Member first name (min length: 1)'] last_name text [not null, note: 'Member last name (min length: 1)'] email text [not null, unique, note: 'Member email address (5-254 chars, validated)'] + birth_date date [null, note: 'Date of birth (cannot be in future)'] paid boolean [null, note: 'Payment status flag'] phone_number text [null, note: 'Contact phone number (format: +?[0-9\- ]{6,20})'] join_date date [null, note: 'Date when member joined club (cannot be in future)'] @@ -152,7 +153,7 @@ Table members { **Club Member Master Data** Core entity for membership management containing: - - Personal information (name, email) + - Personal information (name, birth date, email) - Contact details (phone, address) - Membership status (join/exit dates, payment status) - Additional notes @@ -182,6 +183,7 @@ Table members { **Validation Rules:** - first_name, last_name: min 1 character - email: 5-254 characters, valid email format + - birth_date: cannot be in future - join_date: cannot be in future - exit_date: must be after join_date (if both present) - phone_number: matches pattern ^\+?[0-9\- ]{6,20}$ diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 629987e..5669a19 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -1327,33 +1327,6 @@ end --- -## Session: Bulk Email Copy Feature (2025-12-02) - -### Feature Summary -Implemented bulk email copy functionality for selected members (#230). Users can select members and copy their email addresses to clipboard. - -**Key Features:** -- Copy button appears only when visible members are selected -- Email format: `First Last ` with semicolon separator (email client compatible) -- Button shows count of visible selected members (respects search/filter) -- CopyToClipboard JavaScript hook with clipboard API + fallback for older browsers -- Bilingual UI (English/German) - -### Key Decisions - -1. **Email Format:** "First Last " with semicolon - standard for all major email clients -2. **Visible Member Count:** Button shows only visible selected members, not total selected (better UX when filtering) -3. **Server→Client:** Used `push_event/3` - server formats data, client handles clipboard - -### Files Changed -- `lib/mv_web/live/member_live/index.ex` - Event handler, helper function -- `lib/mv_web/live/member_live/index.html.heex` - Copy button -- `assets/js/app.js` - CopyToClipboard hook -- `test/mv_web/member_live/index_test.exs` - 9 new tests -- `priv/gettext/de/LC_MESSAGES/default.po` - German translations - ---- - ## Session: User-Member Linking UI Enhancement (2025-01-13) ### Feature Summary @@ -1586,8 +1559,8 @@ This project demonstrates a modern Phoenix application built with: --- -**Document Version:** 1.3 -**Last Updated:** 2025-12-02 +**Document Version:** 1.2 +**Last Updated:** 2025-11-27 **Maintainer:** Development Team **Status:** Living Document (update as project evolves) diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 609523c..2313fd7 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -65,7 +65,6 @@ - ✅ Sorting by basic fields - ✅ User-Member linking (optional 1:1) - ✅ Email synchronization between User and Member -- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230) **Closed Issues:** - ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12) @@ -100,10 +99,10 @@ **Closed Issues:** - [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) - [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M) -- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Remove birthday field from default configuration (S) - Closed 2025-12-02 **Open Issues:** - [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks] +- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority) - [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority) **Missing Features:** diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 8d271d7..31a825b 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -24,7 +24,7 @@ defmodule Mv.Membership.Member do - Email format validation (using EctoCommons.EmailValidator) - Phone number format: international format with 6-20 digits - Postal code format: exactly 5 digits (German format) - - Date validations: join_date not in future, exit_date after join_date + - Date validations: birth_date and join_date not in future, exit_date after join_date - Email uniqueness: prevents conflicts with unlinked users ## Full-Text Search @@ -284,6 +284,11 @@ defmodule Mv.Membership.Member do end end + # Birth date not in the future + validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0), + where: [present(:birth_date)], + message: "cannot be in the future" + # Join date not in the future validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0), where: [present(:join_date)], @@ -346,6 +351,10 @@ defmodule Mv.Membership.Member do constraints min_length: 5, max_length: 254 end + attribute :birth_date, :date do + allow_nil? true + end + attribute :paid, :boolean do allow_nil? true end @@ -401,6 +410,70 @@ defmodule Mv.Membership.Member do identity :unique_email, [:email] end + @doc """ + Checks if a member field should be shown in the overview. + + Reads the visibility configuration from Settings resource. If a field is not + configured in settings, it defaults to `true` (visible). + + ## Parameters + - `field` - Atom representing the member field name (e.g., `:email`, `:street`) + + ## Returns + - `true` if the field should be shown in overview (default) + - `false` if the field is configured as hidden in settings + + ## Examples + + iex> Member.show_in_overview?(:email) + true + + iex> Member.show_in_overview?(:street) + true # or false if configured in settings + + """ + @spec show_in_overview?(atom()) :: boolean() + def show_in_overview?(field) when is_atom(field) do + case Mv.Membership.get_settings() do + {:ok, settings} -> + visibility_config = settings.member_field_visibility || %{} + # Normalize map keys to atoms (JSONB may return string keys) + normalized_config = normalize_visibility_config(visibility_config) + + # Get value from normalized config, default to true + Map.get(normalized_config, field, true) + + {:error, _} -> + # If settings can't be loaded, default to visible + true + end + end + + def show_in_overview?(_), do: true + + # Normalizes visibility config map keys from strings to atoms. + # JSONB in PostgreSQL converts atom keys to string keys when storing. + defp normalize_visibility_config(config) when is_map(config) do + Enum.reduce(config, %{}, fn + {key, value}, acc when is_atom(key) -> + Map.put(acc, key, value) + + {key, value}, acc when is_binary(key) -> + try do + atom_key = String.to_existing_atom(key) + Map.put(acc, atom_key, value) + rescue + ArgumentError -> + acc + end + + _, acc -> + acc + end) + end + + defp normalize_visibility_config(_), do: %{} + @doc """ Performs fuzzy search on members using PostgreSQL trigram similarity. diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index f5a708b..516448c 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -134,8 +134,8 @@ defmodule Mv.Membership do ## Parameters - `settings` - The settings record to update - - `visibility_config` - A map of member field names (strings) to boolean visibility values - (e.g., `%{"street" => false, "house_number" => false}`) + - `visibility_config` - A map of member field names (atoms) to boolean visibility values + (e.g., `%{street: false, house_number: false}`) ## Returns @@ -145,9 +145,9 @@ defmodule Mv.Membership do ## Examples iex> {:ok, settings} = Mv.Membership.get_settings() - iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false}) + iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{street: false, house_number: false}) iex> updated.member_field_visibility - %{"street" => false, "house_number" => false} + %{street: false, house_number: false} """ def update_member_field_visibility(settings, visibility_config) do diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 52c0328..3405a3f 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -10,7 +10,7 @@ defmodule Mv.Membership.Setting do ## Attributes - `club_name` - The name of the association/club (required, cannot be empty) - `member_field_visibility` - JSONB map storing visibility configuration for member fields - (e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`. + (e.g., `%{street: false, house_number: false}`). Fields not in the map default to `true`. ## Singleton Pattern This resource uses a singleton pattern - there should only be one settings record. @@ -32,7 +32,7 @@ defmodule Mv.Membership.Setting do {:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"}) # Update member field visibility - {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false}) + {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{street: false, house_number: false}) """ use Ash.Resource, domain: Mv.Membership, @@ -67,6 +67,43 @@ defmodule Mv.Membership.Setting do description "Updates the visibility configuration for member fields in the overview" require_atomic? false accept [:member_field_visibility] + + change fn changeset, _context -> + visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) + + if visibility && is_map(visibility) do + valid_fields = Mv.Constants.member_fields() + # Normalize keys to atoms (JSONB may return string keys) + invalid_keys = + Enum.filter(visibility, fn {key, _value} -> + atom_key = + if is_atom(key) do + key + else + try do + String.to_existing_atom(key) + rescue + ArgumentError -> nil + end + end + + atom_key && atom_key not in valid_fields + end) + |> Enum.map(fn {key, _value} -> key end) + + if Enum.empty?(invalid_keys) do + changeset + else + Ash.Changeset.add_error( + changeset, + field: :member_field_visibility, + message: "Invalid member field keys: #{inspect(invalid_keys)}" + ) + end + else + changeset + end + end end end @@ -74,39 +111,23 @@ defmodule Mv.Membership.Setting do validate present(:club_name), on: [:create, :update] validate string_length(:club_name, min: 1), on: [:create, :update] - # Validate member_field_visibility map structure and content + # Validate that member_field_visibility map contains only boolean values + # This allows dynamic fields without hardcoding specific field names validate fn changeset, _context -> visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility) if visibility && is_map(visibility) do - # Validate all values are booleans - invalid_values = + invalid_entries = Enum.filter(visibility, fn {_key, value} -> not is_boolean(value) end) - # Validate all keys are valid member fields - valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) - - invalid_keys = - Enum.filter(visibility, fn {key, _value} -> - key not in valid_field_strings - end) - |> Enum.map(fn {key, _value} -> key end) - - cond do - not Enum.empty?(invalid_values) -> - {:error, - field: :member_field_visibility, - message: "All values in member_field_visibility must be booleans"} - - not Enum.empty?(invalid_keys) -> - {:error, - field: :member_field_visibility, - message: "Invalid member field keys: #{inspect(invalid_keys)}"} - - true -> - :ok + if Enum.empty?(invalid_entries) do + :ok + else + {:error, + field: :member_field_visibility, + message: "All values in member_field_visibility must be booleans"} end else :ok diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 334bcc1..cd8d3a4 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -7,6 +7,7 @@ defmodule Mv.Constants do :first_name, :last_name, :email, + :birth_date, :paid, :phone_number, :join_date, diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 8fa4495..4f6bf37 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -42,11 +42,7 @@ defmodule MvWeb.CoreComponents do attr :id, :string, doc: "the optional id of flash container" attr :flash, :map, default: %{}, doc: "the map of flash messages to display" attr :title, :string, default: nil - - attr :kind, :atom, - values: [:info, :error, :success, :warning], - doc: "used for styling and flash lookup" - + attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup" attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" slot :inner_block, doc: "the optional inner block that renders the flash message" @@ -60,20 +56,16 @@ defmodule MvWeb.CoreComponents do id={@id} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} role="alert" - class="z-50 toast toast-top toast-end" + class="toast toast-top toast-end z-50" {@rest} >
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" /> <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" /> - <.icon :if={@kind == :success} name="hero-check-circle" class="size-5 shrink-0" /> - <.icon :if={@kind == :warning} name="hero-information-circle" class="size-5 shrink-0" />

{@title}

{msg}

@@ -308,7 +300,7 @@ defmodule MvWeb.CoreComponents do end) ~H""" -
+
<.error :for={msg <- @errors}>{msg} @@ -334,15 +322,9 @@ defmodule MvWeb.CoreComponents do def input(%{type: "select"} = assigns) do ~H""" -
+