Compare commits
58 commits
17ef898274
...
fd6301efab
| Author | SHA1 | Date | |
|---|---|---|---|
| fd6301efab | |||
| 68e6c74a67 | |||
| b60ab3f392 | |||
| ede3df12ef | |||
| 6c22d889a1 | |||
| 140e4a9054 | |||
| 1188320844 | |||
| 9d3c72acff | |||
| 7db609deec | |||
| 02245e6684 | |||
| 124857cc9c | |||
| bc2d91f9e7 | |||
| c33199465c | |||
| e1e0469e41 | |||
| f2bcf68da2 | |||
| 17488a6f42 | |||
| a94c0c0b14 | |||
| a23f999eee | |||
| e4e6cfdd47 | |||
| c46365576d | |||
| 376086ae0f | |||
| 5343b78750 | |||
| 32efe380b7 | |||
| a008cf381a | |||
| a5a4d66655 | |||
| 47284fee98 | |||
| 91839dc426 | |||
| be9d12f181 | |||
| 2619e3ea29 | |||
| 01d901a61d | |||
| d37ba84a74 | |||
| 381e09dd1d | |||
| f3ca492b49 | |||
| 2f8df3f39d | |||
| 3ecbd964ba | |||
| 8430069b45 | |||
| 123227a50e | |||
| 83b104ecf3 | |||
| ec814a8c94 | |||
| 3c79d044d4 | |||
| f4554b8a4b | |||
| 397f7a7975 | |||
| cb932ad6ef | |||
| dbdac5870a | |||
| 01f62297fc | |||
| cbed65de66 | |||
| 3491b4b1ba | |||
| 2315f2588f | |||
| 31fc4f4d0c | |||
| 0fd1b7e142 | |||
| 0333f9e722 | |||
| 9b1aad884e | |||
| e47e266570 | |||
| d1fefcca7d | |||
| b5fc03e94f | |||
| ac13a39e7c | |||
| 002d723d0e | |||
| a25263b721 |
69 changed files with 4487 additions and 652 deletions
|
|
@ -84,7 +84,7 @@ steps:
|
||||||
# Fetch dependencies
|
# Fetch dependencies
|
||||||
- mix deps.get
|
- mix deps.get
|
||||||
# Run fast tests (excludes slow/performance and UI tests)
|
# Run fast tests (excludes slow/performance and UI tests)
|
||||||
- mix test --exclude slow --exclude ui
|
- mix test --exclude slow --exclude ui --max-cases 2
|
||||||
|
|
||||||
- name: rebuild-cache
|
- name: rebuild-cache
|
||||||
image: drillster/drone-volume-cache
|
image: drillster/drone-volume-cache
|
||||||
|
|
|
||||||
|
|
@ -30,3 +30,10 @@ ASSOCIATION_NAME="Sportsclub XYZ"
|
||||||
# OIDC_GROUPS_CLAIM defaults to "groups" (JWT claim name for group list).
|
# OIDC_GROUPS_CLAIM defaults to "groups" (JWT claim name for group list).
|
||||||
# OIDC_ADMIN_GROUP_NAME=admin
|
# OIDC_ADMIN_GROUP_NAME=admin
|
||||||
# OIDC_GROUPS_CLAIM=groups
|
# OIDC_GROUPS_CLAIM=groups
|
||||||
|
|
||||||
|
# Optional: Vereinfacht accounting integration (finance-contacts sync)
|
||||||
|
# If set, these override values from Settings UI; those fields become read-only.
|
||||||
|
# VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1
|
||||||
|
# VEREINFACHT_API_KEY=your-api-key
|
||||||
|
# VEREINFACHT_CLUB_ID=2
|
||||||
|
# VEREINFACHT_APP_URL=https://app.verein.visuel.dev
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,8 @@ def process_user(user), do: {:ok, perform_action(user)}
|
||||||
|
|
||||||
### 2.3 Error Handling
|
### 2.3 Error Handling
|
||||||
|
|
||||||
|
**No silent failures:** When an error path assigns a fallback (e.g. empty list, unchanged assigns), always log the error with enough context (e.g. `inspect(error)`, slug, action) and/or set a user-visible flash. Do not only assign the fallback without logging.
|
||||||
|
|
||||||
**Use Tagged Tuples:**
|
**Use Tagged Tuples:**
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
|
|
@ -623,6 +625,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**LiveView load budget:** Keep UI-triggered events cheap. `phx-change`, `phx-focus`, and `phx-keydown` must **not** perform database reads by default; work from assigns (e.g. filter in memory) or defer reads to an explicit commit step (e.g. "Add", "Save", "Submit"). Perform DB reads or reloads only on commit events, not on every keystroke or focus. If a read in an event is unavoidable, do at most one deliberate read, document why, and prefer debounce/throttle.
|
||||||
|
|
||||||
|
**LiveView size:** When a LiveView accumulates many features and event handlers (CRUD + add/remove + search + keyboard + modals), extract sub-flows into LiveComponents. The parent keeps auth, initial load, and a single reload after child actions; the component owns the sub-flow and notifies the parent when data changes.
|
||||||
|
|
||||||
**Component Design:**
|
**Component Design:**
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
|
|
@ -1258,6 +1264,8 @@ end
|
||||||
|
|
||||||
### 3.12 Internationalization: Gettext
|
### 3.12 Internationalization: Gettext
|
||||||
|
|
||||||
|
**German (de):** Use informal address (“duzen”). All user-facing German text should address the user as “du” (e.g. “Bitte versuche es erneut”, “Deine Einstellungen”), not “Sie”.
|
||||||
|
|
||||||
**Define Translations:**
|
**Define Translations:**
|
||||||
|
|
||||||
```elixir
|
```elixir
|
||||||
|
|
@ -1267,6 +1275,9 @@ gettext("Welcome to Mila")
|
||||||
# With interpolation
|
# With interpolation
|
||||||
gettext("Hello, %{name}!", name: user.name)
|
gettext("Hello, %{name}!", name: user.name)
|
||||||
|
|
||||||
|
# Plural: always pass count binding when message uses %{count}
|
||||||
|
ngettext("Found %{count} member", "Found %{count} members", @count, count: @count)
|
||||||
|
|
||||||
# Domain-specific translations
|
# Domain-specific translations
|
||||||
dgettext("auth", "Sign in with email")
|
dgettext("auth", "Sign in with email")
|
||||||
```
|
```
|
||||||
|
|
@ -1507,6 +1518,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**LiveView test standards:** Prefer selector-based assertions (`has_element?` on `data-testid`, stable IDs, or semantic markers) over free-text matching (`html =~ "..."`) or broad regex. For repeated flows (e.g. "open add member", "search", "select"), use helpers in `test/support/`. If delete is a LiveView event, test that event and assert both UI and data; if delete uses JS `data-confirm` + non-LV submit, cover the real delete path in a context/service test and add at most one smoke test in the UI. Do not assert or document "single request" or "DB-level sort" without measuring (e.g. query count or timing).
|
||||||
|
|
||||||
#### 4.3.5 Component Tests
|
#### 4.3.5 Component Tests
|
||||||
|
|
||||||
Test function components:
|
Test function components:
|
||||||
|
|
@ -1876,6 +1889,8 @@ policies do
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Record-based authorization:** When a concrete record is available, use `can?(actor, :action, record)` (e.g. `can?(@current_user, :update, @group)`). Use `can?(actor, :action, Resource)` only when no specific record exists (e.g. "can create any group"). In events: resolve the record from assigns, run `can?`, then mutate; avoid an extra DB read just for a "freshness" check if the assign is already the source of truth.
|
||||||
|
|
||||||
**Actor Handling in LiveViews:**
|
**Actor Handling in LiveViews:**
|
||||||
|
|
||||||
Always use the `current_actor/1` helper for consistent actor access:
|
Always use the `current_actor/1` helper for consistent actor access:
|
||||||
|
|
@ -2707,7 +2722,9 @@ Building accessible applications ensures that all users, including those with di
|
||||||
|
|
||||||
### 8.2 ARIA Labels and Roles
|
### 8.2 ARIA Labels and Roles
|
||||||
|
|
||||||
**Use ARIA Attributes When Necessary:**
|
**Terminology and semantics:** Use the same terms in UI, tests, and docs (e.g. "modal" vs "inline confirmation"). If the implementation is inline, do not call it a modal in tests or docs.
|
||||||
|
|
||||||
|
**Use ARIA Attributes When Necessary:** Use `role="status"` only for live regions that present advisory status (e.g. search result count, progress). Do not use it on links, buttons, or purely static labels; use semantic HTML or the correct role for the widget. Attach `phx-debounce` / `phx-throttle` to the element that triggers the event (e.g. input), not to the whole form, unless the intent is form-wide.
|
||||||
|
|
||||||
```heex
|
```heex
|
||||||
<!-- Icon-only buttons need labels -->
|
<!-- Icon-only buttons need labels -->
|
||||||
|
|
@ -2931,11 +2948,11 @@ end
|
||||||
**Announce Dynamic Content:**
|
**Announce Dynamic Content:**
|
||||||
|
|
||||||
```heex
|
```heex
|
||||||
<!-- Search results announcement -->
|
<!-- Search results announcement (count: required so %{count} is replaced and pluralisation works) -->
|
||||||
<div role="status" aria-live="polite" aria-atomic="true">
|
<div role="status" aria-live="polite" aria-atomic="true">
|
||||||
<%= if @searched do %>
|
<%= if @searched do %>
|
||||||
<span class="sr-only">
|
<span class="sr-only">
|
||||||
<%= ngettext("Found %{count} member", "Found %{count} members", @count) %>
|
<%= ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) %>
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
14
Dockerfile
14
Dockerfile
|
|
@ -7,25 +7,25 @@
|
||||||
# This file is based on these images:
|
# This file is based on these images:
|
||||||
#
|
#
|
||||||
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
|
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
|
||||||
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20250317-slim - for the release image
|
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=trixie-20260202-slim - for the release image
|
||||||
# - https://pkgs.org/ - resource for finding needed packages
|
# - https://pkgs.org/ - resource for finding needed packages
|
||||||
# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim
|
# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim
|
||||||
#
|
#
|
||||||
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim"
|
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim"
|
||||||
ARG RUNNER_IMAGE="debian:bullseye-20250317-slim"
|
ARG RUNNER_IMAGE="debian:trixie-20260202-slim"
|
||||||
|
|
||||||
FROM ${BUILDER_IMAGE} AS builder
|
FROM ${BUILDER_IMAGE} AS builder
|
||||||
|
|
||||||
# install build dependencies
|
# install build dependencies
|
||||||
RUN apt-get update -y && apt-get install -y build-essential git \
|
RUN apt-get update -y && apt-get install -y build-essential git \
|
||||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||||
|
|
||||||
# prepare build dir
|
# prepare build dir
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# install hex + rebar
|
# install hex + rebar
|
||||||
RUN mix local.hex --force && \
|
RUN mix local.hex --force && \
|
||||||
mix local.rebar --force
|
mix local.rebar --force
|
||||||
|
|
||||||
# set build ENV
|
# set build ENV
|
||||||
ENV MIX_ENV="prod"
|
ENV MIX_ENV="prod"
|
||||||
|
|
@ -64,7 +64,7 @@ RUN mix release
|
||||||
FROM ${RUNNER_IMAGE}
|
FROM ${RUNNER_IMAGE}
|
||||||
|
|
||||||
RUN apt-get update -y && \
|
RUN apt-get update -y && \
|
||||||
apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
|
apt-get install -y libstdc++6 openssl libncurses6 locales ca-certificates \
|
||||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||||
|
|
||||||
# Set the locale
|
# Set the locale
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
@plugin "../vendor/daisyui-theme" {
|
@plugin "../vendor/daisyui-theme" {
|
||||||
name: "dark";
|
name: "dark";
|
||||||
default: false;
|
default: false;
|
||||||
prefersdark: true;
|
prefersdark: false;
|
||||||
color-scheme: "dark";
|
color-scheme: "dark";
|
||||||
--color-base-100: oklch(30.33% 0.016 252.42);
|
--color-base-100: oklch(30.33% 0.016 252.42);
|
||||||
--color-base-200: oklch(25.26% 0.014 253.1);
|
--color-base-200: oklch(25.26% 0.014 253.1);
|
||||||
|
|
@ -99,6 +99,25 @@
|
||||||
/* Make LiveView wrapper divs transparent for layout */
|
/* Make LiveView wrapper divs transparent for layout */
|
||||||
[data-phx-session] { display: contents }
|
[data-phx-session] { display: contents }
|
||||||
|
|
||||||
|
/* WCAG 1.4.12 Text Spacing: allow user stylesheets to adjust text spacing in popovers.
|
||||||
|
Popover content (e.g. from DaisyUI dropdown) must not rely on non-overridable inline
|
||||||
|
spacing; use inherited values so custom stylesheets can override. */
|
||||||
|
[popover] {
|
||||||
|
line-height: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
word-spacing: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WCAG 2 AA: success/error text on light backgrounds (e.g. base-200). Use instead of
|
||||||
|
text-success/text-error when contrast ratio of theme colors is insufficient. */
|
||||||
|
.text-success-aa {
|
||||||
|
color: oklch(0.35 0.12 165);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error-aa {
|
||||||
|
color: oklch(0.45 0.2 25);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
Sidebar Base Styles
|
Sidebar Base Styles
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,8 @@ defmodule Mv.Accounts.User do
|
||||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||||
where [changing(:email)]
|
where [changing(:email)]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||||
end
|
end
|
||||||
|
|
||||||
create :create_user do
|
create :create_user do
|
||||||
|
|
@ -145,6 +147,8 @@ defmodule Mv.Accounts.User do
|
||||||
|
|
||||||
# Sync user email to member when linking (User → Member)
|
# Sync user email to member when linking (User → Member)
|
||||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||||
|
|
||||||
|
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||||
end
|
end
|
||||||
|
|
||||||
update :update_user do
|
update :update_user do
|
||||||
|
|
@ -178,6 +182,8 @@ defmodule Mv.Accounts.User do
|
||||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||||
where any([changing(:email), changing(:member)])
|
where any([changing(:email), changing(:member)])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||||
end
|
end
|
||||||
|
|
||||||
# Internal update used only by SystemActor/bootstrap and tests to assign role to system user.
|
# Internal update used only by SystemActor/bootstrap and tests to assign role to system user.
|
||||||
|
|
@ -211,6 +217,8 @@ defmodule Mv.Accounts.User do
|
||||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||||
where [changing(:email)]
|
where [changing(:email)]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||||
end
|
end
|
||||||
|
|
||||||
# Action to link an OIDC account to an existing password-only user
|
# Action to link an OIDC account to an existing password-only user
|
||||||
|
|
@ -248,6 +256,8 @@ defmodule Mv.Accounts.User do
|
||||||
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
change Mv.EmailSync.Changes.SyncUserEmailToMember do
|
||||||
where [changing(:email)]
|
where [changing(:email)]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||||
end
|
end
|
||||||
|
|
||||||
read :get_by_subject do
|
read :get_by_subject do
|
||||||
|
|
@ -328,6 +338,8 @@ defmodule Mv.Accounts.User do
|
||||||
# Sync user email to member when linking (User → Member)
|
# Sync user email to member when linking (User → Member)
|
||||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||||
|
|
||||||
|
change Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange
|
||||||
|
|
||||||
# Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated
|
# Sync role from OIDC groups (e.g. admin group → Admin role) after user is created/updated
|
||||||
change fn changeset, _ctx ->
|
change fn changeset, _ctx ->
|
||||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ defmodule Mv.Membership.CustomField do
|
||||||
## Attributes
|
## Attributes
|
||||||
- `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday")
|
- `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday")
|
||||||
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
|
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
|
||||||
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
|
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation.
|
||||||
- `description` - Optional human-readable description
|
- `description` - Optional human-readable description
|
||||||
- `required` - If true, all members must have this custom field (future feature)
|
- `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
|
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
|
||||||
|
|
@ -28,6 +28,7 @@ defmodule Mv.Membership.CustomField do
|
||||||
## Constraints
|
## Constraints
|
||||||
- Name must be unique across all custom fields
|
- Name must be unique across all custom fields
|
||||||
- Name maximum length: 100 characters
|
- Name maximum length: 100 characters
|
||||||
|
- `value_type` cannot be changed after creation (immutable)
|
||||||
- Deleting a custom field will cascade delete all associated custom field values
|
- Deleting a custom field will cascade delete all associated custom field values
|
||||||
|
|
||||||
## Calculations
|
## Calculations
|
||||||
|
|
@ -59,7 +60,7 @@ defmodule Mv.Membership.CustomField do
|
||||||
end
|
end
|
||||||
|
|
||||||
actions do
|
actions do
|
||||||
defaults [:read, :update]
|
defaults [:read]
|
||||||
default_accept [:name, :value_type, :description, :required, :show_in_overview]
|
default_accept [:name, :value_type, :description, :required, :show_in_overview]
|
||||||
|
|
||||||
create :create do
|
create :create do
|
||||||
|
|
@ -68,6 +69,19 @@ defmodule Mv.Membership.CustomField do
|
||||||
validate string_length(:slug, min: 1)
|
validate string_length(:slug, min: 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
update :update do
|
||||||
|
accept [:name, :description, :required, :show_in_overview]
|
||||||
|
require_atomic? false
|
||||||
|
|
||||||
|
validate fn changeset, _context ->
|
||||||
|
if Ash.Changeset.changing_attribute?(changeset, :value_type) do
|
||||||
|
{:error, field: :value_type, message: "cannot be changed after creation"}
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
destroy :destroy_with_values do
|
destroy :destroy_with_values do
|
||||||
primary? true
|
primary? true
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,9 @@ defmodule Mv.Membership.Member do
|
||||||
# Requires both join_date and membership_fee_type_id to be present
|
# Requires both join_date and membership_fee_type_id to be present
|
||||||
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
change Mv.MembershipFees.Changes.SetMembershipFeeStartDate
|
||||||
|
|
||||||
|
# Sync member to Vereinfacht as finance contact (if configured)
|
||||||
|
change Mv.Vereinfacht.Changes.SyncContact
|
||||||
|
|
||||||
# Trigger cycle generation after member creation
|
# Trigger cycle generation after member creation
|
||||||
# Only runs if membership_fee_type_id is set
|
# Only runs if membership_fee_type_id is set
|
||||||
# Note: Cycle generation runs asynchronously to not block the action,
|
# Note: Cycle generation runs asynchronously to not block the action,
|
||||||
|
|
@ -190,6 +193,9 @@ defmodule Mv.Membership.Member do
|
||||||
where [changing(:membership_fee_type_id)]
|
where [changing(:membership_fee_type_id)]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Sync member to Vereinfacht as finance contact (if configured)
|
||||||
|
change Mv.Vereinfacht.Changes.SyncContact
|
||||||
|
|
||||||
# Trigger cycle regeneration when membership_fee_type_id changes
|
# Trigger cycle regeneration when membership_fee_type_id changes
|
||||||
# This deletes future unpaid cycles and regenerates them with the new type/amount
|
# This deletes future unpaid cycles and regenerates them with the new type/amount
|
||||||
# Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity
|
# Note: Cycle regeneration runs synchronously in the same transaction to ensure atomicity
|
||||||
|
|
@ -243,6 +249,13 @@ defmodule Mv.Membership.Member do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Internal: set vereinfacht_contact_id after syncing with Vereinfacht API.
|
||||||
|
# Not exposed via code interface; used only by Mv.Vereinfacht.Changes.SyncContact.
|
||||||
|
update :set_vereinfacht_contact_id do
|
||||||
|
require_atomic? false
|
||||||
|
accept [:vereinfacht_contact_id]
|
||||||
|
end
|
||||||
|
|
||||||
# Action to handle fuzzy search on specific fields
|
# Action to handle fuzzy search on specific fields
|
||||||
read :search do
|
read :search do
|
||||||
argument :query, :string, allow_nil?: true
|
argument :query, :string, allow_nil?: true
|
||||||
|
|
@ -320,6 +333,12 @@ defmodule Mv.Membership.Member do
|
||||||
authorize_if Mv.Authorization.Checks.HasPermission
|
authorize_if Mv.Authorization.Checks.HasPermission
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Internal sync action: only SystemActor may set vereinfacht_contact_id (used by SyncContact change).
|
||||||
|
policy action(:set_vereinfacht_contact_id) do
|
||||||
|
description "Only system actor may set Vereinfacht contact ID"
|
||||||
|
authorize_if Mv.Authorization.Checks.ActorIsSystemUser
|
||||||
|
end
|
||||||
|
|
||||||
# CREATE/UPDATE: Forbid member–user link unless admin, then check permissions
|
# CREATE/UPDATE: Forbid member–user link unless admin, then check permissions
|
||||||
# ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty).
|
# ForbidMemberUserLinkUnlessAdmin: only admins may pass :user (link or unlink via nil/empty).
|
||||||
# HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all.
|
# HasPermission: :own_data → update linked; :read_only → no update; :normal_user/admin → update all.
|
||||||
|
|
@ -593,6 +612,14 @@ defmodule Mv.Membership.Member do
|
||||||
public? true
|
public? true
|
||||||
description "Date from which membership fees should be calculated"
|
description "Date from which membership fees should be calculated"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Vereinfacht accounting software integration: ID of the finance contact synced via API.
|
||||||
|
# Set by Mv.Vereinfacht.Changes.SyncContact; not accepted in create/update actions.
|
||||||
|
attribute :vereinfacht_contact_id, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
description "ID of the finance contact in Vereinfacht (set by sync)"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
relationships do
|
relationships do
|
||||||
|
|
@ -1275,7 +1302,10 @@ defmodule Mv.Membership.Member do
|
||||||
|
|
||||||
# Extracts custom field values from existing member data (update scenario)
|
# Extracts custom field values from existing member data (update scenario)
|
||||||
defp extract_existing_values(member_data, changeset) do
|
defp extract_existing_values(member_data, changeset) do
|
||||||
actor = Map.get(changeset.context, :actor)
|
actor =
|
||||||
|
Map.get(changeset.context, :actor) ||
|
||||||
|
Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
opts = Helpers.ash_actor_opts(actor)
|
opts = Helpers.ash_actor_opts(actor)
|
||||||
|
|
||||||
case Ash.load(member_data, :custom_field_values, opts) do
|
case Ash.load(member_data, :custom_field_values, opts) do
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,11 @@ defmodule Mv.Membership.Setting do
|
||||||
:club_name,
|
:club_name,
|
||||||
:member_field_visibility,
|
:member_field_visibility,
|
||||||
:include_joining_cycle,
|
:include_joining_cycle,
|
||||||
:default_membership_fee_type_id
|
:default_membership_fee_type_id,
|
||||||
|
:vereinfacht_api_url,
|
||||||
|
:vereinfacht_api_key,
|
||||||
|
:vereinfacht_club_id,
|
||||||
|
:vereinfacht_app_url
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -81,7 +85,11 @@ defmodule Mv.Membership.Setting do
|
||||||
:club_name,
|
:club_name,
|
||||||
:member_field_visibility,
|
:member_field_visibility,
|
||||||
:include_joining_cycle,
|
:include_joining_cycle,
|
||||||
:default_membership_fee_type_id
|
:default_membership_fee_type_id,
|
||||||
|
:vereinfacht_api_url,
|
||||||
|
:vereinfacht_api_key,
|
||||||
|
:vereinfacht_club_id,
|
||||||
|
:vereinfacht_app_url
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -225,6 +233,33 @@ defmodule Mv.Membership.Setting do
|
||||||
description "Default membership fee type ID for new members"
|
description "Default membership fee type ID for new members"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Vereinfacht accounting software integration (can be overridden by ENV)
|
||||||
|
attribute :vereinfacht_api_url, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
description "Vereinfacht API base URL (e.g. https://api.verein.visuel.dev/api/v1)"
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :vereinfacht_api_key, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
description "Vereinfacht API key (Bearer token)"
|
||||||
|
sensitive? true
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :vereinfacht_club_id, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
description "Vereinfacht club ID for multi-tenancy"
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :vereinfacht_app_url, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
|
||||||
|
description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)"
|
||||||
|
end
|
||||||
|
|
||||||
timestamps()
|
timestamps()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ defmodule Mv.Application do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
|
Mv.Vereinfacht.SyncFlash.create_table!()
|
||||||
|
|
||||||
children = [
|
children = [
|
||||||
MvWeb.Telemetry,
|
MvWeb.Telemetry,
|
||||||
Mv.Repo,
|
Mv.Repo,
|
||||||
|
|
|
||||||
15
lib/mv/authorization/checks/actor_is_system_user.ex
Normal file
15
lib/mv/authorization/checks/actor_is_system_user.ex
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
defmodule Mv.Authorization.Checks.ActorIsSystemUser do
|
||||||
|
@moduledoc """
|
||||||
|
Policy check: true only when the actor is the system user (e.g. system@mila.local).
|
||||||
|
|
||||||
|
Used to restrict internal actions (e.g. Member.set_vereinfacht_contact_id) so that
|
||||||
|
only code paths using SystemActor can perform them, not regular admins.
|
||||||
|
"""
|
||||||
|
use Ash.Policy.SimpleCheck
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def describe(_opts), do: "actor is the system user"
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def match?(actor, _context, _opts), do: Mv.Helpers.SystemActor.system_user?(actor)
|
||||||
|
end
|
||||||
156
lib/mv/config.ex
156
lib/mv/config.ex
|
|
@ -142,4 +142,160 @@ defmodule Mv.Config do
|
||||||
|> Keyword.get(key, default)
|
|> Keyword.get(key, default)
|
||||||
|> parse_and_validate_integer(default)
|
|> parse_and_validate_integer(default)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Vereinfacht accounting software integration
|
||||||
|
# ENV variables take priority; fallback to Settings from database.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the Vereinfacht API base URL.
|
||||||
|
|
||||||
|
Reads from `VEREINFACHT_API_URL` env first, then from Settings.
|
||||||
|
"""
|
||||||
|
@spec vereinfacht_api_url() :: String.t() | nil
|
||||||
|
def vereinfacht_api_url do
|
||||||
|
env_or_setting("VEREINFACHT_API_URL", :vereinfacht_api_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the Vereinfacht API key (Bearer token).
|
||||||
|
|
||||||
|
Reads from `VEREINFACHT_API_KEY` env first, then from Settings.
|
||||||
|
"""
|
||||||
|
@spec vereinfacht_api_key() :: String.t() | nil
|
||||||
|
def vereinfacht_api_key do
|
||||||
|
env_or_setting("VEREINFACHT_API_KEY", :vereinfacht_api_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the Vereinfacht club ID for multi-tenancy.
|
||||||
|
|
||||||
|
Reads from `VEREINFACHT_CLUB_ID` env first, then from Settings.
|
||||||
|
"""
|
||||||
|
@spec vereinfacht_club_id() :: String.t() | nil
|
||||||
|
def vereinfacht_club_id do
|
||||||
|
env_or_setting("VEREINFACHT_CLUB_ID", :vereinfacht_club_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the Vereinfacht app base URL for contact view links (frontend, not API).
|
||||||
|
|
||||||
|
Reads from `VEREINFACHT_APP_URL` env first, then from Settings.
|
||||||
|
Used to build links like https://app.verein.visuel.dev/en/admin/finances/contacts/{id}.
|
||||||
|
If not set, derived from API URL by replacing host \"api.\" with \"app.\" when possible.
|
||||||
|
"""
|
||||||
|
@spec vereinfacht_app_url() :: String.t() | nil
|
||||||
|
def vereinfacht_app_url do
|
||||||
|
env_or_setting("VEREINFACHT_APP_URL", :vereinfacht_app_url) ||
|
||||||
|
derive_app_url_from_api_url(vereinfacht_api_url())
|
||||||
|
end
|
||||||
|
|
||||||
|
defp derive_app_url_from_api_url(nil), do: nil
|
||||||
|
|
||||||
|
defp derive_app_url_from_api_url(api_url) when is_binary(api_url) do
|
||||||
|
api_url = String.trim(api_url)
|
||||||
|
uri = URI.parse(api_url)
|
||||||
|
host = uri.host || ""
|
||||||
|
|
||||||
|
if String.starts_with?(host, "api.") do
|
||||||
|
app_host = "app." <> String.slice(host, 4..-1//1)
|
||||||
|
scheme = uri.scheme || "https"
|
||||||
|
"#{scheme}://#{app_host}"
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp derive_app_url_from_api_url(_), do: nil
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set).
|
||||||
|
"""
|
||||||
|
@spec vereinfacht_configured?() :: boolean()
|
||||||
|
def vereinfacht_configured? do
|
||||||
|
present?(vereinfacht_api_url()) and present?(vereinfacht_api_key()) and
|
||||||
|
present?(vereinfacht_club_id())
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true if any Vereinfacht ENV variable is set (used to show hint in Settings UI).
|
||||||
|
"""
|
||||||
|
@spec vereinfacht_env_configured?() :: boolean()
|
||||||
|
def vereinfacht_env_configured? do
|
||||||
|
vereinfacht_api_url_env_set?() or vereinfacht_api_key_env_set?() or
|
||||||
|
vereinfacht_club_id_env_set?()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true if VEREINFACHT_API_URL is set (field is read-only in Settings).
|
||||||
|
"""
|
||||||
|
def vereinfacht_api_url_env_set?, do: env_set?("VEREINFACHT_API_URL")
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true if VEREINFACHT_API_KEY is set (field is read-only in Settings).
|
||||||
|
"""
|
||||||
|
def vereinfacht_api_key_env_set?, do: env_set?("VEREINFACHT_API_KEY")
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true if VEREINFACHT_CLUB_ID is set (field is read-only in Settings).
|
||||||
|
"""
|
||||||
|
def vereinfacht_club_id_env_set?, do: env_set?("VEREINFACHT_CLUB_ID")
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true if VEREINFACHT_APP_URL is set (field is read-only in Settings).
|
||||||
|
"""
|
||||||
|
def vereinfacht_app_url_env_set?, do: env_set?("VEREINFACHT_APP_URL")
|
||||||
|
|
||||||
|
defp env_set?(key) do
|
||||||
|
case System.get_env(key) do
|
||||||
|
nil -> false
|
||||||
|
v when is_binary(v) -> String.trim(v) != ""
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp env_or_setting(env_key, setting_key) do
|
||||||
|
case System.get_env(env_key) do
|
||||||
|
nil -> get_vereinfacht_from_settings(setting_key)
|
||||||
|
value -> trim_nil(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_vereinfacht_from_settings(key) do
|
||||||
|
case Mv.Membership.get_settings() do
|
||||||
|
{:ok, settings} -> settings |> Map.get(key) |> trim_nil()
|
||||||
|
{:error, _} -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp trim_nil(nil), do: nil
|
||||||
|
|
||||||
|
defp trim_nil(s) when is_binary(s) do
|
||||||
|
t = String.trim(s)
|
||||||
|
if t == "", do: nil, else: t
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the URL to view a finance contact in the Vereinfacht app (frontend).
|
||||||
|
|
||||||
|
Uses the configured app base URL (or derived from API URL) and appends
|
||||||
|
/en/admin/finances/contacts/{id}. Returns nil if no app URL can be determined.
|
||||||
|
"""
|
||||||
|
@spec vereinfacht_contact_view_url(String.t()) :: String.t() | nil
|
||||||
|
def vereinfacht_contact_view_url(contact_id) when is_binary(contact_id) do
|
||||||
|
base = vereinfacht_app_url()
|
||||||
|
|
||||||
|
if present?(base) do
|
||||||
|
base
|
||||||
|
|> String.trim_trailing("/")
|
||||||
|
|> then(&"#{&1}/en/admin/finances/contacts/#{contact_id}")
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp present?(nil), do: false
|
||||||
|
defp present?(s) when is_binary(s), do: String.trim(s) != ""
|
||||||
|
defp present?(_), do: false
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ defmodule Mv.Membership.MemberExport do
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
|
|
||||||
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||||
["membership_fee_status"]
|
["membership_fee_status", "groups"]
|
||||||
@computed_export_fields ["membership_fee_status"]
|
@computed_export_fields ["membership_fee_status"]
|
||||||
@computed_insert_after "membership_fee_start_date"
|
@computed_insert_after "membership_fee_start_date"
|
||||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||||
|
|
@ -323,10 +323,14 @@ defmodule Mv.Membership.MemberExport do
|
||||||
|> Enum.filter(&(&1 in @domain_member_field_strings))
|
|> Enum.filter(&(&1 in @domain_member_field_strings))
|
||||||
|> order_member_fields_like_table()
|
|> order_member_fields_like_table()
|
||||||
|
|
||||||
# final member_fields list (used for column specs order): table order + computed inserted
|
# Separate groups from other fields (groups is handled as a special field, not a member field)
|
||||||
|
groups_field = if "groups" in member_fields, do: ["groups"], else: []
|
||||||
|
|
||||||
|
# final member_fields list (used for column specs order): table order + computed inserted + groups
|
||||||
ordered_member_fields =
|
ordered_member_fields =
|
||||||
selectable_member_fields
|
selectable_member_fields
|
||||||
|> insert_computed_fields_like_table(computed_fields)
|
|> insert_computed_fields_like_table(computed_fields)
|
||||||
|
|> then(fn fields -> fields ++ groups_field end)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
||||||
|
|
|
||||||
|
|
@ -132,12 +132,15 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
parsed.computed_fields != [] or
|
parsed.computed_fields != [] or
|
||||||
"membership_fee_status" in parsed.member_fields
|
"membership_fee_status" in parsed.member_fields
|
||||||
|
|
||||||
|
need_groups = "groups" in parsed.member_fields
|
||||||
|
|
||||||
query =
|
query =
|
||||||
Member
|
Member
|
||||||
|> Ash.Query.new()
|
|> Ash.Query.new()
|
||||||
|> Ash.Query.select(select_fields)
|
|> Ash.Query.select(select_fields)
|
||||||
|> load_custom_field_values_query(custom_field_ids_union)
|
|> load_custom_field_values_query(custom_field_ids_union)
|
||||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
||||||
|
|> maybe_load_groups(need_groups)
|
||||||
|
|
||||||
query =
|
query =
|
||||||
if parsed.selected_ids != [] do
|
if parsed.selected_ids != [] do
|
||||||
|
|
@ -241,16 +244,22 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
defp maybe_sort(query, _field, nil), do: {query, false}
|
defp maybe_sort(query, _field, nil), do: {query, false}
|
||||||
|
|
||||||
defp maybe_sort(query, field, order) when is_binary(field) do
|
defp maybe_sort(query, field, order) when is_binary(field) do
|
||||||
if custom_field_sort?(field) do
|
cond do
|
||||||
{query, true}
|
field == "groups" ->
|
||||||
else
|
# Groups sort → in-memory nach dem Read (wie Tabelle)
|
||||||
field_atom = String.to_existing_atom(field)
|
{query, true}
|
||||||
|
|
||||||
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
|
custom_field_sort?(field) ->
|
||||||
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
|
{query, true}
|
||||||
else
|
|
||||||
{query, false}
|
true ->
|
||||||
end
|
field_atom = String.to_existing_atom(field)
|
||||||
|
|
||||||
|
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
|
||||||
|
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
|
||||||
|
else
|
||||||
|
{query, false}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
ArgumentError -> {query, false}
|
ArgumentError -> {query, false}
|
||||||
|
|
@ -260,11 +269,25 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
do: []
|
do: []
|
||||||
|
|
||||||
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
|
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
|
||||||
|
if field == "groups" do
|
||||||
|
sort_members_by_groups_export(members, order)
|
||||||
|
else
|
||||||
|
sort_by_custom_field_value(members, field, order, custom_fields)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sort_by_custom_field_value(members, field, order, custom_fields) do
|
||||||
id_str = String.trim_leading(field, @custom_field_prefix)
|
id_str = String.trim_leading(field, @custom_field_prefix)
|
||||||
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
|
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
|
||||||
|
|
||||||
if is_nil(custom_field), do: members
|
if is_nil(custom_field) do
|
||||||
|
members
|
||||||
|
else
|
||||||
|
sort_members_with_custom_field(members, custom_field, order)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sort_members_with_custom_field(members, custom_field, order) do
|
||||||
key_fn = fn member ->
|
key_fn = fn member ->
|
||||||
cfv = find_cfv(member, custom_field)
|
cfv = find_cfv(member, custom_field)
|
||||||
raw = if cfv, do: cfv.value, else: nil
|
raw = if cfv, do: cfv.value, else: nil
|
||||||
|
|
@ -277,6 +300,26 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
|> Enum.map(fn {m, _} -> m end)
|
|> Enum.map(fn {m, _} -> m end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp sort_members_by_groups_export(members, order) do
|
||||||
|
# Members with groups first, then by first group name alphabetically (min = first by sort order)
|
||||||
|
# Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
|
||||||
|
first_group_name = fn member ->
|
||||||
|
(member.groups || [])
|
||||||
|
|> Enum.map(& &1.name)
|
||||||
|
|> Enum.min(fn -> nil end)
|
||||||
|
end
|
||||||
|
|
||||||
|
members
|
||||||
|
|> Enum.sort_by(fn member ->
|
||||||
|
name = first_group_name.(member)
|
||||||
|
# Nil (no groups) sorts last in asc, first in desc
|
||||||
|
{name == nil, name || ""}
|
||||||
|
end)
|
||||||
|
|> then(fn list ->
|
||||||
|
if order == "desc", do: Enum.reverse(list), else: list
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp find_cfv(member, custom_field) do
|
defp find_cfv(member, custom_field) do
|
||||||
(member.custom_field_values || [])
|
(member.custom_field_values || [])
|
||||||
|> Enum.find(fn cfv ->
|
|> Enum.find(fn cfv ->
|
||||||
|
|
@ -294,6 +337,13 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_load_groups(query, false), do: query
|
||||||
|
|
||||||
|
defp maybe_load_groups(query, true) do
|
||||||
|
# Load groups with id and name only (for export formatting)
|
||||||
|
Ash.Query.load(query, groups: [:id, :name])
|
||||||
|
end
|
||||||
|
|
||||||
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
||||||
|
|
||||||
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
|
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
|
||||||
|
|
@ -343,6 +393,19 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
groups_col =
|
||||||
|
if "groups" in parsed.member_fields do
|
||||||
|
[
|
||||||
|
%{
|
||||||
|
key: :groups,
|
||||||
|
kind: :groups,
|
||||||
|
label: label_fn.(:groups)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
custom_cols =
|
custom_cols =
|
||||||
parsed.custom_field_ids
|
parsed.custom_field_ids
|
||||||
|> Enum.map(fn id ->
|
|> Enum.map(fn id ->
|
||||||
|
|
@ -361,7 +424,7 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
end)
|
end)
|
||||||
|> Enum.reject(&is_nil/1)
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|
||||||
member_cols ++ computed_cols ++ custom_cols
|
member_cols ++ computed_cols ++ groups_col ++ custom_cols
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_rows(members, columns, custom_fields_by_id) do
|
defp build_rows(members, columns, custom_fields_by_id) do
|
||||||
|
|
@ -391,6 +454,11 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
if is_binary(value), do: value, else: ""
|
if is_binary(value), do: value, else: ""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do
|
||||||
|
groups = Map.get(member, :groups) || []
|
||||||
|
format_groups(groups)
|
||||||
|
end
|
||||||
|
|
||||||
defp key_to_atom(k) when is_atom(k), do: k
|
defp key_to_atom(k) when is_atom(k), do: k
|
||||||
|
|
||||||
defp key_to_atom(k) when is_binary(k) do
|
defp key_to_atom(k) when is_binary(k) do
|
||||||
|
|
@ -424,6 +492,15 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
|
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
|
||||||
defp format_member_value(value), do: to_string(value)
|
defp format_member_value(value), do: to_string(value)
|
||||||
|
|
||||||
|
defp format_groups([]), do: ""
|
||||||
|
|
||||||
|
defp format_groups(groups) when is_list(groups) do
|
||||||
|
groups
|
||||||
|
|> Enum.map(fn group -> Map.get(group, :name) || "" end)
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
|> Enum.join(", ")
|
||||||
|
end
|
||||||
|
|
||||||
defp build_meta(members) do
|
defp build_meta(members) do
|
||||||
%{
|
%{
|
||||||
generated_at: DateTime.utc_now() |> DateTime.to_iso8601(),
|
generated_at: DateTime.utc_now() |> DateTime.to_iso8601(),
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,11 @@ defmodule Mv.Membership.MembersCSV do
|
||||||
if is_binary(value), do: value, else: ""
|
if is_binary(value), do: value, else: ""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp cell_value(member, %{kind: :groups, key: :groups}) do
|
||||||
|
groups = Map.get(member, :groups) || []
|
||||||
|
format_groups(groups)
|
||||||
|
end
|
||||||
|
|
||||||
defp key_to_atom(k) when is_atom(k), do: k
|
defp key_to_atom(k) when is_atom(k), do: k
|
||||||
|
|
||||||
defp key_to_atom(k) when is_binary(k) do
|
defp key_to_atom(k) when is_binary(k) do
|
||||||
|
|
@ -97,4 +102,13 @@ defmodule Mv.Membership.MembersCSV do
|
||||||
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
|
||||||
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
|
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
|
||||||
defp format_member_value(value), do: to_string(value)
|
defp format_member_value(value), do: to_string(value)
|
||||||
|
|
||||||
|
defp format_groups([]), do: ""
|
||||||
|
|
||||||
|
defp format_groups(groups) when is_list(groups) do
|
||||||
|
groups
|
||||||
|
|> Enum.map(fn group -> Map.get(group, :name) || "" end)
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
|> Enum.join(", ")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
91
lib/mv/vereinfacht/changes/sync_contact.ex
Normal file
91
lib/mv/vereinfacht/changes/sync_contact.ex
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
defmodule Mv.Vereinfacht.Changes.SyncContact do
|
||||||
|
@moduledoc """
|
||||||
|
Syncs a member to Vereinfacht as a finance contact after create/update.
|
||||||
|
|
||||||
|
- If the member has no `vereinfacht_contact_id`, creates a contact via API and saves the ID.
|
||||||
|
- If the member already has an ID, updates the contact via API.
|
||||||
|
Runs in `after_transaction` so the member is persisted first. API failures are logged
|
||||||
|
but do not block the member operation. Requires Vereinfacht to be configured
|
||||||
|
(Mv.Config.vereinfacht_configured?/0).
|
||||||
|
|
||||||
|
Only runs when relevant data changed: on create always; on update only when
|
||||||
|
first_name, last_name, email, street, house_number, postal_code, or city changed,
|
||||||
|
or when the member has no vereinfacht_contact_id yet (to avoid unnecessary API calls).
|
||||||
|
"""
|
||||||
|
use Ash.Resource.Change
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@synced_attributes [
|
||||||
|
:first_name,
|
||||||
|
:last_name,
|
||||||
|
:email,
|
||||||
|
:street,
|
||||||
|
:house_number,
|
||||||
|
:postal_code,
|
||||||
|
:city
|
||||||
|
]
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def change(changeset, _opts, _context) do
|
||||||
|
if Mv.Config.vereinfacht_configured?() and sync_relevant?(changeset) do
|
||||||
|
Ash.Changeset.after_transaction(changeset, &sync_after_transaction/2)
|
||||||
|
else
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sync_relevant?(changeset) do
|
||||||
|
case changeset.action_type do
|
||||||
|
:create -> true
|
||||||
|
:update -> relevant_update?(changeset)
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp relevant_update?(changeset) do
|
||||||
|
any_synced_attr_changed? =
|
||||||
|
Enum.any?(@synced_attributes, &Ash.Changeset.changing_attribute?(changeset, &1))
|
||||||
|
|
||||||
|
record = changeset.data
|
||||||
|
no_contact_id_yet? = record && blank_contact_id?(record.vereinfacht_contact_id)
|
||||||
|
|
||||||
|
any_synced_attr_changed? or no_contact_id_yet?
|
||||||
|
end
|
||||||
|
|
||||||
|
defp blank_contact_id?(nil), do: true
|
||||||
|
defp blank_contact_id?(""), do: true
|
||||||
|
defp blank_contact_id?(s) when is_binary(s), do: String.trim(s) == ""
|
||||||
|
defp blank_contact_id?(_), do: false
|
||||||
|
|
||||||
|
# Ash calls after_transaction with (changeset, result) only - 2 args.
|
||||||
|
defp sync_after_transaction(_changeset, {:ok, member}) do
|
||||||
|
case Mv.Vereinfacht.sync_member(member) do
|
||||||
|
:ok ->
|
||||||
|
Mv.Vereinfacht.SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.")
|
||||||
|
{:ok, member}
|
||||||
|
|
||||||
|
{:ok, member_updated} ->
|
||||||
|
Mv.Vereinfacht.SyncFlash.store(
|
||||||
|
to_string(member_updated.id),
|
||||||
|
:ok,
|
||||||
|
"Synced to Vereinfacht."
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, member_updated}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("Vereinfacht sync failed for member #{member.id}: #{inspect(reason)}")
|
||||||
|
|
||||||
|
Mv.Vereinfacht.SyncFlash.store(
|
||||||
|
to_string(member.id),
|
||||||
|
:warning,
|
||||||
|
Mv.Vereinfacht.format_error(reason)
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, member}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sync_after_transaction(_changeset, error), do: error
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
|
||||||
|
@moduledoc """
|
||||||
|
Syncs the linked Member to Vereinfacht after a User action that may have updated
|
||||||
|
the member's email via Ecto (e.g. User email change → SyncUserEmailToMember).
|
||||||
|
|
||||||
|
Attach to any User action that uses SyncUserEmailToMember. After the transaction
|
||||||
|
commits, if the user has a linked member and Vereinfacht is configured, syncs
|
||||||
|
that member to the API. Failures are logged but do not affect the User result.
|
||||||
|
"""
|
||||||
|
use Ash.Resource.Change
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias Mv.Membership
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
alias Mv.Helpers
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def change(changeset, _opts, _context) do
|
||||||
|
if Mv.Config.vereinfacht_configured?() and relevant_change?(changeset) do
|
||||||
|
Ash.Changeset.after_transaction(changeset, &sync_linked_member_after_transaction/2)
|
||||||
|
else
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only sync when something that affects the linked member's data actually changed
|
||||||
|
# (email sync or member link), to avoid unnecessary API calls on every user update.
|
||||||
|
defp relevant_change?(changeset) do
|
||||||
|
Ash.Changeset.changing_attribute?(changeset, :email) or
|
||||||
|
Ash.Changeset.changing_relationship?(changeset, :member)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sync_linked_member_after_transaction(_changeset, {:ok, user}) do
|
||||||
|
case load_linked_member(user) do
|
||||||
|
nil ->
|
||||||
|
{:ok, user}
|
||||||
|
|
||||||
|
member ->
|
||||||
|
case Mv.Vereinfacht.sync_member(member) do
|
||||||
|
:ok ->
|
||||||
|
{:ok, user}
|
||||||
|
|
||||||
|
{:ok, _} ->
|
||||||
|
{:ok, user}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning(
|
||||||
|
"Vereinfacht sync failed for member #{member.id} (linked to user #{user.id}): #{inspect(reason)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, user}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sync_linked_member_after_transaction(_changeset, result), do: result
|
||||||
|
|
||||||
|
defp load_linked_member(%{member_id: nil}), do: nil
|
||||||
|
defp load_linked_member(%{member_id: ""}), do: nil
|
||||||
|
|
||||||
|
defp load_linked_member(user) do
|
||||||
|
actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(actor)
|
||||||
|
|
||||||
|
case Ash.get(Member, user.member_id, [domain: Membership] ++ opts) do
|
||||||
|
{:ok, %Member{} = member} -> member
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
351
lib/mv/vereinfacht/client.ex
Normal file
351
lib/mv/vereinfacht/client.ex
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
defmodule Mv.Vereinfacht.Client do
|
||||||
|
@moduledoc """
|
||||||
|
HTTP client for the Vereinfacht accounting software JSON:API.
|
||||||
|
|
||||||
|
Creates and updates finance contacts. Uses Bearer token authentication and
|
||||||
|
requires club ID for multi-tenancy. Configuration via ENV or Settings
|
||||||
|
(see Mv.Config).
|
||||||
|
"""
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@content_type "application/vnd.api+json"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a finance contact in Vereinfacht for the given member.
|
||||||
|
|
||||||
|
Returns the contact ID on success. Does not update the member record;
|
||||||
|
the caller (e.g. SyncContact change) must persist `vereinfacht_contact_id`.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
- None; URL, API key, and club ID are read from Mv.Config.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> create_contact(member)
|
||||||
|
{:ok, "242"}
|
||||||
|
|
||||||
|
iex> create_contact(member)
|
||||||
|
{:error, {:http, 401, "Unauthenticated."}}
|
||||||
|
"""
|
||||||
|
@spec create_contact(struct()) :: {:ok, String.t()} | {:error, term()}
|
||||||
|
def create_contact(member) do
|
||||||
|
base_url = base_url()
|
||||||
|
api_key = api_key()
|
||||||
|
club_id = club_id()
|
||||||
|
|
||||||
|
if is_nil(base_url) or is_nil(api_key) or is_nil(club_id) do
|
||||||
|
{:error, :not_configured}
|
||||||
|
else
|
||||||
|
body = build_create_body(member, club_id)
|
||||||
|
url = base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts")
|
||||||
|
post_and_parse_contact(url, body, api_key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@sync_timeout_ms 5_000
|
||||||
|
|
||||||
|
# In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits).
|
||||||
|
defp req_http_options do
|
||||||
|
opts = [receive_timeout: @sync_timeout_ms]
|
||||||
|
if Mix.env() == :test, do: [retry: false] ++ opts, else: opts
|
||||||
|
end
|
||||||
|
|
||||||
|
defp post_and_parse_contact(url, body, api_key) do
|
||||||
|
encoded_body = Jason.encode!(body)
|
||||||
|
|
||||||
|
case Req.post(url, [body: encoded_body, headers: headers(api_key)] ++ req_http_options()) do
|
||||||
|
{:ok, %{status: 201, body: resp_body}} ->
|
||||||
|
case get_contact_id_from_response(resp_body) do
|
||||||
|
nil -> {:error, {:invalid_response, resp_body}}
|
||||||
|
id -> {:ok, id}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, %{status: status, body: resp_body}} ->
|
||||||
|
{:error, {:http, status, extract_error_message(resp_body)}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, {:request_failed, reason}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates an existing finance contact in Vereinfacht.
|
||||||
|
|
||||||
|
Only sends attributes that are typically synced from the member (name, email,
|
||||||
|
address fields). Returns the same contact_id on success.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> update_contact("242", member)
|
||||||
|
{:ok, "242"}
|
||||||
|
|
||||||
|
iex> update_contact("242", member)
|
||||||
|
{:error, {:http, 404, "Not Found"}}
|
||||||
|
"""
|
||||||
|
@spec update_contact(String.t(), struct()) :: {:ok, String.t()} | {:error, term()}
|
||||||
|
def update_contact(contact_id, member) when is_binary(contact_id) do
|
||||||
|
base_url = base_url()
|
||||||
|
api_key = api_key()
|
||||||
|
|
||||||
|
if is_nil(base_url) or is_nil(api_key) do
|
||||||
|
{:error, :not_configured}
|
||||||
|
else
|
||||||
|
body = build_update_body(contact_id, member)
|
||||||
|
encoded_body = Jason.encode!(body)
|
||||||
|
|
||||||
|
url =
|
||||||
|
base_url
|
||||||
|
|> String.trim_trailing("/")
|
||||||
|
|> then(&"#{&1}/finance-contacts/#{contact_id}")
|
||||||
|
|
||||||
|
case Req.patch(
|
||||||
|
url,
|
||||||
|
[
|
||||||
|
body: encoded_body,
|
||||||
|
headers: headers(api_key)
|
||||||
|
] ++ req_http_options()
|
||||||
|
) do
|
||||||
|
{:ok, %{status: 200, body: _resp_body}} ->
|
||||||
|
{:ok, contact_id}
|
||||||
|
|
||||||
|
{:ok, %{status: status, body: body}} ->
|
||||||
|
{:error, {:http, status, extract_error_message(body)}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, {:request_failed, reason}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Finds a finance contact by email (GET /finance-contacts, then match in response).
|
||||||
|
|
||||||
|
The Vereinfacht API does not allow filter by email on this endpoint, so we
|
||||||
|
fetch the first page and find the contact client-side. Returns {:ok, contact_id}
|
||||||
|
if a contact with that email exists, {:error, :not_found} if none, or
|
||||||
|
{:error, reason} on API/network failure. Used before create for idempotency.
|
||||||
|
"""
|
||||||
|
@spec find_contact_by_email(String.t()) :: {:ok, String.t()} | {:error, :not_found | term()}
|
||||||
|
def find_contact_by_email(email) when is_binary(email) do
|
||||||
|
if is_nil(base_url()) or is_nil(api_key()) or is_nil(club_id()) do
|
||||||
|
{:error, :not_configured}
|
||||||
|
else
|
||||||
|
do_find_contact_by_email(email)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_find_contact_by_email(email) do
|
||||||
|
url =
|
||||||
|
base_url()
|
||||||
|
|> String.trim_trailing("/")
|
||||||
|
|> then(&"#{&1}/finance-contacts")
|
||||||
|
|
||||||
|
case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do
|
||||||
|
{:ok, %{status: 200, body: body}} when is_map(body) ->
|
||||||
|
parse_find_by_email_response(body, email)
|
||||||
|
|
||||||
|
{:ok, %{status: status, body: body}} ->
|
||||||
|
{:error, {:http, status, extract_error_message(body)}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, {:request_failed, reason}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_find_by_email_response(body, email) do
|
||||||
|
normalized = String.trim(email) |> String.downcase()
|
||||||
|
|
||||||
|
case find_contact_id_by_email_in_list(body, normalized) do
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
id -> {:ok, id}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do
|
||||||
|
Enum.find_value(list, fn
|
||||||
|
%{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => true}}
|
||||||
|
when is_binary(att_email) ->
|
||||||
|
if att_email |> String.trim() |> String.downcase() == normalized do
|
||||||
|
normalize_contact_id(id)
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
%{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => "true"}}
|
||||||
|
when is_binary(att_email) ->
|
||||||
|
if att_email |> String.trim() |> String.downcase() == normalized do
|
||||||
|
normalize_contact_id(id)
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
%{"id" => _id, "attributes" => _} ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find_contact_id_by_email_in_list(_, _), do: nil
|
||||||
|
|
||||||
|
defp normalize_contact_id(id) when is_binary(id), do: id
|
||||||
|
defp normalize_contact_id(id) when is_integer(id), do: to_string(id)
|
||||||
|
defp normalize_contact_id(_), do: nil
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Fetches a single finance contact from Vereinfacht (GET /finance-contacts/:id).
|
||||||
|
|
||||||
|
Returns the full response body (decoded JSON) for debugging/display.
|
||||||
|
"""
|
||||||
|
@spec get_contact(String.t()) :: {:ok, map()} | {:error, term()}
|
||||||
|
def get_contact(contact_id) when is_binary(contact_id) do
|
||||||
|
fetch_contact(contact_id, [])
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Fetches a finance contact with receipts (GET /finance-contacts/:id?include=receipts).
|
||||||
|
|
||||||
|
Returns {:ok, receipts} where receipts is a list of maps with :id and :attributes
|
||||||
|
(and optional :type) for each receipt, or {:error, reason}.
|
||||||
|
"""
|
||||||
|
@spec get_contact_with_receipts(String.t()) :: {:ok, [map()]} | {:error, term()}
|
||||||
|
def get_contact_with_receipts(contact_id) when is_binary(contact_id) do
|
||||||
|
case fetch_contact(contact_id, include: "receipts") do
|
||||||
|
{:ok, body} -> {:ok, extract_receipts_from_response(body)}
|
||||||
|
{:error, _} = err -> err
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_contact(contact_id, query_params) do
|
||||||
|
base_url = base_url()
|
||||||
|
api_key = api_key()
|
||||||
|
|
||||||
|
if is_nil(base_url) or is_nil(api_key) do
|
||||||
|
{:error, :not_configured}
|
||||||
|
else
|
||||||
|
path =
|
||||||
|
base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}")
|
||||||
|
|
||||||
|
url = build_url_with_params(path, query_params)
|
||||||
|
|
||||||
|
case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do
|
||||||
|
{:ok, %{status: 200, body: body}} when is_map(body) ->
|
||||||
|
{:ok, body}
|
||||||
|
|
||||||
|
{:ok, %{status: status, body: body}} ->
|
||||||
|
{:error, {:http, status, extract_error_message(body)}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, {:request_failed, reason}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_url_with_params(base, []), do: base
|
||||||
|
|
||||||
|
defp build_url_with_params(base, include: value) do
|
||||||
|
sep = if String.contains?(base, "?"), do: "&", else: "?"
|
||||||
|
base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_receipts_from_response(%{"included" => included}) when is_list(included) do
|
||||||
|
included
|
||||||
|
|> Enum.filter(&match?(%{"type" => "receipts"}, &1))
|
||||||
|
|> Enum.map(fn %{"id" => id, "attributes" => attrs} = r ->
|
||||||
|
Map.merge(%{id: id, type: r["type"]}, string_keys_to_atoms(attrs || %{}))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_receipts_from_response(_), do: []
|
||||||
|
|
||||||
|
defp string_keys_to_atoms(map) when is_map(map) do
|
||||||
|
Map.new(map, fn {k, v} -> {to_atom_key(k), v} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp to_atom_key(k) when is_binary(k) do
|
||||||
|
try do
|
||||||
|
String.to_existing_atom(k)
|
||||||
|
rescue
|
||||||
|
ArgumentError -> String.to_atom(k)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp to_atom_key(k) when is_atom(k), do: k
|
||||||
|
defp to_atom_key(k), do: to_atom_key(to_string(k))
|
||||||
|
|
||||||
|
defp base_url, do: Mv.Config.vereinfacht_api_url()
|
||||||
|
defp api_key, do: Mv.Config.vereinfacht_api_key()
|
||||||
|
defp club_id, do: Mv.Config.vereinfacht_club_id()
|
||||||
|
|
||||||
|
defp headers(api_key) do
|
||||||
|
[
|
||||||
|
{"Accept", @content_type},
|
||||||
|
{"Content-Type", @content_type},
|
||||||
|
{"Authorization", "Bearer #{api_key}"}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_create_body(member, club_id) do
|
||||||
|
attributes = member_to_attributes(member)
|
||||||
|
|
||||||
|
%{
|
||||||
|
"data" => %{
|
||||||
|
"type" => "finance-contacts",
|
||||||
|
"attributes" => attributes,
|
||||||
|
"relationships" => %{
|
||||||
|
"club" => %{
|
||||||
|
"data" => %{"type" => "clubs", "id" => club_id}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_update_body(contact_id, member) do
|
||||||
|
attributes = member_to_attributes(member)
|
||||||
|
|
||||||
|
%{
|
||||||
|
"data" => %{
|
||||||
|
"type" => "finance-contacts",
|
||||||
|
"id" => contact_id,
|
||||||
|
"attributes" => attributes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp member_to_attributes(member) do
|
||||||
|
address =
|
||||||
|
[member |> Map.get(:street), member |> Map.get(:house_number)]
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|> Enum.map_join(" ", &to_string/1)
|
||||||
|
|> then(fn s -> if s == "", do: nil, else: s end)
|
||||||
|
|
||||||
|
%{}
|
||||||
|
|> put_attr("lastName", member |> Map.get(:last_name))
|
||||||
|
|> put_attr("firstName", member |> Map.get(:first_name))
|
||||||
|
|> put_attr("email", member |> Map.get(:email))
|
||||||
|
|> put_attr("address", address)
|
||||||
|
|> put_attr("zipCode", member |> Map.get(:postal_code))
|
||||||
|
|> put_attr("city", member |> Map.get(:city))
|
||||||
|
|> Map.put("contactType", "person")
|
||||||
|
|> Map.put("isExternal", true)
|
||||||
|
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|
||||||
|
|> Map.new()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_attr(acc, _key, nil), do: acc
|
||||||
|
defp put_attr(acc, key, value), do: Map.put(acc, key, to_string(value))
|
||||||
|
|
||||||
|
defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_binary(id), do: id
|
||||||
|
|
||||||
|
defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_integer(id),
|
||||||
|
do: to_string(id)
|
||||||
|
|
||||||
|
defp get_contact_id_from_response(_), do: nil
|
||||||
|
|
||||||
|
defp extract_error_message(%{"errors" => [%{"detail" => d} | _]}) when is_binary(d), do: d
|
||||||
|
defp extract_error_message(%{"errors" => [%{"title" => t} | _]}) when is_binary(t), do: t
|
||||||
|
defp extract_error_message(body) when is_map(body), do: inspect(body)
|
||||||
|
defp extract_error_message(other), do: inspect(other)
|
||||||
|
end
|
||||||
46
lib/mv/vereinfacht/sync_flash.ex
Normal file
46
lib/mv/vereinfacht/sync_flash.ex
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
defmodule Mv.Vereinfacht.SyncFlash do
|
||||||
|
@moduledoc """
|
||||||
|
Short-lived store for Vereinfacht sync results so the UI can show them after save.
|
||||||
|
|
||||||
|
The SyncContact change runs in after_transaction and cannot access the LiveView
|
||||||
|
socket. This module stores a message keyed by member_id; the form LiveView
|
||||||
|
calls `take/1` after a successful save and displays the message in flash.
|
||||||
|
"""
|
||||||
|
@table :vereinfacht_sync_flash
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Stores a sync result for the given member. Overwrites any previous message.
|
||||||
|
|
||||||
|
- `:ok` - Sync succeeded (optional user message).
|
||||||
|
- `:warning` - Sync failed; message should be shown as a warning.
|
||||||
|
"""
|
||||||
|
@spec store(String.t(), :ok | :warning, String.t()) :: :ok
|
||||||
|
def store(member_id, kind, message) when is_binary(member_id) do
|
||||||
|
:ets.insert(@table, {member_id, {kind, message}})
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Takes and removes the stored sync message for the given member.
|
||||||
|
|
||||||
|
Returns `{kind, message}` if present, otherwise `nil`.
|
||||||
|
"""
|
||||||
|
@spec take(String.t()) :: {:ok | :warning, String.t()} | nil
|
||||||
|
def take(member_id) when is_binary(member_id) do
|
||||||
|
case :ets.take(@table, member_id) do
|
||||||
|
[{^member_id, value}] -> value
|
||||||
|
[] -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def create_table! do
|
||||||
|
# :public so any process can write (SyncContact runs in LiveView/Ash transaction process,
|
||||||
|
# not the process that created the table). :protected would restrict writes to the creating process.
|
||||||
|
if :ets.whereis(@table) == :undefined do
|
||||||
|
:ets.new(@table, [:set, :public, :named_table])
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
162
lib/mv/vereinfacht/vereinfacht.ex
Normal file
162
lib/mv/vereinfacht/vereinfacht.ex
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
defmodule Mv.Vereinfacht do
|
||||||
|
@moduledoc """
|
||||||
|
Business logic for Vereinfacht accounting software integration.
|
||||||
|
|
||||||
|
- `sync_member/1` – Sync a single member to the API (create or update contact).
|
||||||
|
Used by Member create/update (SyncContact) and by User actions that update
|
||||||
|
the linked member's email via Ecto (e.g. user email change).
|
||||||
|
- `sync_members_without_contact/0` – Bulk sync of members without a contact ID.
|
||||||
|
"""
|
||||||
|
require Ash.Query
|
||||||
|
alias Mv.Vereinfacht.Client
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
alias Mv.Helpers
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Syncs a single member to Vereinfacht (create or update finance contact).
|
||||||
|
|
||||||
|
If the member has no `vereinfacht_contact_id`, creates a contact and updates
|
||||||
|
the member with the new ID. If they already have an ID, updates the contact.
|
||||||
|
Uses system actor for any Ash update. Does nothing if Vereinfacht is not configured.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- `:ok` – Contact was updated.
|
||||||
|
- `{:ok, member}` – Contact was created and member was updated with the new ID.
|
||||||
|
- `{:error, reason}` – API or update failed.
|
||||||
|
"""
|
||||||
|
@spec sync_member(struct()) :: :ok | {:ok, struct()} | {:error, term()}
|
||||||
|
def sync_member(member) do
|
||||||
|
if Mv.Config.vereinfacht_configured?() do
|
||||||
|
do_sync_member(member)
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_sync_member(member) do
|
||||||
|
if present_contact_id?(member.vereinfacht_contact_id) do
|
||||||
|
sync_existing_contact(member)
|
||||||
|
else
|
||||||
|
ensure_contact_then_save(member)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sync_existing_contact(member) do
|
||||||
|
case Client.update_contact(member.vereinfacht_contact_id, member) do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_contact_then_save(member) do
|
||||||
|
case get_or_create_contact_id(member) do
|
||||||
|
{:ok, contact_id} -> save_contact_id(member, contact_id)
|
||||||
|
{:error, _} = err -> err
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Before create: find by email to avoid duplicate contacts (idempotency).
|
||||||
|
# When an existing contact is found, update it with current member data.
|
||||||
|
defp get_or_create_contact_id(member) do
|
||||||
|
email = member |> Map.get(:email) |> to_string() |> String.trim()
|
||||||
|
|
||||||
|
if email == "" do
|
||||||
|
Client.create_contact(member)
|
||||||
|
else
|
||||||
|
case Client.find_contact_by_email(email) do
|
||||||
|
{:ok, existing_id} -> update_existing_contact_and_return_id(existing_id, member)
|
||||||
|
{:error, :not_found} -> Client.create_contact(member)
|
||||||
|
{:error, _} = err -> err
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp update_existing_contact_and_return_id(contact_id, member) do
|
||||||
|
case Client.update_contact(contact_id, member) do
|
||||||
|
{:ok, _} -> {:ok, contact_id}
|
||||||
|
{:error, _} = err -> err
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp save_contact_id(member, contact_id) do
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
|
case Ash.update(member, %{vereinfacht_contact_id: contact_id}, [
|
||||||
|
{:action, :set_vereinfacht_contact_id} | opts
|
||||||
|
]) do
|
||||||
|
{:ok, updated} -> {:ok, updated}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp present_contact_id?(nil), do: false
|
||||||
|
defp present_contact_id?(""), do: false
|
||||||
|
defp present_contact_id?(s) when is_binary(s), do: String.trim(s) != ""
|
||||||
|
defp present_contact_id?(_), do: false
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Formats an API/request error reason into a short user-facing message.
|
||||||
|
|
||||||
|
Used by SyncContact (flash) and GlobalSettingsLive (sync result list).
|
||||||
|
"""
|
||||||
|
@spec format_error(term()) :: String.t()
|
||||||
|
def format_error({:http, _status, detail}) when is_binary(detail), do: "Vereinfacht: " <> detail
|
||||||
|
def format_error({:http, status, _}), do: "Vereinfacht: API error (HTTP #{status})."
|
||||||
|
|
||||||
|
def format_error({:request_failed, _}),
|
||||||
|
do: "Vereinfacht: Request failed (e.g. connection error)."
|
||||||
|
|
||||||
|
def format_error({:invalid_response, _}), do: "Vereinfacht: Invalid API response."
|
||||||
|
def format_error(other), do: "Vereinfacht: " <> inspect(other)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates Vereinfacht contacts for all members that do not yet have a
|
||||||
|
`vereinfacht_contact_id`. Uses system actor for reads and updates.
|
||||||
|
|
||||||
|
Returns `{:ok, %{synced: count, errors: list}}` where errors is a list of
|
||||||
|
`{member_id, reason}`. Does nothing if Vereinfacht is not configured.
|
||||||
|
"""
|
||||||
|
@spec sync_members_without_contact() ::
|
||||||
|
{:ok, %{synced: non_neg_integer(), errors: [{String.t(), term()}]}}
|
||||||
|
| {:error, :not_configured}
|
||||||
|
def sync_members_without_contact do
|
||||||
|
if Mv.Config.vereinfacht_configured?() do
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
|
query =
|
||||||
|
Member
|
||||||
|
|> Ash.Query.filter(is_nil(vereinfacht_contact_id))
|
||||||
|
|
||||||
|
case Ash.read(query, opts) do
|
||||||
|
{:ok, members} ->
|
||||||
|
do_sync_members(members, opts)
|
||||||
|
|
||||||
|
{:error, _} = err ->
|
||||||
|
err
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, :not_configured}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_sync_members(members, opts) do
|
||||||
|
{synced, errors} =
|
||||||
|
Enum.reduce(members, {0, []}, fn member, {acc_synced, acc_errors} ->
|
||||||
|
{inc, new_errors} = sync_one_member(member, opts)
|
||||||
|
{acc_synced + inc, acc_errors ++ new_errors}
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, %{synced: synced, errors: errors}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sync_one_member(member, _opts) do
|
||||||
|
case sync_member(member) do
|
||||||
|
:ok -> {1, []}
|
||||||
|
{:ok, _} -> {1, []}
|
||||||
|
{:error, reason} -> {0, [{member.id, reason}]}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -45,4 +45,11 @@ defmodule MvWeb.AuthOverrides do
|
||||||
Gettext.gettext(MvWeb.Gettext, "or")
|
Gettext.gettext(MvWeb.Gettext, "or")
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Hide AshAuthentication's Flash component since we use flash_group in root layout
|
||||||
|
# This prevents duplicate flash messages
|
||||||
|
override AshAuthentication.Phoenix.Components.Flash do
|
||||||
|
set :message_class_info, "hidden"
|
||||||
|
set :message_class_error, "hidden"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -15,24 +15,98 @@
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const setTheme = (theme) => {
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
if (theme === "system") {
|
const systemTheme = () => (mq.matches ? "dark" : "light");
|
||||||
localStorage.removeItem("phx:theme");
|
|
||||||
document.documentElement.removeAttribute("data-theme");
|
// Single source of truth:
|
||||||
} else {
|
// - localStorage["phx:theme"] = "light" | "dark" (explicit override)
|
||||||
localStorage.setItem("phx:theme", theme);
|
// - missing key => "system"
|
||||||
document.documentElement.setAttribute("data-theme", theme);
|
const storedTheme = () => localStorage.getItem("phx:theme") || "system";
|
||||||
}
|
|
||||||
|
const effectiveTheme = (t) => (t === "system" ? systemTheme() : t);
|
||||||
|
|
||||||
|
const applyThemeNow = (t) => {
|
||||||
|
document.documentElement.setAttribute("data-theme", effectiveTheme(t));
|
||||||
};
|
};
|
||||||
if (!document.documentElement.hasAttribute("data-theme")) {
|
|
||||||
setTheme(localStorage.getItem("phx:theme") || "system");
|
const syncToggle = () => {
|
||||||
}
|
const eff = effectiveTheme(storedTheme());
|
||||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
document.querySelectorAll("[data-theme-toggle]").forEach((el) => {
|
||||||
|
el.checked = eff === "dark";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTheme = (t) => {
|
||||||
|
if (t === "system") localStorage.removeItem("phx:theme");
|
||||||
|
else localStorage.setItem("phx:theme", t);
|
||||||
|
|
||||||
|
applyThemeNow(t);
|
||||||
|
syncToggle(); // if toggle exists already
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) Apply theme ASAP to match system on first paint
|
||||||
|
applyThemeNow(storedTheme());
|
||||||
|
|
||||||
|
// 2) Sync toggle once DOM is ready (fixes initial "light" toggle)
|
||||||
|
document.addEventListener("DOMContentLoaded", syncToggle);
|
||||||
|
|
||||||
|
// 3) If toggle appears later (LiveView render), sync immediately
|
||||||
|
const obs = new MutationObserver(() => {
|
||||||
|
if (document.querySelector("[data-theme-toggle]")) syncToggle();
|
||||||
|
});
|
||||||
|
obs.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
|
||||||
window.addEventListener("phx:set-theme", ({ detail: { theme } }) => setTheme(theme));
|
window.addEventListener("phx:set-theme", ({ detail: { theme } }) => setTheme(theme));
|
||||||
|
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||||
|
|
||||||
|
mq.addEventListener("change", () => {
|
||||||
|
if (localStorage.getItem("phx:theme") === null) {
|
||||||
|
applyThemeNow("system");
|
||||||
|
syncToggle();
|
||||||
|
}
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div
|
||||||
|
id="flash-group-root"
|
||||||
|
aria-live="polite"
|
||||||
|
class="z-50 flex flex-col gap-2 toast toast-top toast-end"
|
||||||
|
>
|
||||||
|
<.flash id="flash-success-root" kind={:success} flash={@flash} />
|
||||||
|
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
|
||||||
|
<.flash id="flash-info-root" kind={:info} flash={@flash} />
|
||||||
|
<.flash id="flash-error-root" kind={:error} flash={@flash} />
|
||||||
|
|
||||||
|
<.flash
|
||||||
|
id="client-error-root"
|
||||||
|
kind={:error}
|
||||||
|
title={gettext("We can't find the internet")}
|
||||||
|
phx-disconnected={
|
||||||
|
show(".phx-client-error #client-error-root") |> JS.remove_attribute("hidden")
|
||||||
|
}
|
||||||
|
phx-connected={hide("#client-error-root") |> JS.set_attribute({"hidden", ""})}
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
{gettext("Attempting to reconnect")}
|
||||||
|
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||||
|
</.flash>
|
||||||
|
|
||||||
|
<.flash
|
||||||
|
id="server-error-root"
|
||||||
|
kind={:error}
|
||||||
|
title={gettext("Something went wrong!")}
|
||||||
|
phx-disconnected={
|
||||||
|
show(".phx-server-error #server-error-root") |> JS.remove_attribute("hidden")
|
||||||
|
}
|
||||||
|
phx-connected={hide("#server-error-root") |> JS.set_attribute({"hidden", ""})}
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
{gettext("Attempting to reconnect")}
|
||||||
|
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||||
|
</.flash>
|
||||||
|
</div>
|
||||||
{@inner_content}
|
{@inner_content}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -248,12 +248,17 @@ defmodule MvWeb.Layouts.Sidebar do
|
||||||
aria-label={gettext("Toggle dark mode")}
|
aria-label={gettext("Toggle dark mode")}
|
||||||
>
|
>
|
||||||
<.icon name="hero-sun" class="size-5" aria-hidden="true" />
|
<.icon name="hero-sun" class="size-5" aria-hidden="true" />
|
||||||
<input
|
<div id="theme-toggle" phx-update="ignore">
|
||||||
type="checkbox"
|
<input
|
||||||
value="dark"
|
id="theme-toggle-input"
|
||||||
class="toggle toggle-sm theme-controller focus:outline-none"
|
type="checkbox"
|
||||||
aria-label={gettext("Toggle dark mode")}
|
class="toggle toggle-sm focus:outline-none"
|
||||||
/>
|
data-theme-toggle
|
||||||
|
onchange="window.dispatchEvent(new CustomEvent('phx:set-theme',{detail:{theme:this.checked?'dark':'light'}}))"
|
||||||
|
aria-label={gettext("Toggle dark mode")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<.icon name="hero-moon" class="size-5" aria-hidden="true" />
|
<.icon name="hero-moon" class="size-5" aria-hidden="true" />
|
||||||
</label>
|
</label>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,7 @@ defmodule MvWeb.AuthController do
|
||||||
- Generic authentication failures
|
- Generic authentication failures
|
||||||
"""
|
"""
|
||||||
def failure(conn, activity, reason) do
|
def failure(conn, activity, reason) do
|
||||||
Logger.warning(
|
log_failure_safely(activity, reason)
|
||||||
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
case {activity, reason} do
|
case {activity, reason} do
|
||||||
{{:rauthy, _action}, reason} ->
|
{{:rauthy, _action}, reason} ->
|
||||||
|
|
@ -57,10 +55,70 @@ defmodule MvWeb.AuthController do
|
||||||
handle_authentication_failed(conn, caused_by)
|
handle_authentication_failed(conn, caused_by)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
redirect_with_error(conn, gettext("Incorrect email or password"))
|
conn
|
||||||
|
|> put_flash(:error, gettext("Incorrect email or password"))
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Log authentication failures safely, avoiding sensitive data for {:rauthy, _} activities
|
||||||
|
defp log_failure_safely({:rauthy, _action} = activity, reason) do
|
||||||
|
# For Assent errors, use safe_assent_meta to avoid logging tokens/URLs with query params
|
||||||
|
case reason do
|
||||||
|
%Assent.ServerUnreachableError{} = err ->
|
||||||
|
meta = safe_assent_meta(err)
|
||||||
|
message = format_safe_log_message("Authentication failure", activity, meta)
|
||||||
|
Logger.warning(message)
|
||||||
|
|
||||||
|
%Assent.InvalidResponseError{} = err ->
|
||||||
|
meta = safe_assent_meta(err)
|
||||||
|
message = format_safe_log_message("Authentication failure", activity, meta)
|
||||||
|
Logger.warning(message)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
# For other rauthy errors, log only error type, not full details
|
||||||
|
error_type = get_error_type(reason)
|
||||||
|
|
||||||
|
Logger.warning(
|
||||||
|
"Authentication failure - Activity: #{inspect(activity)}, Error type: #{error_type}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp log_failure_safely(activity, reason) do
|
||||||
|
# For non-rauthy activities, safe to log full reason
|
||||||
|
Logger.warning(
|
||||||
|
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extract safe error type identifier without sensitive data
|
||||||
|
defp get_error_type(%struct{}), do: "#{struct}"
|
||||||
|
defp get_error_type(atom) when is_atom(atom), do: inspect(atom)
|
||||||
|
defp get_error_type(_other), do: "[redacted]"
|
||||||
|
|
||||||
|
# Format safe log message with metadata included in the message string
|
||||||
|
defp format_safe_log_message(base_message, activity, meta) when is_list(meta) do
|
||||||
|
activity_str = "Activity: #{inspect(activity)}"
|
||||||
|
meta_str = format_meta_string(meta)
|
||||||
|
"#{base_message} - #{activity_str}#{meta_str}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_meta_string([]), do: ""
|
||||||
|
|
||||||
|
defp format_meta_string(meta) when is_list(meta) do
|
||||||
|
parts =
|
||||||
|
Enum.map(meta, fn
|
||||||
|
{:request_url, url} -> "Request URL: #{url}"
|
||||||
|
{:status, status} -> "Status: #{status}"
|
||||||
|
{:http_adapter, adapter} -> "HTTP Adapter: #{inspect(adapter)}"
|
||||||
|
_ -> nil
|
||||||
|
end)
|
||||||
|
|> Enum.filter(&(&1 != nil))
|
||||||
|
|
||||||
|
if Enum.empty?(parts), do: "", else: " - " <> Enum.join(parts, ", ")
|
||||||
|
end
|
||||||
|
|
||||||
# Handle all Rauthy (OIDC) authentication failures
|
# Handle all Rauthy (OIDC) authentication failures
|
||||||
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
|
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
|
||||||
handle_oidc_email_collision(conn, errors)
|
handle_oidc_email_collision(conn, errors)
|
||||||
|
|
@ -74,14 +132,46 @@ defmodule MvWeb.AuthController do
|
||||||
handle_oidc_email_collision(conn, errors)
|
handle_oidc_email_collision(conn, errors)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
|
conn
|
||||||
|
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Handle Assent server unreachable errors (network/connectivity issues)
|
||||||
|
defp handle_rauthy_failure(conn, %Assent.ServerUnreachableError{} = _err) do
|
||||||
|
# Logging already done safely in failure/3 via log_failure_safely/2
|
||||||
|
# No need to log again here to avoid duplicate logs
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(
|
||||||
|
:error,
|
||||||
|
gettext("The authentication server is currently unavailable. Please try again later.")
|
||||||
|
)
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle Assent invalid response errors (configuration or malformed responses)
|
||||||
|
defp handle_rauthy_failure(conn, %Assent.InvalidResponseError{} = _err) do
|
||||||
|
# Logging already done safely in failure/3 via log_failure_safely/2
|
||||||
|
# No need to log again here to avoid duplicate logs
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(
|
||||||
|
:error,
|
||||||
|
gettext("Authentication configuration error. Please contact the administrator.")
|
||||||
|
)
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
|
end
|
||||||
|
|
||||||
# Catch-all clause for any other error types
|
# Catch-all clause for any other error types
|
||||||
defp handle_rauthy_failure(conn, reason) do
|
defp handle_rauthy_failure(conn, _reason) do
|
||||||
Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}")
|
# Logging already done safely in failure/3 via log_failure_safely/2
|
||||||
redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
|
# No need to log again here to avoid duplicate logs
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Handle generic AuthenticationFailed errors
|
# Handle generic AuthenticationFailed errors
|
||||||
|
|
@ -93,14 +183,20 @@ defmodule MvWeb.AuthController do
|
||||||
You can confirm your account using the link we sent to you, or by resetting your password.
|
You can confirm your account using the link we sent to you, or by resetting your password.
|
||||||
""")
|
""")
|
||||||
|
|
||||||
redirect_with_error(conn, message)
|
conn
|
||||||
|
|> put_flash(:error, message)
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
else
|
else
|
||||||
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|
conn
|
||||||
|
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_authentication_failed(conn, _other) do
|
defp handle_authentication_failed(conn, _other) do
|
||||||
redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|
conn
|
||||||
|
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Handle OIDC email collision - user needs to verify password to link accounts
|
# Handle OIDC email collision - user needs to verify password to link accounts
|
||||||
|
|
@ -112,7 +208,10 @@ defmodule MvWeb.AuthController do
|
||||||
nil ->
|
nil ->
|
||||||
# Check if it's a "different OIDC account" error or email uniqueness error
|
# Check if it's a "different OIDC account" error or email uniqueness error
|
||||||
error_message = extract_meaningful_error_message(errors)
|
error_message = extract_meaningful_error_message(errors)
|
||||||
redirect_with_error(conn, error_message)
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:error, error_message)
|
||||||
|
|> redirect(to: ~p"/sign-in")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -177,13 +276,47 @@ defmodule MvWeb.AuthController do
|
||||||
|> redirect(to: ~p"/auth/link-oidc-account")
|
|> redirect(to: ~p"/auth/link-oidc-account")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generic error redirect helper
|
# Extract safe metadata from Assent errors for logging
|
||||||
defp redirect_with_error(conn, message) do
|
# Never logs sensitive data: no tokens, secrets, or full request URLs
|
||||||
conn
|
# Returns keyword list for Logger.warning/2
|
||||||
|> put_flash(:error, message)
|
defp safe_assent_meta(%{request_url: url} = err) when is_binary(url) do
|
||||||
|> redirect(to: ~p"/sign-in")
|
[
|
||||||
|
request_url: redact_url(url),
|
||||||
|
http_adapter: Map.get(err, :http_adapter)
|
||||||
|
]
|
||||||
|
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Handle InvalidResponseError which has :response field (HTTPResponse struct)
|
||||||
|
defp safe_assent_meta(%{response: %{status: status} = response} = err) do
|
||||||
|
[
|
||||||
|
status: status,
|
||||||
|
http_adapter: Map.get(response, :http_adapter) || Map.get(err, :http_adapter)
|
||||||
|
]
|
||||||
|
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp safe_assent_meta(err) do
|
||||||
|
# Only extract safe, simple fields
|
||||||
|
[
|
||||||
|
http_adapter: Map.get(err, :http_adapter)
|
||||||
|
]
|
||||||
|
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Redact URL to only show scheme and host, hiding path, query, and fragments
|
||||||
|
defp redact_url(url) when is_binary(url) do
|
||||||
|
case URI.parse(url) do
|
||||||
|
%URI{scheme: scheme, host: host} when not is_nil(scheme) and not is_nil(host) ->
|
||||||
|
"#{scheme}://#{host}"
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
"[redacted]"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp redact_url(_), do: "[redacted]"
|
||||||
|
|
||||||
def sign_out(conn, _params) do
|
def sign_out(conn, _params) do
|
||||||
return_to = get_session(conn, :return_to) || ~p"/"
|
return_to = get_session(conn, :return_to) || ~p"/"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ defmodule MvWeb.MemberExportController do
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
@member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||||
|
["groups"]
|
||||||
@computed_export_fields ["membership_fee_status"]
|
@computed_export_fields ["membership_fee_status"]
|
||||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||||
|
|
||||||
|
|
@ -83,6 +84,7 @@ defmodule MvWeb.MemberExportController do
|
||||||
domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||||
selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
|
selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
|
||||||
computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
|
computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
|
||||||
|
# "groups" is neither a domain field nor a computed field, it's handled separately
|
||||||
{selectable, computed}
|
{selectable, computed}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -235,12 +237,15 @@ defmodule MvWeb.MemberExportController do
|
||||||
need_cycles =
|
need_cycles =
|
||||||
parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields
|
parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields
|
||||||
|
|
||||||
|
need_groups = "groups" in parsed.member_fields
|
||||||
|
|
||||||
query =
|
query =
|
||||||
Member
|
Member
|
||||||
|> Ash.Query.new()
|
|> Ash.Query.new()
|
||||||
|> Ash.Query.select(select_fields)
|
|> Ash.Query.select(select_fields)
|
||||||
|> load_custom_field_values_query(parsed.custom_field_ids)
|
|> load_custom_field_values_query(parsed.custom_field_ids)
|
||||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
||||||
|
|> maybe_load_groups(need_groups)
|
||||||
|
|
||||||
query =
|
query =
|
||||||
if parsed.selected_ids != [] do
|
if parsed.selected_ids != [] do
|
||||||
|
|
@ -284,6 +289,13 @@ defmodule MvWeb.MemberExportController do
|
||||||
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_load_groups(query, false), do: query
|
||||||
|
|
||||||
|
defp maybe_load_groups(query, true) do
|
||||||
|
# Load groups with id and name only (for export formatting)
|
||||||
|
Ash.Query.load(query, groups: [:id, :name])
|
||||||
|
end
|
||||||
|
|
||||||
# Adds computed field values to members (e.g. membership_fee_status)
|
# Adds computed field values to members (e.g. membership_fee_status)
|
||||||
defp add_computed_fields(members, computed_fields, show_current_cycle) do
|
defp add_computed_fields(members, computed_fields, show_current_cycle) do
|
||||||
if "membership_fee_status" in computed_fields do
|
if "membership_fee_status" in computed_fields do
|
||||||
|
|
@ -329,17 +341,23 @@ defmodule MvWeb.MemberExportController do
|
||||||
defp maybe_sort_export(query, _field, nil), do: {query, false}
|
defp maybe_sort_export(query, _field, nil), do: {query, false}
|
||||||
|
|
||||||
defp maybe_sort_export(query, field, order) when is_binary(field) do
|
defp maybe_sort_export(query, field, order) when is_binary(field) do
|
||||||
if custom_field_sort?(field) do
|
cond do
|
||||||
# Custom field sort → in-memory nach dem Read (wie Tabelle)
|
field == "groups" ->
|
||||||
{query, true}
|
# Groups sort → in-memory nach dem Read (wie Tabelle)
|
||||||
else
|
{query, true}
|
||||||
field_atom = String.to_existing_atom(field)
|
|
||||||
|
|
||||||
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
|
custom_field_sort?(field) ->
|
||||||
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
|
# Custom field sort → in-memory nach dem Read (wie Tabelle)
|
||||||
else
|
{query, true}
|
||||||
{query, false}
|
|
||||||
end
|
true ->
|
||||||
|
field_atom = String.to_existing_atom(field)
|
||||||
|
|
||||||
|
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
|
||||||
|
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
|
||||||
|
else
|
||||||
|
{query, false}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
ArgumentError -> {query, false}
|
ArgumentError -> {query, false}
|
||||||
|
|
@ -358,6 +376,15 @@ defmodule MvWeb.MemberExportController do
|
||||||
defp sort_members_by_custom_field_export(members, field, order, custom_fields)
|
defp sort_members_by_custom_field_export(members, field, order, custom_fields)
|
||||||
when is_binary(field) do
|
when is_binary(field) do
|
||||||
order = order || "asc"
|
order = order || "asc"
|
||||||
|
|
||||||
|
if field == "groups" do
|
||||||
|
sort_members_by_groups_export(members, order)
|
||||||
|
else
|
||||||
|
sort_by_custom_field_value(members, field, order, custom_fields)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sort_by_custom_field_value(members, field, order, custom_fields) do
|
||||||
id_str = String.trim_leading(field, @custom_field_prefix)
|
id_str = String.trim_leading(field, @custom_field_prefix)
|
||||||
|
|
||||||
custom_field =
|
custom_field =
|
||||||
|
|
@ -387,6 +414,26 @@ defmodule MvWeb.MemberExportController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp sort_members_by_groups_export(members, order) do
|
||||||
|
# Members with groups first, then by first group name alphabetically (min = first by sort order)
|
||||||
|
# Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
|
||||||
|
first_group_name = fn member ->
|
||||||
|
(member.groups || [])
|
||||||
|
|> Enum.map(& &1.name)
|
||||||
|
|> Enum.min(fn -> nil end)
|
||||||
|
end
|
||||||
|
|
||||||
|
members
|
||||||
|
|> Enum.sort_by(fn member ->
|
||||||
|
name = first_group_name.(member)
|
||||||
|
# Nil (no groups) sorts last in asc, first in desc
|
||||||
|
{name == nil, name || ""}
|
||||||
|
end)
|
||||||
|
|> then(fn list ->
|
||||||
|
if order == "desc", do: Enum.reverse(list), else: list
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp has_non_empty_custom_field_value?(member, custom_field) do
|
defp has_non_empty_custom_field_value?(member, custom_field) do
|
||||||
case find_cfv(member, custom_field) do
|
case find_cfv(member, custom_field) do
|
||||||
nil ->
|
nil ->
|
||||||
|
|
@ -441,6 +488,19 @@ defmodule MvWeb.MemberExportController do
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
groups_col =
|
||||||
|
if "groups" in parsed.member_fields do
|
||||||
|
[
|
||||||
|
%{
|
||||||
|
header: groups_field_header(conn),
|
||||||
|
kind: :groups,
|
||||||
|
key: :groups
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
custom_cols =
|
custom_cols =
|
||||||
parsed.custom_field_ids
|
parsed.custom_field_ids
|
||||||
|> Enum.map(fn id ->
|
|> Enum.map(fn id ->
|
||||||
|
|
@ -459,7 +519,7 @@ defmodule MvWeb.MemberExportController do
|
||||||
end)
|
end)
|
||||||
|> Enum.reject(&is_nil/1)
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|
||||||
member_cols ++ computed_cols ++ custom_cols
|
member_cols ++ computed_cols ++ groups_col ++ custom_cols
|
||||||
end
|
end
|
||||||
|
|
||||||
# --- headers: use MemberFields.label for translations ---
|
# --- headers: use MemberFields.label for translations ---
|
||||||
|
|
@ -499,6 +559,10 @@ defmodule MvWeb.MemberExportController do
|
||||||
cf.name
|
cf.name
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp groups_field_header(_conn) do
|
||||||
|
MemberFields.label(:groups)
|
||||||
|
end
|
||||||
|
|
||||||
defp humanize_field(str) do
|
defp humanize_field(str) do
|
||||||
str
|
str
|
||||||
|> String.replace("_", " ")
|
|> String.replace("_", " ")
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
||||||
class="btn btn-ghost select-none"
|
class="btn btn-ghost select-none"
|
||||||
phx-click="sort"
|
phx-click="sort"
|
||||||
phx-value-field={@field}
|
phx-value-field={@field}
|
||||||
phx-target={@myself}
|
|
||||||
data-testid={@field}
|
data-testid={@field}
|
||||||
>
|
>
|
||||||
{@label}
|
{@label}
|
||||||
|
|
@ -43,12 +42,6 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("sort", %{"field" => field_str}, socket) do
|
|
||||||
send(self(), {:sort, field_str})
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# Hilfsfunktionen für ARIA Attribute & Icon SVG
|
# Hilfsfunktionen für ARIA Attribute & Icon SVG
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||||
## Features
|
## Features
|
||||||
- Create new custom field definitions
|
- Create new custom field definitions
|
||||||
- Edit existing custom fields
|
- Edit existing custom fields
|
||||||
- Select value type from supported types
|
- Select value type from supported types (only on create; immutable after creation)
|
||||||
- Set required flag
|
- Set required flag
|
||||||
- Real-time validation
|
- Real-time validation
|
||||||
|
|
||||||
|
|
@ -44,15 +44,50 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||||
>
|
>
|
||||||
<.input field={@form[:name]} type="text" label={gettext("Name")} />
|
<.input field={@form[:name]} type="text" label={gettext("Name")} />
|
||||||
|
|
||||||
<.input
|
<%= if @custom_field do %>
|
||||||
field={@form[:value_type]}
|
<%!-- Show value_type as read-only input when editing (matches Member Field pattern) --%>
|
||||||
type="select"
|
<div
|
||||||
label={gettext("Value type")}
|
class="tooltip tooltip-right"
|
||||||
options={
|
data-tip={gettext("Value type cannot be changed after creation")}
|
||||||
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
|
aria-label={gettext("Value type cannot be changed after creation")}
|
||||||
|> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end)
|
>
|
||||||
}
|
<fieldset class="mb-2 fieldset">
|
||||||
/>
|
<label>
|
||||||
|
<span class="mb-1 label flex items-center gap-2">
|
||||||
|
{gettext("Value type")}
|
||||||
|
<.icon
|
||||||
|
name="hero-information-circle"
|
||||||
|
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name={@form[:value_type].name}
|
||||||
|
id={@form[:value_type].id}
|
||||||
|
value={MvWeb.Translations.FieldTypes.label(@custom_field.value_type)}
|
||||||
|
disabled
|
||||||
|
readonly
|
||||||
|
class="w-full input"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<%!-- Show value_type as select when creating --%>
|
||||||
|
<.input
|
||||||
|
field={@form[:value_type]}
|
||||||
|
type="select"
|
||||||
|
label={gettext("Value type")}
|
||||||
|
options={
|
||||||
|
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[
|
||||||
|
:one_of
|
||||||
|
]
|
||||||
|
|> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
||||||
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
||||||
<.input
|
<.input
|
||||||
|
|
@ -85,8 +120,16 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do
|
def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do
|
||||||
|
# Remove value_type from params when editing (it's immutable after creation)
|
||||||
|
cleaned_params =
|
||||||
|
if socket.assigns[:custom_field] do
|
||||||
|
Map.delete(custom_field_params, "value_type")
|
||||||
|
else
|
||||||
|
custom_field_params
|
||||||
|
end
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))}
|
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, cleaned_params))}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -94,7 +137,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||||
# Actor must be passed from parent (IndexComponent); component socket has no current_user
|
# Actor must be passed from parent (IndexComponent); component socket has no current_user
|
||||||
actor = socket.assigns[:actor]
|
actor = socket.assigns[:actor]
|
||||||
|
|
||||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do
|
# Remove value_type from params when editing (it's immutable after creation)
|
||||||
|
cleaned_params =
|
||||||
|
if socket.assigns[:custom_field] do
|
||||||
|
Map.delete(custom_field_params, "value_type")
|
||||||
|
else
|
||||||
|
custom_field_params
|
||||||
|
end
|
||||||
|
|
||||||
|
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, cleaned_params, actor) do
|
||||||
{:ok, custom_field} ->
|
{:ok, custom_field} ->
|
||||||
action =
|
action =
|
||||||
case socket.assigns.form.source.type do
|
case socket.assigns.form.source.type do
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
import Ash.Expr
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
|
@ -41,11 +44,23 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|> assign(:settings, settings)
|
|> assign(:settings, settings)
|
||||||
|> assign(:active_editing_section, nil)
|
|> assign(:active_editing_section, nil)
|
||||||
|> assign(:locale, locale)
|
|> assign(:locale, locale)
|
||||||
|
|> assign(:vereinfacht_env_configured, Mv.Config.vereinfacht_env_configured?())
|
||||||
|
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|
||||||
|
|> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
|
||||||
|
|> assign(:vereinfacht_club_id_env_set, Mv.Config.vereinfacht_club_id_env_set?())
|
||||||
|
|> assign(:vereinfacht_app_url_env_set, Mv.Config.vereinfacht_app_url_env_set?())
|
||||||
|
|> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key))
|
||||||
|
|> assign(:last_vereinfacht_sync_result, nil)
|
||||||
|> assign_form()
|
|> assign_form()
|
||||||
|
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp present?(nil), do: false
|
||||||
|
defp present?(""), do: false
|
||||||
|
defp present?(s) when is_binary(s), do: String.trim(s) != ""
|
||||||
|
defp present?(_), do: false
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -74,6 +89,98 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
</.form_section>
|
</.form_section>
|
||||||
|
<%!-- Vereinfacht Integration Section --%>
|
||||||
|
<.form_section title={gettext("Vereinfacht Integration")}>
|
||||||
|
<%= if @vereinfacht_env_configured do %>
|
||||||
|
<p class="text-sm text-base-content/70 mb-4">
|
||||||
|
{gettext("Some values are set via environment variables. Those fields are read-only.")}
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
<.form for={@form} id="vereinfacht-form" phx-change="validate" phx-submit="save">
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<.input
|
||||||
|
field={@form[:vereinfacht_api_url]}
|
||||||
|
type="text"
|
||||||
|
label={gettext("API URL")}
|
||||||
|
disabled={@vereinfacht_api_url_env_set}
|
||||||
|
placeholder={
|
||||||
|
if(@vereinfacht_api_url_env_set,
|
||||||
|
do: gettext("From VEREINFACHT_API_URL"),
|
||||||
|
else: "https://api.verein.visuel.dev/api/v1"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for={@form[:vereinfacht_api_key].id}>
|
||||||
|
<span class="label-text">{gettext("API Key")}</span>
|
||||||
|
<%= if @vereinfacht_api_key_set do %>
|
||||||
|
<span class="label-text-alt badge badge-ghost">{gettext("(set)")}</span>
|
||||||
|
<% end %>
|
||||||
|
</label>
|
||||||
|
<.input
|
||||||
|
field={@form[:vereinfacht_api_key]}
|
||||||
|
type="password"
|
||||||
|
label=""
|
||||||
|
disabled={@vereinfacht_api_key_env_set}
|
||||||
|
placeholder={
|
||||||
|
if(@vereinfacht_api_key_env_set,
|
||||||
|
do: gettext("From VEREINFACHT_API_KEY"),
|
||||||
|
else:
|
||||||
|
if(@vereinfacht_api_key_set,
|
||||||
|
do: gettext("Leave blank to keep current"),
|
||||||
|
else: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<.input
|
||||||
|
field={@form[:vereinfacht_club_id]}
|
||||||
|
type="text"
|
||||||
|
label={gettext("Club ID")}
|
||||||
|
disabled={@vereinfacht_club_id_env_set}
|
||||||
|
placeholder={
|
||||||
|
if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:vereinfacht_app_url]}
|
||||||
|
type="text"
|
||||||
|
label={gettext("App URL (contact view link)")}
|
||||||
|
disabled={@vereinfacht_app_url_env_set}
|
||||||
|
placeholder={
|
||||||
|
if(@vereinfacht_app_url_env_set,
|
||||||
|
do: gettext("From VEREINFACHT_APP_URL"),
|
||||||
|
else: "https://app.verein.visuel.dev"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<.button
|
||||||
|
:if={
|
||||||
|
not (@vereinfacht_api_url_env_set and @vereinfacht_api_key_env_set and
|
||||||
|
@vereinfacht_club_id_env_set)
|
||||||
|
}
|
||||||
|
phx-disable-with={gettext("Saving...")}
|
||||||
|
variant="primary"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
{gettext("Save Vereinfacht Settings")}
|
||||||
|
</.button>
|
||||||
|
<.button
|
||||||
|
:if={Mv.Config.vereinfacht_configured?()}
|
||||||
|
type="button"
|
||||||
|
phx-click="sync_vereinfacht_contacts"
|
||||||
|
phx-disable-with={gettext("Syncing...")}
|
||||||
|
class="mt-4 btn-outline"
|
||||||
|
>
|
||||||
|
{gettext("Sync all members without Vereinfacht contact")}
|
||||||
|
</.button>
|
||||||
|
<%= if @last_vereinfacht_sync_result do %>
|
||||||
|
<.vereinfacht_sync_result result={@last_vereinfacht_sync_result} />
|
||||||
|
<% end %>
|
||||||
|
</.form>
|
||||||
|
</.form_section>
|
||||||
<%!-- Memberdata Section --%>
|
<%!-- Memberdata Section --%>
|
||||||
<.form_section title={gettext("Memberdata")}>
|
<.form_section title={gettext("Memberdata")}>
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -100,18 +207,54 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("sync_vereinfacht_contacts", _params, socket) do
|
||||||
|
case Mv.Vereinfacht.sync_members_without_contact() do
|
||||||
|
{:ok, %{synced: synced, errors: errors}} ->
|
||||||
|
errors_with_names = enrich_sync_errors(errors)
|
||||||
|
result = %{synced: synced, errors: errors_with_names}
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:last_vereinfacht_sync_result, result)
|
||||||
|
|> put_flash(
|
||||||
|
:info,
|
||||||
|
if(errors_with_names == [],
|
||||||
|
do: gettext("Synced %{count} member(s) to Vereinfacht.", count: synced),
|
||||||
|
else:
|
||||||
|
gettext("Synced %{count} member(s). %{error_count} failed.",
|
||||||
|
count: synced,
|
||||||
|
error_count: length(errors_with_names)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
|
{:error, :not_configured} ->
|
||||||
|
{:noreply,
|
||||||
|
put_flash(
|
||||||
|
socket,
|
||||||
|
:error,
|
||||||
|
gettext("Vereinfacht is not configured. Set API URL, API Key, and Club ID.")
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("save", %{"setting" => setting_params}, socket) do
|
def handle_event("save", %{"setting" => setting_params}, socket) do
|
||||||
actor = MvWeb.LiveHelpers.current_actor(socket)
|
actor = MvWeb.LiveHelpers.current_actor(socket)
|
||||||
|
# Never send blank API key so we do not overwrite the stored secret (security)
|
||||||
|
setting_params_clean = drop_blank_vereinfacht_api_key(setting_params)
|
||||||
|
|
||||||
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params, actor) do
|
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, setting_params_clean, actor) do
|
||||||
{:ok, _updated_settings} ->
|
{:ok, _updated_settings} ->
|
||||||
# Reload settings from database to ensure all dependent data is updated
|
|
||||||
{:ok, fresh_settings} = Membership.get_settings()
|
{:ok, fresh_settings} = Membership.get_settings()
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:settings, fresh_settings)
|
|> assign(:settings, fresh_settings)
|
||||||
|
|> assign(:vereinfacht_api_key_set, present?(fresh_settings.vereinfacht_api_key))
|
||||||
|> put_flash(:info, gettext("Settings updated successfully"))
|
|> put_flash(:info, gettext("Settings updated successfully"))
|
||||||
|> assign_form()
|
|> assign_form()
|
||||||
|
|
||||||
|
|
@ -122,6 +265,16 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp drop_blank_vereinfacht_api_key(params) when is_map(params) do
|
||||||
|
case params do
|
||||||
|
%{"vereinfacht_api_key" => v} when v in [nil, ""] ->
|
||||||
|
Map.delete(params, "vereinfacht_api_key")
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
params
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
||||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||||
|
|
@ -202,9 +355,12 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||||
|
# Never put API key into form/DOM to avoid secret leak in source or DevTools
|
||||||
|
settings_for_form = %{settings | vereinfacht_api_key: nil}
|
||||||
|
|
||||||
form =
|
form =
|
||||||
AshPhoenix.Form.for_update(
|
AshPhoenix.Form.for_update(
|
||||||
settings,
|
settings_for_form,
|
||||||
:update,
|
:update,
|
||||||
api: Membership,
|
api: Membership,
|
||||||
as: "setting",
|
as: "setting",
|
||||||
|
|
@ -213,4 +369,74 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|
|
||||||
assign(socket, form: to_form(form))
|
assign(socket, form: to_form(form))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp enrich_sync_errors([]), do: []
|
||||||
|
|
||||||
|
defp enrich_sync_errors(errors) when is_list(errors) do
|
||||||
|
name_by_id = fetch_member_names_by_ids(Enum.map(errors, fn {id, _} -> id end))
|
||||||
|
|
||||||
|
Enum.map(errors, fn {member_id, reason} ->
|
||||||
|
%{
|
||||||
|
member_id: member_id,
|
||||||
|
member_name: Map.get(name_by_id, member_id) || to_string(member_id),
|
||||||
|
message: Mv.Vereinfacht.format_error(reason),
|
||||||
|
detail: extract_vereinfacht_detail(reason)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_member_names_by_ids(ids) do
|
||||||
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
opts = Mv.Helpers.ash_actor_opts(actor)
|
||||||
|
query = Ash.Query.filter(Mv.Membership.Member, expr(id in ^ids))
|
||||||
|
|
||||||
|
case Ash.read(query, opts) do
|
||||||
|
{:ok, members} ->
|
||||||
|
Map.new(members, fn m -> {m.id, MvWeb.Helpers.MemberHelpers.display_name(m)} end)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
%{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_vereinfacht_detail({:http, _status, detail}) when is_binary(detail), do: detail
|
||||||
|
defp extract_vereinfacht_detail(_), do: nil
|
||||||
|
|
||||||
|
defp translate_vereinfacht_message(%{detail: detail}) when is_binary(detail) do
|
||||||
|
gettext("Vereinfacht: %{detail}",
|
||||||
|
detail: Gettext.dgettext(MvWeb.Gettext, "default", detail)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translate_vereinfacht_message(%{message: message}) do
|
||||||
|
Gettext.dgettext(MvWeb.Gettext, "default", message)
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :result, :map, required: true
|
||||||
|
|
||||||
|
defp vereinfacht_sync_result(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="mt-4 p-4 rounded-lg border border-base-300 bg-base-200 space-y-2">
|
||||||
|
<p class="font-medium">
|
||||||
|
{gettext("Last sync result:")}
|
||||||
|
<span class="text-success-aa ml-1">{gettext("%{count} synced", count: @result.synced)}</span>
|
||||||
|
<%= if @result.errors != [] do %>
|
||||||
|
<span class="text-error-aa ml-1">
|
||||||
|
{gettext("%{count} failed", count: length(@result.errors))}
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
<%= if @result.errors != [] do %>
|
||||||
|
<p class="text-sm text-base-content/70 mt-2">{gettext("Failed members:")}</p>
|
||||||
|
<ul class="list-disc list-inside text-sm space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
<%= for err <- @result.errors do %>
|
||||||
|
<li>
|
||||||
|
<span class="font-medium">{err.member_name}</span>: {translate_vereinfacht_message(err)}
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,12 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
import Ash.Expr
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
import MvWeb.Authorization
|
import MvWeb.Authorization
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
|
@ -29,6 +31,7 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
|> assign(:show_add_member_input, false)
|
|> assign(:show_add_member_input, false)
|
||||||
|> assign(:member_search_query, "")
|
|> assign(:member_search_query, "")
|
||||||
|> assign(:available_members, [])
|
|> assign(:available_members, [])
|
||||||
|
|> assign(:add_member_candidates, [])
|
||||||
|> assign(:selected_member_ids, [])
|
|> assign(:selected_member_ids, [])
|
||||||
|> assign(:selected_members, [])
|
|> assign(:selected_members, [])
|
||||||
|> assign(:show_member_dropdown, false)
|
|> assign(:show_member_dropdown, false)
|
||||||
|
|
@ -94,13 +97,21 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
<%= if can?(@current_user, :update, @group) do %>
|
||||||
<.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}>
|
<.button
|
||||||
|
variant="primary"
|
||||||
|
navigate={~p"/groups/#{@group.slug}/edit"}
|
||||||
|
data-testid="group-show-edit-btn"
|
||||||
|
>
|
||||||
{gettext("Edit")}
|
{gettext("Edit")}
|
||||||
</.button>
|
</.button>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if can?(@current_user, :destroy, Mv.Membership.Group) do %>
|
<%= if can?(@current_user, :destroy, @group) do %>
|
||||||
<.button class="btn-error" phx-click="open_delete_modal">
|
<.button
|
||||||
|
class="btn-error"
|
||||||
|
phx-click="open_delete_modal"
|
||||||
|
data-testid="group-show-delete-btn"
|
||||||
|
>
|
||||||
{gettext("Delete")}
|
{gettext("Delete")}
|
||||||
</.button>
|
</.button>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
@ -123,7 +134,7 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
|
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
|
||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
<p class="mb-4">
|
<p class="mb-4" data-testid="group-show-member-count">
|
||||||
{ngettext(
|
{ngettext(
|
||||||
"Total: %{count} member",
|
"Total: %{count} member",
|
||||||
"Total: %{count} members",
|
"Total: %{count} members",
|
||||||
|
|
@ -132,7 +143,7 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
<%= if can?(@current_user, :update, @group) do %>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<%= if assigns[:show_add_member_input] do %>
|
<%= if assigns[:show_add_member_input] do %>
|
||||||
<div class="join w-full">
|
<div class="join w-full">
|
||||||
|
|
@ -160,6 +171,7 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="member-search-input"
|
id="member-search-input"
|
||||||
|
data-testid="group-show-member-search-input"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
phx-hook="ComboBox"
|
phx-hook="ComboBox"
|
||||||
phx-focus="show_member_dropdown"
|
phx-focus="show_member_dropdown"
|
||||||
|
|
@ -228,6 +240,7 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary join-item"
|
class="btn btn-primary join-item"
|
||||||
phx-click="add_selected_members"
|
phx-click="add_selected_members"
|
||||||
|
data-testid="group-show-add-selected-members-btn"
|
||||||
disabled={Enum.empty?(@selected_member_ids)}
|
disabled={Enum.empty?(@selected_member_ids)}
|
||||||
aria-label={gettext("Add members")}
|
aria-label={gettext("Add members")}
|
||||||
>
|
>
|
||||||
|
|
@ -255,15 +268,17 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if Enum.empty?(@group.members || []) do %>
|
<%= if Enum.empty?(@group.members || []) do %>
|
||||||
<p class="text-base-content/50 italic">{gettext("No members in this group")}</p>
|
<p class="text-base-content/50 italic" data-testid="group-show-no-members">
|
||||||
|
{gettext("No members in this group")}
|
||||||
|
</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto" data-testid="group-show-members-table">
|
||||||
<table class="table table-zebra">
|
<table class="table table-zebra">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{gettext("Name")}</th>
|
<th>{gettext("Name")}</th>
|
||||||
<th>{gettext("Email")}</th>
|
<th>{gettext("Email")}</th>
|
||||||
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
<%= if can?(@current_user, :update, @group) do %>
|
||||||
<th class="w-0">{gettext("Actions")}</th>
|
<th class="w-0">{gettext("Actions")}</th>
|
||||||
<% end %>
|
<% end %>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -291,13 +306,14 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
<span class="text-base-content/50 italic">—</span>
|
<span class="text-base-content/50 italic">—</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
|
<%= if can?(@current_user, :update, @group) do %>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-sm text-error"
|
class="btn btn-ghost btn-sm text-error"
|
||||||
phx-click="remove_member"
|
phx-click="remove_member"
|
||||||
phx-value-member_id={member.id}
|
phx-value-member_id={member.id}
|
||||||
|
data-testid="group-show-remove-member"
|
||||||
aria-label={gettext("Remove member from group")}
|
aria-label={gettext("Remove member from group")}
|
||||||
data-tooltip={gettext("Remove")}
|
data-tooltip={gettext("Remove")}
|
||||||
>
|
>
|
||||||
|
|
@ -431,28 +447,31 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
# Add Member Events
|
# Add Member Events
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("show_add_member_input", _params, socket) do
|
def handle_event("show_add_member_input", _params, socket) do
|
||||||
# Reload group to ensure we have the latest members list
|
# Load candidate members once (single DB read). Search/focus then filter in memory (R2).
|
||||||
actor = current_actor(socket)
|
socket =
|
||||||
group = socket.assigns.group
|
socket
|
||||||
socket = reload_group(socket, group.slug, actor)
|
|> assign(:show_add_member_input, true)
|
||||||
|
|> assign(:member_search_query, "")
|
||||||
|
|> assign(:selected_member_ids, [])
|
||||||
|
|> assign(:selected_members, [])
|
||||||
|
|> assign(:show_member_dropdown, false)
|
||||||
|
|> assign(:focused_member_index, nil)
|
||||||
|
|> load_add_member_candidates()
|
||||||
|
|
||||||
{:noreply,
|
{:noreply, socket}
|
||||||
socket
|
|
||||||
|> assign(:show_add_member_input, true)
|
|
||||||
|> assign(:member_search_query, "")
|
|
||||||
|> assign(:available_members, [])
|
|
||||||
|> assign(:selected_member_ids, [])
|
|
||||||
|> assign(:selected_members, [])
|
|
||||||
|> assign(:show_member_dropdown, false)
|
|
||||||
|> assign(:focused_member_index, nil)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("show_member_dropdown", _params, socket) do
|
def handle_event("show_member_dropdown", _params, socket) do
|
||||||
# Use existing group.members for filtering; reload only on add/remove
|
# Filter in memory from preloaded candidates; no DB read (R2).
|
||||||
|
query = socket.assigns.member_search_query || ""
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> load_available_members("")
|
|> assign(
|
||||||
|
:available_members,
|
||||||
|
filter_candidates_in_memory(socket.assigns.add_member_candidates, query)
|
||||||
|
)
|
||||||
|> assign(:show_member_dropdown, true)
|
|> assign(:show_member_dropdown, true)
|
||||||
|> assign(:focused_member_index, nil)
|
|> assign(:focused_member_index, nil)
|
||||||
|
|
||||||
|
|
@ -466,6 +485,7 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
|> assign(:show_add_member_input, false)
|
|> assign(:show_add_member_input, false)
|
||||||
|> assign(:member_search_query, "")
|
|> assign(:member_search_query, "")
|
||||||
|> assign(:available_members, [])
|
|> assign(:available_members, [])
|
||||||
|
|> assign(:add_member_candidates, [])
|
||||||
|> assign(:selected_member_ids, [])
|
|> assign(:selected_member_ids, [])
|
||||||
|> assign(:selected_members, [])
|
|> assign(:selected_members, [])
|
||||||
|> assign(:show_member_dropdown, false)
|
|> assign(:show_member_dropdown, false)
|
||||||
|
|
@ -532,11 +552,13 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("search_members", %{"member_search" => query}, socket) do
|
def handle_event("search_members", %{"member_search" => query}, socket) do
|
||||||
# Use existing group.members for filtering; reload only on add/remove
|
# Filter in memory from preloaded candidates; no DB read (R2).
|
||||||
|
candidates = socket.assigns.add_member_candidates || []
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:member_search_query, query)
|
|> assign(:member_search_query, query)
|
||||||
|> load_available_members(query)
|
|> assign(:available_members, filter_candidates_in_memory(candidates, query))
|
||||||
|> assign(:show_member_dropdown, true)
|
|> assign(:show_member_dropdown, true)
|
||||||
|> assign(:focused_member_index, nil)
|
|> assign(:focused_member_index, nil)
|
||||||
|
|
||||||
|
|
@ -660,47 +682,69 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_available_members(socket, query) do
|
# Load candidate members once when opening add-member UI (single DB read).
|
||||||
|
defp load_add_member_candidates(socket) do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
current_member_ids = group_member_ids_set(socket.assigns.group)
|
group = socket.assigns.group
|
||||||
base_query = available_members_base_query(query)
|
exclude_ids = group_member_ids_set(group) |> MapSet.to_list()
|
||||||
|
|
||||||
# Fetch more than 10, then exclude already-in-group and take 10 (avoids empty dropdown when first N are all in group)
|
|
||||||
fetch_limit = 50
|
|
||||||
limited_query = Ash.Query.limit(base_query, fetch_limit)
|
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
||||||
case Ash.read(limited_query, actor: actor, domain: Mv.Membership) do
|
if exclude_ids == [] do
|
||||||
{:ok, members} ->
|
# No members in group; load first N members
|
||||||
available =
|
query =
|
||||||
members
|
Mv.Membership.Member
|
||||||
|> Enum.reject(fn m -> MapSet.member?(current_member_ids, m.id) end)
|
|> Ash.Query.sort([:last_name, :first_name])
|
||||||
|> Enum.take(10)
|
|> Ash.Query.limit(300)
|
||||||
|
|
||||||
assign(socket, available_members: available)
|
do_load_add_member_candidates(socket, query, actor)
|
||||||
|
else
|
||||||
|
query =
|
||||||
|
Mv.Membership.Member
|
||||||
|
|> Ash.Query.filter(expr(id not in ^exclude_ids))
|
||||||
|
|> Ash.Query.sort([:last_name, :first_name])
|
||||||
|
|> Ash.Query.limit(300)
|
||||||
|
|
||||||
|
do_load_add_member_candidates(socket, query, actor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_load_add_member_candidates(socket, query, actor) do
|
||||||
|
case Ash.read(query, actor: actor, domain: Mv.Membership) do
|
||||||
|
{:ok, candidates} ->
|
||||||
|
socket
|
||||||
|
|> assign(:add_member_candidates, candidates)
|
||||||
|
|> assign(:available_members, Enum.take(candidates, 10))
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
Logger.warning("Failed to load available members for group: #{inspect(error)}")
|
Logger.warning("Failed to load add-member candidates: #{inspect(error)}")
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> put_flash(:error, gettext("Could not load member search. Please try again."))
|
|> put_flash(:error, gettext("Could not load member list. Please try again."))
|
||||||
|
|> assign(:add_member_candidates, [])
|
||||||
|> assign(:available_members, [])
|
|> assign(:available_members, [])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp available_members_base_query(query) do
|
# Filter preloaded candidates by query string (name/email). No DB read. R2.
|
||||||
search_query = if query && String.trim(query) != "", do: String.trim(query), else: nil
|
defp filter_candidates_in_memory(candidates, query) when is_list(candidates) do
|
||||||
|
q = if is_binary(query), do: String.trim(query) |> String.downcase(), else: ""
|
||||||
|
|
||||||
if search_query do
|
if q == "" do
|
||||||
Mv.Membership.Member
|
candidates |> Enum.take(10)
|
||||||
|> Ash.Query.for_read(:search, %{query: search_query})
|
|
||||||
else
|
else
|
||||||
Mv.Membership.Member
|
candidates
|
||||||
|> Ash.Query.new()
|
|> Enum.filter(fn m ->
|
||||||
|
name = MemberHelpers.display_name(m) |> String.downcase()
|
||||||
|
email = (m.email || "") |> String.downcase()
|
||||||
|
String.contains?(name, q) or String.contains?(email, q)
|
||||||
|
end)
|
||||||
|
|> Enum.take(10)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp filter_candidates_in_memory(_, _), do: []
|
||||||
|
|
||||||
defp group_member_ids_set(group) do
|
defp group_member_ids_set(group) do
|
||||||
members = group.members || []
|
members = group.members || []
|
||||||
members |> Enum.map(& &1.id) |> MapSet.new()
|
members |> Enum.map(& &1.id) |> MapSet.new()
|
||||||
|
|
@ -740,6 +784,7 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
|> assign(:show_add_member_input, false)
|
|> assign(:show_add_member_input, false)
|
||||||
|> assign(:member_search_query, "")
|
|> assign(:member_search_query, "")
|
||||||
|> assign(:available_members, [])
|
|> assign(:available_members, [])
|
||||||
|
|> assign(:add_member_candidates, [])
|
||||||
|> assign(:selected_member_ids, [])
|
|> assign(:selected_member_ids, [])
|
||||||
|> assign(:selected_members, [])
|
|> assign(:selected_members, [])
|
||||||
|> assign(:show_member_dropdown, false)
|
|> assign(:show_member_dropdown, false)
|
||||||
|
|
|
||||||
|
|
@ -319,11 +319,40 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> put_flash(:info, flash_message)
|
|> put_flash(:info, flash_message)
|
||||||
|
|> maybe_put_vereinfacht_sync_flash(member.id)
|
||||||
|> push_navigate(to: return_path(socket.assigns.return_to, member))
|
|> push_navigate(to: return_path(socket.assigns.return_to, member))
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_put_vereinfacht_sync_flash(socket, member_id) do
|
||||||
|
case Mv.Vereinfacht.SyncFlash.take(to_string(member_id)) do
|
||||||
|
{:warning, message} ->
|
||||||
|
put_flash(socket, :warning, translate_vereinfacht_flash(message))
|
||||||
|
|
||||||
|
{:ok, _message} ->
|
||||||
|
# Optionally show sync success; for now we keep only the main success message
|
||||||
|
socket
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translate_vereinfacht_flash(message) when is_binary(message) do
|
||||||
|
prefix = "Vereinfacht: "
|
||||||
|
|
||||||
|
if String.starts_with?(message, prefix) do
|
||||||
|
detail = message |> String.trim_leading(prefix) |> String.trim()
|
||||||
|
|
||||||
|
Gettext.dgettext(MvWeb.Gettext, "default", "Vereinfacht: %{detail}",
|
||||||
|
detail: Gettext.dgettext(MvWeb.Gettext, "default", detail)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Gettext.dgettext(MvWeb.Gettext, "default", message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp handle_save_error(socket, form) do
|
defp handle_save_error(socket, form) do
|
||||||
# Always show a flash message when save fails
|
# Always show a flash message when save fails
|
||||||
# Field-level validation errors are displayed in form fields, but flash provides additional feedback
|
# Field-level validation errors are displayed in form fields, but flash provides additional feedback
|
||||||
|
|
|
||||||
|
|
@ -68,18 +68,15 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
# This is appropriate for initialization errors that should be visible to the user.
|
# This is appropriate for initialization errors that should be visible to the user.
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
||||||
custom_fields_visible =
|
|
||||||
Mv.Membership.CustomField
|
|
||||||
|> Ash.Query.filter(expr(show_in_overview == true))
|
|
||||||
|> Ash.Query.sort(name: :asc)
|
|
||||||
|> Ash.read!(actor: actor)
|
|
||||||
|
|
||||||
# Load ALL custom fields for the dropdown (to show all available fields)
|
|
||||||
all_custom_fields =
|
all_custom_fields =
|
||||||
Mv.Membership.CustomField
|
Mv.Membership.CustomField
|
||||||
|> Ash.Query.sort(name: :asc)
|
|> Ash.Query.sort(name: :asc)
|
||||||
|> Ash.read!(actor: actor)
|
|> Ash.read!(actor: actor)
|
||||||
|
|
||||||
|
custom_fields_visible =
|
||||||
|
all_custom_fields
|
||||||
|
|> Enum.filter(& &1.show_in_overview)
|
||||||
|
|
||||||
# Load boolean custom fields (filtered and sorted from all_custom_fields)
|
# Load boolean custom fields (filtered and sorted from all_custom_fields)
|
||||||
boolean_custom_fields =
|
boolean_custom_fields =
|
||||||
all_custom_fields
|
all_custom_fields
|
||||||
|
|
@ -163,6 +160,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
- `"delete"` - Removes a member from the database
|
- `"delete"` - Removes a member from the database
|
||||||
- `"select_member"` - Toggles individual member selection
|
- `"select_member"` - Toggles individual member selection
|
||||||
- `"select_all"` - Toggles selection of all visible members
|
- `"select_all"` - Toggles selection of all visible members
|
||||||
|
- `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
|
|
@ -305,6 +303,46 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("sort", %{"field" => field_str}, socket) do
|
||||||
|
# Handle both atom and string field names (for custom fields)
|
||||||
|
field =
|
||||||
|
try do
|
||||||
|
String.to_existing_atom(field_str)
|
||||||
|
rescue
|
||||||
|
ArgumentError -> field_str
|
||||||
|
end
|
||||||
|
|
||||||
|
{new_field, new_order} = determine_new_sort(field, socket)
|
||||||
|
old_field = socket.assigns.sort_field
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:sort_field, new_field)
|
||||||
|
|> assign(:sort_order, new_order)
|
||||||
|
|> update_sort_components(old_field, new_field, new_order)
|
||||||
|
|> load_members()
|
||||||
|
|> update_selection_assigns()
|
||||||
|
|
||||||
|
# URL sync - push_patch happens synchronously in the event handler
|
||||||
|
query_params =
|
||||||
|
build_query_params(
|
||||||
|
socket.assigns.query,
|
||||||
|
export_sort_field(socket.assigns.sort_field),
|
||||||
|
export_sort_order(socket.assigns.sort_order),
|
||||||
|
socket.assigns.cycle_status_filter,
|
||||||
|
socket.assigns[:group_filters],
|
||||||
|
socket.assigns.show_current_cycle,
|
||||||
|
socket.assigns.boolean_custom_field_filters
|
||||||
|
)
|
||||||
|
|> maybe_add_field_selection(
|
||||||
|
socket.assigns[:user_field_selection],
|
||||||
|
socket.assigns[:fields_in_url?] || false
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
|
||||||
|
end
|
||||||
|
|
||||||
# Helper to format errors for display
|
# Helper to format errors for display
|
||||||
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
defp format_error(%Ash.Error.Invalid{errors: errors}) do
|
||||||
error_messages =
|
error_messages =
|
||||||
|
|
@ -329,50 +367,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
Handles messages from child components.
|
Handles messages from child components.
|
||||||
|
|
||||||
## Supported messages:
|
## Supported messages:
|
||||||
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
|
||||||
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
|
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
|
||||||
- `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
|
- `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
|
||||||
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
|
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
|
||||||
"""
|
"""
|
||||||
@impl true
|
|
||||||
def handle_info({:sort, field_str}, socket) do
|
|
||||||
# Handle both atom and string field names (for custom fields)
|
|
||||||
field =
|
|
||||||
try do
|
|
||||||
String.to_existing_atom(field_str)
|
|
||||||
rescue
|
|
||||||
ArgumentError -> field_str
|
|
||||||
end
|
|
||||||
|
|
||||||
{new_field, new_order} = determine_new_sort(field, socket)
|
|
||||||
old_field = socket.assigns.sort_field
|
|
||||||
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> assign(:sort_field, new_field)
|
|
||||||
|> assign(:sort_order, new_order)
|
|
||||||
|> update_sort_components(old_field, new_field, new_order)
|
|
||||||
|> load_members()
|
|
||||||
|> update_selection_assigns()
|
|
||||||
|
|
||||||
# URL sync
|
|
||||||
query_params =
|
|
||||||
build_query_params(
|
|
||||||
socket.assigns.query,
|
|
||||||
export_sort_field(socket.assigns.sort_field),
|
|
||||||
export_sort_order(socket.assigns.sort_order),
|
|
||||||
socket.assigns.cycle_status_filter,
|
|
||||||
socket.assigns[:group_filters],
|
|
||||||
socket.assigns.show_current_cycle,
|
|
||||||
socket.assigns.boolean_custom_field_filters
|
|
||||||
)
|
|
||||||
|> maybe_add_field_selection(
|
|
||||||
socket.assigns[:user_field_selection],
|
|
||||||
socket.assigns[:fields_in_url?] || false
|
|
||||||
)
|
|
||||||
|
|
||||||
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:search_changed, q}, socket) do
|
def handle_info({:search_changed, q}, socket) do
|
||||||
|
|
@ -684,6 +682,19 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> update_selection_assigns()
|
|> update_selection_assigns()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Update sort components after rendering
|
||||||
|
socket =
|
||||||
|
if socket.assigns[:sort_needs_update] do
|
||||||
|
old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order)
|
||||||
|
|> assign(:sort_needs_update, false)
|
||||||
|
|> assign(:previous_sort_field, nil)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -942,9 +953,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
|
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
|
||||||
|
# Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status
|
||||||
members =
|
members =
|
||||||
if sort_after_load and
|
if sort_after_load and
|
||||||
socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do
|
socket.assigns.sort_field != :membership_fee_status do
|
||||||
sort_members_in_memory(
|
sort_members_in_memory(
|
||||||
members,
|
members,
|
||||||
socket.assigns.sort_field,
|
socket.assigns.sort_field,
|
||||||
|
|
@ -1046,21 +1058,15 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false}
|
defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false}
|
||||||
|
|
||||||
defp maybe_sort(query, field, order, _custom_fields) do
|
defp maybe_sort(query, field, order, _custom_fields) do
|
||||||
if computed_field?(field) do
|
# :groups is in computed_member_fields() but can be sorted in-memory
|
||||||
|
# Only :membership_fee_status should be blocked from sorting
|
||||||
|
if field == :membership_fee_status or field == "membership_fee_status" do
|
||||||
{query, false}
|
{query, false}
|
||||||
else
|
else
|
||||||
apply_sort_to_query(query, field, order)
|
apply_sort_to_query(query, field, order)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp computed_field?(field) do
|
|
||||||
computed_atoms = FieldVisibility.computed_member_fields()
|
|
||||||
computed_strings = Enum.map(computed_atoms, &Atom.to_string/1)
|
|
||||||
|
|
||||||
(is_atom(field) and field in computed_atoms) or
|
|
||||||
(is_binary(field) and field in computed_strings)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_sort_to_query(query, field, order) do
|
defp apply_sort_to_query(query, field, order) do
|
||||||
cond do
|
cond do
|
||||||
# Groups sort -> after load (in memory)
|
# Groups sort -> after load (in memory)
|
||||||
|
|
@ -1088,13 +1094,19 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp valid_sort_field?(field) when is_atom(field) do
|
defp valid_sort_field?(field) when is_atom(field) do
|
||||||
if field in FieldVisibility.computed_member_fields(),
|
# :groups is in computed_member_fields() but can be sorted
|
||||||
do: false,
|
# Only :membership_fee_status should be blocked
|
||||||
else: valid_sort_field_db_or_custom?(field)
|
if field == :membership_fee_status do
|
||||||
|
false
|
||||||
|
else
|
||||||
|
valid_sort_field_db_or_custom?(field)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp valid_sort_field?(field) when is_binary(field) do
|
defp valid_sort_field?(field) when is_binary(field) do
|
||||||
if field in Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) do
|
# "groups" is in computed_member_fields() but can be sorted
|
||||||
|
# Only "membership_fee_status" should be blocked
|
||||||
|
if field == "membership_fee_status" do
|
||||||
false
|
false
|
||||||
else
|
else
|
||||||
valid_sort_field_db_or_custom?(field)
|
valid_sort_field_db_or_custom?(field)
|
||||||
|
|
@ -1251,10 +1263,13 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
|
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
|
||||||
field = determine_field(socket.assigns.sort_field, sf)
|
field = determine_field(socket.assigns.sort_field, sf)
|
||||||
order = determine_order(socket.assigns.sort_order, so)
|
order = determine_order(socket.assigns.sort_order, so)
|
||||||
|
old_field = socket.assigns.sort_field
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:sort_field, field)
|
|> assign(:sort_field, field)
|
||||||
|> assign(:sort_order, order)
|
|> assign(:sort_order, order)
|
||||||
|
|> assign(:sort_needs_update, old_field != field or socket.assigns.sort_order != order)
|
||||||
|
|> assign(:previous_sort_field, old_field)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_update_sort(socket, _), do: socket
|
defp maybe_update_sort(socket, _), do: socket
|
||||||
|
|
@ -1263,17 +1278,27 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp determine_field(default, nil), do: default
|
defp determine_field(default, nil), do: default
|
||||||
|
|
||||||
defp determine_field(default, sf) when is_binary(sf) do
|
defp determine_field(default, sf) when is_binary(sf) do
|
||||||
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
|
# Handle "groups" specially - it's in computed_member_fields() but can be sorted
|
||||||
|
if sf == "groups" do
|
||||||
|
:groups
|
||||||
|
else
|
||||||
|
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
|
||||||
|
|
||||||
if sf in computed_strings,
|
if sf in computed_strings,
|
||||||
do: default,
|
do: default,
|
||||||
else: determine_field_after_computed_check(default, sf)
|
else: determine_field_after_computed_check(default, sf)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp determine_field(default, sf) when is_atom(sf) do
|
defp determine_field(default, sf) when is_atom(sf) do
|
||||||
if sf in FieldVisibility.computed_member_fields(),
|
# Handle :groups specially - it's in computed_member_fields() but can be sorted
|
||||||
do: default,
|
if sf == :groups do
|
||||||
else: determine_field_after_computed_check(default, sf)
|
:groups
|
||||||
|
else
|
||||||
|
if sf in FieldVisibility.computed_member_fields(),
|
||||||
|
do: default,
|
||||||
|
else: determine_field_after_computed_check(default, sf)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp determine_field(default, _), do: default
|
defp determine_field(default, _), do: default
|
||||||
|
|
@ -1622,6 +1647,14 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
FieldVisibility.computed_member_fields()
|
FieldVisibility.computed_member_fields()
|
||||||
|> Enum.filter(&(&1 in member_fields_computed))
|
|> Enum.filter(&(&1 in member_fields_computed))
|
||||||
|
|
||||||
|
# Include groups in export only if it's visible in the table
|
||||||
|
member_fields_with_groups =
|
||||||
|
if :groups in socket.assigns[:member_fields_visible] do
|
||||||
|
ordered_member_fields_db ++ ["groups"]
|
||||||
|
else
|
||||||
|
ordered_member_fields_db
|
||||||
|
end
|
||||||
|
|
||||||
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
|
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
|
||||||
ordered_custom_field_ids =
|
ordered_custom_field_ids =
|
||||||
socket.assigns.all_custom_fields
|
socket.assigns.all_custom_fields
|
||||||
|
|
@ -1630,7 +1663,11 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
%{
|
%{
|
||||||
selected_ids: socket.assigns.selected_members |> MapSet.to_list(),
|
selected_ids: socket.assigns.selected_members |> MapSet.to_list(),
|
||||||
member_fields: Enum.map(ordered_member_fields_db, &Atom.to_string/1),
|
member_fields:
|
||||||
|
Enum.map(member_fields_with_groups, fn
|
||||||
|
f when is_atom(f) -> Atom.to_string(f)
|
||||||
|
f when is_binary(f) -> f
|
||||||
|
end),
|
||||||
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
|
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
|
||||||
custom_field_ids: ordered_custom_field_ids,
|
custom_field_ids: ordered_custom_field_ids,
|
||||||
column_order:
|
column_order:
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,7 @@
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
|
:if={:groups in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
alias Mv.Membership.Helpers.VisibilityConfig
|
alias Mv.Membership.Helpers.VisibilityConfig
|
||||||
|
|
||||||
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
|
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
|
||||||
@pseudo_member_fields [:membership_fee_status]
|
# Groups is also a pseudo field (not a DB attribute, but displayed in the table).
|
||||||
|
@pseudo_member_fields [:membership_fee_status, :groups]
|
||||||
|
|
||||||
# Export/API may accept this as alias; must not appear in the UI options list.
|
# Export/API may accept this as alias; must not appear in the UI options list.
|
||||||
@export_only_alias :payment_status
|
@export_only_alias :payment_status
|
||||||
|
|
@ -201,7 +202,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
"""
|
"""
|
||||||
@spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()]
|
@spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()]
|
||||||
def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do
|
def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do
|
||||||
computed_set = MapSet.new(@pseudo_member_fields)
|
computed_set = MapSet.new([:membership_fee_status])
|
||||||
|
|
||||||
field_selection
|
field_selection
|
||||||
|> Enum.filter(fn {field_string, visible} ->
|
|> Enum.filter(fn {field_string, visible} ->
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
id={"membership-fees-#{@member.id}"}
|
id={"membership-fees-#{@member.id}"}
|
||||||
member={@member}
|
member={@member}
|
||||||
current_user={@current_user}
|
current_user={@current_user}
|
||||||
|
vereinfacht_receipts={@vereinfacht_receipts}
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
@ -264,7 +265,10 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
{:ok, assign(socket, :active_tab, :contact)}
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:active_tab, :contact)
|
||||||
|
|> assign(:vereinfacht_receipts, nil)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -316,6 +320,16 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
{:noreply, assign(socket, :active_tab, :membership_fees)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
|
||||||
|
response =
|
||||||
|
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
|
||||||
|
{:ok, receipts} -> {:ok, receipts}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :vereinfacht_receipts, response)}
|
||||||
|
end
|
||||||
|
|
||||||
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
|
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:put_flash, type, message}, socket) do
|
def handle_info({:put_flash, type, message}, socket) do
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,90 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%!-- Vereinfacht: contact info when synced, or warning when API is configured but no contact --%>
|
||||||
|
<%= if Mv.Config.vereinfacht_configured?() do %>
|
||||||
|
<%= if @member.vereinfacht_contact_id do %>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<.link
|
||||||
|
:if={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
|
||||||
|
href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="link link-accent underline inline-flex items-center gap-1 w-fit"
|
||||||
|
>
|
||||||
|
{gettext("View contact in Vereinfacht")}
|
||||||
|
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
|
||||||
|
</.link>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="load_vereinfacht_receipts"
|
||||||
|
phx-value-contact_id={@member.vereinfacht_contact_id}
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
>
|
||||||
|
{gettext("Show bookings/receipts from Vereinfacht")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<%= if @vereinfacht_receipts do %>
|
||||||
|
<div
|
||||||
|
class="mt-2 rounded border border-base-300 bg-base-200 p-3 overflow-x-auto max-h-96 overflow-y-auto"
|
||||||
|
tabindex="0"
|
||||||
|
role="region"
|
||||||
|
aria-label={gettext("Vereinfacht receipts")}
|
||||||
|
>
|
||||||
|
<%= if match?({:ok, _}, @vereinfacht_receipts) do %>
|
||||||
|
<% {_, receipts} = @vereinfacht_receipts %>
|
||||||
|
<%= if receipts == [] do %>
|
||||||
|
<p class="text-sm text-base-content/70">{gettext("No receipts")}</p>
|
||||||
|
<% else %>
|
||||||
|
<% cols = receipt_display_columns(receipts) %>
|
||||||
|
<table class="table table-xs table-pin-rows">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<%= for {_key, translated_label} <- cols do %>
|
||||||
|
<th>{translated_label}</th>
|
||||||
|
<% end %>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<%= for r <- receipts do %>
|
||||||
|
<tr>
|
||||||
|
<%= for {col_key, _header_key} <- cols do %>
|
||||||
|
<td>{format_receipt_cell(col_key, r[col_key])}</td>
|
||||||
|
<% end %>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<% {:error, reason} = @vereinfacht_receipts %>
|
||||||
|
<p class="text-sm text-error">
|
||||||
|
{gettext("Error loading receipts: %{reason}",
|
||||||
|
reason: format_vereinfacht_error(reason)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="mb-4 rounded-lg border border-warning/50 bg-warning/10 p-3">
|
||||||
|
<p class="text-warning font-medium flex items-center gap-2">
|
||||||
|
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||||
|
{gettext("No Vereinfacht contact exists for this member.")}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-base-content/70 mt-1">
|
||||||
|
{gettext(
|
||||||
|
"Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%!-- Action Buttons (only when user has permission) --%>
|
<%!-- Action Buttons (only when user has permission) --%>
|
||||||
<div class="flex gap-2 mb-4">
|
<div class="flex gap-2 mb-4">
|
||||||
<.button
|
<.button
|
||||||
|
|
@ -439,7 +523,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|> assign_new(:creating_cycle, fn -> false end)
|
|> assign_new(:creating_cycle, fn -> false end)
|
||||||
|> assign_new(:create_cycle_date, fn -> nil end)
|
|> assign_new(:create_cycle_date, fn -> nil end)
|
||||||
|> assign_new(:create_cycle_error, fn -> nil end)
|
|> assign_new(:create_cycle_error, fn -> nil end)
|
||||||
|> assign_new(:regenerating, fn -> false end)}
|
|> assign_new(:regenerating, fn -> false end)
|
||||||
|
|> assign_new(:vereinfacht_receipts, fn -> nil end)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -997,6 +1082,138 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|
|
||||||
defp format_create_cycle_period(_date, _interval), do: ""
|
defp format_create_cycle_period(_date, _interval), do: ""
|
||||||
|
|
||||||
|
defp format_vereinfacht_error({:http, status, detail}) when is_binary(detail),
|
||||||
|
do: "HTTP #{status} – #{detail}"
|
||||||
|
|
||||||
|
defp format_vereinfacht_error({:http, status, _}), do: "HTTP #{status}"
|
||||||
|
defp format_vereinfacht_error(reason), do: inspect(reason)
|
||||||
|
|
||||||
|
# Ordered receipt columns: {api_key, gettext key for header}. Only columns present in data are shown.
|
||||||
|
@receipt_column_spec [
|
||||||
|
{:amount, "Amount"},
|
||||||
|
{:bookingDate, "Booking date"},
|
||||||
|
{:createdAt, "Created at"},
|
||||||
|
{:receiptType, "Receipt type"},
|
||||||
|
{:referenceNumber, "Reference number"},
|
||||||
|
{:status, "Status"},
|
||||||
|
{:updatedAt, "Updated at"}
|
||||||
|
]
|
||||||
|
|
||||||
|
defp receipt_display_columns(receipts) when is_list(receipts) do
|
||||||
|
keys_in_data = receipts |> Enum.flat_map(&Map.keys/1) |> MapSet.new()
|
||||||
|
|
||||||
|
Enum.filter(@receipt_column_spec, fn {key, _} -> MapSet.member?(keys_in_data, key) end)
|
||||||
|
|> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_receipt_cell(:amount, nil), do: "—"
|
||||||
|
|
||||||
|
defp format_receipt_cell(:amount, val) when is_number(val) do
|
||||||
|
case Decimal.cast(val) do
|
||||||
|
{:ok, d} -> MembershipFeeHelpers.format_currency(d)
|
||||||
|
_ -> to_string(val)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_receipt_cell(:amount, val) when is_binary(val) do
|
||||||
|
case Decimal.parse(val) do
|
||||||
|
{d, _} -> MembershipFeeHelpers.format_currency(d)
|
||||||
|
:error -> val
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_receipt_cell(:amount, val), do: to_string(val)
|
||||||
|
|
||||||
|
defp format_receipt_cell(:status, nil), do: "—"
|
||||||
|
|
||||||
|
defp format_receipt_cell(:status, val) when is_binary(val) do
|
||||||
|
translate_receipt_status(val)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val))
|
||||||
|
|
||||||
|
defp format_receipt_cell(:receiptType, nil), do: "—"
|
||||||
|
|
||||||
|
defp format_receipt_cell(:receiptType, val) when is_binary(val) do
|
||||||
|
translate_receipt_type(val)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val))
|
||||||
|
|
||||||
|
defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt],
|
||||||
|
do: "—"
|
||||||
|
|
||||||
|
defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do
|
||||||
|
format_receipt_date(val)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_receipt_cell(_col_key, val) when is_binary(val), do: val
|
||||||
|
defp format_receipt_cell(_col_key, val) when is_number(val), do: to_string(val)
|
||||||
|
|
||||||
|
defp format_receipt_cell(_col_key, val) when is_boolean(val),
|
||||||
|
do: if(val, do: gettext("Yes"), else: gettext("No"))
|
||||||
|
|
||||||
|
defp format_receipt_cell(_col_key, %Date{} = d), do: format_receipt_date_short(d)
|
||||||
|
defp format_receipt_cell(_col_key, val) when is_map(val) or is_list(val), do: Jason.encode!(val)
|
||||||
|
defp format_receipt_cell(_col_key, val), do: to_string(val)
|
||||||
|
|
||||||
|
defp format_receipt_date(%Date{} = d), do: format_receipt_date_short(d)
|
||||||
|
|
||||||
|
defp format_receipt_date(val) when is_binary(val) do
|
||||||
|
case parse_receipt_date(val) do
|
||||||
|
{:ok, d} -> format_receipt_date_short(d)
|
||||||
|
_ -> val
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_receipt_date(val), do: to_string(val)
|
||||||
|
|
||||||
|
# Parses ISO date or datetime string to Date (uses first 10 chars for datetime strings)
|
||||||
|
defp parse_receipt_date(val) when is_binary(val) do
|
||||||
|
date_str = if String.length(val) >= 10, do: String.slice(val, 0, 10), else: val
|
||||||
|
Date.from_iso8601(date_str)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Format as "12. Dez. 2025" (day. abbreviated month. year) with translated month
|
||||||
|
defp format_receipt_date_short(%Date{day: day, month: month, year: year}) do
|
||||||
|
"#{day}. #{receipt_month_abbr(month)} #{year}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp receipt_month_abbr(1), do: gettext("Jan.")
|
||||||
|
defp receipt_month_abbr(2), do: gettext("Feb.")
|
||||||
|
defp receipt_month_abbr(3), do: gettext("Mar.")
|
||||||
|
defp receipt_month_abbr(4), do: gettext("Apr.")
|
||||||
|
defp receipt_month_abbr(5), do: gettext("May")
|
||||||
|
defp receipt_month_abbr(6), do: gettext("Jun.")
|
||||||
|
defp receipt_month_abbr(7), do: gettext("Jul.")
|
||||||
|
defp receipt_month_abbr(8), do: gettext("Aug.")
|
||||||
|
defp receipt_month_abbr(9), do: gettext("Sep.")
|
||||||
|
defp receipt_month_abbr(10), do: gettext("Oct.")
|
||||||
|
defp receipt_month_abbr(11), do: gettext("Nov.")
|
||||||
|
defp receipt_month_abbr(12), do: gettext("Dec.")
|
||||||
|
defp receipt_month_abbr(_), do: ""
|
||||||
|
|
||||||
|
# Translate API status values for display (extend as API returns more values)
|
||||||
|
defp translate_receipt_status("paid"), do: gettext("Paid")
|
||||||
|
defp translate_receipt_status("unpaid"), do: gettext("Unpaid")
|
||||||
|
defp translate_receipt_status("suspended"), do: gettext("Suspended")
|
||||||
|
defp translate_receipt_status("open"), do: gettext("Open")
|
||||||
|
defp translate_receipt_status("cancelled"), do: gettext("Cancelled")
|
||||||
|
defp translate_receipt_status("draft"), do: gettext("Draft")
|
||||||
|
defp translate_receipt_status("incompleted"), do: gettext("Incompleted")
|
||||||
|
defp translate_receipt_status("completed"), do: gettext("Completed")
|
||||||
|
defp translate_receipt_status("empty"), do: "—"
|
||||||
|
defp translate_receipt_status(other), do: other
|
||||||
|
|
||||||
|
# Translate API receipt type values (extend as API returns more values)
|
||||||
|
defp translate_receipt_type("invoice"), do: gettext("Invoice")
|
||||||
|
defp translate_receipt_type("receipt"), do: gettext("Receipt")
|
||||||
|
defp translate_receipt_type("credit_note"), do: gettext("Credit note")
|
||||||
|
defp translate_receipt_type("credit"), do: gettext("Credit")
|
||||||
|
defp translate_receipt_type("expense"), do: gettext("Expense")
|
||||||
|
defp translate_receipt_type("income"), do: gettext("Income")
|
||||||
|
defp translate_receipt_type(other), do: other
|
||||||
|
|
||||||
# Helper component for section box
|
# Helper component for section box
|
||||||
attr :title, :string, required: true
|
attr :title, :string, required: true
|
||||||
slot :inner_block, required: true
|
slot :inner_block, required: true
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ defmodule MvWeb.Translations.MemberFields do
|
||||||
def label(:postal_code), do: gettext("Postal Code")
|
def label(:postal_code), do: gettext("Postal Code")
|
||||||
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
|
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
|
||||||
def label(:membership_fee_status), do: gettext("Membership Fee Status")
|
def label(:membership_fee_status), do: gettext("Membership Fee Status")
|
||||||
|
def label(:groups), do: gettext("Groups")
|
||||||
|
|
||||||
# Fallback for unknown fields
|
# Fallback for unknown fields
|
||||||
def label(field) do
|
def label(field) do
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ msgid "Are you sure?"
|
||||||
msgstr "Bist du sicher?"
|
msgstr "Bist du sicher?"
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Attempting to reconnect"
|
msgid "Attempting to reconnect"
|
||||||
msgstr "Verbindung wird wiederhergestellt"
|
msgstr "Verbindung wird wiederhergestellt"
|
||||||
|
|
@ -115,11 +116,13 @@ msgid "Show"
|
||||||
msgstr "Anzeigen"
|
msgstr "Anzeigen"
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Something went wrong!"
|
msgid "Something went wrong!"
|
||||||
msgstr "Etwas ist schiefgelaufen!"
|
msgstr "Etwas ist schiefgelaufen!"
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "We can't find the internet"
|
msgid "We can't find the internet"
|
||||||
msgstr "Keine Internetverbindung gefunden"
|
msgstr "Keine Internetverbindung gefunden"
|
||||||
|
|
@ -197,6 +200,7 @@ msgstr "Straße"
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No"
|
msgid "No"
|
||||||
|
|
@ -582,6 +586,16 @@ msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifiziere dein Pa
|
||||||
msgid "Unable to authenticate with OIDC. Please try again."
|
msgid "Unable to authenticate with OIDC. Please try again."
|
||||||
msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut."
|
msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut."
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "The authentication server is currently unavailable. Please try again later."
|
||||||
|
msgstr "Der Authentifizierungsserver ist derzeit nicht erreichbar. Bitte versuche es später erneut."
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Authentication configuration error. Please contact the administrator."
|
||||||
|
msgstr "Authentifizierungskonfigurationsfehler. Bitte kontaktiere den Administrator."
|
||||||
|
|
||||||
#: lib/mv_web/controllers/auth_controller.ex
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Unable to sign in. Please try again."
|
msgid "Unable to sign in. Please try again."
|
||||||
|
|
@ -2205,6 +2219,7 @@ msgstr "Gruppe erfolgreich gespeichert."
|
||||||
#: lib/mv_web/live/group_live/index.ex
|
#: lib/mv_web/live/group_live/index.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#: lib/mv_web/translations/member_fields.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Groups"
|
msgid "Groups"
|
||||||
msgstr "Gruppen"
|
msgstr "Gruppen"
|
||||||
|
|
@ -2264,11 +2279,6 @@ msgstr "Nicht berechtigt."
|
||||||
msgid "Could not load data fields. Please check your permissions."
|
msgid "Could not load data fields. Please check your permissions."
|
||||||
msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen."
|
msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen."
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Could not load member search. Please try again."
|
|
||||||
msgstr "Mitgliedersuche konnte nicht geladen werden. Bitte versuchen Sie es erneut."
|
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Add Member"
|
msgid "Add Member"
|
||||||
|
|
@ -2604,17 +2614,300 @@ msgstr "PDF"
|
||||||
msgid "Import"
|
msgid "Import"
|
||||||
msgstr "Import"
|
msgstr "Import"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/import_export_live.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgid "Export Members (CSV)"
|
msgid "Value type cannot be changed after creation"
|
||||||
#~ msgstr "Mitglieder exportieren (CSV)"
|
msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden."
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/import_export_live.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "Export functionality will be available in a future release."
|
msgid "Could not load member list. Please try again."
|
||||||
#~ msgstr "Export-Funktionalität ist im nächsten release verfügbar."
|
msgstr "Mitgliederliste konnte nicht geladen werden. Bitte versuche es erneut."
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/import_export_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgid "Import members from CSV files or export member data."
|
msgid "API Key"
|
||||||
#~ msgstr "Importiere Mitglieder aus CSV-Dateien oder exportiere Mitgliederdaten."
|
msgstr "API-Schlüssel"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "API URL"
|
||||||
|
msgstr "API-URL"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Club ID"
|
||||||
|
msgstr "Vereins-ID"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "From VEREINFACHT_API_KEY"
|
||||||
|
msgstr "Aus VEREINFACHT_API_KEY"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "From VEREINFACHT_API_URL"
|
||||||
|
msgstr "Aus VEREINFACHT_API_URL"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "From VEREINFACHT_CLUB_ID"
|
||||||
|
msgstr "Aus VEREINFACHT_CLUB_ID"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Save Vereinfacht Settings"
|
||||||
|
msgstr "Vereinfacht-Einstellungen speichern"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Sync all members without Vereinfacht contact"
|
||||||
|
msgstr "Alle Mitglieder ohne Vereinfacht-Kontakt synchronisieren"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Synced %{count} member(s) to Vereinfacht."
|
||||||
|
msgstr "%{count} Mitglied(er) mit Vereinfacht synchronisiert."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Syncing..."
|
||||||
|
msgstr "Synchronisiere..."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Vereinfacht Integration"
|
||||||
|
msgstr "Vereinfacht-Integration"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID."
|
||||||
|
msgstr "Vereinfacht ist nicht konfiguriert. Bitte API-URL, API-Schlüssel und Vereins-ID setzen."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "View contact in Vereinfacht"
|
||||||
|
msgstr "Kontakt in Vereinfacht anzeigen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{count} failed"
|
||||||
|
msgstr "%{count} fehlgeschlagen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{count} synced"
|
||||||
|
msgstr "%{count} synchronisiert"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Failed members:"
|
||||||
|
msgstr "Fehlgeschlagene Mitglieder:"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last sync result:"
|
||||||
|
msgstr "Letztes Sync-Ergebnis:"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Synced %{count} member(s). %{error_count} failed."
|
||||||
|
msgstr "%{count} Mitglied(er) synchronisiert. %{error_count} Fehler."
|
||||||
|
|
||||||
|
# Vereinfacht API error messages (translated for UI)
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Vereinfacht: %{detail}"
|
||||||
|
msgstr "Vereinfacht: %{detail}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No Vereinfacht contact exists for this member."
|
||||||
|
msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
|
||||||
|
msgstr "Synchronisieren Sie dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichern Sie das Mitglied erneut, um den Kontakt anzulegen."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "(set)"
|
||||||
|
msgstr "(gesetzt)"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Leave blank to keep current"
|
||||||
|
msgstr "Leer lassen, um den aktuellen Wert beizubehalten"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Some values are set via environment variables. Those fields are read-only."
|
||||||
|
msgstr "Einige Werte werden über Umgebungsvariablen gesetzt. Diese Felder sind schreibgeschützt."
|
||||||
|
|
||||||
|
# Vereinfacht API validation messages (looked up at runtime via dgettext)
|
||||||
|
msgid "The address field is required."
|
||||||
|
msgstr "Das Adressfeld ist erforderlich."
|
||||||
|
|
||||||
|
msgid "The city field is required."
|
||||||
|
msgstr "Das Stadtfeld ist erforderlich."
|
||||||
|
|
||||||
|
msgid "The email field is required."
|
||||||
|
msgstr "Das E-Mail-Feld ist erforderlich."
|
||||||
|
|
||||||
|
msgid "The first name field is required."
|
||||||
|
msgstr "Das Vornamenfeld ist erforderlich."
|
||||||
|
|
||||||
|
msgid "The last name field is required."
|
||||||
|
msgstr "Das Nachnamenfeld ist erforderlich."
|
||||||
|
|
||||||
|
msgid "The zip code field is required."
|
||||||
|
msgstr "Das Postleitzahlenfeld ist erforderlich."
|
||||||
|
|
||||||
|
msgid "Too Many Attempts."
|
||||||
|
msgstr "Zu viele Versuche."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "App URL (contact view link)"
|
||||||
|
msgstr "App-URL (Link zur Kontaktansicht)"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "From VEREINFACHT_APP_URL"
|
||||||
|
msgstr "Aus VEREINFACHT_APP_URL"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Error loading receipts: %{reason}"
|
||||||
|
msgstr "Belege konnten nicht geladen werden: %{reason}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No receipts"
|
||||||
|
msgstr "Keine Belege"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Show bookings/receipts from Vereinfacht"
|
||||||
|
msgstr "Buchungen/Belege aus Vereinfacht anzeigen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Vereinfacht receipts"
|
||||||
|
msgstr "Vereinfacht-Belege"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Cancelled"
|
||||||
|
msgstr "Storniert"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Credit"
|
||||||
|
msgstr "Gutschrift"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Credit note"
|
||||||
|
msgstr "Gutschrift"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Draft"
|
||||||
|
msgstr "Entwurf"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Invoice"
|
||||||
|
msgstr "Rechnung"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Open"
|
||||||
|
msgstr "Offen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Receipt"
|
||||||
|
msgstr "Beleg"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Apr."
|
||||||
|
msgstr "Apr."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Aug."
|
||||||
|
msgstr "Aug."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Completed"
|
||||||
|
msgstr "Abgeschlossen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Dec."
|
||||||
|
msgstr "Dez."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Expense"
|
||||||
|
msgstr "Ausgabe"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Feb."
|
||||||
|
msgstr "Feb."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Income"
|
||||||
|
msgstr "Einnahme"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Incompleted"
|
||||||
|
msgstr "Unvollständig"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Jan."
|
||||||
|
msgstr "Jan."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Jul."
|
||||||
|
msgstr "Jul."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Jun."
|
||||||
|
msgstr "Jun."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Mar."
|
||||||
|
msgstr "Mär."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "May"
|
||||||
|
msgstr "Mai"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Nov."
|
||||||
|
msgstr "Nov."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Oct."
|
||||||
|
msgstr "Okt."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Sep."
|
||||||
|
msgstr "Sep."
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ msgid "Are you sure?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Attempting to reconnect"
|
msgid "Attempting to reconnect"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -116,11 +117,13 @@ msgid "Show"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Something went wrong!"
|
msgid "Something went wrong!"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "We can't find the internet"
|
msgid "We can't find the internet"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -198,6 +201,7 @@ msgstr ""
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No"
|
msgid "No"
|
||||||
|
|
@ -583,6 +587,16 @@ msgstr ""
|
||||||
msgid "Unable to authenticate with OIDC. Please try again."
|
msgid "Unable to authenticate with OIDC. Please try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "The authentication server is currently unavailable. Please try again later."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Authentication configuration error. Please contact the administrator."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/controllers/auth_controller.ex
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Unable to sign in. Please try again."
|
msgid "Unable to sign in. Please try again."
|
||||||
|
|
@ -2206,6 +2220,7 @@ msgstr ""
|
||||||
#: lib/mv_web/live/group_live/index.ex
|
#: lib/mv_web/live/group_live/index.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#: lib/mv_web/translations/member_fields.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Groups"
|
msgid "Groups"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -2265,11 +2280,6 @@ msgstr ""
|
||||||
msgid "Could not load data fields. Please check your permissions."
|
msgid "Could not load data fields. Please check your permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Could not load member search. Please try again."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Add Member"
|
msgid "Add Member"
|
||||||
|
|
@ -2604,3 +2614,300 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Import"
|
msgid "Import"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Value type cannot be changed after creation"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Could not load member list. Please try again."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "API Key"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "API URL"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Club ID"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "From VEREINFACHT_API_KEY"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "From VEREINFACHT_API_URL"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "From VEREINFACHT_CLUB_ID"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Save Vereinfacht Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Sync all members without Vereinfacht contact"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Synced %{count} member(s) to Vereinfacht."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Syncing..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Vereinfacht Integration"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "View contact in Vereinfacht"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{count} failed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{count} synced"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Failed members:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last sync result:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Synced %{count} member(s). %{error_count} failed."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Vereinfacht: %{detail}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No Vereinfacht contact exists for this member."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "(set)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Leave blank to keep current"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Some values are set via environment variables. Those fields are read-only."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
# Vereinfacht API validation messages (looked up at runtime via dgettext)
|
||||||
|
msgid "The address field is required."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "The city field is required."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "The email field is required."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "The first name field is required."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "The last name field is required."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "The zip code field is required."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Too Many Attempts."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "App URL (contact view link)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "From VEREINFACHT_APP_URL"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Error loading receipts: %{reason}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No receipts"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Show bookings/receipts from Vereinfacht"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Vereinfacht receipts"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Cancelled"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Credit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Credit note"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Draft"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Invoice"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Open"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Receipt"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Apr."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Aug."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Completed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Dec."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Expense"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Feb."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Income"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Incompleted"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Jan."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Jul."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Jun."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Mar."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "May"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Nov."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Oct."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Sep."
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ msgid "Are you sure?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Attempting to reconnect"
|
msgid "Attempting to reconnect"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -116,11 +117,13 @@ msgid "Show"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Something went wrong!"
|
msgid "Something went wrong!"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts.ex
|
#: lib/mv_web/components/layouts.ex
|
||||||
|
#: lib/mv_web/components/layouts/root.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "We can't find the internet"
|
msgid "We can't find the internet"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -198,6 +201,7 @@ msgstr ""
|
||||||
#: lib/mv_web/live/member_field_live/index_component.ex
|
#: lib/mv_web/live/member_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/role_live/show.ex
|
#: lib/mv_web/live/role_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No"
|
msgid "No"
|
||||||
|
|
@ -583,6 +587,16 @@ msgstr ""
|
||||||
msgid "Unable to authenticate with OIDC. Please try again."
|
msgid "Unable to authenticate with OIDC. Please try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "The authentication server is currently unavailable. Please try again later."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Authentication configuration error. Please contact the administrator."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/controllers/auth_controller.ex
|
#: lib/mv_web/controllers/auth_controller.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Unable to sign in. Please try again."
|
msgid "Unable to sign in. Please try again."
|
||||||
|
|
@ -2206,6 +2220,7 @@ msgstr ""
|
||||||
#: lib/mv_web/live/group_live/index.ex
|
#: lib/mv_web/live/group_live/index.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
#: lib/mv_web/translations/member_fields.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Groups"
|
msgid "Groups"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -2265,11 +2280,6 @@ msgstr ""
|
||||||
msgid "Could not load data fields. Please check your permissions."
|
msgid "Could not load data fields. Please check your permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Could not load member search. Please try again."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/show.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Add Member"
|
msgid "Add Member"
|
||||||
|
|
@ -2605,17 +2615,299 @@ msgstr ""
|
||||||
msgid "Import"
|
msgid "Import"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/import_export_live.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgid "Export Members (CSV)"
|
msgid "Value type cannot be changed after creation"
|
||||||
#~ msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/import_export_live.ex
|
#: lib/mv_web/live/group_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
#~ msgid "Export functionality will be available in a future release."
|
msgid "Could not load member list. Please try again."
|
||||||
#~ msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/import_export_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
#~ msgid "Import members from CSV files or export member data."
|
msgid "API Key"
|
||||||
#~ msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "API URL"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Club ID"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "From VEREINFACHT_API_KEY"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "From VEREINFACHT_API_URL"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "From VEREINFACHT_CLUB_ID"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Save Vereinfacht Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Sync all members without Vereinfacht contact"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Synced %{count} member(s) to Vereinfacht."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Syncing..."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Vereinfacht Integration"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Vereinfacht is not configured. Set API URL, API Key, and Club ID."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "View contact in Vereinfacht"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{count} failed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{count} synced"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Failed members:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last sync result:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Synced %{count} member(s). %{error_count} failed."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Vereinfacht: %{detail}"
|
||||||
|
msgstr "Vereinfacht: %{detail}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No Vereinfacht contact exists for this member."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
|
||||||
|
msgstr "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "(set)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Leave blank to keep current"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Some values are set via environment variables. Those fields are read-only."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
# Vereinfacht API validation messages (looked up at runtime via dgettext)
|
||||||
|
msgid "The address field is required."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "The city field is required."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "The email field is required."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "The first name field is required."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "The last name field is required."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "The zip code field is required."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Too Many Attempts."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "App URL (contact view link)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "From VEREINFACHT_APP_URL"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Error loading receipts: %{reason}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No receipts"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Show bookings/receipts from Vereinfacht"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Vereinfacht receipts"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Cancelled"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Credit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Credit note"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Draft"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Invoice"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Open"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Receipt"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Apr."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Aug."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Completed"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Dec."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Expense"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Feb."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Income"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Incompleted"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Jan."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Jul."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Jun."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Mar."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "May"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Nov."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Oct."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Sep."
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
defmodule Mv.Repo.Migrations.AddVereinfachtContactIdToMembers 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(:members) do
|
||||||
|
add :vereinfacht_contact_id, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
alter table(:members) do
|
||||||
|
remove :vereinfacht_contact_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
defmodule Mv.Repo.Migrations.AddVereinfachtSettings 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(:settings) do
|
||||||
|
add :vereinfacht_api_url, :text
|
||||||
|
add :vereinfacht_api_key, :text
|
||||||
|
add :vereinfacht_club_id, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
alter table(:settings) do
|
||||||
|
remove :vereinfacht_club_id
|
||||||
|
remove :vereinfacht_api_key
|
||||||
|
remove :vereinfacht_api_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
defmodule Mv.Repo.Migrations.AddVereinfachtAppUrl do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
alter table(:settings) do
|
||||||
|
add :vereinfacht_app_url, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
alter table(:settings) do
|
||||||
|
remove :vereinfacht_app_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
# mix run priv/repo/seeds.exs
|
# mix run priv/repo/seeds.exs
|
||||||
#
|
#
|
||||||
|
|
||||||
alias Mv.Membership
|
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.Membership
|
||||||
alias Mv.MembershipFees.CycleGenerator
|
alias Mv.MembershipFees.CycleGenerator
|
||||||
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
@ -579,6 +579,39 @@ Enum.with_index(linked_members)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
# Create example groups (idempotent: create only if name does not exist)
|
||||||
|
group_configs = [
|
||||||
|
%{name: "Vorstand", description: "Gremium Vorstand"},
|
||||||
|
%{name: "Trainer*innen", description: "Alle lizenzierten Trainer*innen"},
|
||||||
|
%{name: "Jugend", description: "Jugendbereich"},
|
||||||
|
%{name: "Newsletter", description: "Empfänger*innen Newsletter"}
|
||||||
|
]
|
||||||
|
|
||||||
|
existing_groups =
|
||||||
|
case Membership.list_groups(actor: admin_user_with_role) do
|
||||||
|
{:ok, list} -> list
|
||||||
|
{:error, _} -> []
|
||||||
|
end
|
||||||
|
|
||||||
|
existing_names_lower = MapSet.new(existing_groups, &String.downcase(&1.name))
|
||||||
|
|
||||||
|
seed_groups =
|
||||||
|
Enum.reduce(group_configs, %{}, fn config, acc ->
|
||||||
|
name = config.name
|
||||||
|
|
||||||
|
if MapSet.member?(existing_names_lower, String.downcase(name)) do
|
||||||
|
group = Enum.find(existing_groups, &(String.downcase(&1.name) == String.downcase(name)))
|
||||||
|
Map.put(acc, name, group)
|
||||||
|
else
|
||||||
|
group =
|
||||||
|
Membership.create_group!(%{name: name, description: config.description},
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
|
|
||||||
|
Map.put(acc, name, group)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
# Create sample custom field values for some members
|
# Create sample custom field values for some members
|
||||||
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
|
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
|
||||||
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
|
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
|
||||||
|
|
@ -587,6 +620,35 @@ all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_rol
|
||||||
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
|
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
|
||||||
find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end
|
find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end
|
||||||
|
|
||||||
|
# Assign seed members to groups (idempotent: duplicate create_member_group is skipped)
|
||||||
|
member_group_assignments = [
|
||||||
|
{"hans.mueller@example.de", ["Vorstand", "Newsletter"]},
|
||||||
|
{"greta.schmidt@example.de", ["Jugend", "Newsletter"]},
|
||||||
|
{"friedrich.wagner@example.de", ["Trainer*innen"]},
|
||||||
|
{"maria.weber@example.de", ["Newsletter"]},
|
||||||
|
{"thomas.klein@example.de", ["Newsletter"]}
|
||||||
|
]
|
||||||
|
|
||||||
|
Enum.each(member_group_assignments, fn {email, group_names} ->
|
||||||
|
member = find_member.(email)
|
||||||
|
|
||||||
|
if member do
|
||||||
|
Enum.each(group_names, fn group_name ->
|
||||||
|
group = seed_groups[group_name]
|
||||||
|
|
||||||
|
if group do
|
||||||
|
case Membership.create_member_group(
|
||||||
|
%{member_id: member.id, group_id: group.id},
|
||||||
|
actor: admin_user_with_role
|
||||||
|
) do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, _} -> :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
# Add custom field values for Hans Müller
|
# Add custom field values for Hans Müller
|
||||||
if hans = find_member.("hans.mueller@example.de") do
|
if hans = find_member.("hans.mueller@example.de") do
|
||||||
[
|
[
|
||||||
|
|
@ -731,6 +793,7 @@ IO.puts(
|
||||||
)
|
)
|
||||||
|
|
||||||
IO.puts(" - Sample members: Hans, Greta, Friedrich")
|
IO.puts(" - Sample members: Hans, Greta, Friedrich")
|
||||||
|
IO.puts(" - Groups: Vorstand, Trainer*innen, Jugend, Newsletter (with members assigned)")
|
||||||
|
|
||||||
IO.puts(
|
IO.puts(
|
||||||
" - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de"
|
" - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de"
|
||||||
|
|
|
||||||
234
priv/resource_snapshots/repo/members/20260218185510.json
Normal file
234
priv/resource_snapshots/repo/members/20260218185510.json
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"uuid_generate_v7()\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "first_name",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "last_name",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "email",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "join_date",
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "exit_date",
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "notes",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "city",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "street",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "house_number",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "postal_code",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "search_vector",
|
||||||
|
"type": "tsvector"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "membership_fee_start_date",
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "vereinfacht_contact_id",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": {
|
||||||
|
"deferrable": false,
|
||||||
|
"destination_attribute": "id",
|
||||||
|
"destination_attribute_default": null,
|
||||||
|
"destination_attribute_generated": null,
|
||||||
|
"index?": false,
|
||||||
|
"match_type": null,
|
||||||
|
"match_with": null,
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"name": "members_membership_fee_type_id_fkey",
|
||||||
|
"on_delete": null,
|
||||||
|
"on_update": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"schema": "public",
|
||||||
|
"table": "membership_fee_types"
|
||||||
|
},
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "membership_fee_type_id",
|
||||||
|
"type": "uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"create_table_options": null,
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "4DF7F20D4C8D91E229906D6ADF87A4B5EB410672799753012DE4F0F49B470A51",
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"all_tenants?": false,
|
||||||
|
"base_filter": null,
|
||||||
|
"index_name": "members_unique_email_index",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "email"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "unique_email",
|
||||||
|
"nils_distinct?": true,
|
||||||
|
"where": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mv.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "members"
|
||||||
|
}
|
||||||
140
priv/resource_snapshots/repo/settings/20260218185541.json
Normal file
140
priv/resource_snapshots/repo/settings/20260218185541.json
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
{
|
||||||
|
"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": "club_name",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "member_field_visibility",
|
||||||
|
"type": "map"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "true",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "include_joining_cycle",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "default_membership_fee_type_id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "vereinfacht_api_url",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "vereinfacht_api_key",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "vereinfacht_club_id",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "inserted_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "updated_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"create_table_options": null,
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "1038A37F021DFC347E325042D613B0359FEB7DAFAE3286CBCEAA940A52B71217",
|
||||||
|
"identities": [],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mv.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "settings"
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
||||||
- Description length validation (max 500 characters)
|
- Description length validation (max 500 characters)
|
||||||
- Description trimming
|
- Description trimming
|
||||||
- Required vs optional fields
|
- Required vs optional fields
|
||||||
|
- Value type immutability (cannot be changed after creation)
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
|
|
@ -207,4 +208,101 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
||||||
assert [%{field: :value_type}] = changeset.errors
|
assert [%{field: :value_type}] = changeset.errors
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "value_type immutability" do
|
||||||
|
test "rejects attempt to change value_type after creation", %{actor: actor} do
|
||||||
|
# Create custom field with value_type :string
|
||||||
|
{:ok, custom_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "test_field",
|
||||||
|
value_type: :string
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
original_value_type = custom_field.value_type
|
||||||
|
assert original_value_type == :string
|
||||||
|
|
||||||
|
# Attempt to update value_type to :integer
|
||||||
|
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||||
|
custom_field
|
||||||
|
|> Ash.Changeset.for_update(:update, %{
|
||||||
|
value_type: :integer
|
||||||
|
})
|
||||||
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
|
# Verify error message contains expected text
|
||||||
|
error_message = Exception.message(error)
|
||||||
|
assert error_message =~ "cannot be changed" or error_message =~ "value_type"
|
||||||
|
|
||||||
|
# Reload and verify value_type remained unchanged
|
||||||
|
reloaded = Ash.get!(CustomField, custom_field.id, actor: actor)
|
||||||
|
assert reloaded.value_type == original_value_type
|
||||||
|
assert reloaded.value_type == :string
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows updating other fields while value_type remains unchanged", %{actor: actor} do
|
||||||
|
# Create custom field with value_type :string
|
||||||
|
{:ok, custom_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "test_field",
|
||||||
|
value_type: :string,
|
||||||
|
description: "Original description"
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
original_value_type = custom_field.value_type
|
||||||
|
assert original_value_type == :string
|
||||||
|
|
||||||
|
# Update other fields (name, description) without touching value_type
|
||||||
|
{:ok, updated_custom_field} =
|
||||||
|
custom_field
|
||||||
|
|> Ash.Changeset.for_update(:update, %{
|
||||||
|
name: "updated_name",
|
||||||
|
description: "Updated description"
|
||||||
|
})
|
||||||
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
|
# Verify value_type remained unchanged
|
||||||
|
assert updated_custom_field.value_type == original_value_type
|
||||||
|
assert updated_custom_field.value_type == :string
|
||||||
|
# Verify other fields were updated
|
||||||
|
assert updated_custom_field.name == "updated_name"
|
||||||
|
assert updated_custom_field.description == "Updated description"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects value_type change even when other fields are updated", %{actor: actor} do
|
||||||
|
# Create custom field with value_type :boolean
|
||||||
|
{:ok, custom_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "test_field",
|
||||||
|
value_type: :boolean
|
||||||
|
})
|
||||||
|
|> Ash.create(actor: actor)
|
||||||
|
|
||||||
|
original_value_type = custom_field.value_type
|
||||||
|
assert original_value_type == :boolean
|
||||||
|
|
||||||
|
# Attempt to update both name and value_type
|
||||||
|
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||||
|
custom_field
|
||||||
|
|> Ash.Changeset.for_update(:update, %{
|
||||||
|
name: "updated_name",
|
||||||
|
value_type: :date
|
||||||
|
})
|
||||||
|
|> Ash.update(actor: actor)
|
||||||
|
|
||||||
|
# Verify error message
|
||||||
|
error_message = Exception.message(error)
|
||||||
|
assert error_message =~ "cannot be changed" or error_message =~ "value_type"
|
||||||
|
|
||||||
|
# Reload and verify value_type remained unchanged, but name was not updated either
|
||||||
|
reloaded = Ash.get!(CustomField, custom_field.id, actor: actor)
|
||||||
|
assert reloaded.value_type == original_value_type
|
||||||
|
assert reloaded.value_type == :boolean
|
||||||
|
assert reloaded.name == "test_field"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
83
test/mv/config_vereinfacht_test.exs
Normal file
83
test/mv/config_vereinfacht_test.exs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
defmodule Mv.ConfigVereinfachtTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for Mv.Config Vereinfacht-related helpers.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
describe "vereinfacht_env_configured?/0" do
|
||||||
|
test "returns false when no Vereinfacht ENV variables are set" do
|
||||||
|
clear_vereinfacht_env()
|
||||||
|
refute Mv.Config.vereinfacht_env_configured?()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns true when VEREINFACHT_API_URL is set" do
|
||||||
|
set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com")
|
||||||
|
assert Mv.Config.vereinfacht_env_configured?()
|
||||||
|
after
|
||||||
|
clear_vereinfacht_env()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns true when VEREINFACHT_CLUB_ID is set" do
|
||||||
|
set_vereinfacht_env("VEREINFACHT_CLUB_ID", "2")
|
||||||
|
assert Mv.Config.vereinfacht_env_configured?()
|
||||||
|
after
|
||||||
|
clear_vereinfacht_env()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "vereinfacht_configured?/0" do
|
||||||
|
test "returns false when no config is set" do
|
||||||
|
clear_vereinfacht_env()
|
||||||
|
# Settings may have nil for vereinfacht fields
|
||||||
|
refute Mv.Config.vereinfacht_configured?()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "vereinfacht_contact_view_url/1" do
|
||||||
|
test "returns nil when API URL is not configured" do
|
||||||
|
clear_vereinfacht_env()
|
||||||
|
assert Mv.Config.vereinfacht_contact_view_url("123") == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns app contact view URL when API URL is set (derived app URL)" do
|
||||||
|
clear_vereinfacht_env()
|
||||||
|
clear_vereinfacht_app_url_from_settings()
|
||||||
|
set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com/api/v1")
|
||||||
|
|
||||||
|
assert Mv.Config.vereinfacht_contact_view_url("42") ==
|
||||||
|
"https://app.example.com/en/admin/finances/contacts/42"
|
||||||
|
after
|
||||||
|
clear_vereinfacht_env()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns app contact view URL when VEREINFACHT_APP_URL is set" do
|
||||||
|
set_vereinfacht_env("VEREINFACHT_APP_URL", "https://app.verein.visuel.dev")
|
||||||
|
|
||||||
|
assert Mv.Config.vereinfacht_contact_view_url("abc") ==
|
||||||
|
"https://app.verein.visuel.dev/en/admin/finances/contacts/abc"
|
||||||
|
after
|
||||||
|
clear_vereinfacht_env()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp set_vereinfacht_env(key, value) do
|
||||||
|
System.put_env(key, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp clear_vereinfacht_env do
|
||||||
|
System.delete_env("VEREINFACHT_API_URL")
|
||||||
|
System.delete_env("VEREINFACHT_API_KEY")
|
||||||
|
System.delete_env("VEREINFACHT_CLUB_ID")
|
||||||
|
System.delete_env("VEREINFACHT_APP_URL")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp clear_vereinfacht_app_url_from_settings do
|
||||||
|
case Mv.Membership.get_settings() do
|
||||||
|
{:ok, settings} ->
|
||||||
|
Mv.Membership.update_settings(settings, %{vereinfacht_app_url: nil})
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
92
test/mv/vereinfacht/changes/sync_contact_test.exs
Normal file
92
test/mv/vereinfacht/changes/sync_contact_test.exs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
defmodule Mv.Vereinfacht.Changes.SyncContactTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for Mv.Vereinfacht.Changes.SyncContact.
|
||||||
|
|
||||||
|
When Vereinfacht is not configured, member create/update should succeed
|
||||||
|
and vereinfacht_contact_id remains nil.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
alias Mv.Membership
|
||||||
|
|
||||||
|
setup do
|
||||||
|
clear_vereinfacht_env()
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "member create when Vereinfacht not configured" do
|
||||||
|
test "member is created and vereinfacht_contact_id is nil" do
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
attrs = %{
|
||||||
|
first_name: "Sync",
|
||||||
|
last_name: "Test",
|
||||||
|
email: "sync_test_#{System.unique_integer([:positive])}@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, member} = Membership.create_member(attrs, actor: system_actor)
|
||||||
|
assert member.vereinfacht_contact_id == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "member update when Vereinfacht not configured" do
|
||||||
|
test "member is updated and vereinfacht_contact_id is unchanged" do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
assert {:ok, updated} =
|
||||||
|
Membership.update_member(member, %{first_name: "Updated"}, actor: system_actor)
|
||||||
|
|
||||||
|
assert updated.vereinfacht_contact_id == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "when Vereinfacht is configured" do
|
||||||
|
# Regression: after_transaction callback receives 2 args (changeset, result), not 3.
|
||||||
|
# If the callback had arity 3, create_member would raise BadArityError.
|
||||||
|
# Also: Client must send JSON-encoded body (iodata); raw map causes ArgumentError
|
||||||
|
# when the request is sent. With an unreachable URL we get :econnrefused before
|
||||||
|
# that, so this test would not catch the iodata bug; a Bypass/stub server would.
|
||||||
|
test "create_member succeeds and after_transaction runs without error (API may fail)" do
|
||||||
|
set_vereinfacht_env()
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
attrs = %{
|
||||||
|
first_name: "API",
|
||||||
|
last_name: "Test",
|
||||||
|
email: "api_test_#{System.unique_integer([:positive])}@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, member} = Membership.create_member(attrs, actor: system_actor)
|
||||||
|
assert member.id
|
||||||
|
# Sync may fail (e.g. connection refused), so contact_id can stay nil
|
||||||
|
after
|
||||||
|
clear_vereinfacht_env()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "update_member succeeds and after_transaction runs without error (API may fail)" do
|
||||||
|
set_vereinfacht_env()
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
assert {:ok, updated} =
|
||||||
|
Membership.update_member(member, %{first_name: "Updated"}, actor: system_actor)
|
||||||
|
|
||||||
|
assert updated.id == member.id
|
||||||
|
after
|
||||||
|
clear_vereinfacht_env()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp set_vereinfacht_env do
|
||||||
|
System.put_env("VEREINFACHT_API_URL", "http://127.0.0.1:1/api/v1")
|
||||||
|
System.put_env("VEREINFACHT_API_KEY", "test-key")
|
||||||
|
System.put_env("VEREINFACHT_CLUB_ID", "2")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp clear_vereinfacht_env do
|
||||||
|
System.delete_env("VEREINFACHT_API_URL")
|
||||||
|
System.delete_env("VEREINFACHT_API_KEY")
|
||||||
|
System.delete_env("VEREINFACHT_CLUB_ID")
|
||||||
|
end
|
||||||
|
end
|
||||||
50
test/mv/vereinfacht/client_test.exs
Normal file
50
test/mv/vereinfacht/client_test.exs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
defmodule Mv.Vereinfacht.ClientTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for Mv.Vereinfacht.Client.
|
||||||
|
|
||||||
|
Only tests the "not configured" path; no real HTTP calls. Config reads from
|
||||||
|
ENV first, then from Settings (DB), so we use DataCase so get_settings() is available.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
alias Mv.Vereinfacht.Client
|
||||||
|
|
||||||
|
setup do
|
||||||
|
clear_vereinfacht_env()
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "create_contact/1" do
|
||||||
|
test "returns {:error, :not_configured} when Vereinfacht is not configured" do
|
||||||
|
member = build_member_struct()
|
||||||
|
|
||||||
|
assert Client.create_contact(member) == {:error, :not_configured}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "update_contact/2" do
|
||||||
|
test "returns {:error, :not_configured} when Vereinfacht is not configured" do
|
||||||
|
member = build_member_struct()
|
||||||
|
|
||||||
|
assert Client.update_contact("123", member) == {:error, :not_configured}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_member_struct do
|
||||||
|
%{
|
||||||
|
first_name: "Test",
|
||||||
|
last_name: "User",
|
||||||
|
email: "test@example.com",
|
||||||
|
street: "Street 1",
|
||||||
|
house_number: "2",
|
||||||
|
postal_code: "12345",
|
||||||
|
city: "Berlin"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp clear_vereinfacht_env do
|
||||||
|
System.delete_env("VEREINFACHT_API_URL")
|
||||||
|
System.delete_env("VEREINFACHT_API_KEY")
|
||||||
|
System.delete_env("VEREINFACHT_CLUB_ID")
|
||||||
|
end
|
||||||
|
end
|
||||||
59
test/mv/vereinfacht/vereinfacht_test.exs
Normal file
59
test/mv/vereinfacht/vereinfacht_test.exs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
defmodule Mv.VereinfachtTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for Mv.Vereinfacht business logic.
|
||||||
|
|
||||||
|
No real API calls; tests "not configured" path and pure helpers (format_error).
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
alias Mv.Vereinfacht
|
||||||
|
|
||||||
|
setup do
|
||||||
|
clear_vereinfacht_env()
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "sync_member/1" do
|
||||||
|
test "returns :ok when Vereinfacht is not configured (no-op)" do
|
||||||
|
member = Mv.Fixtures.member_fixture()
|
||||||
|
|
||||||
|
assert Vereinfacht.sync_member(member) == :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "sync_members_without_contact/0" do
|
||||||
|
test "returns {:error, :not_configured} when Vereinfacht is not configured" do
|
||||||
|
assert Vereinfacht.sync_members_without_contact() == {:error, :not_configured}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "format_error/1" do
|
||||||
|
test "formats HTTP error with detail" do
|
||||||
|
assert Vereinfacht.format_error({:http, 422, "The email field is required."}) ==
|
||||||
|
"Vereinfacht: The email field is required."
|
||||||
|
end
|
||||||
|
|
||||||
|
test "formats HTTP error without detail" do
|
||||||
|
assert Vereinfacht.format_error({:http, 500, nil}) ==
|
||||||
|
"Vereinfacht: API error (HTTP 500)."
|
||||||
|
end
|
||||||
|
|
||||||
|
test "formats request_failed" do
|
||||||
|
assert Vereinfacht.format_error({:request_failed, %{reason: :econnrefused}}) ==
|
||||||
|
"Vereinfacht: Request failed (e.g. connection error)."
|
||||||
|
end
|
||||||
|
|
||||||
|
test "formats invalid_response and other terms" do
|
||||||
|
assert Vereinfacht.format_error({:invalid_response, %{}}) ==
|
||||||
|
"Vereinfacht: Invalid API response."
|
||||||
|
|
||||||
|
assert Vereinfacht.format_error(:timeout) == "Vereinfacht: :timeout"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp clear_vereinfacht_env do
|
||||||
|
System.delete_env("VEREINFACHT_API_URL")
|
||||||
|
System.delete_env("VEREINFACHT_API_KEY")
|
||||||
|
System.delete_env("VEREINFACHT_CLUB_ID")
|
||||||
|
end
|
||||||
|
end
|
||||||
29
test/mv/vereinfacht/vereinfacht_test_README.md
Normal file
29
test/mv/vereinfacht/vereinfacht_test_README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Vereinfacht tests – scope and rationale
|
||||||
|
|
||||||
|
## Constraint: no real API in CI
|
||||||
|
|
||||||
|
Tests do **not** call the real Vereinfacht API or a shared test endpoint. All tests use dummy data and either:
|
||||||
|
|
||||||
|
- Assert behaviour when **Vereinfacht is not configured** (ENV + Settings unset), or
|
||||||
|
- Run the **full Member/User flow** with a **unreachable URL** (e.g. `http://127.0.0.1:1`) so the HTTP client fails fast (e.g. `:econnrefused`) and we only assert that the application path does not crash.
|
||||||
|
|
||||||
|
## What the tests cover
|
||||||
|
|
||||||
|
| Test file | What it tests | Why it’s enough without an API |
|
||||||
|
|-----------|----------------|---------------------------------|
|
||||||
|
| **ConfigVereinfachtTest** | `vereinfacht_env_configured?`, `vereinfacht_configured?`, `vereinfacht_contact_view_url` with ENV set/cleared | Pure config logic; no HTTP. |
|
||||||
|
| **ClientTest** | `create_contact/1` and `update_contact/2` return `{:error, :not_configured}` when nothing is configured | Ensures the client does not call Req when config is missing. |
|
||||||
|
| **VereinfachtTest** | `sync_members_without_contact/0` returns `{:error, :not_configured}` when not configured | Ensures bulk sync is a no-op when config is missing. |
|
||||||
|
| **SyncContactTest** | Member create/update with SyncContact change: not configured → no sync; configured with bad URL → action still succeeds, sync may fail | Ensures the Ash change and after_transaction arity are correct and the action result is not broken by sync failures. |
|
||||||
|
|
||||||
|
## What is *not* tested (and would need a stub or real endpoint)
|
||||||
|
|
||||||
|
- Actual HTTP request shape (body, headers) and response handling (201/200, error codes).
|
||||||
|
- Persistence of `vereinfacht_contact_id` after a successful create.
|
||||||
|
- Translation of specific API error payloads into user messages.
|
||||||
|
|
||||||
|
Those would require either a **Bypass** (or similar) stub in front of Req or a dedicated test endpoint; both are out of scope for the current “no real API” setup.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Given the constraint that the API is not called in CI, the tests are **meaningful**: they cover config, “not configured” paths, and integration of SyncContact with Member create/update without crashing. They are **sufficient** for regression safety and refactoring; extending them with a Bypass stub would be an optional next step if we want to assert on request/response shape without hitting the real API.
|
||||||
|
|
@ -132,7 +132,7 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
refute html =~ ~s(role="menuitem")
|
refute html =~ ~s(role="menuitem")
|
||||||
|
|
||||||
# Footer section should not be rendered
|
# Footer section should not be rendered
|
||||||
refute html =~ "theme-controller"
|
refute html =~ "data-theme-toggle"
|
||||||
refute html =~ "locale-select"
|
refute html =~ "locale-select"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -253,8 +253,8 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
# Check for language selector form
|
# Check for language selector form
|
||||||
assert html =~ ~s(action="/set_locale")
|
assert html =~ ~s(action="/set_locale")
|
||||||
|
|
||||||
# Check for theme toggle
|
# Check for theme toggle (using data attribute instead of class)
|
||||||
assert has_class?(html, "theme-controller")
|
assert html =~ "data-theme-toggle"
|
||||||
|
|
||||||
# Check for user menu/avatar
|
# Check for user menu/avatar
|
||||||
assert has_class?(html, "avatar")
|
assert has_class?(html, "avatar")
|
||||||
|
|
@ -536,7 +536,7 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
assert html =~ ~s(role="group")
|
assert html =~ ~s(role="group")
|
||||||
|
|
||||||
# Footer section
|
# Footer section
|
||||||
assert html =~ "theme-controller"
|
assert html =~ "data-theme-toggle"
|
||||||
assert html =~ ~s(action="/set_locale")
|
assert html =~ ~s(action="/set_locale")
|
||||||
|
|
||||||
# Check that critical navigation exists (at least /members)
|
# Check that critical navigation exists (at least /members)
|
||||||
|
|
@ -694,8 +694,8 @@ defmodule MvWeb.Layouts.SidebarTest do
|
||||||
test "renders theme toggle" do
|
test "renders theme toggle" do
|
||||||
html = render_sidebar(authenticated_assigns())
|
html = render_sidebar(authenticated_assigns())
|
||||||
|
|
||||||
# Toggle is always visible
|
# Toggle is always visible (using data attribute instead of class)
|
||||||
assert has_class?(html, "theme-controller")
|
assert html =~ "data-theme-toggle"
|
||||||
assert html =~ "hero-sun"
|
assert html =~ "hero-sun"
|
||||||
assert html =~ "hero-moon"
|
assert html =~ "hero-moon"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "component behavior" do
|
describe "component behavior" do
|
||||||
test "clicking sends sort message to parent", %{conn: conn} do
|
test "clicking triggers sort event on parent LiveView", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
|
|
@ -232,7 +232,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|> element("button[phx-value-field='first_name']")
|
|> element("button[phx-value-field='first_name']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# The component should send a message to the parent LiveView
|
# The component triggers a "sort" event on the parent LiveView
|
||||||
# This is tested indirectly through the URL change in integration tests
|
# This is tested indirectly through the URL change in integration tests
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
use MvWeb.ConnCase, async: true
|
use MvWeb.ConnCase, async: true
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
import Phoenix.ConnTest
|
import Phoenix.ConnTest
|
||||||
|
import ExUnit.CaptureLog
|
||||||
|
|
||||||
# Helper to create an unauthenticated conn (preserves sandbox metadata)
|
# Helper to create an unauthenticated conn (preserves sandbox metadata)
|
||||||
defp build_unauthenticated_conn(authenticated_conn) do
|
defp build_unauthenticated_conn(authenticated_conn) do
|
||||||
# Create new conn but preserve sandbox metadata for database access
|
# Create new conn but preserve sandbox metadata for database access
|
||||||
new_conn = build_conn()
|
new_conn =
|
||||||
|
build_conn()
|
||||||
|
|> init_test_session(%{})
|
||||||
|
|> fetch_flash()
|
||||||
|
|
||||||
# Copy sandbox metadata from authenticated conn
|
# Copy sandbox metadata from authenticated conn
|
||||||
if authenticated_conn.private[:ecto_sandbox] do
|
if authenticated_conn.private[:ecto_sandbox] do
|
||||||
|
|
@ -248,4 +252,159 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
|
|
||||||
assert to =~ "/auth/user/password/sign_in_with_token"
|
assert to =~ "/auth/user/password/sign_in_with_token"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# OIDC/Rauthy error handling tests
|
||||||
|
describe "handle_rauthy_failure/2" do
|
||||||
|
test "Assent.ServerUnreachableError redirects to sign-in with error flash", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
# Create a mock Assent.ServerUnreachableError struct with required fields
|
||||||
|
error = %Assent.ServerUnreachableError{
|
||||||
|
http_adapter: Assent.HTTPAdapter.Finch,
|
||||||
|
request_url: "https://auth.example.com/callback?token=secret123",
|
||||||
|
reason: %Mint.TransportError{reason: :econnrefused}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error)
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/sign-in"
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||||
|
"The authentication server is currently unavailable. Please try again later."
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Assent.InvalidResponseError redirects to sign-in with error flash", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
# Create a mock Assent.InvalidResponseError struct with required field
|
||||||
|
# InvalidResponseError only has :response field (HTTPResponse struct)
|
||||||
|
error = %Assent.InvalidResponseError{
|
||||||
|
response: %Assent.HTTPAdapter.HTTPResponse{
|
||||||
|
status: 400,
|
||||||
|
headers: [],
|
||||||
|
body: "invalid_request"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error)
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/sign-in"
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||||
|
"Authentication configuration error. Please contact the administrator."
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unknown reason triggers catch-all and redirects to sign-in with error flash", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
unknown_reason = :oops
|
||||||
|
|
||||||
|
conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, unknown_reason)
|
||||||
|
|
||||||
|
assert redirected_to(conn) == ~p"/sign-in"
|
||||||
|
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||||
|
"Unable to authenticate with OIDC. Please try again."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Logging security tests - ensure no sensitive data is logged
|
||||||
|
describe "failure/3 logging security" do
|
||||||
|
test "does not log full URL with query params for Assent.ServerUnreachableError", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
|
||||||
|
error = %Assent.ServerUnreachableError{
|
||||||
|
http_adapter: Assent.HTTPAdapter.Finch,
|
||||||
|
request_url: "https://auth.example.com/callback?token=secret123&code=abc456",
|
||||||
|
reason: %Mint.TransportError{reason: :econnrefused}
|
||||||
|
}
|
||||||
|
|
||||||
|
log =
|
||||||
|
capture_log(fn ->
|
||||||
|
MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Should log redacted URL (only scheme and host)
|
||||||
|
assert log =~ "https://auth.example.com"
|
||||||
|
# Should NOT log query parameters or tokens
|
||||||
|
refute log =~ "token=secret123"
|
||||||
|
refute log =~ "code=abc456"
|
||||||
|
refute log =~ "callback?token"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not log sensitive data for Assent.InvalidResponseError", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
|
||||||
|
error = %Assent.InvalidResponseError{
|
||||||
|
response: %Assent.HTTPAdapter.HTTPResponse{
|
||||||
|
status: 400,
|
||||||
|
headers: [],
|
||||||
|
body: "invalid_request"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log =
|
||||||
|
capture_log(fn ->
|
||||||
|
MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Should log error type but not full error details
|
||||||
|
assert log =~ "Authentication failure"
|
||||||
|
assert log =~ "rauthy"
|
||||||
|
# Should not log full error struct with inspect
|
||||||
|
refute log =~ "Assent.InvalidResponseError"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not log full reason for unknown rauthy errors", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
# Simulate an error that might contain sensitive data
|
||||||
|
error_with_sensitive_data = %{
|
||||||
|
token: "secret_token_123",
|
||||||
|
url: "https://example.com/callback?access_token=abc123",
|
||||||
|
error: :something_went_wrong
|
||||||
|
}
|
||||||
|
|
||||||
|
log =
|
||||||
|
capture_log(fn ->
|
||||||
|
MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error_with_sensitive_data)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Should log error type but not full error details
|
||||||
|
assert log =~ "Authentication failure"
|
||||||
|
assert log =~ "rauthy"
|
||||||
|
# Should NOT log sensitive data
|
||||||
|
refute log =~ "secret_token_123"
|
||||||
|
refute log =~ "access_token=abc123"
|
||||||
|
refute log =~ "callback?access_token"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "logs full reason for non-rauthy activities (password auth)", %{
|
||||||
|
conn: authenticated_conn
|
||||||
|
} do
|
||||||
|
conn = build_unauthenticated_conn(authenticated_conn)
|
||||||
|
|
||||||
|
reason = %AshAuthentication.Errors.AuthenticationFailed{
|
||||||
|
caused_by: %Ash.Error.Forbidden{errors: []}
|
||||||
|
}
|
||||||
|
|
||||||
|
log =
|
||||||
|
capture_log(fn ->
|
||||||
|
MvWeb.AuthController.failure(conn, {:password, :sign_in}, reason)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# For non-rauthy activities, full reason is safe to log
|
||||||
|
assert log =~ "Authentication failure"
|
||||||
|
assert log =~ "password"
|
||||||
|
assert log =~ "AuthenticationFailed"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -71,4 +71,18 @@ defmodule MvWeb.GlobalSettingsLiveConfigTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "Vereinfacht Integration section" do
|
||||||
|
setup %{conn: conn} do
|
||||||
|
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||||
|
{:ok, conn: conn}
|
||||||
|
end
|
||||||
|
|
||||||
|
@tag :ui
|
||||||
|
test "settings page shows Vereinfacht Integration section", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||||
|
assert html =~ "Vereinfacht"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ defmodule MvWeb.GroupLive.FormTest do
|
||||||
test "form renders with empty fields", %{conn: conn} do
|
test "form renders with empty fields", %{conn: conn} do
|
||||||
{:ok, view, html} = live(conn, "/groups/new")
|
{:ok, view, html} = live(conn, "/groups/new")
|
||||||
|
|
||||||
|
# OR-chain for i18n (Create Group / Gruppe erstellen)
|
||||||
assert html =~ gettext("Create Group") or html =~ "create" or html =~ "Gruppe erstellen"
|
assert html =~ gettext("Create Group") or html =~ "create" or html =~ "Gruppe erstellen"
|
||||||
assert has_element?(view, "form")
|
assert has_element?(view, "form")
|
||||||
end
|
end
|
||||||
|
|
@ -65,6 +66,7 @@ defmodule MvWeb.GroupLive.FormTest do
|
||||||
|> form("#group-form", group: form_data)
|
|> form("#group-form", group: form_data)
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
|
# OR-chain for i18n (required/erforderlich) and validation message wording
|
||||||
assert html =~ gettext("required") or html =~ "name" or html =~ "error" or
|
assert html =~ gettext("required") or html =~ "name" or html =~ "error" or
|
||||||
html =~ "erforderlich"
|
html =~ "erforderlich"
|
||||||
end
|
end
|
||||||
|
|
@ -80,6 +82,7 @@ defmodule MvWeb.GroupLive.FormTest do
|
||||||
|> form("#group-form", group: form_data)
|
|> form("#group-form", group: form_data)
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
|
# OR-chain for i18n (length/Länge) and validation message
|
||||||
assert html =~ "100" or html =~ "length" or html =~ "error" or html =~ "Länge"
|
assert html =~ "100" or html =~ "length" or html =~ "error" or html =~ "Länge"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -98,6 +101,7 @@ defmodule MvWeb.GroupLive.FormTest do
|
||||||
|> form("#group-form", group: form_data)
|
|> form("#group-form", group: form_data)
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
|
# OR-chain for i18n (length/Länge) and validation message
|
||||||
assert html =~ "500" or html =~ "length" or html =~ "error" or html =~ "Länge"
|
assert html =~ "500" or html =~ "length" or html =~ "error" or html =~ "Länge"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -116,6 +120,7 @@ defmodule MvWeb.GroupLive.FormTest do
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
# Check for a validation error on the name field in a robust way
|
# Check for a validation error on the name field in a robust way
|
||||||
|
# OR-chain for i18n and validation message (already taken)
|
||||||
assert html =~ "name" or html =~ gettext("has already been taken")
|
assert html =~ "name" or html =~ gettext("has already been taken")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -131,6 +136,7 @@ defmodule MvWeb.GroupLive.FormTest do
|
||||||
|> form("#group-form", group: form_data)
|
|> form("#group-form", group: form_data)
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
|
# OR-chain for i18n (error/Fehler, invalid/ungültig)
|
||||||
assert html =~ "error" or html =~ "invalid" or html =~ "Fehler" or html =~ "ungültig"
|
assert html =~ "error" or html =~ "invalid" or html =~ "Fehler" or html =~ "ungültig"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -196,6 +202,7 @@ defmodule MvWeb.GroupLive.FormTest do
|
||||||
|> form("#group-form", group: form_data)
|
|> form("#group-form", group: form_data)
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
|
# OR-chain for i18n (already taken / bereits vergeben) and validation wording
|
||||||
assert html =~ "already" or html =~ "taken" or html =~ "exists" or html =~ "error" or
|
assert html =~ "already" or html =~ "taken" or html =~ "exists" or html =~ "error" or
|
||||||
html =~ "bereits" or html =~ "vergeben"
|
html =~ "bereits" or html =~ "vergeben"
|
||||||
end
|
end
|
||||||
|
|
@ -205,7 +212,7 @@ defmodule MvWeb.GroupLive.FormTest do
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}/edit")
|
{:ok, _view, html} = live(conn, "/groups/#{group.slug}/edit")
|
||||||
|
|
||||||
# Slug should not be in form (it's immutable)
|
# Slug should not be in form (it's immutable); regex for input element
|
||||||
refute html =~ ~r/slug.*input/i or html =~ ~r/input.*slug/i
|
refute html =~ ~r/slug.*input/i or html =~ ~r/input.*slug/i
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,14 @@ defmodule MvWeb.GroupLive.IndexTest do
|
||||||
|
|
||||||
assert html =~ "Test Group"
|
assert html =~ "Test Group"
|
||||||
assert html =~ "Test description"
|
assert html =~ "Test description"
|
||||||
# Member count should be displayed (0 for empty group)
|
# OR-chain for i18n (Members/Mitglieder) and alternate copy for count
|
||||||
assert html =~ "0" or html =~ gettext("Members") or html =~ "Mitglieder"
|
assert html =~ "0" or html =~ gettext("Members") or html =~ "Mitglieder"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "displays 'Create Group' button for admin users", %{conn: conn} do
|
test "displays 'Create Group' button for admin users", %{conn: conn} do
|
||||||
{:ok, _view, html} = live(conn, "/groups")
|
{:ok, _view, html} = live(conn, "/groups")
|
||||||
|
|
||||||
|
# OR-chain for i18n (Create Group / Gruppe erstellen) and alternate wording
|
||||||
assert html =~ gettext("Create Group") or html =~ "create" or html =~ "new" or
|
assert html =~ gettext("Create Group") or html =~ "create" or html =~ "new" or
|
||||||
html =~ "Gruppe erstellen"
|
html =~ "Gruppe erstellen"
|
||||||
end
|
end
|
||||||
|
|
@ -54,7 +55,7 @@ defmodule MvWeb.GroupLive.IndexTest do
|
||||||
test "displays empty state when no groups exist", %{conn: conn} do
|
test "displays empty state when no groups exist", %{conn: conn} do
|
||||||
{:ok, _view, html} = live(conn, "/groups")
|
{:ok, _view, html} = live(conn, "/groups")
|
||||||
|
|
||||||
# Should show empty state or empty list message
|
# OR-chain for i18n (No groups / Keine Gruppen) and alternate empty state copy
|
||||||
assert html =~ gettext("No groups") or html =~ "0" or html =~ "empty" or
|
assert html =~ gettext("No groups") or html =~ "0" or html =~ "empty" or
|
||||||
html =~ "Keine Gruppen"
|
html =~ "Keine Gruppen"
|
||||||
end
|
end
|
||||||
|
|
@ -76,6 +77,7 @@ defmodule MvWeb.GroupLive.IndexTest do
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups")
|
{:ok, _view, html} = live(conn, "/groups")
|
||||||
|
|
||||||
|
# Long description may be truncated in UI
|
||||||
assert html =~ long_description or html =~ String.slice(long_description, 0, 100)
|
assert html =~ long_description or html =~ String.slice(long_description, 0, 100)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -109,7 +111,7 @@ defmodule MvWeb.GroupLive.IndexTest do
|
||||||
# Should be able to see groups
|
# Should be able to see groups
|
||||||
assert html =~ gettext("Groups")
|
assert html =~ gettext("Groups")
|
||||||
|
|
||||||
# Should NOT see create button
|
# Read-only must not see create button (OR for i18n)
|
||||||
refute html =~ gettext("Create Group") or html =~ "create"
|
refute html =~ gettext("Create Group") or html =~ "create"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -177,7 +179,7 @@ defmodule MvWeb.GroupLive.IndexTest do
|
||||||
final_count = Agent.get(query_count_agent, & &1)
|
final_count = Agent.get(query_count_agent, & &1)
|
||||||
:telemetry.detach(handler_id)
|
:telemetry.detach(handler_id)
|
||||||
|
|
||||||
# Member count should be displayed (should be 2)
|
# OR-chain for i18n (Members/Mitglieder) and count display
|
||||||
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
|
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
|
||||||
|
|
||||||
# Verify query count is reasonable (member count should be calculated efficiently)
|
# Verify query count is reasonable (member count should be calculated efficiently)
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ defmodule MvWeb.GroupLive.IntegrationTest do
|
||||||
|
|
||||||
assert html =~ "Updated Workflow Test Group"
|
assert html =~ "Updated Workflow Test Group"
|
||||||
assert html =~ "Updated description"
|
assert html =~ "Updated description"
|
||||||
# Slug should remain unchanged
|
# OR-chain: slug may appear as UUID or normalized slug in copy
|
||||||
assert html =~ original_slug or html =~ "workflow-test-group"
|
assert html =~ original_slug or html =~ "workflow-test-group"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -101,7 +101,7 @@ defmodule MvWeb.GroupLive.IntegrationTest do
|
||||||
# View group via slug
|
# View group via slug
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Member count should be 2
|
# OR-chain for i18n (Members/Mitglieder); member names may be first or last
|
||||||
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
|
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
|
||||||
assert html =~ member1.first_name or html =~ member1.last_name
|
assert html =~ member1.first_name or html =~ member1.last_name
|
||||||
assert html =~ member2.first_name or html =~ member2.last_name
|
assert html =~ member2.first_name or html =~ member2.last_name
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,13 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
||||||
|> element("button", "Add Member")
|
|> element("button", "Add Member")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
# OR-chain: at least one of these ARIA/role attributes must be present
|
||||||
|
assert has_element?(view, "[data-testid=group-show-member-search-input][aria-label]") or
|
||||||
# Search input should have proper ARIA attributes
|
has_element?(
|
||||||
assert html =~ ~r/aria-label/ ||
|
view,
|
||||||
html =~ ~r/aria-autocomplete/ ||
|
"[data-testid=group-show-member-search-input][aria-autocomplete]"
|
||||||
html =~ ~r/role=["']combobox["']/
|
) or
|
||||||
|
has_element?(view, "[data-testid=group-show-member-search-input][role=combobox]")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "search input has correct aria-label and aria-autocomplete attributes", %{conn: conn} do
|
test "search input has correct aria-label and aria-autocomplete attributes", %{conn: conn} do
|
||||||
|
|
@ -35,16 +36,14 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(
|
||||||
|
view,
|
||||||
# Search input should have ARIA attributes
|
"[data-testid=group-show-member-search-input][aria-autocomplete=list]"
|
||||||
assert html =~ ~r/aria-label.*[Ss]earch.*member/ ||
|
)
|
||||||
html =~ ~r/aria-autocomplete=["']list["']/
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "remove button has aria-label with tooltip text", %{conn: conn} do
|
test "remove button has aria-label with tooltip text", %{conn: conn} do
|
||||||
|
|
@ -67,11 +66,7 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "[data-testid=group-show-remove-member][aria-label]")
|
||||||
|
|
||||||
# Remove button should have aria-label
|
|
||||||
assert html =~ ~r/aria-label.*[Rr]emove/ ||
|
|
||||||
html =~ ~r/aria-label.*member/i
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "add button has correct aria-label", %{conn: conn} do
|
test "add button has correct aria-label", %{conn: conn} do
|
||||||
|
|
@ -79,16 +74,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][aria-label]")
|
||||||
|
|
||||||
# Add button should have aria-label
|
|
||||||
assert html =~ ~r/aria-label.*[Aa]dd/ ||
|
|
||||||
html =~ ~r/button.*[Aa]dd/
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -100,16 +90,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "[data-testid=group-show-member-search-input]")
|
||||||
|
|
||||||
# Inline add member area should have focusable elements
|
|
||||||
assert html =~ ~r/input|button/ ||
|
|
||||||
html =~ "#member-search-input"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "inline input can be closed", %{conn: conn} do
|
test "inline input can be closed", %{conn: conn} do
|
||||||
|
|
@ -117,17 +102,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
assert has_element?(view, "#member-search-input")
|
assert has_element?(view, "[data-testid=group-show-member-search-input]")
|
||||||
|
|
||||||
# Click Add Member button again to close (or add a member to close it)
|
|
||||||
# For now, we verify the input is visible when opened
|
|
||||||
html = render(view)
|
|
||||||
assert html =~ "#member-search-input" || has_element?(view, "#member-search-input")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "enter/space activates buttons when focused", %{conn: conn} do
|
test "enter/space activates buttons when focused", %{conn: conn} do
|
||||||
|
|
@ -148,17 +127,14 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Select member
|
|
||||||
view
|
view
|
||||||
|> element("#member-search-input")
|
|> element("[data-testid=group-show-member-search-input]")
|
||||||
|> render_focus()
|
|> render_focus()
|
||||||
|
|
||||||
# phx-change is on the form, so we need to trigger it via the form
|
|
||||||
view
|
view
|
||||||
|> element("form[phx-change='search_members']")
|
|> element("form[phx-change='search_members']")
|
||||||
|> render_change(%{"member_search" => "Bob"})
|
|> render_change(%{"member_search" => "Bob"})
|
||||||
|
|
@ -167,14 +143,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
||||||
|> element("[data-member-id='#{member.id}']")
|
|> element("[data-member-id='#{member.id}']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Add button should be enabled and clickable
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='add_selected_members']")
|
|> element("[data-testid=group-show-add-selected-members-btn]")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Should succeed (member should appear in list)
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Bob")
|
||||||
html = render(view)
|
|
||||||
assert html =~ "Bob"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "focus management: focus is set to input when opened", %{conn: conn} do
|
test "focus management: focus is set to input when opened", %{conn: conn} do
|
||||||
|
|
@ -184,16 +157,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "[data-testid=group-show-member-search-input]")
|
||||||
|
|
||||||
# Input should be visible and focusable
|
|
||||||
assert html =~ "#member-search-input" ||
|
|
||||||
html =~ ~r/autofocus|tabindex/
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -203,16 +171,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "[data-testid=group-show-member-search-input][aria-label]")
|
||||||
|
|
||||||
# Input should have aria-label
|
|
||||||
assert html =~ ~r/aria-label.*[Ss]earch.*member/ ||
|
|
||||||
html =~ ~r/aria-label/
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "search results are properly announced", %{conn: conn} do
|
test "search results are properly announced", %{conn: conn} do
|
||||||
|
|
@ -231,27 +194,20 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Search
|
|
||||||
view
|
view
|
||||||
|> element("#member-search-input")
|
|> element("[data-testid=group-show-member-search-input]")
|
||||||
|> render_focus()
|
|> render_focus()
|
||||||
|
|
||||||
# phx-change is on the form, so we need to trigger it via the form
|
|
||||||
view
|
view
|
||||||
|> element("form[phx-change='search_members']")
|
|> element("form[phx-change='search_members']")
|
||||||
|> render_change(%{"member_search" => "Charlie"})
|
|> render_change(%{"member_search" => "Charlie"})
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "#member-dropdown[role=listbox]")
|
||||||
|
assert has_element?(view, "#member-dropdown", "Charlie")
|
||||||
# Search results should have proper ARIA attributes
|
|
||||||
assert html =~ ~r/role=["']listbox["']/ ||
|
|
||||||
html =~ ~r/role=["']option["']/ ||
|
|
||||||
html =~ "Charlie"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "flash messages are properly announced", %{conn: conn} do
|
test "flash messages are properly announced", %{conn: conn} do
|
||||||
|
|
@ -270,16 +226,14 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Add member
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("#member-search-input")
|
|> element("[data-testid=group-show-member-search-input]")
|
||||||
|> render_focus()
|
|> render_focus()
|
||||||
|
|
||||||
# phx-change is on the form, so we need to trigger it via the form
|
|
||||||
view
|
view
|
||||||
|> element("form[phx-change='search_members']")
|
|> element("form[phx-change='search_members']")
|
||||||
|> render_change(%{"member_search" => "David"})
|
|> render_change(%{"member_search" => "David"})
|
||||||
|
|
@ -289,13 +243,10 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='add_selected_members']")
|
|> element("[data-testid=group-show-add-selected-members-btn]")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "[data-testid=group-show-members-table]", "David")
|
||||||
|
|
||||||
# Member should appear in list (no flash message)
|
|
||||||
assert html =~ "David"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,8 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
select_member(view, member)
|
select_member(view, member)
|
||||||
add_selected(view)
|
add_selected(view)
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Alice")
|
||||||
assert html =~ "Alice"
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Johnson")
|
||||||
assert html =~ "Johnson"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "member is successfully added to group (verified in list)", %{conn: conn} do
|
test "member is successfully added to group (verified in list)", %{conn: conn} do
|
||||||
|
|
@ -55,16 +54,14 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input and add member
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("#member-search-input")
|
|> element("[data-testid=group-show-member-search-input]")
|
||||||
|> render_focus()
|
|> render_focus()
|
||||||
|
|
||||||
# phx-change is on the form, so we need to trigger it via the form
|
|
||||||
view
|
view
|
||||||
|> element("form[phx-change='search_members']")
|
|> element("form[phx-change='search_members']")
|
||||||
|> render_change(%{"member_search" => "Bob"})
|
|> render_change(%{"member_search" => "Bob"})
|
||||||
|
|
@ -74,14 +71,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='add_selected_members']")
|
|> element("[data-testid=group-show-add-selected-members-btn]")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Bob")
|
||||||
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Smith")
|
||||||
# Verify member appears in group list (no success flash message)
|
|
||||||
assert html =~ "Bob"
|
|
||||||
assert html =~ "Smith"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "group member list updates automatically after add", %{conn: conn} do
|
test "group member list updates automatically after add", %{conn: conn} do
|
||||||
|
|
@ -98,21 +92,18 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Initially member should NOT be in list
|
refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie")
|
||||||
refute html =~ "Charlie"
|
|
||||||
|
|
||||||
# Add member
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("#member-search-input")
|
|> element("[data-testid=group-show-member-search-input]")
|
||||||
|> render_focus()
|
|> render_focus()
|
||||||
|
|
||||||
# phx-change is on the form, so we need to trigger it via the form
|
|
||||||
view
|
view
|
||||||
|> element("form[phx-change='search_members']")
|
|> element("form[phx-change='search_members']")
|
||||||
|> render_change(%{"member_search" => "Charlie"})
|
|> render_change(%{"member_search" => "Charlie"})
|
||||||
|
|
@ -122,13 +113,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='add_selected_members']")
|
|> element("[data-testid=group-show-add-selected-members-btn]")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Member should now appear in list
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Charlie")
|
||||||
html = render(view)
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Brown")
|
||||||
assert html =~ "Charlie"
|
|
||||||
assert html =~ "Brown"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "member count updates automatically after add", %{conn: conn} do
|
test "member count updates automatically after add", %{conn: conn} do
|
||||||
|
|
@ -152,11 +141,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|
|
||||||
# Add member
|
# Add member
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("#member-search-input")
|
|> element("[data-testid=group-show-member-search-input]")
|
||||||
|> render_focus()
|
|> render_focus()
|
||||||
|
|
||||||
# phx-change is on the form, so we need to trigger it via the form
|
# phx-change is on the form, so we need to trigger it via the form
|
||||||
|
|
@ -169,7 +158,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='add_selected_members']")
|
|> element("[data-testid=group-show-add-selected-members-btn]")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Count should have increased
|
# Count should have increased
|
||||||
|
|
@ -196,14 +185,14 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|
|
||||||
# Open inline input
|
# Open inline input
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
assert has_element?(view, "#member-search-input")
|
assert has_element?(view, "[data-testid=group-show-member-search-input]")
|
||||||
|
|
||||||
# Add member
|
# Add member
|
||||||
view
|
view
|
||||||
|> element("#member-search-input")
|
|> element("[data-testid=group-show-member-search-input]")
|
||||||
|> render_focus()
|
|> render_focus()
|
||||||
|
|
||||||
# phx-change is on the form, so we need to trigger it via the form
|
# phx-change is on the form, so we need to trigger it via the form
|
||||||
|
|
@ -216,11 +205,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='add_selected_members']")
|
|> element("[data-testid=group-show-add-selected-members-btn]")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Inline input should be closed (Add Member button should be visible again)
|
refute has_element?(view, "[data-testid=group-show-member-search-input]")
|
||||||
refute has_element?(view, "#member-search-input")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "Cancel button closes inline add member area without adding", %{conn: conn} do
|
test "Cancel button closes inline add member area without adding", %{conn: conn} do
|
||||||
|
|
@ -229,7 +217,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
open_add_member(view)
|
open_add_member(view)
|
||||||
assert has_element?(view, "#member-search-input")
|
assert has_element?(view, "[data-testid=group-show-member-search-input]")
|
||||||
assert has_element?(view, "button[phx-click='hide_add_member_input']")
|
assert has_element?(view, "button[phx-click='hide_add_member_input']")
|
||||||
|
|
||||||
cancel_add_member(view)
|
cancel_add_member(view)
|
||||||
|
|
@ -263,7 +251,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|
|
||||||
# Try to add same member again
|
# Try to add same member again
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Member should not appear in search (filtered out)
|
# Member should not appear in search (filtered out)
|
||||||
|
|
@ -281,12 +269,12 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add")
|
|> element("[data-testid=group-show-add-selected-members-btn]")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Should show error
|
# OR-chain for i18n and alternate error wording (already in group / duplicate)
|
||||||
html = render(view)
|
html = render(view)
|
||||||
assert html =~ gettext("already in group") || html =~ ~r/already.*group|duplicate/i
|
assert html =~ gettext("already in group") or html =~ ~r/already.*group|duplicate/i
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -300,7 +288,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|
|
||||||
# Open inline input
|
# Open inline input
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Try to add with invalid member ID (if possible)
|
# Try to add with invalid member ID (if possible)
|
||||||
|
|
@ -331,11 +319,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|
|
||||||
# Open inline input
|
# Open inline input
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Inline input should be open
|
assert has_element?(view, "[data-testid=group-show-member-search-input]")
|
||||||
assert has_element?(view, "#member-search-input")
|
|
||||||
|
|
||||||
# If error occurs, inline input should remain open
|
# If error occurs, inline input should remain open
|
||||||
# (Implementation will handle this)
|
# (Implementation will handle this)
|
||||||
|
|
@ -348,11 +335,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|
|
||||||
# Open inline input
|
# Open inline input
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Add button should be disabled
|
assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][disabled]")
|
||||||
assert has_element?(view, "button[phx-click='add_selected_members'][disabled]")
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -375,11 +361,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|
|
||||||
# Add member to empty group
|
# Add member to empty group
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("#member-search-input")
|
|> element("[data-testid=group-show-member-search-input]")
|
||||||
|> render_focus()
|
|> render_focus()
|
||||||
|
|
||||||
# phx-change is on the form, so we need to trigger it via the form
|
# phx-change is on the form, so we need to trigger it via the form
|
||||||
|
|
@ -392,12 +378,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='add_selected_members']")
|
|> element("[data-testid=group-show-add-selected-members-btn]")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Member should be added
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Henry")
|
||||||
html = render(view)
|
|
||||||
assert html =~ "Henry"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "add works when member is already in other groups", %{conn: conn} do
|
test "add works when member is already in other groups", %{conn: conn} do
|
||||||
|
|
@ -424,11 +408,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|
|
||||||
# Add same member to group2 (should work)
|
# Add same member to group2 (should work)
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("#member-search-input")
|
|> element("[data-testid=group-show-member-search-input]")
|
||||||
|> render_focus()
|
|> render_focus()
|
||||||
|
|
||||||
# phx-change is on the form, so we need to trigger it via the form
|
# phx-change is on the form, so we need to trigger it via the form
|
||||||
|
|
@ -441,12 +425,10 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='add_selected_members']")
|
|> element("[data-testid=group-show-add-selected-members-btn]")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Member should be added to group2
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Isabel")
|
||||||
html = render(view)
|
|
||||||
assert html =~ "Isabel"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,18 +22,18 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
|
||||||
test "Add Member button is visible for users with :update permission", %{conn: conn} do
|
test "Add Member button is visible for users with :update permission", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture()
|
group = Fixtures.group_fixture()
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
assert html =~ gettext("Add Member") or html =~ "Add Member"
|
assert has_element?(view, "button[phx-click='show_add_member_input']")
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag role: :read_only
|
@tag role: :read_only
|
||||||
test "Add Member button is NOT visible for users without :update permission", %{conn: conn} do
|
test "Add Member button is NOT visible for users without :update permission", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture()
|
group = Fixtures.group_fixture()
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
refute html =~ gettext("Add Member")
|
refute has_element?(view, "button[phx-click='show_add_member_input']")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "Add Member button is positioned above member table", %{conn: conn} do
|
test "Add Member button is positioned above member table", %{conn: conn} do
|
||||||
|
|
@ -61,11 +61,7 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Remove button should exist (can be icon button with trash icon)
|
assert has_element?(view, "[data-testid=group-show-remove-member]")
|
||||||
html = render(view)
|
|
||||||
|
|
||||||
assert html =~ "Remove" or html =~ "remove" or html =~ "trash" or
|
|
||||||
html =~ ~r/hero-trash|hero-x-mark/
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag role: :read_only
|
@tag role: :read_only
|
||||||
|
|
@ -78,10 +74,9 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Remove button should NOT exist (check for trash icon or remove button specifically)
|
refute has_element?(view, "[data-testid=group-show-remove-member]")
|
||||||
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -110,10 +105,7 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
|
||||||
|> element("button", gettext("Add Member"))
|
|> element("button", gettext("Add Member"))
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "[data-testid=group-show-member-search-input]")
|
||||||
|
|
||||||
assert html =~ gettext("Search for a member...") ||
|
|
||||||
html =~ ~r/search.*member/i
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "Add button (plus icon) is disabled until member selected", %{conn: conn} do
|
test "Add button (plus icon) is disabled until member selected", %{conn: conn} do
|
||||||
|
|
@ -121,15 +113,11 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", gettext("Add Member"))
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][disabled]")
|
||||||
# Add button should exist and be disabled initially
|
|
||||||
assert has_element?(view, "button[phx-click='add_selected_members'][disabled]") ||
|
|
||||||
html =~ ~r/disabled/
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,7 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Should succeed (admin has :update permission, member should appear in list)
|
# Should succeed (admin has :update permission, member should appear in list)
|
||||||
html = render(view)
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Alice")
|
||||||
assert html =~ "Alice"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag role: :read_only
|
@tag role: :read_only
|
||||||
|
|
@ -78,9 +77,7 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
|
||||||
# Note: If button is hidden, we can't click it, but we test the event handler
|
# Note: If button is hidden, we can't click it, but we test the event handler
|
||||||
# by trying to send the event directly if possible
|
# by trying to send the event directly if possible
|
||||||
|
|
||||||
# For now, we verify that the button is not visible
|
refute has_element?(view, "button[phx-click='show_add_member_input']")
|
||||||
html = render(view)
|
|
||||||
refute html =~ "Add Member"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "remove member event handler checks :update permission", %{conn: conn} do
|
test "remove member event handler checks :update permission", %{conn: conn} do
|
||||||
|
|
@ -103,14 +100,11 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Remove member (should succeed for admin)
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Should succeed (member should no longer be in list)
|
refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie")
|
||||||
html = render(view)
|
|
||||||
refute html =~ "Charlie"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag role: :read_only
|
@tag role: :read_only
|
||||||
|
|
@ -134,11 +128,7 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Remove button should not be visible
|
refute has_element?(view, "[data-testid=group-show-remove-member]")
|
||||||
html = render(view)
|
|
||||||
|
|
||||||
# Read-only user should NOT see Remove button (check for trash icon or remove button specifically)
|
|
||||||
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "error flash message on unauthorized access", %{conn: conn} do
|
test "error flash message on unauthorized access", %{conn: conn} do
|
||||||
|
|
@ -174,10 +164,10 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Admin should see buttons
|
assert has_element?(view, "button[phx-click='show_add_member_input']")
|
||||||
assert html =~ "Add Member" || html =~ "Remove"
|
assert has_element?(view, "[data-testid=group-show-remove-member]")
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag role: :read_only
|
@tag role: :read_only
|
||||||
|
|
@ -185,10 +175,9 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
|
||||||
_system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
_system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
group = Fixtures.group_fixture()
|
group = Fixtures.group_fixture()
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Read-only user should NOT see Add Member button
|
refute has_element?(view, "button[phx-click='show_add_member_input']")
|
||||||
refute html =~ "Add Member"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag role: :read_only
|
@tag role: :read_only
|
||||||
|
|
@ -210,21 +199,18 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Read-only user should NOT see Remove button (check for trash icon or remove button specifically)
|
refute has_element?(view, "[data-testid=group-show-remove-member]")
|
||||||
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag role: :read_only
|
@tag role: :read_only
|
||||||
test "inline add member area cannot be opened for unauthorized users", %{conn: conn} do
|
test "inline add member area cannot be opened for unauthorized users", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture()
|
group = Fixtures.group_fixture()
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Inline input should not be accessible (button hidden)
|
refute has_element?(view, "button[phx-click='show_add_member_input']")
|
||||||
refute html =~ "Add Member"
|
|
||||||
refute html =~ "#member-search-input"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -305,9 +305,8 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Both members should be in list
|
# Both members should be in list
|
||||||
html = render(view)
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Frank")
|
||||||
assert html =~ "Frank"
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Grace")
|
||||||
assert html =~ "Grace"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "multiple members can be removed sequentially", %{conn: conn} do
|
test "multiple members can be removed sequentially", %{conn: conn} do
|
||||||
|
|
@ -343,11 +342,11 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Both should be in list initially
|
# Both should be in list initially
|
||||||
assert html =~ "Henry"
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Henry")
|
||||||
assert html =~ "Isabel"
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Isabel")
|
||||||
|
|
||||||
# Remove first member
|
# Remove first member
|
||||||
view
|
view
|
||||||
|
|
@ -360,9 +359,8 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Both should be removed
|
# Both should be removed
|
||||||
html = render(view)
|
refute has_element?(view, "[data-testid=group-show-members-table]", "Henry")
|
||||||
refute html =~ "Henry"
|
refute has_element?(view, "[data-testid=group-show-members-table]", "Isabel")
|
||||||
refute html =~ "Isabel"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "add and remove can be mixed", %{conn: conn} do
|
test "add and remove can be mixed", %{conn: conn} do
|
||||||
|
|
@ -424,9 +422,8 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Only member2 should remain
|
# Only member2 should remain
|
||||||
html = render(view)
|
refute has_element?(view, "[data-testid=group-show-members-table]", "Jack")
|
||||||
refute html =~ "Jack"
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Kate")
|
||||||
assert html =~ "Kate"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -34,21 +34,16 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Type exact name
|
|
||||||
# phx-change is on the form, so we need to trigger it via the form
|
|
||||||
view
|
view
|
||||||
|> element("form[phx-change='search_members']")
|
|> element("form[phx-change='search_members']")
|
||||||
|> render_change(%{"member_search" => "Jonathan"})
|
|> render_change(%{"member_search" => "Jonathan"})
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "#member-dropdown", "Jonathan")
|
||||||
|
assert has_element?(view, "#member-dropdown", "Smith")
|
||||||
assert html =~ "Jonathan"
|
|
||||||
assert html =~ "Smith"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "search finds member by partial name (fuzzy)", %{conn: conn} do
|
test "search finds member by partial name (fuzzy)", %{conn: conn} do
|
||||||
|
|
@ -68,22 +63,16 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Type partial name
|
|
||||||
# phx-change is on the form, so we need to trigger it via the form
|
|
||||||
view
|
view
|
||||||
|> element("form[phx-change='search_members']")
|
|> element("form[phx-change='search_members']")
|
||||||
|> render_change(%{"member_search" => "Jon"})
|
|> render_change(%{"member_search" => "Jon"})
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "#member-dropdown", "Jonathan")
|
||||||
|
assert has_element?(view, "#member-dropdown", "Smith")
|
||||||
# Fuzzy search should find Jonathan
|
|
||||||
assert html =~ "Jonathan"
|
|
||||||
assert html =~ "Smith"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "search finds member by email", %{conn: conn} do
|
test "search finds member by email", %{conn: conn} do
|
||||||
|
|
@ -103,22 +92,17 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Search by email
|
|
||||||
# phx-change is on the form, so we need to trigger it via the form
|
|
||||||
view
|
view
|
||||||
|> element("form[phx-change='search_members']")
|
|> element("form[phx-change='search_members']")
|
||||||
|> render_change(%{"member_search" => "alice.johnson"})
|
|> render_change(%{"member_search" => "alice.johnson"})
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "#member-dropdown", "Alice")
|
||||||
|
assert has_element?(view, "#member-dropdown", "Johnson")
|
||||||
assert html =~ "Alice"
|
assert has_element?(view, "#member-dropdown", "alice.johnson@example.com")
|
||||||
assert html =~ "Johnson"
|
|
||||||
assert html =~ "alice.johnson@example.com"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "dropdown shows member name and email", %{conn: conn} do
|
test "dropdown shows member name and email", %{conn: conn} do
|
||||||
|
|
@ -153,11 +137,9 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
|
||||||
|> element("form[phx-change='search_members']")
|
|> element("form[phx-change='search_members']")
|
||||||
|> render_change(%{"member_search" => "Bob"})
|
|> render_change(%{"member_search" => "Bob"})
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "#member-dropdown", "Bob")
|
||||||
|
assert has_element?(view, "#member-dropdown", "Williams")
|
||||||
assert html =~ "Bob"
|
assert has_element?(view, "#member-dropdown", "bob@example.com")
|
||||||
assert html =~ "Williams"
|
|
||||||
assert html =~ "bob@example.com"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "ComboBox hook works (focus opens dropdown)", %{conn: conn} do
|
test "ComboBox hook works (focus opens dropdown)", %{conn: conn} do
|
||||||
|
|
@ -177,20 +159,15 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Focus input
|
|
||||||
view
|
view
|
||||||
|> element("#member-search-input")
|
|> element("[data-testid=group-show-member-search-input]")
|
||||||
|> render_focus()
|
|> render_focus()
|
||||||
|
|
||||||
html = render(view)
|
assert has_element?(view, "#member-dropdown[role=listbox]")
|
||||||
|
|
||||||
# Dropdown should be visible
|
|
||||||
assert html =~ ~r/role="listbox"/ || html =~ "listbox"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -228,21 +205,16 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Search for "David"
|
|
||||||
# phx-change is on the form, so we need to trigger it via the form
|
|
||||||
view
|
view
|
||||||
|> element("form[phx-change='search_members']")
|
|> element("form[phx-change='search_members']")
|
||||||
|> render_change(%{"member_search" => "David"})
|
|> render_change(%{"member_search" => "David"})
|
||||||
|
|
||||||
# Assert only on dropdown (available members), not the members table
|
assert has_element?(view, "#member-dropdown", "Anderson")
|
||||||
dropdown_html = view |> element("#member-dropdown") |> render()
|
refute has_element?(view, "#member-dropdown", "Miller")
|
||||||
assert dropdown_html =~ "Anderson"
|
|
||||||
refute dropdown_html =~ "Miller"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "search filters correctly when group has many members", %{conn: conn} do
|
test "search filters correctly when group has many members", %{conn: conn} do
|
||||||
|
|
@ -280,23 +252,18 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Search
|
|
||||||
# phx-change is on the form, so we need to trigger it via the form
|
|
||||||
view
|
view
|
||||||
|> element("form[phx-change='search_members']")
|
|> element("form[phx-change='search_members']")
|
||||||
|> render_change(%{"member_search" => "Available"})
|
|> render_change(%{"member_search" => "Available"})
|
||||||
|
|
||||||
# Assert only on dropdown (available members), not the members table
|
assert has_element?(view, "#member-dropdown", "Available")
|
||||||
dropdown_html = view |> element("#member-dropdown") |> render()
|
assert has_element?(view, "#member-dropdown", "Member")
|
||||||
assert dropdown_html =~ "Available"
|
refute has_element?(view, "#member-dropdown", "Member1")
|
||||||
assert dropdown_html =~ "Member"
|
refute has_element?(view, "#member-dropdown", "Member2")
|
||||||
refute dropdown_html =~ "Member1"
|
|
||||||
refute dropdown_html =~ "Member2"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "search shows no results when all available members are already in group", %{conn: conn} do
|
test "search shows no results when all available members are already in group", %{conn: conn} do
|
||||||
|
|
@ -321,18 +288,14 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Open inline input
|
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Search
|
|
||||||
# phx-change is on the form, so we need to trigger it via the form
|
|
||||||
view
|
view
|
||||||
|> element("form[phx-change='search_members']")
|
|> element("form[phx-change='search_members']")
|
||||||
|> render_change(%{"member_search" => "Only"})
|
|> render_change(%{"member_search" => "Only"})
|
||||||
|
|
||||||
# When no available members, dropdown is not rendered (length(@available_members) == 0)
|
|
||||||
refute has_element?(view, "#member-dropdown")
|
refute has_element?(view, "#member-dropdown")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -31,19 +31,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Member should be in list initially
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Alice")
|
||||||
assert html =~ "Alice"
|
|
||||||
|
|
||||||
# Click Remove button
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Member should no longer be in list (no success flash message)
|
refute has_element?(view, "[data-testid=group-show-members-table]", "Alice")
|
||||||
html = render(view)
|
|
||||||
refute html =~ "Alice"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "member is successfully removed from group (verified in list)", %{conn: conn} do
|
test "member is successfully removed from group (verified in list)", %{conn: conn} do
|
||||||
|
|
@ -64,20 +60,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Member should be in list initially
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Bob")
|
||||||
assert html =~ "Bob"
|
|
||||||
|
|
||||||
# Remove member
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
refute has_element?(view, "[data-testid=group-show-members-table]", "Bob")
|
||||||
|
|
||||||
# Member should no longer be in list (no success flash message)
|
|
||||||
refute html =~ "Bob"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "group member list updates automatically after remove", %{conn: conn} do
|
test "group member list updates automatically after remove", %{conn: conn} do
|
||||||
|
|
@ -98,19 +89,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Member should be in list initially
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Charlie")
|
||||||
assert html =~ "Charlie"
|
|
||||||
|
|
||||||
# Remove member
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Member should no longer be in list
|
refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie")
|
||||||
html = render(view)
|
|
||||||
refute html =~ "Charlie"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "member count updates automatically after remove", %{conn: conn} do
|
test "member count updates automatically after remove", %{conn: conn} do
|
||||||
|
|
@ -158,7 +145,7 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
|
||||||
# Extract first member ID from the rendered HTML or use a different approach
|
# Extract first member ID from the rendered HTML or use a different approach
|
||||||
# Since we have member1 and member2, we can target member1 specifically
|
# Since we have member1 and member2, we can target member1 specifically
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']")
|
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member1.id}']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Count should have decreased
|
# Count should have decreased
|
||||||
|
|
@ -187,17 +174,11 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Click Remove - should remove immediately without confirmation
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# No confirmation dialog should appear (immediate removal)
|
refute has_element?(view, "[data-testid=group-show-members-table]", "Frank")
|
||||||
# This is verified by the member being removed without any dialog
|
|
||||||
|
|
||||||
# Member should be removed
|
|
||||||
html = render(view)
|
|
||||||
refute html =~ "Frank"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -220,23 +201,17 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Member should be in list
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Grace")
|
||||||
assert html =~ "Grace"
|
|
||||||
|
|
||||||
# Remove last member
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Group should show empty state
|
assert has_element?(view, "[data-testid=group-show-no-members]")
|
||||||
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
|
|
||||||
assert html =~ gettext("No members in this group") ||
|
|
||||||
html =~ ~r/no.*members/i
|
|
||||||
|
|
||||||
# Count should be 0
|
|
||||||
count = extract_member_count(html)
|
count = extract_member_count(html)
|
||||||
assert count == 0
|
assert count == 0
|
||||||
end
|
end
|
||||||
|
|
@ -269,18 +244,14 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group1.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group1.slug}")
|
||||||
|
|
||||||
# Remove from group1
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Member should be removed from group1
|
refute has_element?(view, "[data-testid=group-show-members-table]", "Henry")
|
||||||
html = render(view)
|
|
||||||
refute html =~ "Henry"
|
|
||||||
|
|
||||||
# Verify member is still in group2
|
{:ok, view2, _html2} = live(conn, "/groups/#{group2.slug}")
|
||||||
{:ok, _view2, html2} = live(conn, "/groups/#{group2.slug}")
|
assert has_element?(view2, "[data-testid=group-show-members-table]", "Henry")
|
||||||
assert html2 =~ "Henry"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "remove is idempotent (no error if member already removed)", %{conn: conn} do
|
test "remove is idempotent (no error if member already removed)", %{conn: conn} do
|
||||||
|
|
@ -303,22 +274,15 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Remove member first time
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Try to remove again (should not error, just be idempotent)
|
if has_element?(view, "[data-testid=group-show-members-table]", "Isabel") do
|
||||||
# Note: Implementation should handle this gracefully
|
|
||||||
# If button is still visible somehow, try to click again
|
|
||||||
html = render(view)
|
|
||||||
|
|
||||||
if html =~ "Isabel" do
|
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Should not crash
|
|
||||||
assert render(view)
|
assert render(view)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -22,34 +22,33 @@ defmodule MvWeb.GroupLive.ShowTest do
|
||||||
test "page renders successfully", %{conn: conn} do
|
test "page renders successfully", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture()
|
group = Fixtures.group_fixture()
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
assert html =~ group.name
|
assert has_element?(view, "h1", group.name)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "displays group name", %{conn: conn} do
|
test "displays group name", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture(%{name: "Test Group Name"})
|
group = Fixtures.group_fixture(%{name: "Test Group Name"})
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
assert html =~ "Test Group Name"
|
assert has_element?(view, "h1", "Test Group Name")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "displays group description when present", %{conn: conn} do
|
test "displays group description when present", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture(%{description: "This is a test description"})
|
group = Fixtures.group_fixture(%{description: "This is a test description"})
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
assert html =~ "This is a test description"
|
assert has_element?(view, "p", "This is a test description")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "displays member count", %{conn: conn} do
|
test "displays member count", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture()
|
group = Fixtures.group_fixture()
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Member count should be displayed (might be 0 or more)
|
assert has_element?(view, "[data-testid=group-show-member-count]")
|
||||||
assert html =~ "0" or html =~ gettext("Members") or html =~ "member" or html =~ "Mitglied"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "displays list of members in group", %{conn: conn} do
|
test "displays list of members in group", %{conn: conn} do
|
||||||
|
|
@ -67,26 +66,26 @@ defmodule MvWeb.GroupLive.ShowTest do
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
assert html =~ "Alice" or html =~ "Smith"
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Alice")
|
||||||
assert html =~ "Bob" or html =~ "Jones"
|
assert has_element?(view, "[data-testid=group-show-members-table]", "Bob")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "displays edit button for admin users", %{conn: conn} do
|
test "displays edit button for admin users", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture()
|
group = Fixtures.group_fixture()
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
assert html =~ gettext("Edit") or html =~ "edit" or html =~ "Bearbeiten"
|
assert has_element?(view, "[data-testid=group-show-edit-btn]")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "displays delete button for admin users", %{conn: conn} do
|
test "displays delete button for admin users", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture()
|
group = Fixtures.group_fixture()
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
assert html =~ gettext("Delete") or html =~ "delete" or html =~ "Löschen"
|
assert has_element?(view, "[data-testid=group-show-delete-btn]")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -94,19 +93,17 @@ defmodule MvWeb.GroupLive.ShowTest do
|
||||||
test "route /groups/:slug works correctly", %{conn: conn} do
|
test "route /groups/:slug works correctly", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture(%{name: "Board Members"})
|
group = Fixtures.group_fixture(%{name: "Board Members"})
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
assert html =~ "Board Members"
|
assert has_element?(view, "h1", "Board Members")
|
||||||
# Verify slug is in URL
|
|
||||||
assert html =~ group.slug or html =~ "board-members"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "group is found by slug via unique_slug identity", %{conn: conn} do
|
test "group is found by slug via unique_slug identity", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture(%{name: "Test Group"})
|
group = Fixtures.group_fixture(%{name: "Test Group"})
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
assert html =~ group.name
|
assert has_element?(view, "h1", group.name)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "non-existent slug returns 404", %{conn: conn} do
|
test "non-existent slug returns 404", %{conn: conn} do
|
||||||
|
|
@ -145,28 +142,26 @@ defmodule MvWeb.GroupLive.ShowTest do
|
||||||
test "displays empty group correctly (0 members)", %{conn: conn} do
|
test "displays empty group correctly (0 members)", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture()
|
group = Fixtures.group_fixture()
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
assert html =~ "0" or html =~ gettext("No members") or html =~ "empty" or
|
assert has_element?(view, "[data-testid=group-show-no-members]")
|
||||||
html =~ "Keine Mitglieder"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles group without description correctly", %{conn: conn} do
|
test "handles group without description correctly", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture(%{description: nil})
|
group = Fixtures.group_fixture(%{description: nil})
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
# Should not crash, description should be optional
|
assert has_element?(view, "h1", group.name)
|
||||||
assert html =~ group.name
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles slug with special characters correctly", %{conn: conn} do
|
test "handles slug with special characters correctly", %{conn: conn} do
|
||||||
# Create group with name that generates slug with hyphens
|
# Create group with name that generates slug with hyphens
|
||||||
group = Fixtures.group_fixture(%{name: "Test-Group-Name"})
|
group = Fixtures.group_fixture(%{name: "Test-Group-Name"})
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
assert html =~ "Test-Group-Name" or html =~ group.name
|
assert has_element?(view, "h1", group.name)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -177,11 +172,11 @@ defmodule MvWeb.GroupLive.ShowTest do
|
||||||
read_only_user = Fixtures.user_with_role_fixture("read_only")
|
read_only_user = Fixtures.user_with_role_fixture("read_only")
|
||||||
conn = conn_with_password_user(conn, read_only_user)
|
conn = conn_with_password_user(conn, read_only_user)
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
assert html =~ group.name
|
assert has_element?(view, "h1", group.name)
|
||||||
# Should NOT see edit/delete buttons
|
refute has_element?(view, "[data-testid=group-show-edit-btn]")
|
||||||
refute html =~ gettext("Edit") or html =~ gettext("Delete")
|
refute has_element?(view, "[data-testid=group-show-delete-btn]")
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag role: :unauthenticated
|
@tag role: :unauthenticated
|
||||||
|
|
@ -246,14 +241,14 @@ defmodule MvWeb.GroupLive.ShowTest do
|
||||||
handler_id = "test-query-counter-#{System.unique_integer([:positive])}"
|
handler_id = "test-query-counter-#{System.unique_integer([:positive])}"
|
||||||
:telemetry.attach(handler_id, [:ash, :query, :start], handler, nil)
|
:telemetry.attach(handler_id, [:ash, :query, :start], handler, nil)
|
||||||
|
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
|
|
||||||
final_count = Agent.get(query_count_agent, & &1)
|
final_count = Agent.get(query_count_agent, & &1)
|
||||||
:telemetry.detach(handler_id)
|
:telemetry.detach(handler_id)
|
||||||
|
|
||||||
# All members should be displayed
|
|
||||||
Enum.each(members, fn member ->
|
Enum.each(members, fn member ->
|
||||||
assert html =~ member.first_name or html =~ member.last_name
|
assert has_element?(view, "[data-testid=group-show-members-table]", member.first_name) or
|
||||||
|
has_element?(view, "[data-testid=group-show-members-table]", member.last_name)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
# Verify query count is reasonable (should avoid N+1 queries)
|
# Verify query count is reasonable (should avoid N+1 queries)
|
||||||
|
|
@ -267,10 +262,9 @@ defmodule MvWeb.GroupLive.ShowTest do
|
||||||
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do
|
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture()
|
group = Fixtures.group_fixture()
|
||||||
|
|
||||||
# Should use index for fast lookup
|
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
|
||||||
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
|
|
||||||
|
|
||||||
assert html =~ group.name
|
assert has_element?(view, "h1", group.name)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
defmodule MvWeb.MemberLive.IndexTest do
|
defmodule MvWeb.MemberLive.IndexTest do
|
||||||
use MvWeb.ConnCase, async: true
|
use MvWeb.ConnCase, async: false
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ defmodule MvWeb.GroupLiveHelpers do
|
||||||
"""
|
"""
|
||||||
def open_add_member(view) do
|
def open_add_member(view) do
|
||||||
view
|
view
|
||||||
|> element("button", "Add Member")
|
|> element("button[phx-click='show_add_member_input']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ defmodule MvWeb.GroupLiveHelpers do
|
||||||
"""
|
"""
|
||||||
def search_member(view, query) do
|
def search_member(view, query) do
|
||||||
view
|
view
|
||||||
|> element("#member-search-input")
|
|> element("[data-testid=group-show-member-search-input]")
|
||||||
|> render_focus()
|
|> render_focus()
|
||||||
|
|
||||||
view
|
view
|
||||||
|
|
@ -44,7 +44,7 @@ defmodule MvWeb.GroupLiveHelpers do
|
||||||
"""
|
"""
|
||||||
def add_selected(view) do
|
def add_selected(view) do
|
||||||
view
|
view
|
||||||
|> element("button[phx-click='add_selected_members']")
|
|> element("[data-testid=group-show-add-selected-members-btn]")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,9 @@
|
||||||
|
# Ensure tests never hit the real Vereinfacht API (e.g. when .env is loaded by just).
|
||||||
|
# Tests that need "configured" sync set a fake URL (127.0.0.1:1) in their own setup.
|
||||||
|
System.delete_env("VEREINFACHT_API_URL")
|
||||||
|
System.delete_env("VEREINFACHT_API_KEY")
|
||||||
|
System.delete_env("VEREINFACHT_CLUB_ID")
|
||||||
|
|
||||||
ExUnit.start(
|
ExUnit.start(
|
||||||
# shows 10 slowest tests at the end of the test run
|
# shows 10 slowest tests at the end of the test run
|
||||||
# slowest: 10
|
# slowest: 10
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue