Add dialyzer and resolve all findings closes #503 #504 #514 #516

Merged
moritz merged 16 commits from issue/mitgliederverwaltung-514 into main 2026-06-02 13:15:00 +02:00
62 changed files with 589 additions and 619 deletions

11
.dialyzer_ignore.exs Normal file
View file

@ -0,0 +1,11 @@
# Dialyzer ignore list.
#
# This file is for PROVEN false positives only. Each entry must carry a
# `# why:` comment explaining why Dialyzer is wrong about the call site.
# Real findings get fixed by adjusting @spec, return types, or pattern
# matches — never silenced here.
#
# Format: each entry is either a path string, a {path, warning} tuple,
# or a {path, warning, line} tuple. See:
# https://hexdocs.pm/dialyxir/readme.html#elixir-format
[]

184
.drone.jsonnet Normal file
View file

@ -0,0 +1,184 @@
local elixir = 'docker.io/library/elixir:1.18.3-otp-27';
local postgres_image = 'docker.io/library/postgres:18.3';
local pg_service = {
name: 'postgres',
image: postgres_image,
environment: {
POSTGRES_USER: 'postgres',
POSTGRES_PASSWORD: 'postgres',
},
};
local cache_volume = { name: 'cache', host: { path: '/tmp/drone_cache' } };
local cache_mount = [{ name: 'cache', path: '/cache' }];
local step_compute_cache = {
name: 'compute cache key',
image: elixir,
commands: [
"mix_lock_hash=$(sha256sum mix.lock | cut -d ' ' -f 1)",
'echo "$DRONE_REPO_OWNER/$DRONE_REPO_NAME/$mix_lock_hash" >> .cache_key',
// Print cache key for debugging
'cat .cache_key',
],
};
local step_restore_cache = {
name: 'restore-cache',
image: 'drillster/drone-volume-cache',
settings: { restore: true, mount: ['./deps', './_build', './priv/plts'], ttl: 30 },
volumes: cache_mount,
};
local step_lint = {
name: 'lint',
image: elixir,
commands: [
'mix local.hex --force', // Install hex package manager
'mix deps.get', // Fetch dependencies
'mix compile --warnings-as-errors', // Check for compilation errors & warnings
'mix format --check-formatted', // Check formatting
'mix sobelow --config', // Security checks
'mix deps.audit --ignore-file .deps_audit_ignore', // Known vulnerabilities
'mix hex.audit', // Unmaintained dependencies
'mix credo --strict', // Code quality hints
'mix gettext.extract --check-up-to-date', // Translations up to date
],
};
local step_typecheck = {
name: 'typecheck',
image: elixir,
commands: [
'mix local.hex --force',
'mix deps.get',
'mkdir -p priv/plts',
// Build/refresh PLT no-op on cache hit, full build (5-15 min) on cache miss.
'mix dialyzer --plt',
// Actual typecheck. --format short keeps log noise down on red builds.
'mix dialyzer --format short',
],
};
local step_wait_postgres = {
name: 'wait_for_postgres',
image: postgres_image,
commands: [
|||
for i in {1..20}; do
if pg_isready -h postgres -U postgres; then
exit 0
else
true
fi
sleep 2
done
echo "Postgres did not become available, aborting."
exit 1
|||,
],
};
local step_rebuild_cache = {
name: 'rebuild-cache',
image: 'drillster/drone-volume-cache',
settings: { rebuild: true, mount: ['./deps', './_build', './priv/plts'] },
volumes: cache_mount,
};
// test_cmd is the only thing that differs between the fast and full suites.
local test_step(name, test_cmd) = {
name: name,
image: elixir,
environment: {
MIX_ENV: 'test',
TEST_POSTGRES_HOST: 'postgres',
TEST_POSTGRES_PORT: '5432',
},
commands: ['mix local.hex --force', 'mix deps.get', test_cmd],
};
local test_fast = test_step('test-fast', 'mix test --exclude slow --exclude ui --max-cases 2');
local test_all = test_step('test-all', 'mix test');
// A full check pipeline: identical steps, only name + trigger + test step vary.
local check_pipeline(name, trigger, test) = {
kind: 'pipeline',
type: 'docker',
name: name,
services: [pg_service],
trigger: trigger,
steps: [
step_compute_cache,
step_restore_cache,
step_lint,
] + (if test.name == 'test-all' then [step_typecheck] else []) + [
step_wait_postgres,
test,
step_rebuild_cache,
],
volumes: [cache_volume],
};
local docker_publish(name, extra_settings, trigger_event, deps) = {
kind: 'pipeline',
type: 'docker',
name: name,
trigger: trigger_event,
steps: [{
name: 'build-and-publish-container' + (if name == 'build-and-publish' then '-branch' else ''),
image: 'plugins/docker',
settings: {
registry: 'git.local-it.org',
repo: 'git.local-it.org/local-it/mitgliederverwaltung',
username: { from_secret: 'DRONE_REGISTRY_USERNAME' },
password: { from_secret: 'DRONE_REGISTRY_TOKEN' },
} + extra_settings,
when: trigger_event,
}],
depends_on: deps,
};
[
check_pipeline('check-fast', { branch: { exclude: ['main'] }, event: ['push'] }, test_fast),
check_pipeline('check-full', { branch: ['main'], event: ['push'] }, test_all),
check_pipeline('check-full-promote', { event: ['promote'], target: ['production'] }, test_all),
check_pipeline('check-full-tag', { event: ['tag'] }, test_all),
docker_publish(
'build-and-publish',
{ tags: ['latest', '${DRONE_COMMIT_SHA:0:8}'] },
{ branch: ['main'], event: ['push'] },
['check-full'],
),
docker_publish(
'build-and-release',
{ auto_tag: true },
{ event: ['tag'] },
['check-full-tag'],
),
{
kind: 'pipeline',
type: 'docker',
name: 'renovate',
trigger: { event: ['cron', 'custom'], branch: ['main'] },
environment: { LOG_LEVEL: 'debug' },
steps: [{
name: 'renovate',
image: 'renovate/renovate:43.165',
environment: {
RENOVATE_CONFIG_FILE: 'renovate_backend_config.js',
RENOVATE_TOKEN: { from_secret: 'RENOVATE_TOKEN' },
GITHUB_COM_TOKEN: { from_secret: 'GITHUB_COM_TOKEN' },
},
commands: [
// https://github.com/renovatebot/renovate/discussions/15049
'unset GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL',
'renovate-config-validator',
'renovate',
],
}],
},
]

