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/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..da69861 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 @@ -42,10 +42,6 @@ defmodule Mv.Membership.Member do @member_search_limit 10 @default_similarity_threshold 0.2 - # Use constants from Mv.Constants for member fields - # This ensures consistency across the codebase - @member_fields Mv.Constants.member_fields() - postgres do table "members" repo Mv.Repo @@ -62,7 +58,21 @@ defmodule Mv.Membership.Member do # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true - accept @member_fields + accept [ + :first_name, + :last_name, + :email, + :birth_date, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] change manage_relationship(:custom_field_values, type: :create) @@ -95,7 +105,21 @@ defmodule Mv.Membership.Member do # user_id is NOT in accept list to prevent direct foreign key manipulation argument :user, :map, allow_nil?: true - accept @member_fields + accept [ + :first_name, + :last_name, + :email, + :birth_date, + :paid, + :phone_number, + :join_date, + :exit_date, + :notes, + :city, + :street, + :house_number, + :postal_code + ] change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) @@ -284,6 +308,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 +375,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 diff --git a/lib/membership/membership.ex b/lib/membership/membership.ex index f5a708b..cb3691b 100644 --- a/lib/membership/membership.ex +++ b/lib/membership/membership.ex @@ -53,7 +53,6 @@ defmodule Mv.Membership do # It's only used internally as fallback in get_settings/0 # Settings should be created via seed script define :update_settings, action: :update - define :update_member_field_visibility, action: :update_member_field_visibility end end @@ -124,37 +123,4 @@ defmodule Mv.Membership do |> Ash.Changeset.for_update(:update, attrs) |> Ash.update(domain: __MODULE__) end - - @doc """ - Updates the member field visibility configuration. - - This is a specialized action for updating only the member field visibility settings. - It validates that all keys are valid member fields and all values are booleans. - - ## 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}`) - - ## Returns - - - `{:ok, updated_settings}` - Successfully updated settings - - `{:error, error}` - Validation or update error - - ## Examples - - iex> {:ok, settings} = Mv.Membership.get_settings() - 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} - - """ - def update_member_field_visibility(settings, visibility_config) do - settings - |> Ash.Changeset.for_update(:update_member_field_visibility, %{ - member_field_visibility: visibility_config - }) - |> Ash.update(domain: __MODULE__) - end end diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex index 52c0328..38624dc 100644 --- a/lib/membership/setting.ex +++ b/lib/membership/setting.ex @@ -9,8 +9,6 @@ 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`. ## Singleton Pattern This resource uses a singleton pattern - there should only be one settings record. @@ -30,9 +28,6 @@ defmodule Mv.Membership.Setting do # Update club name {: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}) """ use Ash.Resource, domain: Mv.Membership, @@ -54,65 +49,18 @@ defmodule Mv.Membership.Setting do # Used only as fallback in get_settings/0 if settings don't exist # Settings should normally be created via seed script create :create do - accept [:club_name, :member_field_visibility] + accept [:club_name] end update :update do primary? true - require_atomic? false - accept [:club_name, :member_field_visibility] - end - - update :update_member_field_visibility do - description "Updates the visibility configuration for member fields in the overview" - require_atomic? false - accept [:member_field_visibility] + accept [:club_name] end end validations 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 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 = - 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 - end - else - :ok - end - end, - on: [:create, :update] end attributes do @@ -127,12 +75,6 @@ defmodule Mv.Membership.Setting do min_length: 1 ] - attribute :member_field_visibility, :map, - allow_nil?: true, - public?: true, - description: - "Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans." - timestamps() end end diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex deleted file mode 100644 index 334bcc1..0000000 --- a/lib/mv/constants.ex +++ /dev/null @@ -1,22 +0,0 @@ -defmodule Mv.Constants do - @moduledoc """ - Module for defining constants and atoms. - """ - - @member_fields [ - :first_name, - :last_name, - :email, - :paid, - :phone_number, - :join_date, - :exit_date, - :notes, - :city, - :street, - :house_number, - :postal_code - ] - - def member_fields, do: @member_fields -end diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 54a5a64..b8fe0fc 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}

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