Compare commits

...
Sign in to create a new pull request.

64 commits

Author SHA1 Message Date
b026cf6d94 Merge pull request 'Fix bulk action dropdown width' (#525) from fix-bulk-action-width into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #525
2026-06-08 12:15:16 +02:00
62c6970bf0 Merge pull request 'chore(deps): update mix dependencies' (#517) from renovate/mix-dependencies into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #517
2026-06-08 12:06:13 +02:00
2a11bfe60a chore: pin nodejs for browser-test tooling; ignore /node_modules
Some checks failed
continuous-integration/drone/push Build is failing
2026-06-08 11:53:27 +02:00
9b7368d0a3
fix: width of bulk action
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-06-08 11:44:26 +02:00
72cf85e5cb
Merge branch 'main' into renovate/mix-dependencies
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-06-08 11:25:57 +02:00
7769fd53dc Merge pull request 'Collect Bulk Actions in Dropdown' (#524) from issue/mitgliederverwaltung-420 into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #524
2026-06-04 17:38:25 +02:00
6a6099659b Merge branch 'main' into issue/mitgliederverwaltung-420
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
Integrate current main (CSV import, GDPR join-form description, dependency and
tooling bumps) into the bulk-actions-dropdown feature. Gettext catalogs were
reconciled with mix gettext.extract --merge; the CHANGELOG Unreleased entries
of both sides were combined.
2026-06-04 16:56:27 +02:00
3f44710a6b docs(changelog): record bulk-actions dropdown under Unreleased 2026-06-04 16:44:38 +02:00
c983c8d5bb feat(member): collect member-overview bulk actions into a single dropdown
The growing row of bulk-action buttons above the member overview is replaced
by one "Aktionen" dropdown holding all four actions (open in email program,
copy addresses, export CSV, export PDF). With no selection the actions operate
on all — or the currently filtered — members; the email-program action is
disabled past a recipient cap, because the browser cannot reliably hand a very
long mailto over to the mail client. The trigger shows the active scope as a
badge: an emphasized count when members are selected, a muted "alle"/"gefiltert"
otherwise.
2026-06-04 16:44:13 +02:00
8e5dd7e4c6 feat(web): add chevron affordance and scope-badge slot to dropdown triggers
Dropdown openers were visually indistinguishable from ordinary buttons. A
trailing chevron now marks every dropdown trigger — both the shared
dropdown_menu component and the bespoke member-filter trigger — and an
optional badge slot lets a trigger show a status indicator beside its label.
2026-06-04 16:40:05 +02:00
397ec69ed3 Merge pull request 'add landingURL for openCode' (#523) from issue/opencode-landingurl into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #523
2026-06-04 16:35:15 +02:00
065ecdfb2c docs(opencode): add landingURL to publiccode.yml
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-04 16:18:24 +02:00
f3e1eeaec5 Merge pull request 'chore(deps): update mix dependencies to v1 (major)' (#488) from renovate/major-mix-dependencies into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #488
2026-06-04 16:17:02 +02:00
7f3b610937
chore: update mix.lock
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-06-04 15:45:00 +02:00
8cdbd63b09
Merge remote-tracking branch 'origin/main' into renovate/major-mix-dependencies
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-04 15:34:04 +02:00
3b21e45322 Merge pull request 'chore(deps): update ghcr.io/sebadob/rauthy docker tag to v0.35.2' (#498) from renovate/ghcr.io-sebadob-rauthy-0.x into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #498
2026-06-04 15:29:58 +02:00
c158454123 Merge pull request 'chore(deps): update postgres docker tag to v18.4' (#518) from renovate/postgres into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #518
2026-06-04 15:29:45 +02:00
cb82b64b55 Merge pull request 'chore(deps): update dependency just to v1.51.0' (#499) from renovate/asdf-tool-versions into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #499
2026-06-04 15:29:28 +02:00
c78d6dbe7f Merge pull request 'Mila on OpenCode: publiccode.yml, logo & screenshots' (#522) from issue/mila-on-opencode-515 into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #522
2026-06-04 15:26:45 +02:00
ff1aa3e56a
fix: removed linux as platform from publiccode.yml
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-04 15:21:39 +02:00
defce80ece docs(opencode): add publiccode.yml, logo and screenshots for software directory
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-04 15:00:31 +02:00
ef21e0ada3 Merge pull request 'Improve Join description handling for GDPR/DSGVO' (#521) from issue/mitgliederverwaltung-508 into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #521
2026-06-04 13:02:39 +02:00
6500dead95
Merge remote-tracking branch 'origin/main' into issue/mitgliederverwaltung-508
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/promote/production Build is passing
2026-06-04 09:53:27 +02:00
03bc895ab1 Merge pull request 'fix path for asdf' (#520) from fix-path into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #520
2026-06-04 09:45:46 +02:00
f9495f557a
chore: prepend asdf paths
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-04 09:06:03 +02:00
Renovate Bot
7a0dff926a chore(deps): update mix dependencies
Some checks failed
renovate/artifacts Artifact file update failure
continuous-integration/drone/push Build is passing
2026-06-04 00:06:08 +00:00
36ca305299 docs(changelog): record GDPR/DSGVO join-form description under Unreleased
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-06-03 12:32:48 +02:00
1f52350562 feat(seeds): shorten the GDPR field to "DSGVO" and seed its join_description 2026-06-03 12:32:15 +02:00
404d524ee1 feat(custom-field): let admins set join_description with a link-syntax hint 2026-06-03 12:28:23 +02:00
df271055a8 feat(member): show join_description as a tooltip on custom-field labels 2026-06-03 12:22:58 +02:00
ee5ccbf7e9 feat(join): highlight auto-linked join-form links 2026-06-03 12:16:53 +02:00
aced57d0fd feat(join): use join_description as the join-form field label 2026-06-03 12:11:39 +02:00
cb5cb68483 feat(join): render join_description with auto-linked URLs and Markdown links 2026-06-03 12:06:48 +02:00
b6c2cf58b1 feat(custom-field): add join_description attribute for GDPR join-form labels 2026-06-03 12:01:41 +02:00
1b671ea41a Merge pull request 'Minor CSV import improvements closes #509' (#519) from issue/mitgliederverwaltung-509 into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #519
2026-06-03 03:02:09 +02:00
2bc5fcec5a docs(changelog): record CSV import improvements under Unreleased
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-06-03 02:37:46 +02:00
45c9b81983 fix(import): collapse duplicate fee-type warnings into a bounded list 2026-06-03 02:37:12 +02:00
118b9f8d57 perf(import): reuse auto-created groups across import chunks 2026-06-03 02:32:15 +02:00
68a1a9530a feat(import): confirm column mapping in a preview before importing members 2026-06-03 02:25:50 +02:00
a93dd9d535 feat(import): serve dynamic CSV import templates reflecting current custom fields 2026-06-03 02:21:36 +02:00
00e1624ee4 feat(import): assign groups and fee types to imported members, creating missing groups 2026-06-03 02:15:54 +02:00
a4a34cab3a feat(import): resolve import group and fee-type names against existing records 2026-06-03 02:10:33 +02:00
Renovate Bot
8429fb2b9c chore(deps): update mix dependencies to v1
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-03 00:06:45 +00:00
Renovate Bot
aaffd7b91c chore(deps): update postgres docker tag to v18.4
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-03 00:06:34 +00:00
95c7bf7a15 feat(import): recognize group and fee-type columns and always ignore fee-status 2026-06-03 02:01:09 +02:00
5c5fd56749 fix(export): emit date custom-field values as ISO-8601 for re-import 2026-06-03 01:54:49 +02:00
d51dcb1ac3 chore(ci): make test workflow faster with test --stale
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-02 23:35:39 +02:00
1ef6ea502e Merge pull request 'Add dialyzer and resolve all findings closes #503 #504 #514' (#516) from issue/mitgliederverwaltung-514 into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #516
2026-06-02 13:15:00 +02:00
9a14cedc14 fix(repo): define all_tenants/0 as empty for non-multitenant schema
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
2026-06-02 12:26:35 +02:00
b5756d8e00 refactor(vereinfacht): gate retry skipping on runtime sandbox flag
The compile-time Mix.env() comparison folded to an always-false literal under analysis. sql_sandbox?/0 reads runtime config (true only in test) and works in releases where Mix is unavailable, preserving the fast-fail-no-retry behavior in tests.
2026-06-02 12:23:04 +02:00
a7ad608051 fix(auth): redirect a live-view socket in the user-required guard
LiveSession.assign_new_resources/2 is typed to return a Phoenix.Socket, which made the on_mount redirect type-incompatible. The authenticated-routes live_session already assigns current_user, so the guard reads it from socket.assigns directly. Also assign the locale into the socket actually used by the no-user redirect instead of discarding it.
2026-06-02 12:19:21 +02:00
6a4a99f638 refactor(types): drop guards and clauses that can never succeed 2026-06-02 12:11:59 +02:00
ec6422d450 fix(membership-fees): show error for unparseable cycle date instead of crashing
Date.from_iso8601/1 returns {:error, reason}, so the with else clause matching a bare :error never fired and an invalid date raised a WithClauseError. Match the real date/calendar error reasons so the user sees the validation message.
2026-06-02 12:08:19 +02:00
2db467d5d1 fix(pdf-export): match DateTime.from_iso8601 three-tuple when formatting cells
DateTime.from_iso8601/1 returns {:ok, datetime, offset}, so the two-tuple clauses never matched and datetime cells fell through to the naive-parse fallback. Matching the real shape routes them through the intended DateTime path; UTC values render identically.
2026-06-02 12:04:37 +02:00
c41d24113f fix(import): return readable string for unreadable upload errors
File.read/1 only yields posix atoms, so the File.Error and bare-reason branches were unreachable, and :file.format_error/1 returns a charlist rather than a String. Normalize the error to a binary so it interpolates correctly in flash messages.
2026-06-02 12:00:38 +02:00
05f66ccf74 refactor(types): remove dead catch-all clauses unreachable per success typing 2026-06-02 11:56:44 +02:00
d9a5a081df refactor(import): drop unreachable CSV error-formatting path
consume_and_read_csv/2 and MemberCSV.prepare/2 only ever return {:error, binary()}, so the non-binary error branch and the format_error_message/* helpers it called were unreachable. Removed them and bound the remaining discarded locale/dispatch results.
2026-06-02 11:50:43 +02:00
c0395f16e8 fix(types): resolve unknown type references in member and authorization specs 2026-06-02 11:46:54 +02:00
848f0cd013 refactor(types): bind intentionally discarded side-effecting results 2026-06-02 11:42:57 +02:00
04ab05f556 fix(member-export): forbid request without actor instead of falling through
The nil-actor guard used a one-armed if and continued into the export path regardless. The CheckPagePermission plug already halts unauthenticated requests before this controller runs, so the corrected early return preserves observable behavior while removing the dead fall-through. The export action is split into per-payload clauses so the guard reads as a flat early return.
2026-06-02 11:39:04 +02:00
5352a635c6 refactor(release): bind discarded results of side-effecting release tasks 2026-06-02 11:33:14 +02:00
fd8e6ac178 refactor(types): reconcile @specs with their success typings 2026-06-02 11:25:03 +02:00
Renovate Bot
1fb6ba814a chore(deps): update dependency just to v1.51.0
Some checks failed
continuous-integration/drone/push Build is failing
2026-06-01 00:05:43 +00:00
Renovate Bot
634b21d1bc chore(deps): update ghcr.io/sebadob/rauthy docker tag to v0.35.2
Some checks failed
continuous-integration/drone/push Build is failing
2026-06-01 00:05:40 +00:00
108 changed files with 4902 additions and 816 deletions

1
.gitignore vendored
View file

@ -34,6 +34,7 @@ mv-*.tar
# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/
/node_modules/
.cursor

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View file

@ -1,3 +1,4 @@
elixir 1.18.3-otp-27
erlang 27.3.4
just 1.50.0
just 1.51.0
nodejs 26.2.0

View file

@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **GDPR/DSGVO join-form description** Custom fields can carry a "join form description" that is shown as the field's label on the public join form, with clickable external links (whole URLs and Markdown `[text](url)`). Useful for presenting a GDPR confirmation with a link to an externally hosted privacy declaration before sign-up.
- **Join-form description tooltip in member details** Custom fields that have a join-form description show an info tooltip (prefixed "Beitrittsformular:") on their label in the member detail view.
- **Editable join-form description** Admins can set a field's join-form description in the custom-field settings, with an inline hint about the supported link syntax.
- **CSV import groups column** Members can be assigned to groups during CSV import via a `Groups`/`Gruppen` column; group names that do not exist yet are created automatically, and re-importing the same file does not create duplicate groups.
- **CSV import membership fee type column** A `Fee Type`/`Beitragsart` column assigns each member's membership fee type; an unknown name falls back to the default fee type and is flagged in the preview with a link to create it.
- **CSV import mapping preview** After uploading a file, a preview shows how every column maps (with sample rows and warnings for ignored or unknown columns) and the import only starts once you confirm.
- **Dynamic CSV import templates** The EN and DE import-template downloads now include the association's current custom fields instead of a fixed column set.
### Changed
- **Member bulk actions in one menu** The actions above the member overview (open in email program, copy email addresses, export to CSV, export to PDF) are now collected in a single "Aktionen" dropdown instead of separate buttons. Without a selection they apply to all members, or to the currently filtered members; the trigger shows the active scope. Opening the email program is disabled when too many recipients are selected, with a hint to copy the addresses or use the export instead.
- **Dropdown buttons** Dropdown buttons (actions, filter, column visibility) now show a chevron so they are recognizable as menus.
- **Default GDPR custom field** The seeded GDPR field was shortened from "Datenschutzerklärung akzeptiert" to "DSGVO" and now ships with a default join-form description (with a placeholder link to replace).
### Fixed
- **CSV date round-trip** Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors.
- **CSV import fee-status columns ignored** Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists.
## [1.2.0] - 2026-05-08
### Changed

View file

@ -1363,6 +1363,8 @@ mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete
### 3.13 Task Runner: Just
The `Justfile` prepends `~/.asdf/shims`, `~/.asdf/bin`, and `~/.asdf` to `PATH` for all recipes (`set export := true`), so `mix` / `elixir` resolve from `.tool-versions` without shell init. The caller's `PATH` is kept (e.g. Homebrew `asdf`, Docker). Run `asdf install` once per machine; no extra `source` is required for `just run`.
**Common Commands:**
```bash

View file

@ -1,11 +1,11 @@
set dotenv-load := true
set export := true
# Non-interactive shells do not source .bashrc,
# PATH includes asdf shims so that mix / elixir / iex resolve without per-shell
# `source ~/.asdf/asdf.sh`. Recipes inherit this via `set export := true`.
home := env_var('HOME')
PATH := home + "/.asdf/shims:" + home + "/.asdf:" + home + "/.local/bin:/usr/local/bin:/usr/bin:/bin"
# Prepend asdf paths so recipes work without sourcing ~/.asdf/asdf.sh in the shell.
# Caller PATH is preserved (Homebrew asdf, docker CLI, etc.). See CODE_GUIDELINES §3.13.
home := env_var("HOME")
asdf_paths := home + "/.asdf/shims:" + home + "/.asdf/bin:" + home + "/.asdf:"
PATH := asdf_paths + env_var("PATH")
MIX_QUIET := "1"
@ -29,7 +29,12 @@ seed-database:
start-database:
docker compose up -d
ci-dev: lint audit test-fast
# Full check suite: lint + audit + the fast tests (slow/ui excluded). No Dialyzer.
ci-dev: install-dependencies lint audit test-fast
# Fast pre-commit check: lint + sobelow + only the affected tests (mix test --stale)
# with reduced property runs. Run the full `ci-dev` before pushing.
check: install-dependencies lint sobelow test-stale
# Build the Dialyzer PLT. Idempotent — no-op once the PLT is up to date.
# First build takes 515 min; subsequent runs are seconds. PLT files live in
@ -58,19 +63,28 @@ lint:
@bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
mix gettext.extract --check-up-to-date
audit:
# Static security scan (Sobelow).
sobelow:
mix sobelow --config
# Full security audit: Sobelow + dependency advisory scans.
audit: sobelow
mix deps.audit --ignore-file .deps_audit_ignore
mix hex.audit
# Run all tests
test *args: install-dependencies
# Run all tests. No install-dependencies prerequisite so single-file runs stay
# fast; run `just install-dependencies` once on a fresh checkout.
test *args:
mix test {{args}}
# Run only fast tests (excludes slow/performance and UI tests)
test-fast *args: install-dependencies
# Fast tests only (excludes slow/performance and UI tests).
test-fast *args:
mix test --exclude slow --exclude ui {{args}}
# Affected fast tests only (mix test --stale) with reduced property runs.
test-stale *args:
PROPERTY_RUNS=25 mix test --stale --exclude slow --exclude ui {{args}}
# Run only UI tests
ui *args: install-dependencies
mix test --only ui {{args}}

View file

@ -62,3 +62,7 @@ config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2
# Ash: silence "after_transaction hooks in surrounding transaction" warning when using
# Ecto sandbox (tests run in a transaction; create_member after_transaction is expected).
config :ash, warn_on_transaction_hooks?: false
# StreamData property tests: generated cases per property, overridable via PROPERTY_RUNS
# (the `just check` recipe sets it low for speed; default 100 otherwise).
config :stream_data, max_runs: String.to_integer(System.get_env("PROPERTY_RUNS") || "100")

View file

@ -33,7 +33,7 @@ services:
restart: unless-stopped
db-prod:
image: postgres:18.3-alpine
image: postgres:18.4-alpine
container_name: mv-prod-db
environment:
POSTGRES_USER: postgres

View file

@ -4,7 +4,7 @@ networks:
services:
db:
image: postgres:18.3-alpine
image: postgres:18.4-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@ -25,7 +25,7 @@ services:
rauthy:
container_name: rauthy-dev
image: ghcr.io/sebadob/rauthy:0.35.1
image: ghcr.io/sebadob/rauthy:0.35.2
environment:
- LOCAL_TEST=true
- SMTP_URL=mailcrab

View file

@ -12,6 +12,8 @@ defmodule Mv.Membership.CustomField do
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation.
- `description` - Optional human-readable description
- `join_description` - Optional label shown for this field on the public join form
(e.g., a GDPR confirmation text); supports inline external links. Falls back to `name` when nil.
- `required` - If true, all members must have this custom field (future feature)
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
@ -61,7 +63,14 @@ defmodule Mv.Membership.CustomField do
end
actions do
default_accept [:name, :value_type, :description, :required, :show_in_overview]
default_accept [
:name,
:value_type,
:description,
:join_description,
:required,
:show_in_overview
]
read :read do
primary? true
@ -69,13 +78,13 @@ defmodule Mv.Membership.CustomField do
end
create :create do
accept [:name, :value_type, :description, :required, :show_in_overview]
accept [:name, :value_type, :description, :join_description, :required, :show_in_overview]
change Mv.Membership.Changes.GenerateSlug
validate string_length(:slug, min: 1)
end
update :update do
accept [:name, :description, :required, :show_in_overview]
accept [:name, :description, :join_description, :required, :show_in_overview]
require_atomic? false
validate fn changeset, _context ->
@ -139,6 +148,15 @@ defmodule Mv.Membership.CustomField do
trim?: true
]
attribute :join_description, :string,
allow_nil?: true,
public?: true,
description: "Label shown for this field on the public join form; supports external links",
constraints: [
max_length: 1000,
trim?: true
]
attribute :required, :boolean,
default: false,
allow_nil?: false

View file

@ -17,16 +17,10 @@ defmodule Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist do
form_data = Ash.Changeset.get_attribute(changeset, :form_data) || %{}
allowlist_ids =
case Membership.get_join_form_allowlist() do
list when is_list(list) ->
list
|> Enum.map(fn item -> item.id end)
|> MapSet.new()
|> MapSet.difference(MapSet.new(@typed_fields))
_ ->
MapSet.new()
end
Membership.get_join_form_allowlist()
|> Enum.map(fn item -> item.id end)
|> MapSet.new()
|> MapSet.difference(MapSet.new(@typed_fields))
filtered =
form_data

View file

@ -51,6 +51,9 @@ defmodule Mv.Membership.Member do
require Logger
@typedoc "An `Mv.Membership.Member` resource record."
@type t :: %__MODULE__{}
# Module constants
@member_search_limit 10
@ -791,7 +794,7 @@ defmodule Mv.Membership.Member do
# nil/[] when membership_fee_type is missing.
@doc false
@spec get_current_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
@spec get_current_cycle(t()) :: MembershipFeeCycle.t() | nil
def get_current_cycle(member) do
today = Date.utc_today()
@ -821,7 +824,7 @@ defmodule Mv.Membership.Member do
end
@doc false
@spec get_last_completed_cycle(Member.t()) :: MembershipFeeCycle.t() | nil
@spec get_last_completed_cycle(t()) :: MembershipFeeCycle.t() | nil
def get_last_completed_cycle(member) do
today = Date.utc_today()
@ -867,7 +870,7 @@ defmodule Mv.Membership.Member do
end
@doc false
@spec get_overdue_cycles(Member.t()) :: [MembershipFeeCycle.t()]
@spec get_overdue_cycles(t()) :: [MembershipFeeCycle.t()]
def get_overdue_cycles(member) do
today = Date.utc_today()
@ -939,7 +942,7 @@ defmodule Mv.Membership.Member do
# Already in transaction: use advisory lock directly
# Returns {:ok, notifications} - notifications should be returned to after_action hook
defp regenerate_cycles_in_transaction(member, today, lock_key) do
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
_ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
end
@ -947,7 +950,7 @@ defmodule Mv.Membership.Member do
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
defp regenerate_cycles_new_transaction(member, today, lock_key) do
Repo.transaction(fn ->
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
_ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
{:ok, notifications} ->
@ -1093,7 +1096,7 @@ defmodule Mv.Membership.Member do
initiator: initiator
) do
{:ok, cycles, notifications} ->
send_notifications_if_any(notifications)
_ = send_notifications_if_any(notifications)
log_cycle_generation_success(member, cycles, notifications,
sync: true,
@ -1112,7 +1115,7 @@ defmodule Mv.Membership.Member do
initiator: initiator
) do
{:ok, cycles, notifications} ->
send_notifications_if_any(notifications)
_ = send_notifications_if_any(notifications)
log_cycle_generation_success(member, cycles, notifications,
sync: false,
@ -1231,8 +1234,6 @@ defmodule Mv.Membership.Member do
|> String.replace("_", "\\_")
end
defp sanitize_search_query(_), do: ""
# ============================================================================
# Search Filter Builders
# ============================================================================

View file

@ -37,9 +37,10 @@ defmodule Mv.Membership.Member.Changes.UnrelateUserWhenArgumentNil do
{:ok, %{user: user}} when not is_nil(user) ->
# User's :update action only accepts [:email]; use :update_user so
# manage_relationship(:member, ..., on_missing: :unrelate) runs and clears member_id.
user
|> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts)
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false)
_ =
user
|> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts)
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false)
changeset

View file

@ -836,7 +836,10 @@ defmodule Mv.Membership do
- `{:ok, rejected_request}` - Rejected JoinRequest
- `{:error, error}` - Status error or authorization error
"""
@spec reject_join_request(String.t(), keyword()) :: {:ok, JoinRequest.t()} | {:error, term()}
@spec reject_join_request(String.t(), keyword()) ::
{:ok, JoinRequest.t()}
| {:ok, JoinRequest.t(), [Ash.Notifier.Notification.t()]}
| {:error, term()}
def reject_join_request(id, opts \\ []) do
actor = Keyword.get(opts, :actor)

View file

@ -26,8 +26,6 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
"""
use Ash.Resource.Change
require Logger
alias Mv.MembershipFees.CalendarCycles
@impl true
@ -83,11 +81,6 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
field: :membership_fee_type_id,
message: "not found"
)
{:error, reason} ->
# Log warning for other unexpected errors
Logger.warning("Could not auto-set membership_fee_start_date: #{inspect(reason)}")
changeset
end
end

View file

@ -43,6 +43,7 @@ defmodule Mv.Authorization.PermissionSets do
pattern matches and map lookups with no database queries or external calls.
"""
@type permission_set_name :: :own_data | :read_only | :normal_user | :admin
@type scope :: :own | :linked | :all
@type action :: :read | :create | :update | :destroy
@ -88,7 +89,7 @@ defmodule Mv.Authorization.PermissionSets do
iex> PermissionSets.all_permission_sets()
[:own_data, :read_only, :normal_user, :admin]
"""
@spec all_permission_sets() :: [atom()]
@spec all_permission_sets() :: [permission_set_name(), ...]
def all_permission_sets do
[:own_data, :read_only, :normal_user, :admin]
end
@ -107,7 +108,7 @@ defmodule Mv.Authorization.PermissionSets do
iex> PermissionSets.get_permissions(:invalid)
** (ArgumentError) invalid permission set: :invalid. Must be one of: [:own_data, :read_only, :normal_user, :admin]
"""
@spec get_permissions(atom()) :: permission_set()
@spec get_permissions(permission_set_name()) :: permission_set()
def get_permissions(set) when set not in [:own_data, :read_only, :normal_user, :admin] do
raise ArgumentError,

View file

@ -207,8 +207,6 @@ defmodule Mv.Config do
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).
"""
@ -251,7 +249,6 @@ defmodule Mv.Config do
case System.get_env(key) do
nil -> false
v when is_binary(v) -> String.trim(v) != ""
_ -> false
end
end
@ -270,9 +267,6 @@ defmodule Mv.Config do
value when is_binary(value) ->
v = String.trim(value) |> String.downcase()
v in ["true", "1", "yes"]
_ ->
false
end
end
@ -328,7 +322,6 @@ defmodule Mv.Config do
defp present?(nil), do: false
defp present?(s) when is_binary(s), do: String.trim(s) != ""
defp present?(_), do: false
# ---------------------------------------------------------------------------
# OIDC authentication
@ -409,7 +402,7 @@ defmodule Mv.Config do
@doc """
Returns the OIDC groups claim name (default "groups"). ENV first, then Settings.
"""
@spec oidc_groups_claim() :: String.t() | nil
@spec oidc_groups_claim() :: String.t()
def oidc_groups_claim do
case env_or_setting("OIDC_GROUPS_CLAIM", :oidc_groups_claim) do
nil -> "groups"
@ -492,7 +485,7 @@ defmodule Mv.Config do
- ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_PORT`
- Settings mode: read from Settings only
"""
@spec smtp_port() :: non_neg_integer() | nil
@spec smtp_port() :: pos_integer() | nil
def smtp_port do
if smtp_env_mode?() do
parse_smtp_port_env(System.get_env("SMTP_PORT"))
@ -638,9 +631,15 @@ defmodule Mv.Config do
"""
@spec mail_from_name() :: String.t()
def mail_from_name do
case System.get_env("MAIL_FROM_NAME") do
nil -> get_from_settings(:smtp_from_name) || "Mila"
value -> trim_nil(value) || "Mila"
name =
case System.get_env("MAIL_FROM_NAME") do
nil -> get_from_settings(:smtp_from_name)
value -> trim_nil(value)
end
case name do
nil -> "Mila"
name -> name
end
end

View file

@ -40,6 +40,8 @@ defmodule Mv.Constants do
@max_boolean_filters 50
@max_mailto_bulk_recipients 50
@max_uuid_length 36
@email_validator_checks [:html_input, :pow]
@ -173,6 +175,21 @@ defmodule Mv.Constants do
"""
def max_boolean_filters, do: @max_boolean_filters
@doc """
Returns the maximum number of mailto recipients before the bulk "open in email
program" action is disabled.
The mailto link carries every recipient in its BCC; browsers cannot reliably
hand a too-long mailto URI to the mail program. At or above this count the
action is disabled in the UI (Copy and Export have no such limit).
## Examples
iex> Mv.Constants.max_mailto_bulk_recipients()
50
"""
def max_mailto_bulk_recipients, do: @max_mailto_bulk_recipients
@doc """
Returns the maximum length of a UUID string (36 characters including hyphens).

View file

@ -225,7 +225,10 @@ defmodule Mv.Helpers.SystemActor do
# This allows configuration via SYSTEM_ACTOR_EMAIL env var
@spec system_user_email_config() :: String.t()
defp system_user_email_config do
System.get_env("SYSTEM_ACTOR_EMAIL") || "system@mila.local"
case System.get_env("SYSTEM_ACTOR_EMAIL") do
nil -> "system@mila.local"
email -> email
end
end
# Loads the system actor from the database
@ -257,7 +260,7 @@ defmodule Mv.Helpers.SystemActor do
end
# Handles database error when loading system user
@spec handle_system_user_error(term()) :: Mv.Accounts.User.t() | no_return()
@spec handle_system_user_error({:error, Ash.Error.t()}) :: Mv.Accounts.User.t() | no_return()
defp handle_system_user_error(error) do
case load_admin_user_fallback() do
{:ok, admin_user} ->
@ -393,15 +396,18 @@ defmodule Mv.Helpers.SystemActor do
# 1. Only creates system user with known email
# 2. Only called during system actor initialization (bootstrap)
# 3. Once created, all subsequent operations use proper authorization
Accounts.create_user!(%{email: system_user_email_config()},
upsert?: true,
upsert_identity: :unique_email,
authorize?: false
)
|> Ash.Changeset.for_update(:update_internal, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
user =
Accounts.create_user!(%{email: system_user_email_config()},
upsert?: true,
upsert_identity: :unique_email,
authorize?: false
)
|> Ash.Changeset.for_update(:update_internal, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
%Accounts.User{} = user
end
# Finds a user by email address

View file

@ -190,6 +190,4 @@ defmodule Mv.Mailer do
defp valid_email?(email) when is_binary(email) do
Regex.match?(@email_regex, String.trim(email))
end
defp valid_email?(_), do: false
end

View file

@ -4,13 +4,13 @@ defmodule Mv.Membership.CustomFieldValueFormatter do
Same logic as the member overview Formatter but without Gettext or web helpers,
so it can be used from the Membership context. For boolean: "Yes"/"No";
for date: European format (dd.mm.yyyy).
for date: ISO-8601 (YYYY-MM-DD) so exported values can be re-imported.
"""
@doc """
Formats a custom field value for plain text (e.g. CSV).
Handles nil, Ash.Union, JSONB map, and direct values. Uses custom_field.value_type
for typing. Boolean -> "Yes"/"No", Date -> dd.mm.yyyy.
for typing. Boolean -> "Yes"/"No", Date -> ISO-8601 (YYYY-MM-DD).
"""
def format_custom_field_value(nil, _custom_field), do: ""
@ -18,6 +18,10 @@ defmodule Mv.Membership.CustomFieldValueFormatter do
format_value_by_type(value, type, custom_field)
end
def format_custom_field_value(%Date{} = value, custom_field) do
format_value_by_type(value, :date, custom_field)
end
def format_custom_field_value(value, custom_field) when is_map(value) do
type = Map.get(value, "type") || Map.get(value, "_union_type")
val = Map.get(value, "value") || Map.get(value, "_union_value")
@ -41,12 +45,12 @@ defmodule Mv.Membership.CustomFieldValueFormatter do
defp format_value_by_type(value, :boolean, _), do: to_string(value)
defp format_value_by_type(%Date{} = date, :date, _) do
Calendar.strftime(date, "%d.%m.%Y")
Date.to_iso8601(date)
end
defp format_value_by_type(value, :date, _) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> Calendar.strftime(date, "%d.%m.%Y")
{:ok, date} -> Date.to_iso8601(date)
_ -> value
end
end

View file

@ -0,0 +1,258 @@
defmodule Mv.Membership.Import.ColumnResolver do
@moduledoc """
Read-only resolution of CSV import columns against the database.
Given the `HeaderMapper.build_maps/2` result, the raw numbered rows, and an
actor, `resolve/3` determines:
- which group names in the groups column already exist (`groups_found`) and
which would have to be created (`groups_to_create`);
- a small set of preview rows for the mapping preview UI.
No database writes happen here; the resolver only reads. Group creation and
member-group assignment happen during processing via `create_or_find_group/3`.
This module has no Phoenix or web dependencies.
"""
require Logger
alias Mv.Membership.Import.HeaderMapper
@preview_row_limit 3
@type numbered_row :: {pos_integer(), [String.t()]}
@type resolution :: %{
groups_found: [%{id: String.t(), name: String.t()}],
groups_to_create: [String.t()],
fee_type_map: %{String.t() => String.t()},
fee_type_warnings: [String.t()],
has_empty_fee_type_cells?: boolean(),
preview_rows: [[String.t()]]
}
@doc """
Resolves the group and fee-type columns of an import against the database and
extracts preview rows.
Returns a map with `:groups_found`, `:groups_to_create`, `:fee_type_map`,
`:fee_type_warnings`, `:has_empty_fee_type_cells?`, and `:preview_rows`.
"""
@spec resolve(map(), [numbered_row()], term()) :: resolution()
def resolve(header_maps, rows, actor) do
%{
groups_found: groups_found,
groups_to_create: groups_to_create
} = resolve_groups(header_maps, rows, actor)
%{
fee_type_map: fee_type_map,
fee_type_warnings: fee_type_warnings,
has_empty_fee_type_cells?: has_empty_fee_type_cells?
} = resolve_fee_types(header_maps, rows, actor)
%{
groups_found: groups_found,
groups_to_create: groups_to_create,
fee_type_map: fee_type_map,
fee_type_warnings: fee_type_warnings,
has_empty_fee_type_cells?: has_empty_fee_type_cells?,
preview_rows: preview_rows(rows)
}
end
defp resolve_groups(%{groups_column_index: nil}, _rows, _actor) do
%{groups_found: [], groups_to_create: []}
end
defp resolve_groups(%{groups_column_index: index}, rows, actor) do
existing_groups = list_groups(actor)
lookup = build_group_lookup(existing_groups)
names = unique_group_names(rows, index)
{found, to_create} =
Enum.reduce(names, {[], []}, fn name, {found, to_create} ->
case Map.get(lookup, normalize_name(name)) do
nil -> {found, [name | to_create]}
group -> {[%{id: group.id, name: group.name} | found], to_create}
end
end)
%{groups_found: Enum.reverse(found), groups_to_create: Enum.reverse(to_create)}
end
defp resolve_fee_types(%{fee_type_column_index: nil}, _rows, _actor) do
%{fee_type_map: %{}, fee_type_warnings: [], has_empty_fee_type_cells?: false}
end
defp resolve_fee_types(%{fee_type_column_index: index}, rows, actor) do
lookup = build_fee_type_lookup(actor)
cells = Enum.map(rows, fn {_line, values} -> Enum.at(values, index) end)
has_empty? = Enum.any?(cells, &blank?/1)
{fee_type_map, warnings} =
cells
|> Enum.reject(&blank?/1)
|> Enum.uniq_by(&normalize_fee_type_name/1)
|> Enum.reduce({%{}, []}, fn name, {map, warnings} ->
case Map.get(lookup, normalize_fee_type_name(name)) do
nil -> {map, [String.trim(name) | warnings]}
id -> {Map.put(map, normalize_fee_type_name(name), id), warnings}
end
end)
%{
fee_type_map: fee_type_map,
fee_type_warnings: Enum.reverse(warnings),
has_empty_fee_type_cells?: has_empty?
}
end
@doc """
Normalizes a fee-type name using the same rules as CSV header normalization
(trim, lowercase, transliterate, drop hyphens and whitespace).
"""
@spec normalize_fee_type_name(String.t() | nil) :: String.t()
def normalize_fee_type_name(name) when is_binary(name), do: HeaderMapper.normalize_header(name)
def normalize_fee_type_name(_), do: ""
defp build_fee_type_lookup(actor) do
actor
|> list_fee_types()
|> Enum.reduce(%{}, fn fee_type, acc ->
normalized = normalize_fee_type_name(fee_type.name)
if Map.has_key?(acc, normalized) do
Logger.warning(
"Multiple membership fee types normalize to #{inspect(normalized)}; using the first match for CSV import."
)
acc
else
Map.put(acc, normalized, fee_type.id)
end
end)
end
defp list_fee_types(actor) do
Mv.MembershipFees.list_membership_fee_types!(actor: actor)
end
defp blank?(nil), do: true
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
defp blank?(_), do: false
@doc """
Finds an existing group by name (case-insensitive) or creates it.
Looks first in the pre-fetched `groups` list, then in the database (to catch
groups created earlier in the same import), and only creates a new group when
none is found. This keeps group resolution idempotent across re-imports.
"""
@spec create_or_find_group(String.t(), [Mv.Membership.Group.t()], term()) ::
{:ok, Mv.Membership.Group.t()} | {:error, term()}
def create_or_find_group(name, groups, actor) when is_binary(name) do
trimmed = String.trim(name)
normalized = normalize_name(trimmed)
case find_group_in_list(groups, normalized) do
nil -> find_or_create_group(trimmed, normalized, actor)
group -> {:ok, group}
end
end
defp find_group_in_list(groups, normalized) do
Enum.find(groups, fn group -> normalize_name(group.name) == normalized end)
end
defp find_or_create_group(trimmed, normalized, actor) do
case fetch_group_by_normalized_name(normalized, actor) do
nil -> create_group(trimmed, normalized, actor)
group -> {:ok, group}
end
end
# Normalizes the Ash code-interface return to a two-shape result.
#
# On a create failure the group may have been created concurrently by another
# import session between our read and our write (the DB unique index is the
# final arbiter, and the name validation is fail-open). Re-fetch by normalized
# name and link to the existing group rather than failing the row.
defp create_group(name, normalized, actor) do
case Mv.Membership.create_group(%{name: name}, actor: actor) do
{:ok, %Mv.Membership.Group{} = group} ->
{:ok, group}
{:error, reason} ->
case fetch_group_by_normalized_name(normalized, actor) do
nil -> {:error, reason}
group -> {:ok, group}
end
end
end
# Fetches a single group by case-insensitive name using a name-filtered query
# rather than reading the whole groups table. `normalized` is the trimmed,
# lower-cased name; the DB comparison uses LOWER(name) consistent with the
# Group resource's case-insensitive uniqueness constraint.
defp fetch_group_by_normalized_name(normalized, actor) do
require Ash.Query
Mv.Membership.Group
|> Ash.Query.filter(fragment("LOWER(?) = ?", name, ^normalized))
|> Ash.read(actor: actor, domain: Mv.Membership)
|> case do
{:ok, [group | _]} -> group
_ -> nil
end
end
@doc """
Splits a raw groups-cell value into trimmed, non-empty group names.
"""
@spec split_group_names(String.t() | nil) :: [String.t()]
def split_group_names(nil), do: []
def split_group_names(cell) when is_binary(cell) do
cell
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp unique_group_names(rows, index) do
rows
|> Enum.flat_map(fn {_line, values} ->
values
|> Enum.at(index)
|> split_group_names()
end)
|> Enum.uniq_by(&normalize_name/1)
end
defp preview_rows(rows) do
rows
|> Enum.take(@preview_row_limit)
|> Enum.map(fn {_line, values} -> values end)
end
defp list_groups(actor) do
Mv.Membership.list_groups!(actor: actor)
end
defp build_group_lookup(groups) do
Enum.reduce(groups, %{}, fn group, acc ->
Map.put(acc, normalize_name(group.name), group)
end)
end
# Case-insensitive comparison consistent with the Group resource's
# case-insensitive name uniqueness.
defp normalize_name(name) when is_binary(name) do
name |> String.trim() |> String.downcase()
end
end

View file

@ -100,7 +100,8 @@ defmodule Mv.Membership.Import.CsvParser do
|> String.replace("\r", "\n")
end
@spec get_parser(String.t()) :: module()
@spec get_parser(String.t()) ::
Mv.Membership.Import.CsvParserSemicolon | Mv.Membership.Import.CsvParserComma
defp get_parser(";"), do: Mv.Membership.Import.CsvParserSemicolon
defp get_parser(","), do: Mv.Membership.Import.CsvParserComma
defp get_parser(_), do: Mv.Membership.Import.CsvParserSemicolon
@ -116,7 +117,10 @@ defmodule Mv.Membership.Import.CsvParser do
if semicolon_score >= comma_score, do: ";", else: ","
end
@spec header_field_count(module(), binary()) :: non_neg_integer()
@spec header_field_count(
Mv.Membership.Import.CsvParserSemicolon | Mv.Membership.Import.CsvParserComma,
binary()
) :: non_neg_integer()
defp header_field_count(parser, header_record) do
case parse_single_record(parser, header_record, nil) do
{:ok, fields} -> Enum.count(fields, &(String.trim(&1) != ""))

View file

@ -29,12 +29,21 @@ defmodule Mv.Membership.Import.HeaderMapper do
Supports English and German header variants (e.g. "Email" / "E-Mail", "Join Date" / "Beitrittsdatum").
## Special columns
- **groups** Many-to-many relationship (through member_groups). Recognized via the
`groups_column_index` key (headers `Groups`, `Gruppen`, `Gruppe`). Comma-separated
names are resolved during processing; missing groups are auto-created.
- **membership_fee_type** Recognized via the `fee_type_column_index` key (headers
`Fee Type`, `fee_type`, `membership_fee_type`, `Beitragsart`). Names are matched to
existing fee types; unknown names fall back to the default fee type.
## Fields not supported for import
- **membership_fee_status** Computed (calculation from membership fee cycles). Not stored;
cannot be set via CSV. Export can include it.
- **groups** Many-to-many relationship (through member_groups). Import would require
resolving group names/slugs to IDs and creating associations; not in current import scope.
cannot be set via CSV. Export can include it. Fee-status header variants
(`Membership Fee Status`, `Bezahlstatus`, `Mitgliedsbeitragsstatus`) are explicitly
placed in the `ignored` list and never mapped.
## Custom Field Detection
@ -47,10 +56,10 @@ defmodule Mv.Membership.Import.HeaderMapper do
"e-mail"
iex> HeaderMapper.build_maps(["Email", "First Name"], [])
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}}
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
iex> HeaderMapper.build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}])
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}}
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
"""
@type column_map :: %{atom() => non_neg_integer()}
@ -60,6 +69,33 @@ defmodule Mv.Membership.Import.HeaderMapper do
# Required member fields
@required_member_fields [:email]
# Fee-status header variants that must never be imported (computed/read-only field).
# Stored already-normalized; checked before member, custom, groups, and fee-type mapping.
# Maintain this list when new locale translations for fee-status are added.
@ignored_normalized [
"membershipfeestatus",
"mitgliedsbeitragsstatus",
"bezahlstatus",
# DE export label for membership_fee_start_date — system-managed, not importable
"startdatummitgliedsbeitrag"
]
# Normalized header variants for the groups column. The column is resolved to
# group associations during import; it is never a member or custom field.
@groups_column_normalized [
"groups",
"gruppen",
"gruppe"
]
# Normalized header variants for the membership fee-type column. The column is
# resolved to a MembershipFeeType during import; it is never a member or custom field.
@fee_type_column_normalized [
"membershipfeetype",
"feetype",
"beitragsart"
]
# Canonical member fields with their raw variants
# These will be normalized at runtime when building the lookup map
@member_field_variants_raw %{
@ -239,30 +275,79 @@ defmodule Mv.Membership.Import.HeaderMapper do
## Returns
- `{:ok, %{member: column_map, custom: custom_field_map, unknown: unknown_headers}}` on success
- `{:ok, %{member: column_map, custom: custom_field_map, unknown: unknown_headers,
ignored: [non_neg_integer], groups_column_index: non_neg_integer | nil,
fee_type_column_index: non_neg_integer | nil}}` on success
- `{:error, reason}` on error (missing required field, duplicate headers)
The `ignored` list holds the indices of fee-status columns (computed/read-only),
which are never mapped to member or custom fields.
## Examples
iex> build_maps(["Email", "First Name"], [])
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: []}}
{:ok, %{member: %{email: 0, first_name: 1}, custom: %{}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
iex> build_maps(["Email", "CustomField"], [%{id: "cf1", name: "CustomField"}])
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: []}}
{:ok, %{member: %{email: 0}, custom: %{"cf1" => 1}, unknown: [], ignored: [], groups_column_index: nil, fee_type_column_index: nil}}
"""
@spec build_maps([String.t()], [map()]) ::
{:ok, %{member: column_map(), custom: custom_field_map(), unknown: unknown_headers()}}
{:ok,
%{
member: column_map(),
custom: custom_field_map(),
unknown: unknown_headers(),
ignored: [non_neg_integer()],
groups_column_index: non_neg_integer() | nil,
fee_type_column_index: non_neg_integer() | nil
}}
| {:error, String.t()}
def build_maps(headers, custom_fields) when is_list(headers) and is_list(custom_fields) do
with {:ok, member_map, unknown_after_member} <- build_member_map(headers),
ignored = ignored_indices(headers)
groups_column_index = first_matching_index(headers, @groups_column_normalized)
fee_type_column_index = first_matching_index(headers, @fee_type_column_normalized)
reserved =
[groups_column_index, fee_type_column_index | ignored]
|> Enum.reject(&is_nil/1)
|> MapSet.new()
with {:ok, member_map, unknown_after_member} <- build_member_map(headers, reserved),
{:ok, custom_map, unknown_after_custom} <-
build_custom_field_map(headers, unknown_after_member, custom_fields, member_map) do
unknown = Enum.map(unknown_after_custom, &Enum.at(headers, &1))
{:ok, %{member: member_map, custom: custom_map, unknown: unknown}}
{:ok,
%{
member: member_map,
custom: custom_map,
unknown: unknown,
ignored: ignored,
groups_column_index: groups_column_index,
fee_type_column_index: fee_type_column_index
}}
end
end
# Returns the index of the first header whose normalized form is in `variants`,
# or nil if none match.
defp first_matching_index(headers, variants) do
headers
|> Enum.with_index()
|> Enum.find_value(fn {header, index} ->
if normalize_header(header) in variants, do: index
end)
end
# Returns the column indices whose normalized header is in the fee-status ignore list.
defp ignored_indices(headers) do
headers
|> Enum.with_index()
|> Enum.filter(fn {header, _index} -> normalize_header(header) in @ignored_normalized end)
|> Enum.map(fn {_header, index} -> index end)
end
# --- Private Functions ---
# Transliterates German umlauts and special characters
@ -304,13 +389,14 @@ defmodule Mv.Membership.Import.HeaderMapper do
|> String.replace(" ", "")
end
# Builds member field column map
defp build_member_map(headers) do
# Builds member field column map, skipping reserved (e.g. ignored) indices.
defp build_member_map(headers, reserved) do
result =
headers
|> Enum.with_index()
|> Enum.reduce_while({%{}, []}, fn {header, index}, {acc_map, acc_unknown} ->
normalized = normalize_header(header)
normalized =
if MapSet.member?(reserved, index), do: "", else: normalize_header(header)
case process_member_header(header, index, normalized, acc_map, %{}) do
{:error, reason} ->

View file

@ -26,14 +26,8 @@ defmodule Mv.Membership.Import.ImportRunner do
{:ok, content} ->
{:ok, content}
{:error, reason} when is_atom(reason) ->
{:error, :file.format_error(reason)}
{:error, %File.Error{reason: reason}} ->
{:error, :file.format_error(reason)}
{:error, reason} ->
{:error, Exception.message(reason)}
{:error, to_string(:file.format_error(reason))}
end
end
@ -86,7 +80,7 @@ defmodule Mv.Membership.Import.ImportRunner do
all_errors = progress.errors ++ chunk_result.errors
new_errors = Enum.take(all_errors, max_errors)
errors_truncated? = length(all_errors) > max_errors
new_warnings = progress.warnings ++ Map.get(chunk_result, :warnings, [])
new_warnings = Enum.uniq(progress.warnings ++ Map.get(chunk_result, :warnings, []))
chunks_processed = current_chunk_idx + 1
new_status = if chunks_processed >= progress.total_chunks, do: :done, else: :running
@ -103,6 +97,20 @@ defmodule Mv.Membership.Import.ImportRunner do
}
end
@doc """
Carries the in-memory group snapshot grown by a chunk back into `import_state`
so the next chunk reuses groups created earlier instead of re-reading the
Group table. When the chunk result omits `groups_found`, the state is returned
unchanged.
"""
@spec carry_groups_forward(map(), map()) :: map()
def carry_groups_forward(import_state, chunk_result) do
case Map.fetch(chunk_result, :groups_found) do
{:ok, groups_found} -> Map.put(import_state, :groups_found, groups_found)
:error -> import_state
end
end
@doc """
Returns the next action after processing a chunk: send the next chunk index or done.
"""

View file

@ -6,7 +6,7 @@ defmodule Mv.Membership.Import.MemberCSV do
This module provides the core API for CSV member import functionality:
- `prepare/2` - Parses and validates CSV content, returns import state
- `process_chunk/3` - Processes a chunk of rows and creates members
- `process_chunk/4` - Processes a chunk of rows and creates members
## Error Handling
@ -22,13 +22,24 @@ defmodule Mv.Membership.Import.MemberCSV do
- `column_map` - Map of canonical field names to column indices
- `custom_field_map` - Map of custom field names to column indices
- `warnings` - List of warning messages (e.g., unknown custom field columns)
- `headers` - The raw CSV header row
- `ignored` - Header names of ignored (fee-status) columns
- `groups_column_index` / `fee_type_column_index` - Indices for resolved columns (or nil)
- `groups_found` / `groups_to_create` - Existing and to-be-created groups from the preview
- `fee_type_map` - Normalized fee-type name to id, for matched fee types
- `fee_type_warnings` - Unmatched fee-type names surfaced in the preview
- `has_empty_fee_type_cells?` - Whether any fee-type cell is blank (default applies)
- `preview_rows` - Up to 3 sample data rows for the mapping preview
## Chunk Results
The `chunk_result` returned by `process_chunk/3` contains:
The `chunk_result` returned by `process_chunk/4` contains:
- `inserted` - Number of successfully created members
- `failed` - Number of failed member creations
- `errors` - List of `%MemberCSV.Error{}` structs (capped at 50 per import)
- `groups_found` - The in-memory group snapshot grown while processing this
chunk; thread it into the next chunk's `:groups_found` opt so groups created
in an earlier chunk are reused without re-reading the Group table
## Examples
@ -37,7 +48,9 @@ defmodule Mv.Membership.Import.MemberCSV do
# Process first chunk
chunk = Enum.at(import_state.chunks, 0)
{:ok, result} = MemberCSV.process_chunk(chunk, import_state.column_map)
{:ok, result} =
MemberCSV.process_chunk(chunk, import_state.column_map, import_state.custom_field_map, [])
"""
defmodule Error do
@ -66,16 +79,29 @@ defmodule Mv.Membership.Import.MemberCSV do
custom_field_lookup: %{
String.t() => %{id: String.t(), value_type: atom(), name: String.t()}
},
warnings: list(String.t())
warnings: list(String.t()),
headers: list(String.t()),
ignored: list(String.t()),
groups_column_index: non_neg_integer() | nil,
fee_type_column_index: non_neg_integer() | nil,
groups_found: list(%{id: String.t(), name: String.t()}),
groups_to_create: list(String.t()),
fee_type_map: %{String.t() => String.t()},
fee_type_warnings: list(String.t()),
has_empty_fee_type_cells?: boolean(),
preview_rows: list(list(String.t()))
}
@type chunk_result :: %{
inserted: non_neg_integer(),
failed: non_neg_integer(),
errors: list(Error.t()),
errors_truncated?: boolean()
errors_truncated?: boolean(),
warnings: list(String.t()),
groups_found: list(Mv.Membership.Group.t() | %{id: String.t(), name: String.t()})
}
alias Mv.Membership.Import.ColumnResolver
alias Mv.Membership.Import.CsvParser
alias Mv.Membership.Import.HeaderMapper
@ -139,13 +165,27 @@ defmodule Mv.Membership.Import.MemberCSV do
# Build custom field lookup for efficient value processing
custom_field_lookup = build_custom_field_lookup(custom_fields)
# Resolve DB-backed columns (groups, fee types) read-only for the preview.
resolution = ColumnResolver.resolve(maps, rows, actor)
ignored_headers = Enum.map(maps.ignored, &Enum.at(headers, &1))
{:ok,
%{
chunks: chunks,
column_map: maps.member,
custom_field_map: maps.custom,
custom_field_lookup: custom_field_lookup,
warnings: warnings
warnings: warnings,
headers: headers,
ignored: ignored_headers,
groups_column_index: maps.groups_column_index,
fee_type_column_index: maps.fee_type_column_index,
groups_found: resolution.groups_found,
groups_to_create: resolution.groups_to_create,
fee_type_map: resolution.fee_type_map,
fee_type_warnings: resolution.fee_type_warnings,
has_empty_fee_type_cells?: resolution.has_empty_fee_type_cells?,
preview_rows: resolution.preview_rows
}}
end
end
@ -180,7 +220,7 @@ defmodule Mv.Membership.Import.MemberCSV do
end)
case HeaderMapper.build_maps(headers, custom_field_maps) do
{:ok, %{member: member_map, custom: custom_map, unknown: unknown}} ->
{:ok, %{unknown: unknown} = maps} ->
# Build warnings for unknown custom field columns
warnings =
unknown
@ -197,7 +237,7 @@ defmodule Mv.Membership.Import.MemberCSV do
)
end)
{:ok, %{member: member_map, custom: custom_map}, warnings}
{:ok, maps, warnings}
{:error, reason} ->
{:error, reason}
@ -210,8 +250,6 @@ defmodule Mv.Membership.Import.MemberCSV do
MapSet.member?(HeaderMapper.known_member_fields(), normalized)
end
defp member_field?(_), do: false
# Validates that row count doesn't exceed limit
defp validate_row_count(rows, max_rows) do
if length(rows) > max_rows do
@ -252,9 +290,20 @@ defmodule Mv.Membership.Import.MemberCSV do
Map.put(acc, custom_field_id, value)
end)
%{member: member_map, custom: custom_map}
%{
member: member_map,
custom: custom_map,
fee_type: cell_at(row_tuple, tuple_size, maps.fee_type_column_index),
groups: cell_at(row_tuple, tuple_size, maps.groups_column_index)
}
end
# Returns the raw cell at the given index, or nil if the column is absent.
defp cell_at(_row_tuple, _size, nil), do: nil
defp cell_at(row_tuple, size, index) when index < size, do: elem(row_tuple, index)
defp cell_at(_row_tuple, _size, _index), do: ""
@doc """
Processes a chunk of CSV rows and creates members.
@ -270,12 +319,18 @@ defmodule Mv.Membership.Import.MemberCSV do
- `chunk_rows_with_lines` - List of tuples `{csv_line_number, row_map}` where:
- `csv_line_number` - Physical line number in CSV (1-based)
- `row_map` - Map with `:member` and `:custom` keys containing field values
- `column_map` - Map of canonical field names (atoms) to column indices (for reference)
- `custom_field_map` - Map of custom field IDs (strings) to column indices (for reference)
- `column_map` - Unused; kept for backward-compatible call sites. Field values are
read from each row's pre-built `:member`/`:custom` maps, not from this argument.
- `custom_field_map` - Unused; kept for backward-compatible call sites (see above).
- `opts` - Optional keyword list for processing options:
- `:custom_field_lookup` - Map of custom field IDs to metadata (default: `%{}`)
- `:existing_error_count` - Number of errors already collected in previous chunks (default: `0`)
- `:max_errors` - Maximum number of errors to collect per import overall (default: `50`)
- `:actor` - Actor used for all writes (default: the system actor)
- `:fee_type_map` - Map of normalized fee-type name to fee-type id, used to resolve
each row's fee-type cell (default: `%{}`)
- `:groups_found` - List of pre-fetched `Group` structs seeding in-memory group
resolution; the snapshot grows as groups are auto-created (default: `[]`)
## Error Capping
@ -314,27 +369,49 @@ defmodule Mv.Membership.Import.MemberCSV do
existing_error_count = Keyword.get(opts, :existing_error_count, 0)
max_errors = Keyword.get(opts, :max_errors, @default_max_errors)
actor = Keyword.get(opts, :actor, SystemActor.get_system_actor())
fee_type_map = Keyword.get(opts, :fee_type_map, %{})
groups_found = Keyword.get(opts, :groups_found, [])
{inserted, failed, errors, _collected_error_count, truncated?} =
Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false}, fn {line_number, row_map},
{acc_inserted, acc_failed,
acc_errors, acc_error_count,
acc_truncated?} ->
base_row_opts = %{
custom_field_lookup: custom_field_lookup,
fee_type_map: fee_type_map,
actor: actor
}
{inserted, failed, errors, _collected_error_count, truncated?, warnings, groups_acc} =
Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false, [], groups_found}, fn {line_number,
row_map},
{acc_inserted,
acc_failed,
acc_errors,
acc_error_count,
acc_truncated?,
acc_warnings,
acc_groups} ->
current_error_count = existing_error_count + acc_error_count
row_opts = Map.put(base_row_opts, :groups_found, acc_groups)
case process_row(row_map, line_number, custom_field_lookup, actor) do
{:ok, _member} ->
update_inserted(
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?}
)
case process_row(row_map, line_number, row_opts) do
{:ok, _member, row_warnings, new_groups} ->
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?} =
update_inserted(
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?}
)
{:error, error} ->
handle_row_error(
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?},
error,
current_error_count,
max_errors
)
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?,
acc_warnings ++ row_warnings, new_groups}
{:error, error, new_groups} ->
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?} =
handle_row_error(
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?},
error,
current_error_count,
max_errors
)
{new_inserted, new_failed, new_errors, new_error_count, new_truncated?, acc_warnings,
new_groups}
end
end)
@ -343,7 +420,9 @@ defmodule Mv.Membership.Import.MemberCSV do
inserted: inserted,
failed: failed,
errors: Enum.reverse(errors),
errors_truncated?: truncated?
errors_truncated?: truncated?,
warnings: warnings,
groups_found: groups_acc
}}
end
@ -507,18 +586,27 @@ defmodule Mv.Membership.Import.MemberCSV do
defp gettext_error_message(_), do: gettext("Email is invalid.")
# Processes a single row and creates member with custom field values
# Processes a single row and creates member with custom field values.
# On success returns {:ok, member, warnings, groups}; warnings carry non-fatal
# notices such as an unresolved fee-type name. The returned groups list is the
# accumulated in-memory group snapshot (seeded from the chunk, grown with any
# group created while linking this row) so later rows reuse it instead of
# re-reading the whole Group table per row.
defp process_row(
row_map,
line_number,
custom_field_lookup,
actor
%{
custom_field_lookup: custom_field_lookup,
fee_type_map: fee_type_map,
groups_found: groups_found,
actor: actor
} = _row_opts
) do
# Validate row before database insertion
case validate_row(row_map, line_number, []) do
{:error, error} ->
# Return validation error immediately, no DB insert attempted
{:error, error}
{:error, error, groups_found}
{:ok, %{member: trimmed_member_attrs, custom: custom_attrs}} ->
# Prepare custom field values for Ash
@ -526,20 +614,119 @@ defmodule Mv.Membership.Import.MemberCSV do
{:error, validation_errors} ->
# Custom field validation errors - return first error
first_error = List.first(validation_errors)
{:error, %Error{csv_line_number: line_number, field: nil, message: first_error}}
{:error, %Error{csv_line_number: line_number, field: nil, message: first_error},
groups_found}
{:ok, custom_field_values} ->
create_member_with_custom_fields(
trimmed_member_attrs,
{fee_attrs, warnings} =
resolve_fee_type_attrs(Map.get(row_map, :fee_type), fee_type_map)
create_member_and_assign_groups(
Map.merge(trimmed_member_attrs, fee_attrs),
custom_field_values,
Map.get(row_map, :groups),
groups_found,
line_number,
actor
actor,
warnings
)
end
end
rescue
e ->
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)}}
{:error, %Error{csv_line_number: line_number, field: nil, message: Exception.message(e)},
groups_found}
end
# Creates the member, then assigns groups as a post-creation step. A group
# assignment failure fails the row (the member was already created, but the
# row is reported as failed so the operator can act on it).
defp create_member_and_assign_groups(
member_attrs,
custom_field_values,
groups_cell,
groups_found,
line_number,
actor,
warnings
) do
case create_member_with_custom_fields(
member_attrs,
custom_field_values,
line_number,
actor,
warnings
) do
{:ok, member, member_warnings} ->
assign_groups(member, groups_cell, groups_found, line_number, actor, member_warnings)
{:error, error} ->
{:error, error, groups_found}
end
end
# Assigns the member to all groups listed in the cell, creating missing groups.
# Returns the (possibly grown) group snapshot so the caller can reuse it.
defp assign_groups(member, groups_cell, groups_found, line_number, actor, warnings) do
names = ColumnResolver.split_group_names(groups_cell)
Enum.reduce_while(names, {:ok, member, warnings, groups_found}, fn name,
{:ok, _m, _w, acc_groups} ->
case link_member_to_group(member, name, acc_groups, actor) do
{:ok, group} ->
{:cont, {:ok, member, warnings, add_group(acc_groups, group)}}
{:error, reason} ->
{:halt,
{:error,
%Error{
csv_line_number: line_number,
field: nil,
message: gettext("Group assignment failed: %{reason}", reason: inspect(reason))
}, acc_groups}}
end
end)
end
defp add_group(groups, group) do
if Enum.any?(groups, &(&1.id == group.id)), do: groups, else: [group | groups]
end
defp link_member_to_group(member, name, groups_found, actor) do
with {:ok, group} <- ColumnResolver.create_or_find_group(name, groups_found, actor),
{:ok, _member_group} <-
Mv.Membership.create_member_group(
%{member_id: member.id, group_id: group.id},
actor: actor
) do
{:ok, group}
end
end
# Resolves the fee-type cell into member attrs plus optional warnings.
# Empty cell -> default fee type (SetDefaultMembershipFeeType), no warning.
# Matched name -> membership_fee_type_id attr.
# Unmatched name -> no attr (default applies), warning naming the value.
defp resolve_fee_type_attrs(nil, _fee_type_map), do: {%{}, []}
defp resolve_fee_type_attrs(cell, fee_type_map) when is_binary(cell) do
trimmed = String.trim(cell)
if trimmed == "" do
{%{}, []}
else
case Map.get(fee_type_map, ColumnResolver.normalize_fee_type_name(trimmed)) do
nil ->
{%{},
[
gettext("Fee type '%{name}' not found; using the default fee type.", name: trimmed)
]}
fee_type_id ->
{%{membership_fee_type_id: fee_type_id}, []}
end
end
end
# Creates a member with custom field values, handling errors appropriately
@ -547,7 +734,8 @@ defmodule Mv.Membership.Import.MemberCSV do
trimmed_member_attrs,
custom_field_values,
line_number,
actor
actor,
warnings
) do
# Convert empty strings to nil for date fields so Ash accepts them
member_attrs = sanitize_date_fields(trimmed_member_attrs)
@ -567,7 +755,7 @@ defmodule Mv.Membership.Import.MemberCSV do
case Mv.Membership.create_member(final_attrs, actor: actor) do
{:ok, member} ->
{:ok, member}
{:ok, member, warnings}
{:error, %Ash.Error.Invalid{} = error} ->
# Extract email from final_attrs for better error messages

View file

@ -59,7 +59,7 @@ defmodule Mv.Membership.Member.Validations.EmailChangePermission do
# Ash stores actor in changeset.context.private.actor; validation context has .actor; some callsites use context.actor
defp resolve_actor(changeset, context) do
ctx = changeset.context || %{}
ctx = changeset.context
get_in(ctx, [:private, :actor]) ||
Map.get(ctx, :actor) ||

View file

@ -16,6 +16,21 @@ defmodule Mv.Membership.MemberExport do
alias MvWeb.MemberLive.Index
alias MvWeb.MemberLive.Index.MembershipFeeStatus
@typedoc "Validated export parameters produced by `parse_params/1`."
@type parsed_params :: %{
selected_ids: [String.t()],
member_fields: [String.t()],
selectable_member_fields: [String.t()],
computed_fields: [String.t()],
custom_field_ids: [String.t()],
query: String.t() | nil,
sort_field: String.t() | nil,
sort_order: String.t() | nil,
show_current_cycle: boolean(),
cycle_status_filter: :paid | :unpaid | nil,
boolean_filters: %{optional(String.t()) => boolean()}
}
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
["membership_fee_type", "membership_fee_status", "groups"]
@computed_export_fields ["membership_fee_status"]
@ -305,7 +320,7 @@ defmodule Mv.Membership.MemberExport do
:computed_fields, :custom_field_ids, :query, :sort_field, :sort_order,
:show_current_cycle, :cycle_status_filter, :boolean_filters.
"""
@spec parse_params(map()) :: map()
@spec parse_params(map()) :: parsed_params()
def parse_params(params) do
# DB fields come from "member_fields"
raw_member_fields = extract_list(params, "member_fields")
@ -458,9 +473,6 @@ defmodule Mv.Membership.MemberExport do
computed_fields,
member_fields
) do
computed_fields = computed_fields || []
member_fields = member_fields || []
db_with_insert =
Enum.flat_map(db_fields_ordered, fn f ->
expand_field_with_computed(f, member_fields, computed_fields)
@ -507,6 +519,4 @@ defmodule Mv.Membership.MemberExport do
other -> other
end)
end
defp normalize_computed_fields(_), do: []
end

View file

@ -21,7 +21,7 @@ defmodule Mv.Membership.MembersCSV do
Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body.
RFC 4180 escaping and formula-injection safe_cell are applied.
"""
@spec export([struct() | map()], [map()]) :: iodata()
@spec export([struct() | map()], [map()]) :: [iodata()] | Enumerable.t()
def export(members, columns) when is_list(members) do
header = build_header(columns)
rows = Enum.map(members, fn member -> build_row(member, columns) end)

View file

@ -143,7 +143,7 @@ defmodule Mv.Membership.MembersPDF do
defp convert_to_template_format(export_data, locale, club_name) do
# Set locale for translations
Gettext.put_locale(MvWeb.Gettext, locale)
_ = Gettext.put_locale(MvWeb.Gettext, locale)
headers = Enum.map(export_data.columns, & &1.label)
column_count = length(export_data.columns)
@ -211,9 +211,6 @@ defmodule Mv.Membership.MembersPDF do
{:ok, datetime, _offset} ->
format_datetime(datetime, locale)
{:ok, datetime} ->
format_datetime(datetime, locale)
{:error, _} ->
# Try NaiveDateTime if DateTime parsing fails
case NaiveDateTime.from_iso8601(iso8601_string) do
@ -257,8 +254,6 @@ defmodule Mv.Membership.MembersPDF do
end
end
defp format_date(_, _), do: ""
defp format_dates_in_rows(rows, columns, locale) do
date_indices = find_date_column_indices(columns)
@ -321,7 +316,7 @@ defmodule Mv.Membership.MembersPDF do
defp format_cell_date_datetime(cell_value, locale) do
case DateTime.from_iso8601(cell_value) do
{:ok, datetime} -> format_datetime(datetime, locale)
{:ok, datetime, _offset} -> format_datetime(datetime, locale)
_ -> format_cell_date_naive(cell_value, locale)
end
end

View file

@ -58,7 +58,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
{:ok, %{success: 45, failed: 0, total: 45}}
"""
@spec run() :: {:ok, map()} | {:error, term()}
@spec run() :: {:ok, CycleGenerator.results_summary()} | {:error, Ash.Error.t()}
def run do
Logger.info("Starting membership fee cycle generation job")
start_time = System.monotonic_time(:millisecond)
@ -98,7 +98,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
Mv.MembershipFees.CycleGenerationJob.run(batch_size: 5)
"""
@spec run(keyword()) :: {:ok, map()} | {:error, term()}
@spec run(keyword()) :: {:ok, CycleGenerator.results_summary()} | {:error, Ash.Error.t()}
def run(opts) when is_list(opts) do
Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}")
start_time = System.monotonic_time(:millisecond)
@ -135,7 +135,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
- `{:error, reason}` - Error with reason
"""
@spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, term()}
@spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, Ash.Error.t()}
def pending_members_count do
today = Date.utc_today()
@ -166,7 +166,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
- `{:error, reason}` - Error with reason
"""
@spec run_for_member(String.t()) :: {:ok, [map()]} | {:error, term()}
@spec run_for_member(String.t()) :: CycleGenerator.generate_result()
def run_for_member(member_id) when is_binary(member_id) do
Logger.info("Generating cycles for member #{member_id}")
CycleGenerator.generate_cycles_for_member(member_id)

View file

@ -1,4 +1,11 @@
defmodule Mv.MembershipFees.CycleGenerator do
@typedoc "Aggregate counts returned by a batch cycle-generation run."
@type results_summary :: %{
success: non_neg_integer(),
failed: non_neg_integer(),
total: non_neg_integer()
}
@moduledoc """
Module for generating membership fee cycles for members.
@ -115,7 +122,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
lock_key = Member.advisory_lock_key_for_member_id(member.id)
Repo.transaction(fn ->
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
_ = EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
case do_generate_cycles(member, today, opts) do
{:ok, cycles, notifications} ->
@ -159,7 +166,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
- `{:error, reason}` - Error with reason
"""
@spec generate_cycles_for_all_members(keyword()) :: {:ok, map()} | {:error, term()}
@spec generate_cycles_for_all_members(keyword()) ::
{:ok, results_summary()} | {:error, Ash.Error.t()}
def generate_cycles_for_all_members(opts \\ []) do
today = Keyword.get(opts, :today, Date.utc_today())
batch_size = Keyword.get(opts, :batch_size, 10)
@ -212,7 +220,7 @@ defmodule Mv.MembershipFees.CycleGenerator do
defp process_member_cycle_generation(member, today) do
case generate_cycles_for_member(member, today: today) do
{:ok, _cycles, notifications} = ok ->
send_notifications_for_batch_job(notifications)
_ = send_notifications_for_batch_job(notifications)
{member.id, ok}
{:error, _reason} = err ->

View file

@ -87,8 +87,6 @@ defmodule Mv.OidcRoleSync do
ArgumentError -> nil
end
defp safe_get_atom(_map, _key), do: nil
defp peek_jwt_claims(token) do
parts = String.split(token, ".")

View file

@ -15,6 +15,6 @@ defmodule Mv.OidcRoleSyncConfig do
@doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
def oidc_groups_claim do
Mv.Config.oidc_groups_claim() || "groups"
Mv.Config.oidc_groups_claim()
end
end

View file

@ -22,7 +22,7 @@ defmodule Mv.Release do
require Logger
def migrate do
load_app()
_ = load_app()
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
@ -75,14 +75,14 @@ defmodule Mv.Release do
dev_path = Path.join(priv, "repo/seeds_dev.exs")
prev = Code.compiler_options()
Code.compiler_options(ignore_module_conflict: true)
_ = Code.compiler_options(ignore_module_conflict: true)
try do
Code.eval_file(bootstrap_path)
_ = Code.eval_file(bootstrap_path)
IO.puts("✅ Bootstrap seeds completed.")
if System.get_env("RUN_DEV_SEEDS") == "true" do
Code.eval_file(dev_path)
_ = Code.eval_file(dev_path)
IO.puts("✅ Dev seeds completed.")
end
after
@ -92,7 +92,7 @@ defmodule Mv.Release do
end
def rollback(repo, version) do
load_app()
_ = load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
@ -139,10 +139,11 @@ defmodule Mv.Release do
{:ok, %Role{} = admin_role} ->
case get_user_by_email(email) do
{:ok, %User{} = user} ->
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
_ =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
:ok
@ -189,15 +190,16 @@ defmodule Mv.Release do
defp create_admin_user(email, password, admin_role) do
case Accounts.create_user(%{email: email}, authorize?: false) do
{:ok, user} ->
user
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|> Ash.update!(authorize?: false)
|> then(fn u ->
u
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
_ =
user
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|> Ash.update!(authorize?: false)
end)
|> then(fn u ->
u
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
end)
:ok
@ -207,15 +209,16 @@ defmodule Mv.Release do
end
defp update_admin_user(user, password, admin_role) do
user
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|> Ash.update!(authorize?: false)
|> then(fn u ->
u
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
_ =
user
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|> Ash.update!(authorize?: false)
end)
|> then(fn u ->
u
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update!(authorize?: false)
end)
:ok
end

View file

@ -19,4 +19,12 @@ defmodule Mv.Repo do
def min_pg_version do
%Version{major: 17, minor: 2, patch: 0}
end
# This app does not use schema-based multitenancy, so there are no tenant
# schemas to migrate. Returning [] keeps the AshPostgres callback total
# rather than raising the default "not defined" error.
@impl true
def all_tenants do
[]
end
end

View file

@ -8,6 +8,12 @@ defmodule Mv.Vereinfacht.Client do
"""
require Logger
@typedoc "Error reasons returned by Vereinfacht API calls."
@type error_reason ::
:not_configured
| {:request_failed, map()}
| {:http, non_neg_integer(), :html_response | binary()}
@content_type "application/vnd.api+json"
@doc """
@ -31,7 +37,7 @@ defmodule Mv.Vereinfacht.Client do
{:error, :not_configured}
"""
@spec test_connection(String.t() | nil, String.t() | nil, String.t() | nil) ::
{:ok, :connected} | {:error, term()}
{:ok, :connected} | {:error, error_reason()}
def test_connection(api_url, api_key, club_id) do
if blank?(api_url) or blank?(api_key) or blank?(club_id) do
{:error, :not_configured}
@ -92,13 +98,12 @@ defmodule Mv.Vereinfacht.Client do
@sync_timeout_ms 5_000
# Resolved at compile time so Mix is never called at runtime (Mix is not available in releases).
@env Mix.env()
# In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits).
# `sql_sandbox?/0` reads runtime config (true only in test) and avoids calling Mix at runtime,
# which is unavailable in releases.
defp req_http_options do
opts = [receive_timeout: @sync_timeout_ms]
if @env == :test, do: [retry: false] ++ opts, else: opts
if Mv.Config.sql_sandbox?(), do: [retry: false] ++ opts, else: opts
end
defp post_and_parse_contact(url, body, api_key) do
@ -230,7 +235,7 @@ defmodule Mv.Vereinfacht.Client do
Returns the full response body (decoded JSON) for debugging/display.
"""
@spec get_contact(String.t()) :: {:ok, map()} | {:error, term()}
@spec get_contact(String.t()) :: {:ok, map()} | {:error, error_reason()}
def get_contact(contact_id) when is_binary(contact_id) do
fetch_contact(contact_id, [])
end

View file

@ -37,9 +37,10 @@ defmodule Mv.Vereinfacht.SyncFlash do
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
_ =
if :ets.whereis(@table) == :undefined do
:ets.new(@table, [:set, :public, :named_table])
end
:ok
end

View file

@ -26,7 +26,7 @@ defmodule Mv.Vereinfacht do
- `{:error, {:http, status, message}}` API returned an error (e.g. 401, 403)
- `{:error, {:request_failed, reason}}` network/transport error
"""
@spec test_connection() :: {:ok, :connected} | {:error, term()}
@spec test_connection() :: {:ok, :connected} | {:error, Mv.Vereinfacht.Client.error_reason()}
def test_connection do
Client.test_connection(
Mv.Config.vereinfacht_api_url(),

View file

@ -113,8 +113,7 @@ defmodule MvWeb.Authorization do
iex> can_access_page?(mitglied, "/members")
false
"""
@spec can_access_page?(map() | nil, String.t() | Phoenix.VerifiedRoutes.unverified_path()) ::
boolean()
@spec can_access_page?(map() | nil, String.t()) :: boolean()
def can_access_page?(nil, _page_path), do: false
def can_access_page?(user, page_path) do

View file

@ -0,0 +1,243 @@
defmodule MvWeb.Components.BulkActionsDropdown do
@moduledoc """
Single "Aktionen" dropdown bundling the four member bulk actions, flattened to
one level: open in email program (mailto), copy email addresses, export to CSV,
export to PDF.
It keeps the CSRF-protected `<form>` POST export items unchanged (CSV/PDF) and
adds the mailto and copy items that previously lived as standalone header
buttons next to a separate export dropdown.
## Scope and trigger badge
The trigger reads `Aktionen` followed by a scope badge: an emphasized
(`primary`) count `N` when `N` members are selected, and a muted (`neutral`)
badge otherwise `gefiltert` when a search term or filter narrows the list,
`alle` when nothing is selected and no search/filter is active. Only an actual
selection is emphasized. The badge sits inside the shared `dropdown_menu/1`
trigger via its `trigger_badge` slot, matching the member-filter dropdown's
count badge. The `scope`, `selected_count`, `mailto_bcc`, `recipient_count`
and `mailto_disabled?` are computed by the parent LiveView and passed in.
## Recipient handling (mailto / copy)
The parent already excludes members without an email when building
`mailto_bcc` and `recipient_count` (defensive filter preserved verbatim from
the previous behaviour). Export, by contrast, still includes every member in
scope regardless of email its payload is unchanged.
## Mailto recipient cap
A mailto URI carries every recipient in its BCC; browsers cannot reliably hand
a very long mailto over to the mail program. When `mailto_disabled?` is true
(recipient count at or above `Mv.Constants.max_mailto_bulk_recipients/0`) the
mailto item is rendered disabled (`aria-disabled`, `tabindex="-1"`, href
dropped) with an explanatory tooltip. Copy and Export have no such cap.
## Event routing
`dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` to `@myself`, so the
component owns its own `:open` state. The copy item carries an *un-targeted*
`phx-click="copy_emails"`, which therefore reaches the parent LiveView's
`handle_event/3` (which keeps access to `@members`), plus the
`CopyToClipboard` hook.
"""
use MvWeb, :live_component
use Gettext, backend: MvWeb.Gettext
# Same focus ring as CoreComponents button/dropdown (WCAG 2.4.7)
defp dropdown_item_class do
focus =
MvWeb.CoreComponents.button_focus_classes()
|> Kernel.++(["focus-visible:ring-inset"])
|> Enum.join(" ")
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left whitespace-nowrap #{focus}"
end
@impl true
def mount(socket) do
{:ok, assign(socket, :open, false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(:id, assigns.id)
|> assign(:export_payload_json, assigns[:export_payload_json] || "")
|> assign(:selected_count, assigns[:selected_count] || 0)
|> assign(:scope, assigns[:scope] || :all)
|> assign(:mailto_bcc, assigns[:mailto_bcc] || "")
|> assign(:recipient_count, assigns[:recipient_count] || 0)
|> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false)
# The parent never sets :open (the component owns it via toggle/close).
# Honouring an explicit :open assign keeps the component renderable in
# isolation (render_component/2) for structural tests.
socket =
case Map.fetch(assigns, :open) do
{:ok, open} -> assign(socket, :open, open)
:error -> socket
end
{:ok, socket}
end
@impl true
def render(assigns) do
assigns =
assigns
|> assign(:scope_label, scope_label(assigns))
|> assign(:scope_variant, scope_variant(assigns))
~H"""
<div id={@id} data-testid="bulk-actions-dropdown" class="flex-auto flex-wrap">
<.dropdown_menu
id={"#{@id}-menu"}
button_label={gettext("Actions")}
icon="hero-bolt"
open={@open}
phx_target={@myself}
menu_width="w-70"
menu_align="left"
button_class="btn-secondary gap-2"
testid="bulk-actions-dropdown"
button_testid="bulk-actions-button"
menu_testid="bulk-actions-menu"
>
<:trigger_badge>
<.badge variant={@scope_variant} size="sm" data-testid="bulk-actions-scope-badge">
{@scope_label}
</.badge>
</:trigger_badge>
<li role="none">
<.mailto_item mailto_bcc={@mailto_bcc} disabled={@mailto_disabled?} />
</li>
<li role="none">
<button
type="button"
role="menuitem"
id="bulk-actions-copy"
phx-hook="CopyToClipboard"
phx-click="copy_emails"
class={dropdown_item_class()}
aria-label={gettext("Copy email addresses")}
data-testid="bulk-actions-copy"
>
<.icon name="hero-clipboard-document" class="h-4 w-4" />
<span>{gettext("Copy email addresses")}</span>
</button>
</li>
<li role="none">
<form method="post" action={~p"/members/export.csv"} target="_blank" class="w-full">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<input type="hidden" name="payload" value={@export_payload_json} />
<button
type="submit"
role="menuitem"
class={dropdown_item_class()}
aria-label={gettext("Export members to CSV")}
data-testid="export-csv-link"
>
<.icon name="hero-document-arrow-down" class="h-4 w-4" />
<span>{gettext("Export to CSV")}</span>
</button>
</form>
</li>
<li role="none">
<form method="post" action={~p"/members/export.pdf"} target="_blank" class="w-full">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<input type="hidden" name="payload" value={@export_payload_json} />
<button
type="submit"
role="menuitem"
class={dropdown_item_class()}
aria-label={gettext("Export members to PDF")}
data-testid="export-pdf-link"
>
<.icon name="hero-document-text" class="h-4 w-4" />
<span>{gettext("Export to PDF")}</span>
</button>
</form>
</li>
</.dropdown_menu>
</div>
"""
end
# The mailto item is an anchor menu item. When over the recipient cap it is
# rendered disabled following the same a11y pattern as a disabled CoreComponents
# link button (href dropped, tabindex=-1, aria-disabled=true) and exposes the
# explanatory tooltip via title.
attr :mailto_bcc, :string, required: true
attr :disabled, :boolean, required: true
defp mailto_item(%{disabled: true} = assigns) do
assigns = assign(assigns, :item_class, dropdown_item_class())
~H"""
<a
role="menuitem"
tabindex="-1"
aria-disabled="true"
title={over_threshold_tooltip()}
class={[@item_class, "opacity-50 pointer-events-none"]}
aria-label={gettext("Open in email program")}
data-testid="bulk-actions-mailto"
>
<.icon name="hero-envelope" class="h-4 w-4" />
<span>{gettext("Open in email program")}</span>
</a>
"""
end
defp mailto_item(%{disabled: false} = assigns) do
assigns = assign(assigns, :item_class, dropdown_item_class())
~H"""
<a
role="menuitem"
tabindex="0"
href={"mailto:?bcc=" <> @mailto_bcc}
class={@item_class}
aria-label={gettext("Open in email program")}
data-testid="bulk-actions-mailto"
>
<.icon name="hero-envelope" class="h-4 w-4" />
<span>{gettext("Open in email program")}</span>
</a>
"""
end
defp over_threshold_tooltip do
gettext("Too many recipients for this function. Copy the addresses or export the list.")
end
# The trigger scope is shown as a badge after the "Aktionen" label. Only an
# actual selection is emphasized (primary); both the "filtered" and "all"
# scopes are muted (neutral), since neither means members are selected.
defp scope_label(assigns) do
case assigns.scope do
:selection -> to_string(assigns.selected_count)
:filtered -> gettext("filtered")
_ -> gettext("all")
end
end
defp scope_variant(assigns) do
case assigns.scope do
:selection -> "primary"
_ -> "neutral"
end
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
end

View file

@ -464,6 +464,9 @@ defmodule MvWeb.CoreComponents do
slot :inner_block, doc: "Custom content for the dropdown menu (e.g., forms)"
slot :trigger_badge,
doc: "Optional badge rendered in the trigger after the label (e.g. a scope badge)"
def dropdown_menu(assigns) do
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
@ -498,6 +501,8 @@ defmodule MvWeb.CoreComponents do
<.icon name={@icon} />
<% end %>
<span>{@button_label}</span>
{render_slot(@trigger_badge)}
<.icon name="hero-chevron-down" class="size-4" />
</button>
<ul

View file

@ -1,110 +0,0 @@
defmodule MvWeb.Components.ExportDropdown do
@moduledoc """
Export dropdown component for member export (CSV/PDF).
Provides an accessible dropdown menu with CSV and PDF export options.
Uses the same export payload as the previous single-button export.
"""
use MvWeb, :live_component
use Gettext, backend: MvWeb.Gettext
# Same focus ring as CoreComponents button/dropdown (WCAG 2.4.7)
defp dropdown_item_class do
focus =
MvWeb.CoreComponents.button_focus_classes()
|> Kernel.++(["focus-visible:ring-inset"])
|> Enum.join(" ")
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left #{focus}"
end
@impl true
def mount(socket) do
{:ok, assign(socket, :open, false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(:id, assigns.id)
|> assign(:export_payload_json, assigns[:export_payload_json] || "")
|> assign(:selected_count, assigns[:selected_count] || 0)
{:ok, socket}
end
@impl true
def render(assigns) do
button_label =
gettext("Export") <>
" (" <>
if(assigns.selected_count == 0,
do: gettext("all"),
else: to_string(assigns.selected_count)
) <>
")"
assigns = assign(assigns, :button_label, button_label)
~H"""
<div id={@id} data-testid="export-dropdown" class="flex-auto flex-wrap">
<.dropdown_menu
id={"#{@id}-menu"}
button_label={@button_label}
icon="hero-arrow-down-tray"
open={@open}
phx_target={@myself}
menu_width="w-48"
menu_align="left"
button_class="btn-secondary gap-2"
testid="export-dropdown"
button_testid="export-dropdown-button"
menu_testid="export-dropdown-menu"
>
<li role="none">
<form method="post" action={~p"/members/export.csv"} target="_blank" class="w-full">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<input type="hidden" name="payload" value={@export_payload_json} />
<button
type="submit"
role="menuitem"
class={dropdown_item_class()}
aria-label={gettext("Export members to CSV")}
data-testid="export-csv-link"
>
<.icon name="hero-document-arrow-down" class="h-4 w-4" />
<span>{gettext("CSV")}</span>
</button>
</form>
</li>
<li role="none">
<form method="post" action={~p"/members/export.pdf"} target="_blank" class="w-full">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<input type="hidden" name="payload" value={@export_payload_json} />
<button
type="submit"
role="menuitem"
class={dropdown_item_class()}
aria-label={gettext("Export members to PDF")}
data-testid="export-pdf-link"
>
<.icon name="hero-document-text" class="h-4 w-4" />
<span>{gettext("PDF")}</span>
</button>
</form>
</li>
</.dropdown_menu>
</div>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
end

View file

@ -335,8 +335,6 @@ defmodule MvWeb.AuthController do
end
end
defp redact_url(_), do: "[redacted]"
def sign_out(conn, _params) do
conn = clear_session(conn, :mv) |> put_flash(:success, gettext("You are now signed out"))

View file

@ -0,0 +1,120 @@
defmodule MvWeb.ImportTemplateController do
@moduledoc """
Serves CSV import templates generated on the fly from the current custom fields.
Two actions provide an English (`en/2`) and a German (`de/2`) template. Each
template has a single header row listing the standard member columns followed
by every existing custom field name (exact match, as the import expects), plus
the importable groups and fee-type columns. A single placeholder example row is
included to illustrate the format.
Both actions require the same authorization as the import page
(`can?(:create, Member)`); unauthorized requests are rejected.
"""
use MvWeb, :controller
alias Mv.Authorization.Actor
alias Mv.Membership.Member
alias Mv.Membership.MembersCSV
alias MvWeb.Authorization
# Standard member columns in template order, with their English and German headers
# and a placeholder example value. Groups and fee type are importable extras.
@columns [
{"first name", "Vorname", "John", "Max"},
{"last name", "Nachname", "Doe", "Mustermann"},
{"email", "E-Mail", "john.doe@example.com", "max.mustermann@example.com"},
{"country", "Land", "Germany", "Deutschland"},
{"city", "Stadt", "Berlin", "Berlin"},
{"street", "Straße", "Main Street", "Hauptstraße"},
{"house number", "Hausnummer", "1a", "12"},
{"postal_code", "PLZ", "12345", "10115"},
{"join_date", "Beitrittsdatum", "2020-01-15", "2020-01-15"},
{"exit_date", "Austrittsdatum", "", ""},
{"notes", "Notizen", "", ""},
{"membership_fee_start_date", "Beitragsbeginn", "", ""},
{"Groups", "Gruppen", "", ""},
{"Fee Type", "Beitragsart", "", ""}
]
@spec en(Plug.Conn.t(), map()) :: Plug.Conn.t()
def en(conn, _params) do
serve_template(conn, :en, "member_import_en.csv")
end
@spec de(Plug.Conn.t(), map()) :: Plug.Conn.t()
def de(conn, _params) do
serve_template(conn, :de, "member_import_de.csv")
end
defp serve_template(conn, locale, filename) do
actor = current_actor(conn)
if Authorization.can?(actor, :create, Member) do
csv = build_csv(locale, actor)
send_download(conn, {:binary, csv},
filename: filename,
content_type: "text/csv; charset=utf-8"
)
else
return_forbidden(conn)
end
end
defp build_csv(locale, actor) do
custom_field_names = custom_field_names(actor)
header =
Enum.map(@columns, &header_for(&1, locale)) ++ custom_field_names
example =
Enum.map(@columns, &example_for(&1, locale)) ++ Enum.map(custom_field_names, fn _ -> "" end)
[csv_row(header), csv_row(example)]
|> Enum.join("\n")
end
defp header_for({en, _de, _ex_en, _ex_de}, :en), do: en
defp header_for({_en, de, _ex_en, _ex_de}, :de), do: de
defp example_for({_en, _de, ex_en, _ex_de}, :en), do: ex_en
defp example_for({_en, _de, _ex_en, ex_de}, :de), do: ex_de
defp custom_field_names(actor) do
Mv.Membership.list_custom_fields!(actor: actor)
|> Enum.map(& &1.name)
end
# Serializes a row using the semicolon delimiter (the import auto-detects it),
# quoting any field that contains a delimiter, quote, or newline.
defp csv_row(fields) do
Enum.map_join(fields, ";", &escape_field/1)
end
# Neutralizes spreadsheet formula triggers (the same guard the export writer
# applies) before RFC 4180 quoting, so a custom-field name like
# `=HYPERLINK(...)` is not evaluated when the template is opened.
defp escape_field(field) do
field = field |> to_string() |> MembersCSV.safe_cell()
if String.contains?(field, [";", "\"", "\n", "\r"]) do
"\"" <> String.replace(field, "\"", "\"\"") <> "\""
else
field
end
end
defp current_actor(conn) do
conn.assigns[:current_user]
|> Actor.ensure_loaded()
end
defp return_forbidden(conn) do
conn
|> put_status(403)
|> put_resp_content_type("application/json")
|> json(%{error: "Forbidden"})
|> halt()
end
end

View file

@ -25,31 +25,33 @@ defmodule MvWeb.MemberExportController do
@custom_field_prefix Mv.Constants.custom_field_prefix()
def export(conn, params) do
actor = current_actor(conn)
if is_nil(actor), do: return_forbidden(conn)
case params["payload"] do
nil ->
conn
|> put_status(400)
|> put_resp_content_type("application/json")
|> json(%{error: "payload required"})
payload when is_binary(payload) ->
case Jason.decode(payload) do
{:ok, decoded} when is_map(decoded) ->
parsed = parse_and_validate(decoded)
run_export(conn, actor, parsed)
_ ->
conn
|> put_status(400)
|> put_resp_content_type("application/json")
|> json(%{error: "invalid JSON"})
end
case current_actor(conn) do
nil -> return_forbidden(conn)
actor -> export_with_actor(conn, actor, params["payload"])
end
end
defp export_with_actor(conn, actor, payload) when is_binary(payload) do
case Jason.decode(payload) do
{:ok, decoded} when is_map(decoded) ->
run_export(conn, actor, parse_and_validate(decoded))
_ ->
json_error(conn, "invalid JSON")
end
end
defp export_with_actor(conn, _actor, _payload) do
json_error(conn, "payload required")
end
defp json_error(conn, message) do
conn
|> put_status(400)
|> put_resp_content_type("application/json")
|> json(%{error: message})
end
defp current_actor(conn) do
conn.assigns[:current_user]
|> Actor.ensure_loaded()

View file

@ -0,0 +1,70 @@
defmodule MvWeb.Helpers.JoinDescriptionRenderer do
@moduledoc """
Renders a custom field's `join_description` into Phoenix-safe HTML for the
public join form.
The renderer auto-links two patterns into `<a href="...">` tags:
- Markdown links of the form `[text](url)` (processed first)
- bare `http(s)://` URLs in the remaining text
All other content is HTML-escaped: only `<a href="...">` tags are ever
emitted, so arbitrary HTML in the input is rendered as inert text. This is a
defense-in-depth measure `join_description` is admin-set content, never
end-user input but the renderer must not become a vector for injecting
arbitrary markup.
Markdown links are matched before bare URLs and their matched region is
consumed, so a Markdown link whose URL also looks like a bare URL is linked
exactly once (no nested anchors).
"""
@markdown_link ~r/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/
@bare_url ~r/(https?:\/\/[^\s<]+)/
@bare_url_anchored ~r/\A(https?:\/\/[^\s<]+)\z/
@doc """
Converts `value` to a Phoenix-safe HTML iolist.
Returns `{:safe, ""}` for `nil`. For a string, returns `{:safe, iolist}` with
links rendered and all other text HTML-escaped.
"""
@spec render(String.t() | nil) :: Phoenix.HTML.safe()
def render(nil), do: {:safe, ""}
def render(value) when is_binary(value) do
{:safe, render_segments(value)}
end
# Split on Markdown links first; for each non-Markdown segment, link bare URLs;
# everything that is not a link is HTML-escaped.
defp render_segments(text) do
Regex.split(@markdown_link, text, include_captures: true)
|> Enum.map(&render_markdown_or_plain/1)
end
defp render_markdown_or_plain(segment) do
case Regex.run(@markdown_link, segment) do
[^segment, label, url] -> anchor(url, label)
_ -> render_plain(segment)
end
end
# Auto-link bare URLs in a plain-text segment, escaping all surrounding text.
defp render_plain(segment) do
Regex.split(@bare_url, segment, include_captures: true)
|> Enum.map(fn part ->
if Regex.match?(@bare_url_anchored, part) do
anchor(part, part)
else
escape(part)
end
end)
end
defp anchor(url, label) do
["<a href=\"", escape(url), "\" class=\"link link-primary\">", escape(label), "</a>"]
end
defp escape(text), do: Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string()
end

View file

@ -30,8 +30,8 @@ defmodule MvWeb.SignInLive do
# Set both backend-specific and global locale so Gettext.get_locale/0 and
# Gettext.get_locale/1 both return the correct value (important for the
# language-selector `selected` attribute in Layouts.public_page).
Gettext.put_locale(MvWeb.Gettext, locale)
Gettext.put_locale(locale)
_ = Gettext.put_locale(MvWeb.Gettext, locale)
_ = Gettext.put_locale(locale)
# Prepend DE-specific overrides when locale is German so that components
# without _gettext support (e.g. HorizontalRule) still render in German.

View file

@ -16,8 +16,8 @@ defmodule MvWeb.SignOutLive do
@impl true
def mount(_params, session, socket) do
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
Gettext.put_locale(MvWeb.Gettext, locale)
Gettext.put_locale(locale)
_ = Gettext.put_locale(MvWeb.Gettext, locale)
_ = Gettext.put_locale(locale)
club_name =
case Membership.get_settings() do

View file

@ -156,6 +156,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
>
{@member_count}
</.badge>
<.icon name="hero-chevron-down" class="size-4" />
</.button>
<!--
@ -935,7 +936,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
{nil, true} -> "#{base_classes} btn-active"
{:in, true} -> "#{base_classes} btn-success btn-active"
{:not_in, true} -> "#{base_classes} btn-error btn-active"
_ -> "#{base_classes} btn-outline"
end
end

View file

@ -91,6 +91,45 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
<% end %>
<.input field={@form[:description]} type="text" label={gettext("Description")} />
<fieldset class="mb-2 fieldset">
<label>
<span class="mb-1 label flex items-center gap-2">
{gettext("Description for join form")}
<.tooltip
content={
gettext(
"You can add links: full addresses (https://…) or as [link text](https://…)."
)
}
position="right"
>
<span
data-testid="join-description-link-hint"
aria-label={
gettext(
"You can add links: full addresses (https://…) or as [link text](https://…)."
)
}
>
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
</.tooltip>
</span>
<input
type="text"
name={@form[:join_description].name}
id={@form[:join_description].id}
value={Phoenix.HTML.Form.normalize_value("text", @form[:join_description].value)}
class="w-full input"
/>
</label>
</fieldset>
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.input
field={@form[:show_in_overview]}

View file

@ -52,7 +52,7 @@ defmodule MvWeb.GlobalSettingsLive do
# Get locale from session; same fallback as router/LiveUserAuth (respects config :default_locale in test)
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
Gettext.put_locale(MvWeb.Gettext, locale)
_ = Gettext.put_locale(MvWeb.Gettext, locale)
actor = MvWeb.LiveHelpers.current_actor(socket)
custom_fields = load_custom_fields(actor)

View file

@ -836,12 +836,6 @@ defmodule MvWeb.GroupLive.Show do
end
end
defp perform_add_members(socket, _group, _member_ids, _actor) do
{:noreply,
socket
|> put_flash(:error, gettext("No members selected."))}
end
defp handle_successful_add_members(socket, group, actor) do
socket = reload_group(socket, group.slug, actor)

View file

@ -47,14 +47,11 @@ defmodule MvWeb.ImportLive do
# after this limit is reached.
@max_errors 50
# Maximum length for error messages before truncation
@max_error_message_length 200
@impl true
def mount(_params, session, socket) do
# Get locale from session for translations
locale = session["locale"] || "de"
Gettext.put_locale(MvWeb.Gettext, locale)
_ = Gettext.put_locale(MvWeb.Gettext, locale)
# Get club name from settings
club_name =
@ -102,7 +99,12 @@ defmodule MvWeb.ImportLive do
<.form_section title={gettext("Choose CSV file")}>
<Components.custom_fields_notice {assigns} />
<Components.template_links {assigns} />
<Components.import_form {assigns} />
<%= if @import_status != :preview do %>
<Components.import_form {assigns} />
<% end %>
<%= if @import_status == :preview do %>
<Components.preview {assigns} />
<% end %>
<%= if @import_status == :running or @import_status == :done or @import_status == :error do %>
<Components.import_progress {assigns} />
<% end %>
@ -136,6 +138,29 @@ defmodule MvWeb.ImportLive do
end
end
@impl true
def handle_event("confirm_import", _params, socket) do
case socket.assigns do
%{import_state: import_state} when is_map(import_state) ->
start_import(socket, import_state)
_ ->
{:noreply,
put_flash(socket, :error, gettext("No prepared import to confirm. Please upload again."))}
end
end
@impl true
def handle_event("cancel_import", _params, socket) do
socket =
socket
|> assign(:import_state, nil)
|> assign(:import_progress, nil)
|> assign(:import_status, :idle)
{:noreply, socket}
end
# Checks if all prerequisites for starting an import are met.
#
# Validates:
@ -172,10 +197,10 @@ defmodule MvWeb.ImportLive do
end
end
# Processes CSV upload and starts import process.
# Processes CSV upload and enters the mapping preview.
#
# Reads the uploaded CSV file, prepares it for import, and initiates
# the chunked processing workflow.
# Reads the uploaded CSV file and prepares it (read-only at the DB level), then
# shows the mapping preview. No member is created until the user confirms.
@spec process_csv_upload(Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
defp process_csv_upload(socket) do
@ -184,7 +209,7 @@ defmodule MvWeb.ImportLive do
with {:ok, content} <- consume_and_read_csv(socket),
{:ok, import_state} <-
MemberCSV.prepare(content, max_rows: Config.csv_import_max_rows(), actor: actor) do
start_import(socket, import_state)
enter_preview(socket, import_state)
else
{:error, reason} when is_binary(reason) ->
{:noreply,
@ -193,19 +218,22 @@ defmodule MvWeb.ImportLive do
:error,
gettext("Failed to prepare CSV import: %{reason}", reason: reason)
)}
{:error, error} ->
error_message = format_error_message(error)
{:noreply,
put_flash(
socket,
:error,
gettext("Failed to prepare CSV import: %{reason}", reason: error_message)
)}
end
end
# Shows the mapping preview without starting any processing.
@spec enter_preview(Phoenix.LiveView.Socket.t(), map()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
defp enter_preview(socket, import_state) do
socket =
socket
|> assign(:import_state, import_state)
|> assign(:import_progress, nil)
|> assign(:import_status, :preview)
{:noreply, socket}
end
# Starts the import process by initializing progress tracking and scheduling the first chunk.
@spec start_import(Phoenix.LiveView.Socket.t(), map()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
@ -223,64 +251,6 @@ defmodule MvWeb.ImportLive do
{:noreply, socket}
end
# Formats error messages for user-friendly display.
#
# Handles various error types including Ash errors, maps with message fields,
# lists of errors, and fallback formatting for unknown types.
@spec format_error_message(any()) :: String.t()
defp format_error_message(error) do
case error do
%Ash.Error.Invalid{} = ash_error ->
format_ash_error(ash_error)
%{message: msg} when is_binary(msg) ->
msg
%{errors: errors} when is_list(errors) ->
format_error_list(errors)
reason when is_binary(reason) ->
reason
other ->
format_unknown_error(other)
end
end
# Formats Ash validation errors for display
defp format_ash_error(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
Enum.map_join(errors, ", ", &format_single_error/1)
end
defp format_ash_error(error) do
format_unknown_error(error)
end
# Formats a list of errors into a readable string
defp format_error_list(errors) do
Enum.map_join(errors, ", ", &format_single_error/1)
end
# Formats a single error item
defp format_single_error(error) when is_map(error) do
Map.get(error, :message) || Map.get(error, :field) || inspect(error, limit: :infinity)
end
defp format_single_error(error) do
to_string(error)
end
# Formats unknown error types with truncation for very long messages
defp format_unknown_error(other) do
error_str = inspect(other, limit: :infinity, pretty: true)
if String.length(error_str) > @max_error_message_length do
String.slice(error_str, 0, @max_error_message_length - 3) <> "..."
else
error_str
end
end
@impl true
def handle_info({:process_chunk, idx}, socket) do
case socket.assigns do
@ -334,35 +304,38 @@ defmodule MvWeb.ImportLive do
custom_field_lookup: import_state.custom_field_lookup,
existing_error_count: length(progress.errors),
max_errors: @max_errors,
actor: actor
actor: actor,
fee_type_map: import_state.fee_type_map,
groups_found: import_state.groups_found
]
if Config.sql_sandbox?() do
run_chunk_with_locale(
locale,
chunk,
import_state.column_map,
import_state.custom_field_map,
opts,
live_view_pid,
idx
)
else
Task.Supervisor.start_child(
Mv.TaskSupervisor,
fn ->
run_chunk_with_locale(
locale,
chunk,
import_state.column_map,
import_state.custom_field_map,
opts,
live_view_pid,
idx
)
end
)
end
_ =
if Config.sql_sandbox?() do
run_chunk_with_locale(
locale,
chunk,
import_state.column_map,
import_state.custom_field_map,
opts,
live_view_pid,
idx
)
else
Task.Supervisor.start_child(
Mv.TaskSupervisor,
fn ->
run_chunk_with_locale(
locale,
chunk,
import_state.column_map,
import_state.custom_field_map,
opts,
live_view_pid,
idx
)
end
)
end
{:noreply, socket}
end
@ -378,7 +351,7 @@ defmodule MvWeb.ImportLive do
live_view_pid,
idx
) do
Gettext.put_locale(MvWeb.Gettext, locale)
_ = Gettext.put_locale(MvWeb.Gettext, locale)
ImportRunner.process_chunk(chunk, column_map, custom_field_map, opts, live_view_pid, idx)
end
@ -394,8 +367,11 @@ defmodule MvWeb.ImportLive do
new_progress =
ImportRunner.merge_progress(progress, chunk_result, idx, max_errors: @max_errors)
new_import_state = ImportRunner.carry_groups_forward(import_state, chunk_result)
socket =
socket
|> assign(:import_state, new_import_state)
|> assign(:import_progress, new_progress)
|> assign(:import_status, new_progress.status)
|> maybe_send_next_chunk(idx, length(import_state.chunks))

View file

@ -25,7 +25,22 @@ defmodule MvWeb.ImportLive.Components do
<div>
<p class="text-sm mb-2">
{gettext(
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
"Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored."
)}
</p>
<p class="text-sm mb-2">
{gettext(
"Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically."
)}
</p>
<p class="text-sm mb-2">
{gettext(
"Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
)}
</p>
<p class="text-sm">
{gettext(
"Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported."
)}
</p>
</div>
@ -44,20 +59,12 @@ defmodule MvWeb.ImportLive.Components do
</p>
<ul class="list-disc list-inside space-y-1">
<li>
<.link
href={~p"/templates/member_import_en.csv"}
download="member_import_en.csv"
class="link link-primary"
>
<.link href={~p"/admin/import/template/en"} class="link link-primary">
{gettext("English Template")}
</.link>
</li>
<li>
<.link
href={~p"/templates/member_import_de.csv"}
download="member_import_de.csv"
class="link link-primary"
>
<.link href={~p"/admin/import/template/de"} class="link link-primary">
{gettext("German Template")}
</.link>
</li>
@ -108,6 +115,194 @@ defmodule MvWeb.ImportLive.Components do
"""
end
@doc """
Renders the mapping preview shown between upload and processing.
Shows the column-to-role mapping, up to 3 sample rows, and notices for
auto-created groups, unresolved fee types, empty fee-type cells, and unknown
columns. Nothing is written until the user confirms.
"""
def preview(assigns) do
state = assigns.import_state
column_roles = column_roles(state)
column_samples = column_samples(state.preview_rows, length(state.headers))
assigns =
assigns
|> assign(:column_roles, column_roles)
|> assign(:column_samples, column_samples)
~H"""
<section
class="mt-4 space-y-4"
data-testid="import-preview"
aria-labelledby="import-preview-heading"
>
<h2 id="import-preview-heading" class="text-lg font-semibold">
{gettext("Preview import")}
</h2>
<div class="overflow-x-auto">
<table class="table table-sm w-full" data-testid="preview-mapping-table">
<thead>
<tr>
<th>{gettext("Role")}</th>
<th>{gettext("Column")}</th>
<th class="text-base-content/60">{gettext("Row 1")}</th>
<th class="text-base-content/60">{gettext("Row 2")}</th>
<th class="text-base-content/60">{gettext("Row 3")}</th>
</tr>
</thead>
<tbody>
<%= for {{header, role}, samples} <- Enum.zip(@column_roles, @column_samples) do %>
<tr class={role_row_class(role)} data-testid="preview-column-row">
<td>
<span class={"badge badge-sm #{role_badge_class(role)}"}>
{role_label(role)}
</span>
</td>
<td class="font-medium">{header}</td>
<%= for sample <- samples do %>
<td class="text-base-content/70 max-w-32 truncate">{sample}</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<%= if @import_state.groups_to_create != [] do %>
<div class="alert alert-info" role="note" data-testid="preview-groups-notice">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<p class="text-sm">
{gettext("These groups will be created automatically: %{names}",
names: Enum.join(@import_state.groups_to_create, ", ")
)}
</p>
</div>
</div>
<% end %>
<%= if @import_state.fee_type_warnings != [] do %>
<div class="alert alert-warning" role="alert" data-testid="preview-fee-type-warning">
<.icon name="hero-exclamation-circle" class="size-5" aria-hidden="true" />
<div>
<p class="text-sm">
{gettext("Unknown fee types (members get the default): %{names}",
names: Enum.join(@import_state.fee_type_warnings, ", ")
)}
</p>
<.link
navigate={~p"/membership_fee_settings/new_fee_type"}
class="link link-primary text-sm"
>
{gettext("Create fee type")}
</.link>
</div>
</div>
<% end %>
<%= if @import_state.has_empty_fee_type_cells? do %>
<div class="alert alert-info" role="note" data-testid="preview-fee-type-info">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<p class="text-sm">
{gettext("Rows with an empty fee type will get the default fee type.")}
</p>
</div>
</div>
<% end %>
<%= if @import_state.warnings != [] do %>
<div class="alert alert-warning" role="alert" data-testid="preview-unknown-warning">
<.icon name="hero-information-circle" class="size-5" aria-hidden="true" />
<div>
<ul class="list-disc list-inside space-y-1 text-sm">
<%= for warning <- @import_state.warnings do %>
<li>{warning}</li>
<% end %>
</ul>
<.link navigate={~p"/admin/datafields"} class="link link-primary text-sm">
{gettext("Create custom field")}
</.link>
</div>
</div>
<% end %>
<div class="flex gap-2">
<.button
type="button"
phx-click="confirm_import"
variant="primary"
data-testid="confirm-import-button"
>
{gettext("Confirm and Import")}
</.button>
<.button type="button" phx-click="cancel_import" data-testid="cancel-import-button">
{gettext("Cancel")}
</.button>
</div>
</section>
"""
end
# Pairs each CSV header with its resolved role for the preview mapping table.
defp column_roles(state) do
member_indices = MapSet.new(Map.values(state.column_map))
custom_indices = MapSet.new(Map.values(state.custom_field_map))
ignored_headers = MapSet.new(state.ignored)
state.headers
|> Enum.with_index()
|> Enum.map(fn {header, index} ->
{header, role_for(index, header, state, member_indices, custom_indices, ignored_headers)}
end)
end
defp role_for(index, header, state, member_indices, custom_indices, ignored_headers) do
cond do
index == state.groups_column_index -> :groups
index == state.fee_type_column_index -> :fee_type
MapSet.member?(ignored_headers, header) -> :ignored
MapSet.member?(member_indices, index) -> :member_field
MapSet.member?(custom_indices, index) -> :custom_field
true -> :unknown
end
end
defp role_label(:member_field), do: gettext("Member field")
defp role_label(:custom_field), do: gettext("Custom field")
defp role_label(:groups), do: gettext("Groups")
defp role_label(:fee_type), do: gettext("Fee type")
defp role_label(:ignored), do: gettext("Ignored (system-computed field)")
defp role_label(:unknown), do: gettext("Unknown (ignored)")
defp role_badge_class(:member_field), do: "badge-primary"
defp role_badge_class(:custom_field), do: "badge-secondary"
defp role_badge_class(:groups), do: "badge-success"
defp role_badge_class(:fee_type), do: "badge-warning"
defp role_badge_class(:ignored), do: "badge-ghost"
defp role_badge_class(:unknown), do: "badge-error"
defp role_row_class(:ignored), do: "opacity-50"
defp role_row_class(:unknown), do: "opacity-50"
defp role_row_class(_), do: nil
defp column_samples([], col_count), do: List.duplicate([], col_count)
defp column_samples(rows, col_count) do
Enum.map(0..(col_count - 1), fn col_idx ->
rows
|> Enum.map(fn row -> Enum.at(row, col_idx, "") end)
|> pad_to(3, "")
end)
end
defp pad_to(list, target, fill) do
list ++ List.duplicate(fill, max(0, target - length(list)))
end
@doc """
Renders import progress text and, when done or aborted, the import results section.
"""
@ -254,8 +449,10 @@ defmodule MvWeb.ImportLive.Components do
@doc """
Returns whether the Start Import button should be disabled.
"""
@spec import_button_disabled?(:idle | :running | :done | :error, [map()]) :: boolean()
@spec import_button_disabled?(:idle | :preview | :running | :done | :error, [map()]) ::
boolean()
def import_button_disabled?(:running, _entries), do: true
def import_button_disabled?(:preview, _entries), do: true
def import_button_disabled?(_status, []), do: true
def import_button_disabled?(_status, [entry | _]) when not entry.done?, do: true
def import_button_disabled?(_status, _entries), do: false

View file

@ -8,6 +8,7 @@ defmodule MvWeb.JoinLive do
alias Ash.Resource.Info
alias Mv.Membership
alias Mv.Membership.CustomFieldLookup
alias MvWeb.Helpers.JoinDescriptionRenderer
alias MvWeb.JoinRateLimit
alias MvWeb.Translations.MemberFields
@ -96,14 +97,20 @@ defmodule MvWeb.JoinLive do
class="checkbox checkbox-sm"
/>
<span class="label-text">
{field.label}<span :if={field.required} aria-hidden="true"> *</span>
{render_field_label(field)}<span
:if={field.required}
aria-hidden="true"
> *</span>
</span>
</label>
<% else %>
<div>
<label for={"join-field-#{field.id}"} class="label">
<span class="label-text">
{field.label}<span :if={field.required} aria-hidden="true"> *</span>
{render_field_label(field)}<span
:if={field.required}
aria-hidden="true"
> *</span>
</span>
</label>
<input
@ -237,6 +244,17 @@ defmodule MvWeb.JoinLive do
|> assign(:form, to_form(params, as: "join"))}
end
# Renders a join field's label. When a custom field has a join_description it is
# rendered with auto-linked URLs/Markdown; otherwise the plain field label is used.
# Safe: join_description is admin-set settings content, never end-user input, and
# JoinDescriptionRenderer escapes all non-link text (only emits <a href> tags).
defp render_field_label(%{join_description: join_description})
when is_binary(join_description) do
JoinDescriptionRenderer.render(join_description)
end
defp render_field_label(%{label: label}), do: label
defp build_join_fields_with_labels(allowlist) do
member_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
custom_field_by_id = custom_field_map(allowlist, member_field_strings)
@ -249,20 +267,36 @@ defmodule MvWeb.JoinLive do
defp build_join_field(id, required, member_field_strings, custom_field_by_id) do
if id in member_field_strings do
label = MemberFields.label(String.to_existing_atom(id))
%{id: id, label: label, required: required, input_type: member_field_input_type(id)}
%{
id: id,
label: label,
required: required,
input_type: member_field_input_type(id),
join_description: nil
}
else
custom_field = Map.get(custom_field_by_id, id)
label = if custom_field, do: custom_field.name, else: gettext("Field")
input_type = custom_field_input_type(custom_field && custom_field.value_type)
%{id: id, label: label, required: required, input_type: input_type}
%{
id: id,
label: label,
required: required,
input_type: input_type,
join_description: custom_field && custom_field.join_description
}
end
end
defp custom_field_map(allowlist, _member_field_strings) do
allowlist
|> Enum.map(& &1.id)
|> CustomFieldLookup.fetch_map_by_ids(authorize?: false, select: [:id, :name, :value_type])
|> CustomFieldLookup.fetch_map_by_ids(
authorize?: false,
select: [:id, :name, :value_type, :join_description]
)
end
defp initial_form_params(join_fields) do
@ -287,8 +321,6 @@ defmodule MvWeb.JoinLive do
end
end
defp member_field_input_type(_), do: "text"
defp member_field_atom(field_id) when is_binary(field_id) do
Mv.Constants.member_fields()
|> Enum.find(&(Atom.to_string(&1) == field_id))

View file

@ -17,7 +17,7 @@ defmodule MvWeb.MemberLive.Index do
## Events
- `select_member` - Toggle individual member selection
- `select_all` - Toggle selection of all visible members
- `copy_emails` - Copy email addresses of selected members to clipboard
- `copy_emails` - Copy email addresses of the selected members, or of all/filtered members when nothing is selected
## Implementation Notes
- Search uses PostgreSQL full-text search (plainto_tsquery)
@ -250,41 +250,42 @@ defmodule MvWeb.MemberLive.Index do
@impl true
def handle_event("copy_emails", _params, socket) do
members = socket.assigns.members
selected_ids = socket.assigns.selected_members
any_selected? = Enum.any?(members, &MapSet.member?(selected_ids, &1.id))
# Filter members that are in the selection and have email addresses
formatted_emails = format_selected_member_emails(socket.assigns.members, selected_ids)
# Recipients follow the current scope: the selection when present, otherwise
# every member in the (filtered) list. Members without an email are excluded
# in both cases (unchanged missing-email handling). With no selection we no
# longer hard-stop with "No members selected" — we act on the scope; the
# empty-recipient feedback below is preserved.
formatted_emails = scope_member_emails(members, selected_ids, any_selected?)
email_count = length(formatted_emails)
cond do
MapSet.size(selected_ids) == 0 ->
{:noreply, put_flash(socket, :error, gettext("No members selected"))}
if email_count == 0 do
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
else
# RFC 5322 uses comma as separator for email address lists
email_string = Enum.join(formatted_emails, ", ")
email_count == 0 ->
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
true ->
# RFC 5322 uses comma as separator for email address lists
email_string = Enum.join(formatted_emails, ", ")
socket =
socket
|> push_event("copy_to_clipboard", %{text: email_string})
|> put_flash(
:success,
ngettext(
"Copied %{count} email address to clipboard",
"Copied %{count} email addresses to clipboard",
email_count,
count: email_count
)
)
|> put_flash(
:warning,
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
socket =
socket
|> push_event("copy_to_clipboard", %{text: email_string})
|> put_flash(
:success,
ngettext(
"Copied %{count} email address to clipboard",
"Copied %{count} email addresses to clipboard",
email_count,
count: email_count
)
)
|> put_flash(
:warning,
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
)
{:noreply, socket}
{:noreply, socket}
end
end
@ -1218,8 +1219,6 @@ defmodule MvWeb.MemberLive.Index do
end
end
defp apply_one_fee_type_filter(query, _, _), do: query
defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current)
@ -1297,8 +1296,6 @@ defmodule MvWeb.MemberLive.Index do
end
end
defp valid_sort_field?(_), do: false
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
non_sortable_fields = [:notes]
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
@ -1558,8 +1555,6 @@ defmodule MvWeb.MemberLive.Index do
assign(socket, :group_filters, Map.take(filters, valid_group_ids))
end
defp maybe_update_group_filters(socket, _), do: socket
defp maybe_update_fee_type_filters(socket, params) when is_map(params) do
prefix = @fee_type_filter_prefix
prefix_len = String.length(prefix)
@ -1586,8 +1581,6 @@ defmodule MvWeb.MemberLive.Index do
assign(socket, :fee_type_filters, Map.take(filters, valid_fee_type_ids))
end
defp maybe_update_fee_type_filters(socket, _), do: socket
defp add_fee_type_filter_entry(acc, key, value_str, prefix_len) do
key_str = to_string(key)
raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)
@ -1719,8 +1712,6 @@ defmodule MvWeb.MemberLive.Index do
assign(socket, :date_filters, DateFilter.from_params(params, date_custom_fields))
end
defp maybe_update_date_filters(socket, _params), do: socket
# -------------------------------------------------------------
# Custom Field Value Helpers
# -------------------------------------------------------------
@ -1822,24 +1813,79 @@ defmodule MvWeb.MemberLive.Index do
selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id))
any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id))
# Scope drives the trigger label: the selection when present, otherwise the
# whole list (filtered, when a search term or any filter is active).
scope =
cond do
any_selected? -> :selection
filters_active?(socket.assigns) -> :filtered
true -> :all
end
# Copy/Mailto recipients: the members in scope that have a usable email.
# With a selection that is the selected subset (existing behaviour); without
# a selection it is every member in scope (deliberate behaviour change). In
# both cases members without an email are excluded, exactly as today's
# format_selected_member_emails does for the selection case.
recipient_emails = scope_member_emails(members, selected_members, any_selected?)
recipient_count = length(recipient_emails)
# RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +)
mailto_bcc =
if any_selected? do
format_selected_member_emails(members, selected_members)
|> Enum.join(", ")
|> URI.encode_www_form()
|> String.replace("+", "%20")
else
""
end
recipient_emails
|> Enum.join(", ")
|> URI.encode_www_form()
|> String.replace("+", "%20")
mailto_disabled? = recipient_count >= Mv.Constants.max_mailto_bulk_recipients()
socket
|> assign(:selected_count, selected_count)
|> assign(:any_selected?, any_selected?)
|> assign(:scope, scope)
|> assign(:recipient_count, recipient_count)
|> assign(:mailto_disabled?, mailto_disabled?)
|> assign(:mailto_bcc, mailto_bcc)
|> assign_export_payload()
end
# Returns the formatted "Name <email>" recipient list for the current scope:
# the selected members when any are selected, otherwise every member in the
# (filtered) list. Members without an email are excluded in both cases.
defp scope_member_emails(members, selected_members, true = _any_selected?),
do: format_selected_member_emails(members, selected_members)
defp scope_member_emails(members, _selected_members, false = _any_selected?) do
members
|> Enum.filter(fn member -> member.email && member.email != "" end)
|> Enum.map(&format_member_email/1)
end
@doc """
Returns true when the member list is restricted by a non-empty search term or
any active filter (cycle status, group, fee type, boolean custom field, or a
date filter differing from the default). Drives the "(gefiltert)" vs "(alle)"
trigger label and reads only assigns no DB access.
"""
def filters_active?(assigns) do
search_active?(assigns) or selection_filters_active?(assigns) or date_filter_active?(assigns)
end
defp search_active?(assigns) do
query = assigns[:query]
is_binary(query) and query != ""
end
defp selection_filters_active?(assigns) do
not is_nil(assigns[:cycle_status_filter]) or
map_size(assigns[:group_filters] || %{}) > 0 or
map_size(assigns[:fee_type_filters] || %{}) > 0 or
map_size(assigns[:boolean_custom_field_filters] || %{}) > 0
end
defp date_filter_active?(assigns) do
(assigns[:date_filters] || DateFilter.default()) != DateFilter.default()
end
defp assign_export_payload(socket) do
payload = build_export_payload(socket)
assign(socket, :export_payload_json, Jason.encode!(payload))

View file

@ -3,32 +3,15 @@
{@content_title}
<:actions>
<.live_component
module={MvWeb.Components.ExportDropdown}
id="export-dropdown"
module={MvWeb.Components.BulkActionsDropdown}
id="bulk-actions-dropdown"
export_payload_json={@export_payload_json}
selected_count={@selected_count}
scope={@scope}
mailto_bcc={@mailto_bcc}
recipient_count={@recipient_count}
mailto_disabled?={@mailto_disabled?}
/>
<.button
variant="secondary"
id="copy-emails-btn"
phx-hook="CopyToClipboard"
phx-click="copy_emails"
disabled={not @any_selected?}
aria-label={gettext("Copy email addresses of selected members")}
>
<.icon name="hero-clipboard-document" />
{gettext("Copy email addresses")} ({@selected_count})
</.button>
<.button
variant="secondary"
id="open-email-btn"
href={"mailto:?bcc=" <> @mailto_bcc}
disabled={not @any_selected?}
aria-label={gettext("Open email program with BCC recipients")}
>
<.icon name="hero-envelope" />
{gettext("Open in email program")}
</.button>
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
<.button variant="primary" navigate={~p"/members/new"} data-testid="member-new">
<.icon name="hero-plus" /> {gettext("New Member")}

View file

@ -103,8 +103,6 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
end)
end
defp parse_cookie_header(_), do: %{}
@doc """
Saves field selection to cookie.
@ -218,8 +216,6 @@ defmodule MvWeb.MemberLive.Index.FieldSelection do
end
end
defp parse_json(_), do: %{}
# Parses a comma-separated string of field names
defp parse_fields_string(fields_string) do
fields_string

View file

@ -190,7 +190,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
These fields are not in the database; they must not be used for Ash query
select/sort. Use this to filter sort options and validate sort_field.
"""
@spec computed_member_fields() :: [atom()]
@spec computed_member_fields() :: [:membership_fee_status | :membership_fee_type | :groups, ...]
def computed_member_fields, do: @pseudo_member_fields
@doc """

View file

@ -235,6 +235,19 @@ defmodule MvWeb.MemberLive.Show do
<%= for custom_field <- @custom_fields do %>
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
<.data_field label={custom_field.name}>
<:label_suffix :if={custom_field.join_description}>
<.tooltip
content={"#{gettext("Join form:")} #{custom_field.join_description}"}
wrap_class="ml-1 inline-flex items-center"
>
<span data-testid="join-description-tooltip">
<.icon
name="hero-information-circle"
class="size-3.5 text-base-content/50"
/>
</span>
</.tooltip>
</:label_suffix>
{format_custom_field_value(cfv, custom_field.value_type)}
</.data_field>
<% end %>
@ -605,11 +618,14 @@ defmodule MvWeb.MemberLive.Show do
attr :value, :string, default: nil
attr :class, :string, default: ""
slot :inner_block
slot :label_suffix
defp data_field(assigns) do
~H"""
<dl class={@class}>
<dt class="text-sm font-medium text-base-content/70">{@label}</dt>
<dt class="text-sm font-medium text-base-content/70 flex items-center">
{@label}{render_slot(@label_suffix)}
</dt>
<dd class="mt-1 text-base-content">
<%= if @inner_block != [] do %>
{render_slot(@inner_block)}

View file

@ -1027,7 +1027,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:create_cycle_error, format_error(error))}
end
else
:error ->
{:error, reason} when reason in [:invalid_format, :invalid_date, :incompatible_calendars] ->
{:noreply,
socket
|> assign(:create_cycle_error, gettext("Invalid date format"))}

View file

@ -464,7 +464,6 @@ defmodule MvWeb.MembershipFeeSettingsLive do
Enum.map_join(error.errors, ", ", fn e -> e.message end)
end
defp format_error(error) when is_binary(error), do: error
defp format_error(_error), do: gettext("An error occurred")
defp assign_form(%{assigns: %{settings: settings}} = socket) do

View file

@ -326,7 +326,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
case submit_form(socket.assigns.form, params, actor) do
{:ok, membership_fee_type} ->
notify_parent({:saved, membership_fee_type})
_ = notify_parent({:saved, membership_fee_type})
socket =
socket
@ -341,7 +341,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
end
end
@spec notify_parent(any()) :: any()
@spec notify_parent(any()) :: {module(), any()}
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()

View file

@ -214,7 +214,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
Enum.map_join(error.errors, ", ", fn e -> e.message end)
end
defp format_error(error) when is_binary(error), do: error
defp format_error(_error), do: gettext("An error occurred")
# Info card explaining the membership fee type concept

View file

@ -165,7 +165,7 @@ defmodule MvWeb.RoleLive.Form do
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, role_params, actor) do
{:ok, role} ->
notify_parent({:saved, role})
_ = notify_parent({:saved, role})
redirect_path =
if socket.assigns.return_to == "show" do
@ -186,7 +186,7 @@ defmodule MvWeb.RoleLive.Form do
end
end
@spec notify_parent(any()) :: any()
@spec notify_parent(any()) :: {module(), any()}
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()

View file

@ -734,7 +734,7 @@ defmodule MvWeb.UserLive.Form do
end
defp handle_save_success(socket, updated_user) do
notify_parent({:saved, updated_user})
_ = notify_parent({:saved, updated_user})
action = get_action_name(socket.assigns.form.source.type)
@ -775,7 +775,7 @@ defmodule MvWeb.UserLive.Form do
)}
end
@spec notify_parent(any()) :: any()
@spec notify_parent(any()) :: {module(), any()}
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
# Helper to ignore keyboard events when dropdown is closed
@ -913,7 +913,7 @@ defmodule MvWeb.UserLive.Form do
MemberResource.filter_by_email_match(members, user_email_str)
end
@spec load_roles(any()) :: [Mv.Authorization.Role.t()]
@spec load_roles(any()) :: [Mv.Authorization.Role.t()] | Ash.Page.page()
defp load_roles(actor) do
case Authorization.list_roles(actor: actor) do
{:ok, roles} -> roles
@ -922,7 +922,7 @@ defmodule MvWeb.UserLive.Form do
end
# Extract user-friendly error message from Ash.Error
@spec extract_error_message(any()) :: String.t()
@spec extract_error_message(Ash.Error.t()) :: String.t()
defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do
# Take first error and extract message
case List.first(errors) do
@ -932,6 +932,5 @@ defmodule MvWeb.UserLive.Form do
end
end
defp extract_error_message(error) when is_binary(error), do: error
defp extract_error_message(_), do: gettext("Unknown error")
end

View file

@ -22,7 +22,7 @@ defmodule MvWeb.LiveHelpers do
def on_mount(:default, _params, session, socket) do
locale = session["locale"] || "de"
Gettext.put_locale(locale)
_ = Gettext.put_locale(locale)
# Browser timezone from LiveSocket connect params (set in app.js via Intl API)
connect_params = socket.private[:connect_params] || %{}
@ -145,7 +145,10 @@ defmodule MvWeb.LiveHelpers do
end
"""
@spec submit_form(AshPhoenix.Form.t(), map(), Mv.Accounts.User.t() | nil) ::
{:ok, Ash.Resource.t()} | {:error, AshPhoenix.Form.t()}
{:ok, Ash.Resource.record() | nil | [Ash.Notifier.Notification.t()]}
| {:ok, Ash.Resource.record(), [Ash.Notifier.Notification.t()]}
| :ok
| {:error, AshPhoenix.Form.t()}
def submit_form(form, params, actor) do
AshPhoenix.Form.submit(form, params: params, action_opts: ash_actor_opts(actor))
end

View file

@ -31,27 +31,24 @@ defmodule MvWeb.LiveUserAuth do
end
end
def on_mount(:live_user_required, _params, session, socket) do
socket = LiveSession.assign_new_resources(socket, session)
def on_mount(:live_user_required, _params, _session, socket) do
case socket.assigns do
%{current_user: %{} = user} ->
{:cont, assign(socket, :current_user, user)}
_ ->
socket = LiveView.redirect(socket, to: ~p"/sign-in")
{:halt, socket}
{:halt, LiveView.redirect(socket, to: ~p"/sign-in")}
end
end
def on_mount(:live_no_user, _params, session, socket) do
# Set the locale for not logged in user (default from config, "de" in dev/prod).
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
Gettext.put_locale(MvWeb.Gettext, locale)
{:cont, assign(socket, :locale, locale)}
_ = Gettext.put_locale(MvWeb.Gettext, locale)
socket = assign(socket, :locale, locale)
if socket.assigns[:current_user] do
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
{:halt, LiveView.redirect(socket, to: ~p"/")}
else
{:cont, assign(socket, :current_user, nil)}
end

View file

@ -102,6 +102,10 @@ defmodule MvWeb.Router do
# Import (Admin only)
live "/admin/import", ImportLive
# Dynamic CSV import templates (admin only; generated from current custom fields)
get "/admin/import/template/en", ImportTemplateController, :en
get "/admin/import/template/de", ImportTemplateController, :de
post "/members/export.csv", MemberExportController, :export
post "/members/export.pdf", MemberPdfExportController, :export
post "/set_locale", LocaleController, :set_locale
@ -188,7 +192,7 @@ defmodule MvWeb.Router do
get_locale_from_cookie(conn) ||
extract_locale_from_headers(conn.req_headers)
Gettext.put_locale(MvWeb.Gettext, locale)
_ = Gettext.put_locale(MvWeb.Gettext, locale)
conn
|> put_session(:locale, locale)

View file

@ -12,7 +12,9 @@ defmodule MvWeb.Translations.FieldTypes do
"""
use Gettext, backend: MvWeb.Gettext
@spec label(atom()) :: String.t()
@type field_type :: :string | :integer | :boolean | :date | :email
@spec label(field_type()) :: String.t()
def label(:string), do: gettext("Text")
def label(:integer), do: gettext("Number")
def label(:boolean), do: gettext("Yes/No-Selection")

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View file

@ -39,15 +39,15 @@ defmodule Mv.MixProject do
[
{:tidewave, "~> 0.5", only: [:dev]},
{:sourceror, "~> 1.8", only: [:dev, :test]},
{:live_debugger, "~> 0.8", only: [:dev]},
{:ash_admin, "~> 0.14"},
{:live_debugger, "~> 1.0", only: [:dev]},
{:ash_admin, "~> 1.0"},
{:ash_postgres, "~> 2.0"},
{:ash_phoenix, "~> 2.0"},
{:ash, "~> 3.0"},
{:bcrypt_elixir, "~> 3.0"},
{:ash_authentication, "~> 4.9"},
{:ash_authentication_phoenix, "~> 2.10"},
{:igniter, "~> 0.7", only: [:dev, :test]},
{:igniter, "~> 0.8", only: [:dev, :test]},
{:phoenix, "~> 1.8.0-rc.4", override: true},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.10"},

View file

@ -1,6 +1,6 @@
%{
"ash": {:hex, :ash, "3.24.7", "6e2f32011e7c8f0809dae36712ccfb2efaf3c669cbda7443685436e80acdebf7", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c9fb4d21c3c8bb85636338d448afdc283dd98a433d869e4b2210ac57ade00624"},
"ash_admin": {:hex, :ash_admin, "0.14.0", "1a8f61f6cef7af757852e94a916a152bd3f3c3620b094de84a008120675adccd", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "d3bc34c266491ae3177f2a76ad97bbe916c4d3a41d56196db9d95e76413b3455"},
"ash": {:hex, :ash, "3.27.7", "349e47b9fc293c8de56866f900f6e1a3a5deea1e110d205749f94a9833431811", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eb8a1a74090d7f1753a63fe422cd493b7f50736e2d95d280ccfb508956dccc1d"},
"ash_admin": {:hex, :ash_admin, "1.1.0", "df7c8075347ca9229f132648534a33319f29ae5aceed6c1015d138bba1a4811f", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.20 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "f8bd6c08b584a315a9574c7bbe9c1c914bc5c51838045994b0e5369871f9b3d8"},
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.16.0", "02045ecde9eeb30ab1bfffbdf693c64426af24902bcd533765eba725b9b9f46f", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1107a45af771ee7c02ebe82abcaf9a778096e66b3e6cb2b6e614d22d1fe385f7"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.22", "f59a347ee09e1fa9973fe1b2faf7f8f793acc14dc09341062783b8eb1a9f5c99", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "4b34ea84e122c238ad1843888b8fd4d21aec27605b9b1e6e27e1b70329560fbb"},
@ -11,18 +11,18 @@
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"},
"cinder": {:hex, :cinder, "0.14.0", "ae0866aaa3c166cc882de04f1c9d9906d5be0e5cfb8d7e9f7d62c097d97013e7", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e05d9c6c75dc839faaaa2063e46cd69dda3907718af71964231c93d2f602bc49"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
"crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"},
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
"decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"},
"decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
@ -32,10 +32,11 @@
"erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
"ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"},
"ex_phone_number": {:hex, :ex_phone_number, "0.4.10", "11809f6600b2ecb0a2e75d496c2ec2f273d49d1e2f58b2be2667decb0aabfb43", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "eefccf58d8149d64af658721bff0edcb9e9b8943f74000ede151948ef03046c1"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
@ -44,7 +45,7 @@
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"igniter": {:hex, :igniter, "0.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"},
"igniter": {:hex, :igniter, "0.8.1", "3c6ea47f3a6031015e29da8b4ba5c685f0a2e409facf63041fd83e982ca3aa89", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "d99472e6daf3bfc3675d699c6c7ace9196f377207aab83e09d7b95e9d90e8ae8"},
"imprintor": {:hex, :imprintor, "0.6.0", "c6dfeb3c47d15cfb7e8491cf0a40548b3b9e37d0fc33940ca6dd283d36c4bada", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9c26b0e665c0ab183860e77c450e0b0a4e390249e8322328dbe1be70d0fbca36"},
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
@ -52,22 +53,23 @@
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
"live_debugger": {:hex, :live_debugger, "0.8.0", "02545b5accdf42f48aa9bddabdd7574ddf532d5aa0cb0270d7e35031bc184286", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "4ecdec3c4267e665afd2266e3cb86239dd457f8c8fc4e63de6e150a2a7665920"},
"live_debugger": {:hex, :live_debugger, "1.0.0", "0bbcbdcb3b40b6862b6dfbb76579e7832e2787fee643031e5958f597def6fb32", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 1.1.7 and < 2.0.0-0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "b80f5d9db874d3270eb534738a10982b2b4ce55d58f94544e43b4a36111585fc"},
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
"multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"},
"nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
"phoenix": {:hex, :phoenix, "1.8.6", "7106a0da114619c4b12b056bbaef39fdbc75d3d0cf9cf24af683364064c12dc3", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d0d0b7931916c8196b6903a1efa118b5da28487e7a75ad32a54dfd77de59d421"},
"phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.30", "a84af1610755dc208da35d4d45564485edbf18c3f3c77373c4a650dc994cdcdb", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a353c51ac1e3190910f01a6100c7d5cc02c5e22e7374fd817bd3aedd21149039"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.31", "c45c85df509dd79c917bc530e26c71299e3920850f65ea52ab6a19ccee66875a", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2f53cc6a9e149f30449341c2775990819d97e3b22338fe719c4d30342e6f9638"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
@ -78,15 +80,15 @@
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
"ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"},
"reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"},
"req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"},
"rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"},
"spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"},
"spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"},
"spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"},
"splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"},
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
@ -96,13 +98,13 @@
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
"tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"},
"tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"},
"ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"},
}

View file

@ -84,6 +84,7 @@ msgstr "Über Mitgliedsbeitragsarten"
msgid "Accounting-Software (Vereinfacht) Integration"
msgstr "Buchhaltungs-Software (Vereinfacht) Integration"
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
@ -366,11 +367,6 @@ msgstr "Mitglied werden"
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
msgstr "Mit Absenden deines Antrags erhältst du eine Mail mit einem Bestätigungslink. Sobald du deine Mail-Adresse bestätigt hast, wird dein Antrag geprüft."
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "CSV"
msgstr "CSV"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "CSV File"
@ -390,6 +386,7 @@ msgstr "Kann jederzeit geändert werden. Änderungen des Betrags betreffen nur z
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@ -669,16 +666,11 @@ msgid_plural "Copied %{count} email addresses to clipboard"
msgstr[0] "%{count} E-Mail-Adresse in die Zwischenablage kopiert"
msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert"
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Copy email addresses"
msgstr "E-Mail-Adressen kopieren"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Copy email addresses of selected members"
msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Could not load data fields. Please check your permissions."
@ -1197,22 +1189,17 @@ msgstr "Austritte"
msgid "Expense"
msgstr "Ausgabe"
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export"
msgstr "Export"
#: lib/mv_web/controllers/member_pdf_export_controller.ex
#, elixir-autogen, elixir-format
msgid "Export contains %{count} rows, maximum is %{max}"
msgstr "Export enthält %{count} Zeilen, Maximum ist %{max}."
#: lib/mv_web/components/export_dropdown.ex
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export members to CSV"
msgstr "Mitglieder als CSV exportieren"
#: lib/mv_web/components/export_dropdown.ex
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export members to PDF"
msgstr "Mitglieder als PDF exportieren"
@ -1329,6 +1316,7 @@ msgstr "Feb."
msgid "Fee Type"
msgstr "Beitragsart"
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Fee type"
@ -1488,6 +1476,7 @@ msgstr "Gruppe erfolgreich gespeichert."
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
@ -2203,16 +2192,6 @@ msgstr "Kein Mitglied verknüpft"
msgid "No members in this group"
msgstr "Keine Mitglieder in dieser Gruppe"
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "No members selected"
msgstr "Keine Mitglieder ausgewählt"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "No members selected."
msgstr "Keine Mitglieder ausgewählt."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
@ -2366,12 +2345,7 @@ msgstr "Nur Administrator*innen oder die verknüpfte*n Benutzer*in(nen) können
msgid "Only possible if no members are assigned to this type."
msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind."
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Open email program with BCC recipients"
msgstr "E-Mail-Programm mit BCC-Empfänger*innen öffnen"
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Open in email program"
msgstr "Im E-Mail-Programm öffnen"
@ -2397,11 +2371,6 @@ msgstr "Optional"
msgid "Options"
msgstr "Optionen"
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "PDF"
msgstr "PDF"
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
@ -2648,6 +2617,7 @@ msgstr "Geprüft von"
msgid "Reviewed at"
msgstr "Geprüft am"
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
@ -3308,11 +3278,6 @@ msgstr "Aufhebung der Verknüpfung geplant"
msgid "Unpaid"
msgstr "Unbezahlt"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV-Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden. Gruppen und Beitragsstatus können nicht importiert werden."
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "User"
@ -3580,7 +3545,7 @@ msgstr "Dein Passwort wurde erfolgreich zurückgesetzt"
msgid "admin - Unrestricted access"
msgstr "admin Uneingeschränkter Zugriff"
#: lib/mv_web/components/export_dropdown.ex
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "all"
msgstr "alle"
@ -3972,3 +3937,178 @@ msgstr "Zeitraum"
#, elixir-autogen, elixir-format
msgid "To"
msgstr "Bis"
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join form:"
msgstr "Beitrittsformular:"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Description for join form"
msgstr "Beschreibung für das Beitrittsformular"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "You can add links: full addresses (https://…) or as [link text](https://…)."
msgstr "Du kannst Links einfügen: ganze Adressen (https://…) oder als [Linktext](https://…)."
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Fee type '%{name}' not found; using the default fee type."
msgstr "Beitragsart '%{name}' nicht gefunden; Standard-Beitragsart wird verwendet."
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Group assignment failed: %{reason}"
msgstr "Gruppenzuordnung fehlgeschlagen: %{reason}"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Confirm and Import"
msgstr "Bestätigen und importieren"
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format
msgid "No prepared import to confirm. Please upload again."
msgstr "Kein vorbereiteter Import zum Bestätigen. Bitte erneut hochladen."
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Preview import"
msgstr "Importvorschau"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Column"
msgstr "Spalte"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Custom field"
msgstr "Benutzerdefiniertes Feld"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Ignored (system-computed field)"
msgstr "Ignoriert (vom System berechnetes Feld)"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Member field"
msgstr "Mitgliedsfeld"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Rows with an empty fee type will get the default fee type."
msgstr "Zeilen ohne Beitragsart erhalten die Standard-Beitragsart."
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "These groups will be created automatically: %{names}"
msgstr "Diese Gruppen werden automatisch erstellt: %{names}"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Unknown (ignored)"
msgstr "Unbekannt (ignoriert)"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Unknown fee types (members get the default): %{names}"
msgstr "Unbekannte Beitragsarten (Mitglieder erhalten den Standard): %{names}"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported."
msgstr "Beitragsstatus-Spalten (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) werden immer ignoriert und können nicht importiert werden."
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically."
msgstr "Gruppen-Spalte (erkannte Spaltennamen): Groups, Gruppen, Gruppe. Mehrere durch Komma getrennte Gruppennamen werden unterstützt; fehlende Gruppen werden automatisch erstellt."
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored."
msgstr "Verwende die Namen der Datenfelder als Spaltennamen in der CSV-Datei. Datenfelder müssen in Mila bereits angelegt sein, da unbekannte Spaltennamen ignoriert werden."
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
msgstr "Beitragsart-Spalte (erkannte Spaltennamen): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unbekannte Beitragsarten erhalten die Standard-Beitragsart."
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Create custom field"
msgstr "Datenfeld erstellen"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Create fee type"
msgstr "Beitragsart erstellen"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Row 1"
msgstr "Zeile 1"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Row 2"
msgstr "Zeile 2"
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Row 3"
msgstr "Zeile 3"
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export to CSV"
msgstr "Mitglieder als CSV exportieren"
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export to PDF"
msgstr "Mitglieder als PDF exportieren"
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Too many recipients for this function. Copy the addresses or export the list."
msgstr "Zu viele Empfänger für diese Funktion. Kopiere die Adressen oder exportiere die Liste."
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "filtered"
msgstr "gefiltert"
#~ #: lib/mv_web/components/export_dropdown.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "CSV"
#~ msgstr "CSV"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Copy email addresses of selected members"
#~ msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
#~ #: lib/mv_web/components/export_dropdown.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Export"
#~ msgstr "Export"
#~ #: lib/mv_web/live/member_live/index.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "No members selected"
#~ msgstr "Keine Mitglieder ausgewählt"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Open email program with BCC recipients"
#~ msgstr "E-Mail-Programm mit BCC-Empfänger*innen öffnen"
#~ #: lib/mv_web/components/export_dropdown.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "PDF"
#~ msgstr "PDF"

View file

@ -85,6 +85,7 @@ msgstr ""
msgid "Accounting-Software (Vereinfacht) Integration"
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
@ -367,11 +368,6 @@ msgstr ""
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "CSV"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "CSV File"
@ -391,6 +387,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@ -670,16 +667,11 @@ msgid_plural "Copied %{count} email addresses to clipboard"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Copy email addresses"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Copy email addresses of selected members"
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Could not load data fields. Please check your permissions."
@ -1198,22 +1190,17 @@ msgstr ""
msgid "Expense"
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Export"
msgstr ""
#: lib/mv_web/controllers/member_pdf_export_controller.ex
#, elixir-autogen, elixir-format
msgid "Export contains %{count} rows, maximum is %{max}"
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Export members to CSV"
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Export members to PDF"
msgstr ""
@ -1330,6 +1317,7 @@ msgstr ""
msgid "Fee Type"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format
msgid "Fee type"
@ -1489,6 +1477,7 @@ msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
@ -2204,16 +2193,6 @@ msgstr ""
msgid "No members in this group"
msgstr ""
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format
msgid "No members selected"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "No members selected."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
@ -2367,12 +2346,7 @@ msgstr ""
msgid "Only possible if no members are assigned to this type."
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Open email program with BCC recipients"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Open in email program"
msgstr ""
@ -2398,11 +2372,6 @@ msgstr ""
msgid "Options"
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "PDF"
msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
@ -2649,6 +2618,7 @@ msgstr ""
msgid "Reviewed at"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
@ -3309,11 +3279,6 @@ msgstr ""
msgid "Unpaid"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "User"
@ -3580,7 +3545,7 @@ msgstr ""
msgid "admin - Unrestricted access"
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "all"
msgstr ""
@ -3972,3 +3937,148 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "To"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format
msgid "Join form:"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Description for join form"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "You can add links: full addresses (https://…) or as [link text](https://…)."
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Fee type '%{name}' not found; using the default fee type."
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Group assignment failed: %{reason}"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Confirm and Import"
msgstr ""
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format
msgid "No prepared import to confirm. Please upload again."
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Preview import"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Column"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Custom field"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Ignored (system-computed field)"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Member field"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Rows with an empty fee type will get the default fee type."
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "These groups will be created automatically: %{names}"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Unknown (ignored)"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Unknown fee types (members get the default): %{names}"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported."
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically."
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored."
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Create custom field"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Create fee type"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Row 1"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Row 2"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Row 3"
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Export to CSV"
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Export to PDF"
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Too many recipients for this function. Copy the addresses or export the list."
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "filtered"
msgstr ""

View file

@ -85,6 +85,7 @@ msgstr ""
msgid "Accounting-Software (Vereinfacht) Integration"
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
@ -367,11 +368,6 @@ msgstr ""
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "CSV"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "CSV File"
@ -391,6 +387,7 @@ msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@ -670,16 +667,11 @@ msgid_plural "Copied %{count} email addresses to clipboard"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Copy email addresses"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Copy email addresses of selected members"
msgstr ""
#: lib/mv_web/live/datafields_live.ex
#, elixir-autogen, elixir-format
msgid "Could not load data fields. Please check your permissions."
@ -1198,22 +1190,17 @@ msgstr ""
msgid "Expense"
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export"
msgstr ""
#: lib/mv_web/controllers/member_pdf_export_controller.ex
#, elixir-autogen, elixir-format
msgid "Export contains %{count} rows, maximum is %{max}"
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export members to CSV"
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export members to PDF"
msgstr ""
@ -1330,6 +1317,7 @@ msgstr ""
msgid "Fee Type"
msgstr "Fee Type"
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/statistics_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Fee type"
@ -1489,6 +1477,7 @@ msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
@ -2204,16 +2193,6 @@ msgstr ""
msgid "No members in this group"
msgstr ""
#: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "No members selected"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "No members selected."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
@ -2367,12 +2346,7 @@ msgstr ""
msgid "Only possible if no members are assigned to this type."
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Open email program with BCC recipients"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Open in email program"
msgstr ""
@ -2398,11 +2372,6 @@ msgstr ""
msgid "Options"
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "PDF"
msgstr ""
#: lib/mv/membership/members_pdf.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/member_live/show.ex
@ -2649,6 +2618,7 @@ msgstr "Review by"
msgid "Reviewed at"
msgstr "Review date"
#: lib/mv_web/live/import_live/components.ex
#: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex
@ -3309,11 +3279,6 @@ msgstr ""
msgid "Unpaid"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored. Groups and membership fees are not supported for import."
msgstr ""
#: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format
msgid "User"
@ -3580,7 +3545,7 @@ msgstr ""
msgid "admin - Unrestricted access"
msgstr ""
#: lib/mv_web/components/export_dropdown.ex
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "all"
msgstr ""
@ -3972,3 +3937,178 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "To"
msgstr ""
#: lib/mv_web/live/member_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Join form:"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Description for join form"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "You can add links: full addresses (https://…) or as [link text](https://…)."
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Fee type '%{name}' not found; using the default fee type."
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#, elixir-autogen, elixir-format
msgid "Group assignment failed: %{reason}"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Confirm and Import"
msgstr ""
#: lib/mv_web/live/import_live.ex
#, elixir-autogen, elixir-format
msgid "No prepared import to confirm. Please upload again."
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Preview import"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Column"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Custom field"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Ignored (system-computed field)"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Member field"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Rows with an empty fee type will get the default fee type."
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "These groups will be created automatically: %{names}"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Unknown (ignored)"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Unknown fee types (members get the default): %{names}"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Fee status columns (Membership Fee Status, Bezahlstatus, Mitgliedsbeitragsstatus) are always ignored and cannot be imported."
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Groups column (recognized headers): Groups, Gruppen, Gruppe. Comma-separated group names are supported and missing groups are created automatically."
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Use the data field name as the CSV column header in your file. Data fields must exist in Mila before importing, because unknown data field columns will be ignored."
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Fee type column (recognized headers): Fee Type, fee type, fee_type, membership_fee_type, Beitragsart. Unknown fee types fall back to the default."
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Create custom field"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Create fee type"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Row 1"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Row 2"
msgstr ""
#: lib/mv_web/live/import_live/components.ex
#, elixir-autogen, elixir-format
msgid "Row 3"
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export to CSV"
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export to PDF"
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Too many recipients for this function. Copy the addresses or export the list."
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#, elixir-autogen, elixir-format
msgid "filtered"
msgstr ""
#~ #: lib/mv_web/components/export_dropdown.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "CSV"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Copy email addresses of selected members"
#~ msgstr ""
#~ #: lib/mv_web/components/export_dropdown.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Export"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "No members selected"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Open email program with BCC recipients"
#~ msgstr ""
#~ #: lib/mv_web/components/export_dropdown.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "PDF"
#~ msgstr ""

View file

@ -0,0 +1,21 @@
defmodule Mv.Repo.Migrations.AddJoinDescriptionToCustomFields do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:custom_fields) do
add :join_description, :text
end
end
def down do
alter table(:custom_fields) do
remove :join_description
end
end
end

View file

@ -81,9 +81,11 @@ custom_field_configs = [
show_in_overview: true
},
%{
name: "Datenschutzerklärung akzeptiert",
name: "DSGVO",
value_type: :boolean,
description: "Angabe, ob Datenschutzerklärung akzeptiert wurde",
description: "Angabe, ob die Datenschutzerklärung akzeptiert wurde",
join_description:
"Ich habe die [Datenschutzerklärung](https://example.org/datenschutz) gelesen und akzeptiere sie.",
required: false,
show_in_overview: false
},
@ -302,11 +304,15 @@ case Membership.get_settings() do
ArgumentError -> Map.has_key?(vis, k)
end
end
merged =
Enum.reduce(default_hidden_in_overview, visibility_config, fn {key, val}, vis ->
if has_key.(vis, key), do: vis, else: Map.put(vis, key, val)
end)
if merged != visibility_config, do: Map.put(acc, :member_field_visibility, merged), else: acc
if merged != visibility_config,
do: Map.put(acc, :member_field_visibility, merged),
else: acc
end)
if map_size(updates) > 0 do
@ -332,9 +338,7 @@ IO.puts(
" - Fee types: 5 (Standard, Ermäßigt, Unterstützer, Fördermitglied, Probemitgliedschaft)"
)
IO.puts(
" - Custom fields: 6 (Geburtsdatum shown in overview; others hidden by default)"
)
IO.puts(" - Custom fields: 6 (Geburtsdatum shown in overview; others hidden by default)")
IO.puts(" - Roles: 5 (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)")
IO.puts(" - Default fee type: Standard (120€ yearly)")

View file

@ -431,15 +431,16 @@ end)
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
# 16 members with 46 custom field values each (Geburtsdatum, Datenschutz, SEPA, Rechnungs-E-Mail, IBAN, Stunden)
# 16 members with 46 custom field values each (Geburtsdatum, DSGVO, SEPA, Rechnungs-E-Mail, IBAN, Stunden)
custom_value_assignments =
Enum.map(1..16, fn n ->
email = "mitglied#{n}@example.de"
# Vary birth dates and values per index
base_date = Date.add(~D[1970-01-01], 365 * (n + 10) + rem(n * 31, 365))
values = [
{"Geburtsdatum", %{"_union_type" => "date", "_union_value" => base_date}},
{"Datenschutzerklärung akzeptiert",
{"DSGVO",
%{"_union_type" => "boolean", "_union_value" => n in [1, 2, 3, 5, 7, 9, 11, 13, 15]}},
{"SEPA-Mandat", %{"_union_type" => "boolean", "_union_value" => rem(n, 3) != 0}},
{"Rechnungs-E-Mail",
@ -448,10 +449,12 @@ custom_value_assignments =
%{
"_union_type" => "string",
"_union_value" =>
"DE8937040044#{String.pad_leading(to_string(rem(532013000 + n, 1_000_000_000)), 10, "0")}"
"DE8937040044#{String.pad_leading(to_string(rem(532_013_000 + n, 1_000_000_000)), 10, "0")}"
}},
{"Stunden ehrenamtlich", %{"_union_type" => "integer", "_union_value" => 5 + rem(n * 3, 25)}}
{"Stunden ehrenamtlich",
%{"_union_type" => "integer", "_union_value" => 5 + rem(n * 3, 25)}}
]
# Drop 02 fields per member so not all have 6 (still ~80% overall filled)
drop_count = rem(n, 3)
{email, Enum.take(values, 6 - drop_count)}
@ -502,19 +505,36 @@ case Membership.get_settings() do
Membership.update_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: settings.join_form_field_ids || default_join_form_field_ids,
join_form_field_required: settings.join_form_field_required || default_join_form_field_required
join_form_field_required:
settings.join_form_field_required || default_join_form_field_required
})
end
_ ->
:ok
end
# Membership applications (join requests) for UI testing: 4 submitted, 1 with extra form_data
join_request_configs = [
%{email: "antrag1@example.de", first_name: "Sandra", last_name: "Meier", form_data: %{"city" => "Berlin"}},
%{
email: "antrag1@example.de",
first_name: "Sandra",
last_name: "Meier",
form_data: %{"city" => "Berlin"}
},
%{email: "antrag2@example.de", first_name: "Thomas", last_name: "Bauer", form_data: %{}},
%{email: "antrag3@example.de", first_name: "Julia", last_name: "Krause", form_data: %{"city" => "Hamburg", "notes" => "Interesse an Jugendgruppe"}},
%{email: "antrag4@example.de", first_name: "Michael", last_name: "Schmitt", form_data: %{"city" => "München"}}
%{
email: "antrag3@example.de",
first_name: "Julia",
last_name: "Krause",
form_data: %{"city" => "Hamburg", "notes" => "Interesse an Jugendgruppe"}
},
%{
email: "antrag4@example.de",
first_name: "Michael",
last_name: "Schmitt",
form_data: %{"city" => "München"}
}
]
for config <- join_request_configs do
@ -532,8 +552,15 @@ for config <- join_request_configs do
end
IO.puts("✅ Dev seeds completed.")
IO.puts(" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz), fee types distributed, 5 with exit date")
IO.puts(" - Test users: 4 linked to mitglied14 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung")
IO.puts(
" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz), fee types distributed, 5 with exit date"
)
IO.puts(
" - Test users: 4 linked to mitglied14 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung"
)
IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)")
IO.puts(" - Custom field values: ~80% filled (16 members, 46 fields each)")
IO.puts(" - Join form enabled; 4 membership applications (join requests) for UI testing")

View file

@ -0,0 +1,145 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "slug",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "value_type",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "join_description",
"type": "text"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "required",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "true",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "show_in_overview",
"type": "boolean"
}
],
"base_filter": null,
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "2600667D140A2A846F9A848ACEFCADA1F1206950B38EF407B0BB13816E508A2A",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_fields_unique_name_index",
"keys": [
{
"type": "atom",
"value": "name"
}
],
"name": "unique_name",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_fields_unique_slug_index",
"keys": [
{
"type": "atom",
"value": "slug"
}
],
"name": "unique_slug",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "custom_fields"
}

101
publiccode.yml Normal file
View file

@ -0,0 +1,101 @@
publiccodeYmlVersion: "0.2"
name: Mila
url: "https://git.local-it.org/local-it/mitgliederverwaltung"
landingURL: "https://local-it.org"
softwareVersion: "1.2.0"
releaseDate: "2026-05-08"
developmentStatus: beta
logo: logo.png
platforms:
- web
softwareType: standalone/web
categories:
- contact-management
- crm
- billing-and-invoicing
intendedAudience:
scope:
- society
dependsOn:
open:
- name: PostgreSQL
versionMin: "18"
maintenance:
type: internal
contacts:
- name: "Local-IT e.V."
email: "info@local-it.org"
legal:
license: AGPL-3.0-only
mainCopyrightOwner: "Local-IT e.V."
repoOwner: "Local-IT e.V."
localisation:
localisationReady: true
availableLanguages:
- de
- en
description:
de:
genericName: Mitgliederverwaltung
shortDescription: >-
Einfache, barrierearme und freie Mitgliederverwaltung
für kleine und mittlere Vereine.
longDescription: >
**Mila** ist eine freie und quelloffene Mitgliederverwaltung, die auf
die realen Bedürfnisse von Vereinen ausgerichtet ist. Statt
überladener Funktionen oder teurer Lizenzen setzt Mila auf
Bedienbarkeit, Barrierefreiheit und DSGVO-Konformität. Vereine
verwalten ihre Mitgliederdaten, behalten Mitgliedsbeiträge und
Zahlungsstatus im Blick und passen erfasste Datenfelder, Rollen und
Berechtigungen flexibel an ihre Struktur an. Die Anwendung ist
self-hosting-freundlich, mehrsprachig (Deutsch und Englisch) und
unterstützt Single Sign-on über OIDC sowie Self-Service und
Online-Aufnahmeanträge für Mitglieder.
documentation: "https://wiki.local-it.org/s/mila-user-dokumentation"
features:
- Mitgliederdaten komfortabel verwalten
- Mitglieder in Gruppen organisieren
- Mitgliedsbeiträge und Zahlungsstatus verfolgen
- Volltextsuche mit unscharfer Suche (Fuzzy-Matching)
- Rollen und Berechtigungen (RBAC)
- Anpassbare Datenfelder pro Verein
- Single Sign-on über OIDC (Authentik, Rauthy, Keycloak)
- Self-Service und Online-Aufnahmeanträge
- Mitglieder per CSV importieren (mit Vorschau und Vorlagen)
- Anbindung an die Buchhaltungssoftware Vereinfacht
screenshots:
- .opencode/screenshots/01_mitglieder.png
- .opencode/screenshots/02_statistik.png
- .opencode/screenshots/03_beitraege.png
- .opencode/screenshots/04_aufnahmeantraege.png
en:
genericName: Membership management
shortDescription: >-
Simple, accessible and free membership management
for small and mid-sized clubs.
longDescription: >
**Mila** is a free and open-source membership management tool designed
for the real needs of clubs and associations. Instead of feature
overload or expensive licences, Mila focuses on usability,
accessibility and GDPR compliance. Clubs manage their member data,
keep track of membership fees and payment status, and adapt the
collected data fields, roles and permissions to their own structure.
The application is self-hosting friendly, multilingual (German and
English) and supports single sign-on via OIDC as well as member
self-service and online membership applications.
features:
- Manage member data with ease
- Organize members into groups
- Track membership fees and payment status
- Full-text search with fuzzy matching
- Roles and permissions (RBAC)
- Custom data fields per club
- Single sign-on via OIDC (Authentik, Rauthy, Keycloak)
- Member self-service and online application
- Import members via CSV (with preview and templates)
- Integration with the Vereinfacht accounting software
screenshots:
- .opencode/screenshots/01_mitglieder.png
- .opencode/screenshots/02_statistik.png
- .opencode/screenshots/03_beitraege.png
- .opencode/screenshots/04_aufnahmeantraege.png

View file

@ -159,6 +159,61 @@ defmodule Mv.Membership.CustomFieldValidationTest do
end
end
describe "join_description" do
test "persists join_description when set", %{actor: actor} do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "dsgvo_field",
value_type: :boolean,
join_description: "hereby I confirm the GDPR"
})
|> Ash.create(actor: actor)
assert custom_field.join_description == "hereby I confirm the GDPR"
end
test "defaults to nil when not given", %{actor: actor} do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "no_join_desc",
value_type: :boolean
})
|> Ash.create(actor: actor)
assert custom_field.join_description == nil
end
test "rejects join_description longer than 1000 characters", %{actor: actor} do
assert {:error, changeset} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "too_long_join_desc",
value_type: :boolean,
join_description: String.duplicate("a", 1001)
})
|> Ash.create(actor: actor)
assert [%{field: :join_description, message: message}] = changeset.errors
assert message =~ "max" or message =~ "length" or message =~ "1000"
end
test "is writable via the update action", %{actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{name: "updatable", value_type: :boolean})
|> Ash.create(actor: actor)
assert {:ok, updated} =
custom_field
|> Ash.Changeset.for_update(:update, %{join_description: "Accept the GDPR"})
|> Ash.update(actor: actor)
assert updated.join_description == "Accept the GDPR"
end
end
describe "name uniqueness" do
test "rejects duplicate names", %{actor: actor} do
assert {:ok, _} =

View file

@ -0,0 +1,27 @@
defmodule Mv.Membership.CustomFieldValueFormatterTest do
use ExUnit.Case, async: true
alias Mv.Membership.CustomFieldValueFormatter
describe "format_custom_field_value/2 for :date" do
test "formats an Ash.Union date value as ISO-8601" do
union = %Ash.Union{value: ~D[2024-03-15], type: :date}
assert CustomFieldValueFormatter.format_custom_field_value(union, %{value_type: :date}) ==
"2024-03-15"
end
test "formats a direct Date value as ISO-8601" do
assert CustomFieldValueFormatter.format_custom_field_value(~D[2024-03-15], %{
value_type: :date
}) == "2024-03-15"
end
test "formats an already-stored ISO-8601 string date as ISO-8601" do
union = %Ash.Union{value: "2024-03-15", type: :date}
assert CustomFieldValueFormatter.format_custom_field_value(union, %{value_type: :date}) ==
"2024-03-15"
end
end
end

View file

@ -0,0 +1,72 @@
defmodule Mv.Membership.Import.ColumnResolverQueryTest do
# async: false — attaches a global telemetry handler to inspect emitted SQL.
use Mv.DataCase, async: false
alias Mv.Membership.Import.ColumnResolver
setup do
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
end
describe "create_or_find_group/3 group lookup is name-filtered (no full-table scan)" do
test "resolving a new name absent from the snapshot queries by name, not the whole table",
%{actor: actor} do
# Populate the table so a full-table read would be costly and observable.
for n <- 1..20, do: Mv.Fixtures.group_fixture(%{name: "Existing #{n}"})
queries =
capture_group_select_queries(fn ->
# The name is absent from the (empty) snapshot, forcing a DB lookup
# before the create attempt. That lookup must filter by name.
assert {:ok, group} = ColumnResolver.create_or_find_group("New One", [], actor)
assert group.name == "New One"
end)
# No SELECT against the groups table issued during resolution may be an
# unfiltered full-table scan. The pre-create existence check must filter by
# name (carry a WHERE predicate).
refute Enum.any?(queries, &unfiltered_groups_select?/1),
"expected no unfiltered groups table scan, got:\n#{Enum.join(queries, "\n")}"
end
end
defp capture_group_select_queries(fun) do
test_pid = self()
handler_id = "test-group-query-#{System.unique_integer([:positive])}"
:telemetry.attach(
handler_id,
[:mv, :repo, :query],
fn _event, _measurements, metadata, _config ->
sql = metadata[:query] || ""
if String.contains?(sql, "SELECT") and String.contains?(sql, "\"groups\"") do
send(test_pid, {:group_query, sql})
end
end,
nil
)
try do
fun.()
after
:telemetry.detach(handler_id)
end
collect_group_queries([])
end
defp collect_group_queries(acc) do
receive do
{:group_query, sql} -> collect_group_queries([sql | acc])
after
0 -> Enum.reverse(acc)
end
end
# An unfiltered groups SELECT reads the whole table: it selects FROM "groups"
# with no WHERE clause at all. A name-filtered lookup carries a WHERE predicate.
defp unfiltered_groups_select?(sql) do
String.contains?(sql, "FROM \"groups\"") and not String.contains?(sql, "WHERE")
end
end

View file

@ -0,0 +1,227 @@
defmodule Mv.Membership.Import.ColumnResolverTest do
use Mv.DataCase, async: true
use ExUnitProperties
alias Mv.Membership.Import.ColumnResolver
setup do
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
end
defp fee_type_fixture(name, actor) do
{:ok, fee_type} =
Mv.MembershipFees.create_membership_fee_type(
%{name: name, amount: Decimal.new("10.00"), interval: :yearly},
actor: actor
)
fee_type
end
defp header_maps(overrides) do
Map.merge(
%{
member: %{email: 0},
custom: %{},
unknown: [],
ignored: [],
groups_column_index: nil,
fee_type_column_index: nil
},
overrides
)
end
describe "resolve/3 group classification" do
test "splits group names into found (existing) and to_create (missing)", %{actor: actor} do
existing = Mv.Fixtures.group_fixture(%{name: "Orchester"})
maps = header_maps(%{member: %{email: 0}, groups_column_index: 1})
rows = [
{2, ["a@example.com", "Orchester"]},
{3, ["b@example.com", "Neues Ensemble"]}
]
result = ColumnResolver.resolve(maps, rows, actor)
assert Enum.any?(result.groups_found, &(&1.name == "Orchester" and &1.id == existing.id))
assert "Neues Ensemble" in result.groups_to_create
refute "Orchester" in result.groups_to_create
end
test "groups_found and groups_to_create are empty when no groups column", %{actor: actor} do
maps = header_maps(%{})
rows = [{2, ["a@example.com"]}]
result = ColumnResolver.resolve(maps, rows, actor)
assert result.groups_found == []
assert result.groups_to_create == []
end
end
describe "resolve/3 preview rows" do
test "returns up to 3 preview rows", %{actor: actor} do
maps = header_maps(%{})
rows = [
{2, ["a@example.com"]},
{3, ["b@example.com"]},
{4, ["c@example.com"]},
{5, ["d@example.com"]}
]
result = ColumnResolver.resolve(maps, rows, actor)
assert length(result.preview_rows) == 3
assert result.preview_rows == [["a@example.com"], ["b@example.com"], ["c@example.com"]]
end
test "returns fewer preview rows when file has fewer data rows", %{actor: actor} do
maps = header_maps(%{})
rows = [{2, ["a@example.com"]}]
result = ColumnResolver.resolve(maps, rows, actor)
assert result.preview_rows == [["a@example.com"]]
end
end
describe "resolve/3 fee-type resolution" do
test "maps known fee-type names to their id by normalized name", %{actor: actor} do
standard = fee_type_fixture("Standard", actor)
maps = header_maps(%{fee_type_column_index: 1})
rows = [{2, ["a@example.com", "Standard"]}]
result = ColumnResolver.resolve(maps, rows, actor)
assert result.fee_type_map["standard"] == standard.id
assert result.fee_type_warnings == []
end
test "records a warning for an unknown fee-type name", %{actor: actor} do
maps = header_maps(%{fee_type_column_index: 1})
rows = [{2, ["a@example.com", "Nonexistent Type"]}]
result = ColumnResolver.resolve(maps, rows, actor)
assert "Nonexistent Type" in result.fee_type_warnings
end
test "sets has_empty_fee_type_cells? when a fee-type cell is blank", %{actor: actor} do
fee_type_fixture("Standard", actor)
maps = header_maps(%{fee_type_column_index: 1})
rows = [
{2, ["a@example.com", "Standard"]},
{3, ["b@example.com", " "]}
]
result = ColumnResolver.resolve(maps, rows, actor)
assert result.has_empty_fee_type_cells? == true
end
test "has_empty_fee_type_cells? is false when all cells filled", %{actor: actor} do
fee_type_fixture("Standard", actor)
maps = header_maps(%{fee_type_column_index: 1})
rows = [{2, ["a@example.com", "Standard"]}]
result = ColumnResolver.resolve(maps, rows, actor)
assert result.has_empty_fee_type_cells? == false
end
test "fee-type resolution defaults are empty when no fee-type column", %{actor: actor} do
maps = header_maps(%{})
rows = [{2, ["a@example.com"]}]
result = ColumnResolver.resolve(maps, rows, actor)
assert result.fee_type_map == %{}
assert result.fee_type_warnings == []
assert result.has_empty_fee_type_cells? == false
end
end
describe "create_or_find_group/3" do
test "creates a new group when none exists", %{actor: actor} do
assert {:ok, group} = ColumnResolver.create_or_find_group("Brand New Group", [], actor)
assert group.name == "Brand New Group"
end
test "returns the existing group from the pre-fetched list without creating", %{actor: actor} do
existing = Mv.Fixtures.group_fixture(%{name: "Existing Group"})
before_count = length(Mv.Membership.list_groups!(actor: actor))
assert {:ok, group} =
ColumnResolver.create_or_find_group("Existing Group", [existing], actor)
assert group.id == existing.id
assert length(Mv.Membership.list_groups!(actor: actor)) == before_count
end
test "resolves to a group created concurrently after the snapshot was taken",
%{actor: actor} do
# Simulates a concurrent import session: the group name is absent from the
# caller's pre-fetched snapshot, but the group now exists in the DB. The
# resolver must link to the existing group, never error or duplicate it.
stale_snapshot = []
_concurrently_created = Mv.Fixtures.group_fixture(%{name: "Concurrent Group"})
before_count = length(Mv.Membership.list_groups!(actor: actor))
assert {:ok, group} =
ColumnResolver.create_or_find_group("Concurrent Group", stale_snapshot, actor)
assert group.name == "Concurrent Group"
assert length(Mv.Membership.list_groups!(actor: actor)) == before_count
end
property "is idempotent: same names never create duplicate groups", %{actor: actor} do
check all(
names <-
StreamData.list_of(
StreamData.string(:alphanumeric, min_length: 1, max_length: 20),
min_length: 1,
max_length: 5
),
max_runs: 25
) do
names = Enum.map(names, &("grp-" <> &1))
existing = Mv.Membership.list_groups!(actor: actor)
first_ids = resolve_all(names, existing, actor)
existing_after = Mv.Membership.list_groups!(actor: actor)
second_ids = resolve_all(names, existing_after, actor)
# Same name always resolves to the same group id across both passes.
assert first_ids == second_ids
# No duplicate groups exist for any of the names (case-insensitive).
all_groups = Mv.Membership.list_groups!(actor: actor)
for name <- Enum.uniq_by(names, &String.downcase/1) do
matching =
Enum.filter(all_groups, fn g ->
String.downcase(g.name) == String.downcase(name)
end)
assert length(matching) == 1
end
end
end
end
defp resolve_all(names, existing, actor) do
Enum.map(names, fn name ->
{:ok, group} = ColumnResolver.create_or_find_group(name, existing, actor)
{String.downcase(name), group.id}
end)
|> Map.new()
end
end

View file

@ -1,5 +1,6 @@
defmodule Mv.Membership.Import.HeaderMapperTest do
use ExUnit.Case, async: true
use ExUnitProperties
alias Mv.Membership.Import.HeaderMapper
@ -272,4 +273,167 @@ defmodule Mv.Membership.Import.HeaderMapperTest do
assert unknown == []
end
end
describe "build_maps/2 fee-status ignore list" do
test "places fee-status variants in ignored, not member or custom map" do
headers = ["email", "Bezahlstatus"]
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
assert result.member[:email] == 0
assert result.custom == %{}
assert result.ignored == [1]
refute Map.has_key?(result.member, :bezahlstatus)
end
test "ignores membership_fee_status snake-case variant" do
headers = ["email", "membership_fee_status"]
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
assert result.ignored == [1]
assert result.custom == %{}
end
test "ignores German Mitgliedsbeitragsstatus variant" do
headers = ["email", "Mitgliedsbeitragsstatus"]
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
assert result.ignored == [1]
end
test "fee-status takes priority over a same-named custom field" do
headers = ["email", "Bezahlstatus"]
custom_fields = [%{id: "cf1", name: "Bezahlstatus"}]
assert {:ok, result} = HeaderMapper.build_maps(headers, custom_fields)
assert result.ignored == [1]
assert result.custom == %{}
end
test "result carries groups_column_index and fee_type_column_index keys" do
assert {:ok, result} = HeaderMapper.build_maps(["email"], [])
assert Map.has_key?(result, :groups_column_index)
assert Map.has_key?(result, :fee_type_column_index)
end
end
describe "build_maps/2 groups column detection" do
test "detects German Gruppen variant and excludes it from member/custom maps" do
headers = ["email", "Gruppen"]
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
assert result.groups_column_index == 1
assert result.custom == %{}
assert result.unknown == []
refute Map.has_key?(result.member, :gruppen)
end
test "detects English Groups variant" do
headers = ["email", "Groups"]
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
assert result.groups_column_index == 1
end
test "detects singular Gruppe and lowercase groups variants" do
assert {:ok, %{groups_column_index: 1}} = HeaderMapper.build_maps(["email", "Gruppe"], [])
assert {:ok, %{groups_column_index: 1}} = HeaderMapper.build_maps(["email", "groups"], [])
end
test "groups column takes priority over a same-named custom field" do
headers = ["email", "Gruppen"]
custom_fields = [%{id: "cf1", name: "Gruppen"}]
assert {:ok, result} = HeaderMapper.build_maps(headers, custom_fields)
assert result.groups_column_index == 1
assert result.custom == %{}
end
test "groups_column_index is nil when no groups column present" do
assert {:ok, %{groups_column_index: nil}} = HeaderMapper.build_maps(["email"], [])
end
end
describe "build_maps/2 fee-type column detection" do
test "detects German Beitragsart variant and excludes it from member/custom maps" do
headers = ["email", "Beitragsart"]
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
assert result.fee_type_column_index == 1
assert result.custom == %{}
assert result.unknown == []
end
test "detects English fee type variants" do
assert {:ok, %{fee_type_column_index: 1}} =
HeaderMapper.build_maps(["email", "Fee Type"], [])
assert {:ok, %{fee_type_column_index: 1}} =
HeaderMapper.build_maps(["email", "fee type"], [])
assert {:ok, %{fee_type_column_index: 1}} =
HeaderMapper.build_maps(["email", "fee_type"], [])
assert {:ok, %{fee_type_column_index: 1}} =
HeaderMapper.build_maps(["email", "membership_fee_type"], [])
end
test "fee-type column takes priority over a same-named custom field" do
headers = ["email", "Beitragsart"]
custom_fields = [%{id: "cf1", name: "Beitragsart"}]
assert {:ok, result} = HeaderMapper.build_maps(headers, custom_fields)
assert result.fee_type_column_index == 1
assert result.custom == %{}
end
test "fee_type_column_index is nil when no fee-type column present" do
assert {:ok, %{fee_type_column_index: nil}} = HeaderMapper.build_maps(["email"], [])
end
test "detects groups and fee-type columns together" do
headers = ["email", "Gruppen", "Beitragsart"]
assert {:ok, result} = HeaderMapper.build_maps(headers, [])
assert result.groups_column_index == 1
assert result.fee_type_column_index == 2
assert result.member[:email] == 0
assert result.custom == %{}
assert result.unknown == []
end
end
describe "build_maps/2 fee-status ignore property" do
property "every fee-status variant is ignored, never member or custom" do
check all(
variant <-
StreamData.member_of([
"Membership Fee Status",
"membership_fee_status",
"Mitgliedsbeitragsstatus",
"Bezahlstatus",
" Bezahlstatus ",
"BEZAHLSTATUS"
])
) do
custom_fields = [%{id: "cf1", name: variant}]
assert {:ok, result} = HeaderMapper.build_maps(["email", variant], custom_fields)
assert result.ignored == [1]
assert result.custom == %{}
refute Map.has_key?(result.member, :bezahlstatus)
end
end
end
end

View file

@ -0,0 +1,110 @@
defmodule Mv.Membership.Import.ImportRunnerTest do
use ExUnit.Case, async: true
alias Mv.Membership.Import.ImportRunner
describe "carry_groups_forward/2" do
test "replaces import_state groups_found with the chunk's grown snapshot" do
import_state = %{groups_found: [%{id: "1", name: "A"}]}
chunk_result = %{groups_found: [%{id: "1", name: "A"}, %{id: "2", name: "B"}]}
assert ImportRunner.carry_groups_forward(import_state, chunk_result) == %{
groups_found: [%{id: "1", name: "A"}, %{id: "2", name: "B"}]
}
end
test "leaves import_state unchanged when the chunk result omits groups_found" do
import_state = %{groups_found: [%{id: "1", name: "A"}], other: :kept}
chunk_result = %{inserted: 1}
assert ImportRunner.carry_groups_forward(import_state, chunk_result) == import_state
end
end
describe "merge_progress/4 warning accumulation" do
test "deduplicates identical warnings across chunks instead of growing unbounded" do
progress = %{
inserted: 0,
failed: 0,
errors: [],
warnings: ["Fee type 'Ghost' not found; using the default fee type."],
status: :running,
current_chunk: 0,
total_chunks: 3
}
chunk_result = %{
inserted: 2,
failed: 0,
errors: [],
errors_truncated?: false,
warnings: [
"Fee type 'Ghost' not found; using the default fee type.",
"Fee type 'Ghost' not found; using the default fee type."
]
}
result = ImportRunner.merge_progress(progress, chunk_result, 0)
assert result.warnings == ["Fee type 'Ghost' not found; using the default fee type."]
end
test "preserves distinct warnings while collapsing duplicates" do
progress = %{
inserted: 0,
failed: 0,
errors: [],
warnings: ["Fee type 'A' not found; using the default fee type."],
status: :running,
current_chunk: 0,
total_chunks: 2
}
chunk_result = %{
inserted: 1,
failed: 0,
errors: [],
errors_truncated?: false,
warnings: [
"Fee type 'A' not found; using the default fee type.",
"Fee type 'B' not found; using the default fee type."
]
}
result = ImportRunner.merge_progress(progress, chunk_result, 0)
assert result.warnings == [
"Fee type 'A' not found; using the default fee type.",
"Fee type 'B' not found; using the default fee type."
]
end
end
describe "read_file_entry/2" do
test "returns {:ok, content} for a readable file" do
path =
Path.join(
System.tmp_dir!(),
"import_runner_read_#{System.unique_integer([:positive])}.csv"
)
File.write!(path, "email;first_name\njohn@example.com;John")
on_exit(fn -> File.rm_rf(path) end)
assert {:ok, "email;first_name\njohn@example.com;John"} =
ImportRunner.read_file_entry(%{path: path}, %{})
end
test "returns {:error, message} with a binary message when the file cannot be read" do
missing_path =
Path.join(
System.tmp_dir!(),
"import_runner_missing_#{System.unique_integer([:positive])}.csv"
)
assert {:error, message} = ImportRunner.read_file_entry(%{path: missing_path}, %{})
assert is_binary(message)
assert message != ""
end
end
end

View file

@ -1,5 +1,6 @@
defmodule Mv.Membership.Import.MemberCSVTest do
use Mv.DataCase, async: true
use ExUnitProperties
alias Mv.Membership.Import.MemberCSV
@ -899,4 +900,302 @@ defmodule Mv.Membership.Import.MemberCSVTest do
assert import_state.chunks != []
end
end
describe "prepare/2 column resolution integration" do
setup do
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
end
test "exposes resolver output keys in import_state", %{actor: actor} do
csv_content = "email\njohn@example.com"
assert {:ok, import_state} = MemberCSV.prepare(csv_content, actor: actor)
assert Map.has_key?(import_state, :ignored)
assert Map.has_key?(import_state, :groups_to_create)
assert Map.has_key?(import_state, :fee_type_map)
assert Map.has_key?(import_state, :fee_type_warnings)
assert Map.has_key?(import_state, :has_empty_fee_type_cells?)
assert Map.has_key?(import_state, :preview_rows)
end
test "fee-status column is reported as ignored, not as a custom field", %{actor: actor} do
{:ok, _custom_field} =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{name: "Bezahlstatus", value_type: :string})
|> Ash.create(actor: actor)
csv_content = "email;Bezahlstatus\njohn@example.com;paid"
assert {:ok, import_state} = MemberCSV.prepare(csv_content, actor: actor)
assert import_state.ignored == ["Bezahlstatus"]
assert import_state.custom_field_map == %{}
end
test "preview rows are limited to 3", %{actor: actor} do
csv_content = "email\na@example.com\nb@example.com\nc@example.com\nd@example.com"
assert {:ok, import_state} = MemberCSV.prepare(csv_content, actor: actor)
assert length(import_state.preview_rows) == 3
end
end
describe "process_chunk/4 fee-type assignment" do
setup do
actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, fee_type} =
Mv.MembershipFees.create_membership_fee_type(
%{name: "Premium", amount: Decimal.new("25.00"), interval: :yearly},
actor: actor
)
%{actor: actor, fee_type: fee_type}
end
test "sets membership_fee_type_id when fee-type cell matches a known type", %{
actor: actor,
fee_type: fee_type
} do
chunk = [
{2, %{member: %{email: "fee-known@example.com"}, custom: %{}, fee_type: "Premium"}}
]
opts = [
actor: actor,
fee_type_map: %{"premium" => fee_type.id}
]
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
assert result.inserted == 1
member =
Mv.Membership.list_members!(actor: actor)
|> Enum.find(&(&1.email == "fee-known@example.com"))
assert member.membership_fee_type_id == fee_type.id
end
test "adds a warning when the fee-type name is unknown", %{actor: actor} do
chunk = [
{2, %{member: %{email: "fee-unknown@example.com"}, custom: %{}, fee_type: "Ghost Type"}}
]
opts = [actor: actor, fee_type_map: %{}]
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
assert result.inserted == 1
assert Enum.any?(result.warnings, &(&1 =~ "Ghost Type"))
end
test "uses the default fee type when the fee-type cell is empty", %{
actor: actor,
fee_type: fee_type
} do
{:ok, settings} = Mv.Membership.get_settings()
{:ok, _settings} =
Mv.Membership.update_settings(
settings,
%{default_membership_fee_type_id: fee_type.id},
actor: actor
)
chunk = [{2, %{member: %{email: "fee-empty@example.com"}, custom: %{}, fee_type: ""}}]
opts = [actor: actor, fee_type_map: %{}]
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
assert result.inserted == 1
member =
Mv.Membership.list_members!(actor: actor)
|> Enum.find(&(&1.email == "fee-empty@example.com"))
# Default fee type assigned via SetDefaultMembershipFeeType.
assert member.membership_fee_type_id == fee_type.id
end
end
describe "process_chunk/4 group assignment" do
setup do
%{actor: Mv.Helpers.SystemActor.get_system_actor()}
end
defp group_names_for(email, actor) do
member =
Mv.Membership.list_members!(actor: actor)
|> Enum.find(&(&1.email == email))
member = Ash.load!(member, :groups, actor: actor)
member.groups |> Enum.map(& &1.name) |> Enum.sort()
end
test "assigns member to an existing group", %{actor: actor} do
existing = Mv.Fixtures.group_fixture(%{name: "Orchester"})
chunk = [
{2, %{member: %{email: "g-existing@example.com"}, custom: %{}, groups: "Orchester"}}
]
opts = [actor: actor, groups_found: [existing]]
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
assert result.inserted == 1
assert group_names_for("g-existing@example.com", actor) == ["Orchester"]
# No new group was created.
orchester = Enum.filter(Mv.Membership.list_groups!(actor: actor), &(&1.name == "Orchester"))
assert length(orchester) == 1
end
test "auto-creates an unknown group and assigns the member", %{actor: actor} do
chunk = [
{2, %{member: %{email: "g-new@example.com"}, custom: %{}, groups: "Frische Gruppe"}}
]
opts = [actor: actor, groups_found: []]
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
assert result.inserted == 1
assert group_names_for("g-new@example.com", actor) == ["Frische Gruppe"]
assert Enum.any?(Mv.Membership.list_groups!(actor: actor), &(&1.name == "Frische Gruppe"))
end
test "handles multiple comma-separated groups", %{actor: actor} do
chunk = [
{2, %{member: %{email: "g-multi@example.com"}, custom: %{}, groups: "Orchester, Chor"}}
]
opts = [actor: actor, groups_found: []]
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
assert result.inserted == 1
assert group_names_for("g-multi@example.com", actor) == ["Chor", "Orchester"]
end
test "does not re-read the group table once per row for a repeated novel name",
%{actor: actor} do
rows =
for i <- 1..10 do
{i + 1,
%{member: %{email: "g-nplus1-#{i}@example.com"}, custom: %{}, groups: "Shared Group"}}
end
group_read_count = Agent.start_link(fn -> 0 end) |> elem(1)
test_pid = self()
# process_chunk runs synchronously in this test process, so the telemetry
# handler (invoked in the query-executing process) sees self() == test_pid.
# Filtering on the pid keeps concurrent tests' group queries out of the count.
handler = fn _event, _measurements, metadata, _config ->
if self() == test_pid and metadata[:source] == "groups" and
is_binary(metadata[:query]) and String.starts_with?(metadata.query, "SELECT") do
Agent.update(group_read_count, &(&1 + 1))
end
end
handler_id = "test-group-read-counter-#{System.unique_integer([:positive])}"
:telemetry.attach(handler_id, [:mv, :repo, :query], handler, nil)
assert {:ok, %{inserted: 10}} =
MemberCSV.process_chunk(rows, %{email: 0}, %{}, actor: actor, groups_found: [])
reads = Agent.get(group_read_count, & &1)
:telemetry.detach(handler_id)
# The novel group is created on the first row and reused in memory for the
# remaining nine. Without accumulation each row triggers a fresh full-table
# read, scaling linearly with the row count.
assert reads <= 3,
"Expected the group table read at most a few times, got #{reads} reads for 10 rows (N+1)."
end
test "returns the grown group snapshot so later chunks skip the table read",
%{actor: actor} do
chunk1 = [
{2, %{member: %{email: "g-xchunk-1@example.com"}, custom: %{}, groups: "Shared X"}}
]
chunk2 = [
{3, %{member: %{email: "g-xchunk-2@example.com"}, custom: %{}, groups: "Shared X"}}
]
assert {:ok, result1} =
MemberCSV.process_chunk(chunk1, %{email: 0}, %{}, actor: actor, groups_found: [])
# The chunk result must expose the accumulated snapshot, including the group
# auto-created while processing this chunk, so the LiveView can thread it
# into the next chunk's opts.
assert is_list(result1.groups_found)
assert Enum.any?(result1.groups_found, &(&1.name == "Shared X"))
group_read_count = Agent.start_link(fn -> 0 end) |> elem(1)
test_pid = self()
handler = fn _event, _measurements, metadata, _config ->
if self() == test_pid and metadata[:source] == "groups" and
is_binary(metadata[:query]) and String.starts_with?(metadata.query, "SELECT") do
Agent.update(group_read_count, &(&1 + 1))
end
end
handler_id = "test-xchunk-group-read-#{System.unique_integer([:positive])}"
:telemetry.attach(handler_id, [:mv, :repo, :query], handler, nil)
assert {:ok, %{inserted: 1}} =
MemberCSV.process_chunk(chunk2, %{email: 0}, %{},
actor: actor,
groups_found: result1.groups_found
)
reads = Agent.get(group_read_count, & &1)
:telemetry.detach(handler_id)
# The second chunk receives the snapshot grown by the first, so the shared
# group resolves from memory without any full-table read.
assert reads == 0,
"Expected no group table read in the second chunk, got #{reads} (snapshot not threaded across chunks)."
end
test "empty groups cell leaves the member without group assignment", %{actor: actor} do
chunk = [{2, %{member: %{email: "g-empty@example.com"}, custom: %{}, groups: " "}}]
opts = [actor: actor, groups_found: []]
assert {:ok, result} = MemberCSV.process_chunk(chunk, %{email: 0}, %{}, opts)
assert result.inserted == 1
assert result.errors == []
assert group_names_for("g-empty@example.com", actor) == []
end
property "re-importing the same groups does not create duplicates", %{actor: actor} do
check all(
name <- StreamData.string(:alphanumeric, min_length: 1, max_length: 15),
max_runs: 15
) do
group_name = "dup-" <> name
email1 = "dup-#{System.unique_integer([:positive])}@example.com"
email2 = "dup-#{System.unique_integer([:positive])}@example.com"
opts = [actor: actor, groups_found: []]
chunk1 = [{2, %{member: %{email: email1}, custom: %{}, groups: group_name}}]
chunk2 = [{2, %{member: %{email: email2}, custom: %{}, groups: group_name}}]
assert {:ok, %{inserted: 1}} = MemberCSV.process_chunk(chunk1, %{email: 0}, %{}, opts)
assert {:ok, %{inserted: 1}} = MemberCSV.process_chunk(chunk2, %{email: 0}, %{}, opts)
matching =
Mv.Membership.list_groups!(actor: actor)
|> Enum.filter(&(String.downcase(&1.name) == String.downcase(group_name)))
assert length(matching) == 1
end
end
end
end

View file

@ -101,6 +101,29 @@ defmodule Mv.Membership.MembersPDFTest do
assert byte_size(pdf_binary) > 1000
end
test "renders date column holding an ISO8601 datetime value" do
# Regression: a date column whose value is a full datetime string must be
# parsed via DateTime.from_iso8601/1 (which returns a 3-tuple) and rendered,
# not silently dropped.
export_data = %{
columns: [
%{key: "first_name", kind: :member_field, label: "Vorname"},
%{key: "join_date", kind: :member_field, label: "Eintritt"}
],
rows: [
["Max", "2024-01-15T14:30:00Z"]
],
meta: %{
generated_at: "2024-01-15T14:30:00Z",
member_count: 1
}
}
assert {:ok, pdf_binary} = MembersPDF.render(export_data)
assert String.starts_with?(pdf_binary, "%PDF")
assert byte_size(pdf_binary) > 1000
end
test "generates valid PDF with custom fields and computed fields" do
export_data = %{
columns: [

View file

@ -0,0 +1,155 @@
defmodule MvWeb.Components.BulkActionsDropdownTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias MvWeb.Components.BulkActionsDropdown
defp render_open(assigns) do
base = %{
id: "bulk-actions-dropdown",
open: true,
export_payload_json: ~s({"selected_ids":[]}),
selected_count: 0,
scope: :all,
mailto_bcc: "a%40example.com",
recipient_count: 1,
mailto_disabled?: false
}
render_component(BulkActionsDropdown, Map.merge(base, assigns))
end
defp scope_badge(html) do
html
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"]))
end
describe "trigger scope badge" do
test "shows an emphasized primary count badge when members are selected" do
html =
render_component(BulkActionsDropdown, %{id: "d", scope: :selection, selected_count: 3})
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "3"
classes = badge |> LazyHTML.attribute("class") |> List.first()
assert classes =~ "badge-primary"
assert classes =~ "badge-sm"
# The trigger label itself is just the bare action verb, no parenthetical.
assert html =~ "Actions"
refute html =~ "(3)"
end
test "shows a muted neutral 'all' badge when nothing selected and no filter" do
html = render_component(BulkActionsDropdown, %{id: "d", scope: :all, selected_count: 0})
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "all"
classes = badge |> LazyHTML.attribute("class") |> List.first()
assert classes =~ "badge-neutral"
assert classes =~ "badge-sm"
refute html =~ "(all)"
end
test "shows a muted neutral 'filtered' badge when a filter is active" do
html =
render_component(BulkActionsDropdown, %{id: "d", scope: :filtered, selected_count: 0})
badge = scope_badge(html)
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
classes = badge |> LazyHTML.attribute("class") |> List.first()
assert classes =~ "badge-neutral"
assert classes =~ "badge-sm"
refute html =~ "(filtered)"
end
end
describe "trigger affordance" do
test "carries a trailing chevron" do
html = render_component(BulkActionsDropdown, %{id: "d", scope: :all, selected_count: 0})
assert html =~ "hero-chevron-down"
end
end
describe "menu item layout" do
test "all menu items carry whitespace-nowrap to prevent label text wrapping" do
html = render_open(%{})
doc = LazyHTML.from_fragment(html)
# Collect all elements with role="menuitem" (both <a> and <button>)
items = LazyHTML.query(doc, ~s([role="menuitem"]))
classes_list = LazyHTML.attribute(items, "class")
assert length(classes_list) >= 4,
"expected at least 4 menu items, got #{length(classes_list)}"
for classes <- classes_list do
assert classes =~ "whitespace-nowrap",
"expected whitespace-nowrap on menu item, got class: #{inspect(classes)}"
end
end
end
describe "menu items" do
test "lists the four bulk actions in order, flattened to one level" do
html = render_open(%{})
mailto = :binary.match(html, "bulk-actions-mailto") |> elem(0)
copy = :binary.match(html, "bulk-actions-copy") |> elem(0)
csv = :binary.match(html, "export-csv-link") |> elem(0)
pdf = :binary.match(html, "export-pdf-link") |> elem(0)
assert mailto < copy
assert copy < csv
assert csv < pdf
# No nested "Export" submenu trigger — the export items sit at the top level.
refute html =~ ~s(data-testid="export-dropdown")
end
test "copy item carries the clipboard hook and an un-targeted copy_emails click" do
html = render_open(%{})
assert html =~ ~s(phx-hook="CopyToClipboard")
assert html =~ ~s(phx-click="copy_emails")
# The copy click must NOT be targeted at the component, so it reaches the
# parent LiveView handler.
refute html =~ ~r/phx-click="copy_emails"[^>]*phx-target/
end
end
describe "mailto recipient cap" do
test "mailto item is enabled below the threshold with a BCC link" do
html = render_open(%{mailto_disabled?: false, mailto_bcc: "a%40example.com"})
assert html =~ ~s(href="mailto:?bcc=a%40example.com")
refute html =~ ~r/data-testid="bulk-actions-mailto"[^>]*aria-disabled="true"/s
end
test "mailto item is disabled with the explanatory tooltip at the threshold" do
html = render_open(%{mailto_disabled?: true})
assert html =~ ~s(aria-disabled="true")
assert html =~ ~s(tabindex="-1")
assert html =~ "Too many recipients for this function"
# Disabled mailto must not expose an actionable BCC link.
refute html =~ "href=\"mailto:"
end
end
describe "export forms" do
test "CSV and PDF items keep the CSRF-protected form POST and payload" do
payload = ~s({"selected_ids":["x"]})
html = render_open(%{export_payload_json: payload})
assert html =~ ~s(action="/members/export.csv")
assert html =~ ~s(action="/members/export.pdf")
assert html =~ ~s(name="_csrf_token")
assert html =~ ~s(name="payload")
# The payload lands HTML-escaped in the hidden input value attribute; both
# export forms carry the same payload.
escaped = Phoenix.HTML.html_escape(payload) |> Phoenix.HTML.safe_to_string()
assert html =~ ~s(name="payload" value="#{escaped}")
end
end
end

View file

@ -17,5 +17,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do
assert has_element?(view, "button[phx-click='select_all']")
assert has_element?(view, "button[phx-click='select_none']")
end
test "trigger carries a trailing chevron affordance", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members")
# The shared dropdown trigger signals "opens a menu" with a trailing chevron.
assert html =~ "hero-chevron-down"
end
end
end

View file

@ -49,6 +49,20 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
end
describe "rendering" do
test "trigger carries a trailing chevron affordance", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Mirror the shared dropdown affordance: a trailing chevron inside the
# bespoke filter trigger button.
chevron =
html
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s(#member-filter button[aria-haspopup="true"] .hero-chevron-down))
assert Enum.count(chevron) == 1
end
test "renders boolean custom fields when present", %{conn: conn} do
conn = conn_with_oidc_user(conn)
boolean_field = create_boolean_custom_field(%{name: "Active Member"})

View file

@ -82,8 +82,10 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
# Count occurrences to ensure only one descending icon
down_count = html |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
# Count occurrences to ensure only one descending sort icon. Dropdown
# triggers carry their own trailing "hero-chevron-down size-4" chevron, so
# the sort-active icon is identified by its bare class (no size-4 suffix).
down_count = active_sort_down_count(html)
# Should be exactly one chevrondown icon
assert down_count == 1
end
@ -158,7 +160,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
# Count active icons (should be exactly 1 - ascending for default sort field)
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
down_count = active_sort_down_count(html_neutral)
assert up_count == 1, "Expected exactly 1 ascending icon, got #{up_count}"
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
@ -167,13 +169,24 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
{:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc")
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = html_desc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
down_count = active_sort_down_count(html_desc)
assert up_count == 0, "Expected 0 ascending icons, got #{up_count}"
assert down_count == 1, "Expected exactly 1 descending icon, got #{down_count}"
end
end
# Counts only the descending chevron icons that belong to a sort header. Both
# the sort-active icon and the dropdown-trigger chevron render as
# "hero-chevron-down size-4", so they are told apart by their containing
# button: sort headers carry phx-click="sort", dropdown triggers do not.
defp active_sort_down_count(html) do
html
|> LazyHTML.from_fragment()
|> LazyHTML.query(~s(button[phx-click="sort"] .hero-chevron-down))
|> Enum.count()
end
describe "accessibility" do
test "sets aria-label correctly for unsorted state", %{conn: conn} do
conn = conn_with_oidc_user(conn)

View file

@ -0,0 +1,104 @@
defmodule MvWeb.ImportTemplateControllerTest do
use MvWeb.ConnCase, async: true
setup %{conn: conn} do
actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, custom_field} =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{name: "Lieblingsfarbe", value_type: :string})
|> Ash.create(actor: actor)
%{conn: conn, custom_field: custom_field}
end
describe "authenticated EN template" do
setup %{conn: conn} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
%{conn: MvWeb.ConnCase.conn_with_password_user(conn, admin)}
end
test "returns CSV with English headers and current custom fields", %{conn: conn} do
conn = get(conn, ~p"/admin/import/template/en")
assert response_content_type(conn, :csv) =~ "text/csv"
body = response(conn, 200)
header = body |> String.split("\n") |> List.first()
assert header =~ "email"
# EN headers use the canonical English variant from HeaderMapper, not the
# underscore form, so the template stays faithful to the documented variant list.
assert header =~ "first name"
assert header =~ "last name"
refute header =~ "first_name"
assert header =~ "house number"
refute header =~ "house_number"
assert header =~ "Lieblingsfarbe"
assert get_resp_header(conn, "content-disposition")
|> Enum.any?(&(&1 =~ "member_import_en.csv"))
end
test "neutralizes formula-injection in a custom field header", %{conn: conn} do
actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, _} =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "=cmd|'/c calc'!A1",
value_type: :string
})
|> Ash.create(actor: actor)
conn = get(conn, ~p"/admin/import/template/en")
body = response(conn, 200)
header = body |> String.split("\n") |> List.first()
# The dangerous cell must be prefixed with a single quote so spreadsheet
# software does not evaluate it as a formula, matching the export writer.
refute header =~ ~r/(^|;)=cmd/
assert header =~ "'=cmd|'/c calc'!A1"
end
end
describe "authenticated DE template" do
setup %{conn: conn} do
admin = Mv.Fixtures.user_with_role_fixture("admin")
%{conn: MvWeb.ConnCase.conn_with_password_user(conn, admin)}
end
test "returns CSV with German headers and current custom fields", %{conn: conn} do
conn = get(conn, ~p"/admin/import/template/de")
body = response(conn, 200)
header = body |> String.split("\n") |> List.first()
assert header =~ "E-Mail"
assert header =~ "Vorname"
assert header =~ "Lieblingsfarbe"
assert get_resp_header(conn, "content-disposition")
|> Enum.any?(&(&1 =~ "member_import_de.csv"))
end
end
describe "authorization" do
@tag role: :unauthenticated
test "unauthenticated request does not receive a CSV", %{conn: conn} do
conn = get(conn, ~p"/admin/import/template/en")
refute conn.status == 200
refute get_resp_header(conn, "content-type") |> Enum.any?(&(&1 =~ "text/csv"))
refute to_string(conn.resp_body) =~ "email"
end
@tag role: :member
test "user without import permission is forbidden", %{conn: conn} do
conn = get(conn, ~p"/admin/import/template/en")
refute conn.status == 200
refute get_resp_header(conn, "content-type") |> Enum.any?(&(&1 =~ "text/csv"))
refute to_string(conn.resp_body) =~ "email"
end
end
end

View file

@ -0,0 +1,85 @@
defmodule MvWeb.Helpers.JoinDescriptionRendererTest do
@moduledoc """
Tests for the join-description renderer that auto-links raw URLs and Markdown
links while escaping all other content.
"""
use ExUnit.Case, async: true
use ExUnitProperties
alias MvWeb.Helpers.JoinDescriptionRenderer
defp html(value) do
value
|> JoinDescriptionRenderer.render()
|> Phoenix.HTML.safe_to_string()
end
describe "render/1" do
test "converts a raw URL to an anchor tag with the standard link class" do
result = html("Akzeptiere https://example.com/dsgvo")
assert result =~ ~s(<a href="https://example.com/dsgvo" class="link link-primary")
assert result =~ "https://example.com/dsgvo</a>"
assert result =~ "Akzeptiere "
end
test "converts Markdown [text](url) to an anchor tag with the standard link class" do
result = html("[Datenschutzerklärung](https://example.com/dsgvo)")
assert result =~ ~s(<a href="https://example.com/dsgvo" class="link link-primary")
assert result =~ ">Datenschutzerklärung</a>"
end
test "returns an empty safe string for nil input" do
assert JoinDescriptionRenderer.render(nil) == {:safe, ""}
end
test "escapes arbitrary HTML in non-link text" do
result = html("<script>alert(1)</script>")
refute result =~ "<script>"
assert result =~ "&lt;script&gt;"
end
test "does not double-link a Markdown link whose URL also looks like a raw URL" do
result = html("[Datenschutz](https://example.com/x)")
# exactly one anchor, no nested anchor for the inner raw URL
assert result |> :binary.matches("<a ") |> length() == 1
end
end
describe "property: link-free text" do
property "preserves non-link text content as HTML-escaped output" do
check all(text <- link_free_string()) do
result = html(text)
# No links emitted, and text content equals the HTML-escaped input.
refute result =~ "<a "
assert result == Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string()
end
end
end
describe "property: well-formed Markdown links" do
property "renders every [text](https://...) as a single anchor with verbatim text and url" do
check all(
label <- string(:alphanumeric, min_length: 1),
path <- string(:alphanumeric)
) do
url = "https://example.com/#{path}"
result = html("[#{label}](#{url})")
assert result =~ ~s(<a href="#{url}" class="link link-primary">#{label}</a>)
assert result |> :binary.matches("<a ") |> length() == 1
end
end
end
# Printable strings that contain no bare URLs and no Markdown-link opening bracket.
defp link_free_string do
:printable
|> string()
|> filter(fn s -> not String.contains?(s, "http") and not String.contains?(s, "[") end)
end
end

View file

@ -0,0 +1,102 @@
defmodule MvWeb.CustomFieldLive.FormTest do
@moduledoc """
Tests for the CustomFieldLive.FormComponent join_description input.
Covers that an admin can set and persist a custom field's join_description via
the settings edit form.
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.CustomField
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
admin_role = Mv.Fixtures.role_fixture("admin")
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create(actor: system_actor)
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update(actor: system_actor)
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts, actor: system_actor)
conn = log_in_user(build_conn(), user_with_role)
session = conn.private[:plug_session] || %{}
conn = Plug.Test.init_test_session(conn, Map.put(session, "locale", "en"))
%{conn: conn, actor: system_actor}
end
defp log_in_user(conn, user) do
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> AshAuthentication.Plug.Helpers.store_in_session(user)
end
defp open_edit_form(view, custom_field) do
view
|> element("tr#custom_fields-#{custom_field.id} td", custom_field.name)
|> render_click()
end
describe "join_description input" do
test "form shows a join_description input", %{conn: conn, actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{name: "dsgvo_field", value_type: :boolean})
|> Ash.create(actor: actor)
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
open_edit_form(view, custom_field)
assert has_element?(view, "input[name='custom_field[join_description]']")
end
test "form shows an info tooltip explaining allowed link syntax", %{conn: conn, actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{name: "dsgvo_field", value_type: :boolean})
|> Ash.create(actor: actor)
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
open_edit_form(view, custom_field)
assert has_element?(
view,
"[data-testid='join-description-link-hint'] .hero-information-circle"
)
end
test "form accepts and persists join_description", %{conn: conn, actor: actor} do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{name: "dsgvo_field", value_type: :boolean})
|> Ash.create(actor: actor)
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
open_edit_form(view, custom_field)
view
|> form("#custom-field-form-#{custom_field.id}-form", %{
"custom_field" => %{
"name" => custom_field.name,
"join_description" => "Accept the GDPR at https://example.com/dsgvo"
}
})
|> render_submit()
updated = Ash.get!(CustomField, custom_field.id, actor: actor)
assert updated.join_description == "Accept the GDPR at https://example.com/dsgvo"
end
end
end

Some files were not shown because too many files have changed in this diff Show more