View file

@ -1,298 +0,0 @@
kind: pipeline
type: docker
name: check-fast
services:
- name: postgres
image: docker.io/library/postgres:18.3
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
trigger:
event:
- push
steps:
- name: compute cache key
image: docker.io/library/elixir:1.18.3-otp-27
commands:
- mix_lock_hash=$(sha256sum mix.lock | cut -d ' ' -f 1)
- echo "$DRONE_REPO_OWNER/$DRONE_REPO_NAME/$mix_lock_hash" >> .cache_key
# Print cache key for debugging
- cat .cache_key
- name: restore-cache
image: drillster/drone-volume-cache
settings:
restore: true
mount:
- ./deps
- ./_build
ttl: 30
volumes:
- name: cache
path: /cache
- name: lint
image: docker.io/library/elixir:1.18.3-otp-27
commands:
# Install hex package manager
- mix local.hex --force
# Fetch dependencies
- mix deps.get
# Check for compilation errors & warnings
- mix compile --warnings-as-errors
# Check formatting
- mix format --check-formatted
# Security checks
- mix sobelow --config
# Check dependencies for known vulnerabilities
- mix deps.audit --ignore-file .deps_audit_ignore
# Check for dependencies that are not maintained anymore
- mix hex.audit
# Provide hints for improving code quality
- mix credo --strict
# Check that translations are up to date
- mix gettext.extract --check-up-to-date
- name: wait_for_postgres
image: docker.io/library/postgres:18.3
commands:
# Wait for postgres to become available
- |
for i in {1..20}; do
if pg_isready -h postgres -U postgres; then
exit 0
else
true
fi
sleep 2
done
echo "Postgres did not become available, aborting."
exit 1
- name: test-fast
image: docker.io/library/elixir:1.18.3-otp-27
environment:
MIX_ENV: test
TEST_POSTGRES_HOST: postgres
TEST_POSTGRES_PORT: 5432
commands:
# Install hex package manager
- mix local.hex --force
# Fetch dependencies
- mix deps.get
# Run fast tests (excludes slow/performance and UI tests)
- mix test --exclude slow --exclude ui --max-cases 2
- name: rebuild-cache
image: drillster/drone-volume-cache
settings:
rebuild: true
mount:
- ./deps
- ./_build
volumes:
- name: cache
path: /cache
volumes:
- name: cache
host:
path: /tmp/drone_cache
---
kind: pipeline
type: docker
name: check-full
services:
- name: postgres
image: docker.io/library/postgres:18.3
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
trigger:
event:
- promote
target:
- production
steps:
- name: compute cache key
image: docker.io/library/elixir:1.18.3-otp-27
commands:
- mix_lock_hash=$(sha256sum mix.lock | cut -d ' ' -f 1)
- echo "$DRONE_REPO_OWNER/$DRONE_REPO_NAME/$mix_lock_hash" >> .cache_key
# Print cache key for debugging
- cat .cache_key
- name: restore-cache
image: drillster/drone-volume-cache
settings:
restore: true
mount:
- ./deps
- ./_build
ttl: 30
volumes:
- name: cache
path: /cache
- name: lint
image: docker.io/library/elixir:1.18.3-otp-27
commands:
# Install hex package manager
- mix local.hex --force
# Fetch dependencies
- mix deps.get
# Check for compilation errors & warnings
- mix compile --warnings-as-errors
# Check formatting
- mix format --check-formatted
# Security checks
- mix sobelow --config
# Check dependencies for known vulnerabilities
- mix deps.audit --ignore-file .deps_audit_ignore
# Check for dependencies that are not maintained anymore
- mix hex.audit
# Provide hints for improving code quality
- mix credo --strict
# Check that translations are up to date
- mix gettext.extract --check-up-to-date
- name: wait_for_postgres
image: docker.io/library/postgres:18.3
commands:
# Wait for postgres to become available
- |
for i in {1..20}; do
if pg_isready -h postgres -U postgres; then
exit 0
else
true
fi
sleep 2
done
echo "Postgres did not become available, aborting."
exit 1
- name: test-all
image: docker.io/library/elixir:1.18.3-otp-27
environment:
MIX_ENV: test
TEST_POSTGRES_HOST: postgres
TEST_POSTGRES_PORT: 5432
commands:
# Install hex package manager
- mix local.hex --force
# Fetch dependencies
- mix deps.get
# Run all tests (including slow/performance and UI tests)
- mix test
- name: rebuild-cache
image: drillster/drone-volume-cache
settings:
rebuild: true
mount:
- ./deps
- ./_build
volumes:
- name: cache
path: /cache
volumes:
- name: cache
host:
path: /tmp/drone_cache
---
kind: pipeline
type: docker
name: build-and-publish
trigger:
branch:
- main
event:
- push
steps:
- name: build-and-publish-container-branch
image: plugins/docker
settings:
registry: git.local-it.org
repo: git.local-it.org/local-it/mitgliederverwaltung
username:
from_secret: DRONE_REGISTRY_USERNAME
password:
from_secret: DRONE_REGISTRY_TOKEN
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
when:
event:
- push
depends_on:
- check-fast
---
kind: pipeline
type: docker
name: build-and-release
trigger:
event:
- tag
steps:
- name: build-and-publish-container
image: plugins/docker
settings:
registry: git.local-it.org
repo: git.local-it.org/local-it/mitgliederverwaltung
username:
from_secret: DRONE_REGISTRY_USERNAME
password:
from_secret: DRONE_REGISTRY_TOKEN
auto_tag: true
when:
event:
- tag
depends_on:
- check-fast
---
kind: pipeline
type: docker
name: renovate
trigger:
event:
- cron
- custom
branch:
- main
environment:
LOG_LEVEL: debug
steps:
- name: renovate
image: renovate/renovate:43.165
environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN:
from_secret: RENOVATE_TOKEN
GITHUB_COM_TOKEN:
from_secret: GITHUB_COM_TOKEN
commands:
# https://github.com/renovatebot/renovate/discussions/15049
- unset GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL
- renovate-config-validator
- renovate

4
.gitignore vendored
View file

@ -49,3 +49,7 @@ notes.md
# Do NOT commit these — they are local to the dev machine # Do NOT commit these — they are local to the dev machine
.pipeline/ .pipeline/
.claude/ .claude/
# Dialyzer PLT files — built locally and in CI cache, never tracked.
/priv/plts/*.plt
/priv/plts/*.plt.hash

View file

@ -31,6 +31,21 @@ start-database:
ci-dev: lint audit test-fast ci-dev: lint audit test-fast
# 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
# priv/plts/ and are gitignored.
plt: install-dependencies
@mkdir -p priv/plts
mix dialyzer --plt
# Typecheck via Dialyzer. Slow stage, NOT part of ci-dev.
typecheck: plt
mix dialyzer --format short
# Full CI: inner loop plus typecheck. Use locally before pushing; Drone CI
# runs equivalent steps with PLT caching.
ci: ci-dev typecheck
gettext: gettext:
mix gettext.extract mix gettext.extract
mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete

View file

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

View file

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

View file

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

View file

@ -836,7 +836,10 @@ defmodule Mv.Membership do
- `{:ok, rejected_request}` - Rejected JoinRequest - `{:ok, rejected_request}` - Rejected JoinRequest
- `{:error, error}` - Status error or authorization error - `{: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 def reject_join_request(id, opts \\ []) do
actor = Keyword.get(opts, :actor) actor = Keyword.get(opts, :actor)

View file

@ -26,8 +26,6 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
""" """
use Ash.Resource.Change use Ash.Resource.Change
require Logger
alias Mv.MembershipFees.CalendarCycles alias Mv.MembershipFees.CalendarCycles
@impl true @impl true
@ -83,11 +81,6 @@ defmodule Mv.MembershipFees.Changes.SetMembershipFeeStartDate do
field: :membership_fee_type_id, field: :membership_fee_type_id,
message: "not found" 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
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. 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 scope :: :own | :linked | :all
@type action :: :read | :create | :update | :destroy @type action :: :read | :create | :update | :destroy
@ -88,7 +89,7 @@ defmodule Mv.Authorization.PermissionSets do
iex> PermissionSets.all_permission_sets() iex> PermissionSets.all_permission_sets()
[:own_data, :read_only, :normal_user, :admin] [:own_data, :read_only, :normal_user, :admin]
""" """
@spec all_permission_sets() :: [atom()] @spec all_permission_sets() :: [permission_set_name(), ...]
def all_permission_sets do def all_permission_sets do
[:own_data, :read_only, :normal_user, :admin] [:own_data, :read_only, :normal_user, :admin]
end end
@ -107,7 +108,7 @@ defmodule Mv.Authorization.PermissionSets do
iex> PermissionSets.get_permissions(:invalid) iex> PermissionSets.get_permissions(:invalid)
** (ArgumentError) invalid permission set: :invalid. Must be one of: [:own_data, :read_only, :normal_user, :admin] ** (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 def get_permissions(set) when set not in [:own_data, :read_only, :normal_user, :admin] do
raise ArgumentError, raise ArgumentError,

View file

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

View file

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

View file

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

View file

@ -26,14 +26,8 @@ defmodule Mv.Membership.Import.ImportRunner do
{:ok, content} -> {:ok, content} ->
{: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, reason} ->
{:error, Exception.message(reason)} {:error, to_string(:file.format_error(reason))}
end end
end end

View file

@ -210,8 +210,6 @@ defmodule Mv.Membership.Import.MemberCSV do
MapSet.member?(HeaderMapper.known_member_fields(), normalized) MapSet.member?(HeaderMapper.known_member_fields(), normalized)
end end
defp member_field?(_), do: false
# Validates that row count doesn't exceed limit # Validates that row count doesn't exceed limit
defp validate_row_count(rows, max_rows) do defp validate_row_count(rows, max_rows) do
if length(rows) > max_rows do if length(rows) > max_rows do

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 # Ash stores actor in changeset.context.private.actor; validation context has .actor; some callsites use context.actor
defp resolve_actor(changeset, context) do defp resolve_actor(changeset, context) do
ctx = changeset.context || %{} ctx = changeset.context
get_in(ctx, [:private, :actor]) || get_in(ctx, [:private, :actor]) ||
Map.get(ctx, :actor) || Map.get(ctx, :actor) ||

View file

@ -16,6 +16,21 @@ defmodule Mv.Membership.MemberExport do
alias MvWeb.MemberLive.Index alias MvWeb.MemberLive.Index
alias MvWeb.MemberLive.Index.MembershipFeeStatus 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)) ++ @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
["membership_fee_type", "membership_fee_status", "groups"] ["membership_fee_type", "membership_fee_status", "groups"]
@computed_export_fields ["membership_fee_status"] @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, :computed_fields, :custom_field_ids, :query, :sort_field, :sort_order,
:show_current_cycle, :cycle_status_filter, :boolean_filters. :show_current_cycle, :cycle_status_filter, :boolean_filters.
""" """
@spec parse_params(map()) :: map() @spec parse_params(map()) :: parsed_params()
def parse_params(params) do def parse_params(params) do
# DB fields come from "member_fields" # DB fields come from "member_fields"
raw_member_fields = extract_list(params, "member_fields") raw_member_fields = extract_list(params, "member_fields")
@ -458,9 +473,6 @@ defmodule Mv.Membership.MemberExport do
computed_fields, computed_fields,
member_fields member_fields
) do ) do
computed_fields = computed_fields || []
member_fields = member_fields || []
db_with_insert = db_with_insert =
Enum.flat_map(db_fields_ordered, fn f -> Enum.flat_map(db_fields_ordered, fn f ->
expand_field_with_computed(f, member_fields, computed_fields) expand_field_with_computed(f, member_fields, computed_fields)
@ -507,6 +519,4 @@ defmodule Mv.Membership.MemberExport do
other -> other other -> other
end) end)
end end
defp normalize_computed_fields(_), do: []
end 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. Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body.
RFC 4180 escaping and formula-injection safe_cell are applied. 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 def export(members, columns) when is_list(members) do
header = build_header(columns) header = build_header(columns)
rows = Enum.map(members, fn member -> build_row(member, columns) end) 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 defp convert_to_template_format(export_data, locale, club_name) do
# Set locale for translations # Set locale for translations
Gettext.put_locale(MvWeb.Gettext, locale) _ = Gettext.put_locale(MvWeb.Gettext, locale)
headers = Enum.map(export_data.columns, & &1.label) headers = Enum.map(export_data.columns, & &1.label)
column_count = length(export_data.columns) column_count = length(export_data.columns)
@ -211,9 +211,6 @@ defmodule Mv.Membership.MembersPDF do
{:ok, datetime, _offset} -> {:ok, datetime, _offset} ->
format_datetime(datetime, locale) format_datetime(datetime, locale)
{:ok, datetime} ->
format_datetime(datetime, locale)
{:error, _} -> {:error, _} ->
# Try NaiveDateTime if DateTime parsing fails # Try NaiveDateTime if DateTime parsing fails
case NaiveDateTime.from_iso8601(iso8601_string) do case NaiveDateTime.from_iso8601(iso8601_string) do
@ -257,8 +254,6 @@ defmodule Mv.Membership.MembersPDF do
end end
end end
defp format_date(_, _), do: ""
defp format_dates_in_rows(rows, columns, locale) do defp format_dates_in_rows(rows, columns, locale) do
date_indices = find_date_column_indices(columns) 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 defp format_cell_date_datetime(cell_value, locale) do
case DateTime.from_iso8601(cell_value) 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) _ -> format_cell_date_naive(cell_value, locale)
end end
end end

View file

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

View file

@ -1,4 +1,11 @@
defmodule Mv.MembershipFees.CycleGenerator do 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 """ @moduledoc """
Module for generating membership fee cycles for members. 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) lock_key = Member.advisory_lock_key_for_member_id(member.id)
Repo.transaction(fn -> 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 case do_generate_cycles(member, today, opts) do
{:ok, cycles, notifications} -> {:ok, cycles, notifications} ->
@ -159,7 +166,8 @@ defmodule Mv.MembershipFees.CycleGenerator do
- `{:error, reason}` - Error with reason - `{: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 def generate_cycles_for_all_members(opts \\ []) do
today = Keyword.get(opts, :today, Date.utc_today()) today = Keyword.get(opts, :today, Date.utc_today())
batch_size = Keyword.get(opts, :batch_size, 10) 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 defp process_member_cycle_generation(member, today) do
case generate_cycles_for_member(member, today: today) do case generate_cycles_for_member(member, today: today) do
{:ok, _cycles, notifications} = ok -> {:ok, _cycles, notifications} = ok ->
send_notifications_for_batch_job(notifications) _ = send_notifications_for_batch_job(notifications)
{member.id, ok} {member.id, ok}
{:error, _reason} = err -> {:error, _reason} = err ->

View file

@ -87,8 +87,6 @@ defmodule Mv.OidcRoleSync do
ArgumentError -> nil ArgumentError -> nil
end end
defp safe_get_atom(_map, _key), do: nil
defp peek_jwt_claims(token) do defp peek_jwt_claims(token) do
parts = String.split(token, ".") 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\"." @doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
def oidc_groups_claim do def oidc_groups_claim do
Mv.Config.oidc_groups_claim() || "groups" Mv.Config.oidc_groups_claim()
end end
end end

View file

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

View file

@ -19,4 +19,12 @@ defmodule Mv.Repo do
def min_pg_version do def min_pg_version do
%Version{major: 17, minor: 2, patch: 0} %Version{major: 17, minor: 2, patch: 0}
end 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 end

View file

@ -8,6 +8,12 @@ defmodule Mv.Vereinfacht.Client do
""" """
require Logger 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" @content_type "application/vnd.api+json"
@doc """ @doc """
@ -31,7 +37,7 @@ defmodule Mv.Vereinfacht.Client do
{:error, :not_configured} {:error, :not_configured}
""" """
@spec test_connection(String.t() | nil, String.t() | nil, String.t() | nil) :: @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 def test_connection(api_url, api_key, club_id) do
if blank?(api_url) or blank?(api_key) or blank?(club_id) do if blank?(api_url) or blank?(api_key) or blank?(club_id) do
{:error, :not_configured} {:error, :not_configured}
@ -92,13 +98,12 @@ defmodule Mv.Vereinfacht.Client do
@sync_timeout_ms 5_000 @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). # 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 defp req_http_options do
opts = [receive_timeout: @sync_timeout_ms] 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 end
defp post_and_parse_contact(url, body, api_key) do 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. 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 def get_contact(contact_id) when is_binary(contact_id) do
fetch_contact(contact_id, []) fetch_contact(contact_id, [])
end end

View file

@ -37,9 +37,10 @@ defmodule Mv.Vereinfacht.SyncFlash do
def create_table! do def create_table! do
# :public so any process can write (SyncContact runs in LiveView/Ash transaction process, # :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. # 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]) if :ets.whereis(@table) == :undefined do
end :ets.new(@table, [:set, :public, :named_table])
end
:ok :ok
end end

View file

@ -26,7 +26,7 @@ defmodule Mv.Vereinfacht do
- `{:error, {:http, status, message}}` API returned an error (e.g. 401, 403) - `{:error, {:http, status, message}}` API returned an error (e.g. 401, 403)
- `{:error, {:request_failed, reason}}` network/transport error - `{: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 def test_connection do
Client.test_connection( Client.test_connection(
Mv.Config.vereinfacht_api_url(), Mv.Config.vereinfacht_api_url(),

View file

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

View file

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

View file

@ -25,31 +25,33 @@ defmodule MvWeb.MemberExportController do
@custom_field_prefix Mv.Constants.custom_field_prefix() @custom_field_prefix Mv.Constants.custom_field_prefix()
def export(conn, params) do def export(conn, params) do
actor = current_actor(conn) case current_actor(conn) do
if is_nil(actor), do: return_forbidden(conn) nil -> return_forbidden(conn)
actor -> export_with_actor(conn, actor, params["payload"])
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
end end
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 defp current_actor(conn) do
conn.assigns[:current_user] conn.assigns[:current_user]
|> Actor.ensure_loaded() |> Actor.ensure_loaded()

View file

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

View file

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

View file

@ -935,7 +935,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
{nil, true} -> "#{base_classes} btn-active" {nil, true} -> "#{base_classes} btn-active"
{:in, true} -> "#{base_classes} btn-success btn-active" {:in, true} -> "#{base_classes} btn-success btn-active"
{:not_in, true} -> "#{base_classes} btn-error btn-active" {:not_in, true} -> "#{base_classes} btn-error btn-active"
_ -> "#{base_classes} btn-outline"
end end
end end

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) # 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") 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) actor = MvWeb.LiveHelpers.current_actor(socket)
custom_fields = load_custom_fields(actor) custom_fields = load_custom_fields(actor)

View file

@ -836,12 +836,6 @@ defmodule MvWeb.GroupLive.Show do
end end
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 defp handle_successful_add_members(socket, group, actor) do
socket = reload_group(socket, group.slug, actor) socket = reload_group(socket, group.slug, actor)

View file

@ -47,14 +47,11 @@ defmodule MvWeb.ImportLive do
# after this limit is reached. # after this limit is reached.
@max_errors 50 @max_errors 50
# Maximum length for error messages before truncation
@max_error_message_length 200
@impl true @impl true
def mount(_params, session, socket) do def mount(_params, session, socket) do
# Get locale from session for translations # Get locale from session for translations
locale = session["locale"] || "de" locale = session["locale"] || "de"
Gettext.put_locale(MvWeb.Gettext, locale) _ = Gettext.put_locale(MvWeb.Gettext, locale)
# Get club name from settings # Get club name from settings
club_name = club_name =
@ -193,16 +190,6 @@ defmodule MvWeb.ImportLive do
:error, :error,
gettext("Failed to prepare CSV import: %{reason}", reason: reason) 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
end end
@ -223,64 +210,6 @@ defmodule MvWeb.ImportLive do
{:noreply, socket} {:noreply, socket}
end 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 @impl true
def handle_info({:process_chunk, idx}, socket) do def handle_info({:process_chunk, idx}, socket) do
case socket.assigns do case socket.assigns do
@ -337,32 +266,33 @@ defmodule MvWeb.ImportLive do
actor: actor actor: actor
] ]
if Config.sql_sandbox?() do _ =
run_chunk_with_locale( if Config.sql_sandbox?() do
locale, run_chunk_with_locale(
chunk, locale,
import_state.column_map, chunk,
import_state.custom_field_map, import_state.column_map,
opts, import_state.custom_field_map,
live_view_pid, opts,
idx live_view_pid,
) idx
else )
Task.Supervisor.start_child( else
Mv.TaskSupervisor, Task.Supervisor.start_child(
fn -> Mv.TaskSupervisor,
run_chunk_with_locale( fn ->
locale, run_chunk_with_locale(
chunk, locale,
import_state.column_map, chunk,
import_state.custom_field_map, import_state.column_map,
opts, import_state.custom_field_map,
live_view_pid, opts,
idx live_view_pid,
) idx
end )
) end
end )
end
{:noreply, socket} {:noreply, socket}
end end
@ -378,7 +308,7 @@ defmodule MvWeb.ImportLive do
live_view_pid, live_view_pid,
idx idx
) do ) 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) ImportRunner.process_chunk(chunk, column_map, custom_field_map, opts, live_view_pid, idx)
end end

View file

@ -287,8 +287,6 @@ defmodule MvWeb.JoinLive do
end end
end end
defp member_field_input_type(_), do: "text"
defp member_field_atom(field_id) when is_binary(field_id) do defp member_field_atom(field_id) when is_binary(field_id) do
Mv.Constants.member_fields() Mv.Constants.member_fields()
|> Enum.find(&(Atom.to_string(&1) == field_id)) |> Enum.find(&(Atom.to_string(&1) == field_id))

View file

@ -1218,8 +1218,6 @@ defmodule MvWeb.MemberLive.Index do
end end
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, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current) defp apply_cycle_status_filter(members, status, show_current)
@ -1297,8 +1295,6 @@ defmodule MvWeb.MemberLive.Index do
end end
end end
defp valid_sort_field?(_), do: false
defp valid_sort_field_db_or_custom?(field) when is_atom(field) do defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
non_sortable_fields = [:notes] non_sortable_fields = [:notes]
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
@ -1558,8 +1554,6 @@ defmodule MvWeb.MemberLive.Index do
assign(socket, :group_filters, Map.take(filters, valid_group_ids)) assign(socket, :group_filters, Map.take(filters, valid_group_ids))
end end
defp maybe_update_group_filters(socket, _), do: socket
defp maybe_update_fee_type_filters(socket, params) when is_map(params) do defp maybe_update_fee_type_filters(socket, params) when is_map(params) do
prefix = @fee_type_filter_prefix prefix = @fee_type_filter_prefix
prefix_len = String.length(prefix) prefix_len = String.length(prefix)
@ -1586,8 +1580,6 @@ defmodule MvWeb.MemberLive.Index do
assign(socket, :fee_type_filters, Map.take(filters, valid_fee_type_ids)) assign(socket, :fee_type_filters, Map.take(filters, valid_fee_type_ids))
end end
defp maybe_update_fee_type_filters(socket, _), do: socket
defp add_fee_type_filter_entry(acc, key, value_str, prefix_len) do defp add_fee_type_filter_entry(acc, key, value_str, prefix_len) do
key_str = to_string(key) key_str = to_string(key)
raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len) raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)
@ -1719,8 +1711,6 @@ defmodule MvWeb.MemberLive.Index do
assign(socket, :date_filters, DateFilter.from_params(params, date_custom_fields)) assign(socket, :date_filters, DateFilter.from_params(params, date_custom_fields))
end end
defp maybe_update_date_filters(socket, _params), do: socket
# ------------------------------------------------------------- # -------------------------------------------------------------
# Custom Field Value Helpers # Custom Field Value Helpers
# ------------------------------------------------------------- # -------------------------------------------------------------

View file

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

View file

@ -1027,7 +1027,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign(:create_cycle_error, format_error(error))} |> assign(:create_cycle_error, format_error(error))}
end end
else else
:error -> {:error, reason} when reason in [:invalid_format, :invalid_date, :incompatible_calendars] ->
{:noreply, {:noreply,
socket socket
|> assign(:create_cycle_error, gettext("Invalid date format"))} |> 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) Enum.map_join(error.errors, ", ", fn e -> e.message end)
end end
defp format_error(error) when is_binary(error), do: error
defp format_error(_error), do: gettext("An error occurred") defp format_error(_error), do: gettext("An error occurred")
defp assign_form(%{assigns: %{settings: settings}} = socket) do 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 case submit_form(socket.assigns.form, params, actor) do
{:ok, membership_fee_type} -> {:ok, membership_fee_type} ->
notify_parent({:saved, membership_fee_type}) _ = notify_parent({:saved, membership_fee_type})
socket = socket =
socket socket
@ -341,7 +341,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
end end
end end
@spec notify_parent(any()) :: any() @spec notify_parent(any()) :: {module(), any()}
defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() @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) Enum.map_join(error.errors, ", ", fn e -> e.message end)
end end
defp format_error(error) when is_binary(error), do: error
defp format_error(_error), do: gettext("An error occurred") defp format_error(_error), do: gettext("An error occurred")
# Info card explaining the membership fee type concept # 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 case MvWeb.LiveHelpers.submit_form(socket.assigns.form, role_params, actor) do
{:ok, role} -> {:ok, role} ->
notify_parent({:saved, role}) _ = notify_parent({:saved, role})
redirect_path = redirect_path =
if socket.assigns.return_to == "show" do if socket.assigns.return_to == "show" do
@ -186,7 +186,7 @@ defmodule MvWeb.RoleLive.Form do
end end
end end
@spec notify_parent(any()) :: any() @spec notify_parent(any()) :: {module(), any()}
defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() @spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()

View file

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

View file

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

View file

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

View file

@ -188,7 +188,7 @@ defmodule MvWeb.Router do
get_locale_from_cookie(conn) || get_locale_from_cookie(conn) ||
extract_locale_from_headers(conn.req_headers) extract_locale_from_headers(conn.req_headers)
Gettext.put_locale(MvWeb.Gettext, locale) _ = Gettext.put_locale(MvWeb.Gettext, locale)
conn conn
|> put_session(:locale, locale) |> put_session(:locale, locale)

View file

@ -12,7 +12,9 @@ defmodule MvWeb.Translations.FieldTypes do
""" """
use Gettext, backend: MvWeb.Gettext 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(:string), do: gettext("Text")
def label(:integer), do: gettext("Number") def label(:integer), do: gettext("Number")
def label(:boolean), do: gettext("Yes/No-Selection") def label(:boolean), do: gettext("Yes/No-Selection")

19
mix.exs
View file

@ -12,6 +12,7 @@ defmodule Mv.MixProject do
compilers: [:phoenix_live_view] ++ Mix.compilers(), compilers: [:phoenix_live_view] ++ Mix.compilers(),
aliases: aliases(), aliases: aliases(),
deps: deps(), deps: deps(),
dialyzer: dialyzer(),
listeners: [Phoenix.CodeReloader], listeners: [Phoenix.CodeReloader],
gettext: [write_reference_line_numbers: false] gettext: [write_reference_line_numbers: false]
] ]
@ -80,6 +81,7 @@ defmodule Mv.MixProject do
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false}, {:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
{:bypass, "~> 2.1", only: [:dev, :test]}, {:bypass, "~> 2.1", only: [:dev, :test]},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:picosat_elixir, "~> 0.1"}, {:picosat_elixir, "~> 0.1"},
{:ecto_commons, "~> 0.3"}, {:ecto_commons, "~> 0.3"},
{:slugify, "~> 1.3"}, {:slugify, "~> 1.3"},
@ -112,4 +114,21 @@ defmodule Mv.MixProject do
"phx.routes": ["phx.routes", "ash_authentication.phoenix.routes"] "phx.routes": ["phx.routes", "ash_authentication.phoenix.routes"]
] ]
end end
defp dialyzer do
[
plt_file: {:no_warn, "priv/plts/dialyzer.plt"},
plt_core_path: "priv/plts/core.plt",
plt_add_apps: [:mix, :ex_unit],
flags: [
:error_handling,
:unmatched_returns,
:extra_return,
:missing_return,
:underspecs
],
ignore_warnings: ".dialyzer_ignore.exs",
list_unused_filters: true
]
end
end end

View file

@ -23,11 +23,13 @@
"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.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"},
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, "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.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"},
"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"}, "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"}, "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"},
"ecto_commons": {:hex, :ecto_commons, "0.3.7", "f33c162a6f63695d5939af02c65a0e76aa6e7278b82c7bfc357ffbfea353bf0f", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.4", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "9c33771ebd38cd83d3f90fab6069826ba9d4f7580f1481b3c0913f8b9795c5fd"}, "ecto_commons": {:hex, :ecto_commons, "0.3.7", "f33c162a6f63695d5939af02c65a0e76aa6e7278b82c7bfc357ffbfea353bf0f", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.4", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "9c33771ebd38cd83d3f90fab6069826ba9d4f7580f1481b3c0913f8b9795c5fd"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"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"}, "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"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
"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"}, "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"},

View file

@ -2208,11 +2208,6 @@ msgstr "Keine Mitglieder in dieser Gruppe"
msgid "No members selected" msgid "No members selected"
msgstr "Keine Mitglieder ausgewählt" 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 #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned." msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
@ -3972,3 +3967,8 @@ msgstr "Zeitraum"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "To" msgid "To"
msgstr "Bis" msgstr "Bis"
#~ #: lib/mv_web/live/group_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "No members selected."
#~ msgstr "Keine Mitglieder ausgewählt."

View file

@ -2209,11 +2209,6 @@ msgstr ""
msgid "No members selected" msgid "No members selected"
msgstr "" 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 #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned." msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."

View file

@ -2209,11 +2209,6 @@ msgstr ""
msgid "No members selected" msgid "No members selected"
msgstr "" 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 #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned." msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
@ -3972,3 +3967,8 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "To" msgid "To"
msgstr "" msgstr ""
#~ #: lib/mv_web/live/group_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "No members selected."
#~ msgstr ""

View file

@ -0,0 +1,33 @@
defmodule Mv.Membership.Import.ImportRunnerTest do
use ExUnit.Case, async: true
alias Mv.Membership.Import.ImportRunner
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

@ -101,6 +101,29 @@ defmodule Mv.Membership.MembersPDFTest do
assert byte_size(pdf_binary) > 1000 assert byte_size(pdf_binary) > 1000
end 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 test "generates valid PDF with custom fields and computed fields" do
export_data = %{ export_data = %{
columns: [ columns: [

View file

@ -0,0 +1,35 @@
defmodule MvWeb.LiveUserAuthTest do
@moduledoc """
Regression tests for the `MvWeb.LiveUserAuth` on_mount guards:
the unauthenticated `:live_user_required` redirect to the sign-in page and
the authenticated `:live_no_user` redirect away from the sign-in page.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
describe ":live_user_required" do
@tag role: :unauthenticated
test "unauthenticated request to a protected route is redirected to sign-in", %{conn: conn} do
assert {:error, {:redirect, %{to: to}}} = live(conn, "/members")
assert to == "/sign-in"
end
@tag role: :admin
test "authenticated user can mount a protected route", %{conn: conn} do
assert {:ok, _view, _html} = live(conn, "/members")
end
end
describe ":live_no_user" do
@tag role: :admin
test "authenticated user visiting the sign-in page is redirected to root", %{conn: conn} do
assert {:error, {:redirect, %{to: "/"}}} = live(conn, "/sign-in")
end
@tag role: :unauthenticated
test "unauthenticated user can reach the sign-in page", %{conn: conn} do
assert {:ok, _view, _html} = live(conn, "/sign-in")
end
end
end

View file

@ -268,6 +268,28 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
# Should not crash # Should not crash
assert html =~ member.first_name assert html =~ member.first_name
end end
test "create_cycle with an unparseable date shows an error instead of crashing", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
view
|> element("button[phx-click='open_create_cycle_modal']")
|> render_click()
html =
view
|> element("form[phx-submit='create_cycle']")
|> render_submit(%{"date" => "not-a-date", "amount" => "10"})
assert html =~ "Invalid date format"
end
end end
describe "read_only user (Vorstand/Buchhaltung) - no cycle action buttons" do describe "read_only user (Vorstand/Buchhaltung) - no cycle action buttons" do