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
.pipeline/
.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
# 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:
mix gettext.extract
mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete

View file

@ -17,17 +17,11 @@ 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
Membership.get_join_form_allowlist()
|> Enum.map(fn item -> item.id end)
|> MapSet.new()
|> MapSet.difference(MapSet.new(@typed_fields))
_ ->
MapSet.new()
end
filtered =
form_data
|> Enum.filter(fn {key, _} -> MapSet.member?(allowlist_ids, to_string(key)) end)

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,6 +37,7 @@ 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)

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
name =
case System.get_env("MAIL_FROM_NAME") do
nil -> get_from_settings(:smtp_from_name) || "Mila"
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

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,6 +396,7 @@ 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
user =
Accounts.create_user!(%{email: system_user_email_config()},
upsert?: true,
upsert_identity: :unique_email,
@ -402,6 +406,8 @@ defmodule Mv.Helpers.SystemActor do
|> 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

@ -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

@ -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

View file

@ -210,8 +210,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

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,6 +139,7 @@ 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)
@ -189,6 +190,7 @@ 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)
@ -207,6 +209,7 @@ 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)

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,6 +37,7 @@ 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

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

@ -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

@ -25,29 +25,31 @@ 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 current_actor(conn) do
nil -> return_forbidden(conn)
actor -> export_with_actor(conn, actor, params["payload"])
end
end
case params["payload"] do
nil ->
conn
|> put_status(400)
|> put_resp_content_type("application/json")
|> json(%{error: "payload required"})
payload when is_binary(payload) ->
defp export_with_actor(conn, actor, payload) when is_binary(payload) do
case Jason.decode(payload) do
{:ok, decoded} when is_map(decoded) ->
parsed = parse_and_validate(decoded)
run_export(conn, actor, parsed)
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: "invalid JSON"})
end
end
|> json(%{error: message})
end
defp current_actor(conn) do

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

@ -935,7 +935,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

@ -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 =
@ -193,16 +190,6 @@ 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
@ -223,64 +210,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
@ -337,6 +266,7 @@ defmodule MvWeb.ImportLive do
actor: actor
]
_ =
if Config.sql_sandbox?() do
run_chunk_with_locale(
locale,
@ -378,7 +308,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

View file

@ -287,8 +287,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

@ -1218,8 +1218,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 +1295,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 +1554,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 +1580,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 +1711,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
# -------------------------------------------------------------

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

@ -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

@ -188,7 +188,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")

19
mix.exs
View file

@ -12,6 +12,7 @@ defmodule Mv.MixProject do
compilers: [:phoenix_live_view] ++ Mix.compilers(),
aliases: aliases(),
deps: deps(),
dialyzer: dialyzer(),
listeners: [Phoenix.CodeReloader],
gettext: [write_reference_line_numbers: false]
]
@ -80,6 +81,7 @@ defmodule Mv.MixProject do
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
{:bypass, "~> 2.1", only: [:dev, :test]},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:picosat_elixir, "~> 0.1"},
{:ecto_commons, "~> 0.3"},
{:slugify, "~> 1.3"},
@ -112,4 +114,21 @@ defmodule Mv.MixProject do
"phx.routes": ["phx.routes", "ash_authentication.phoenix.routes"]
]
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

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"},
"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"},
"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"},
"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"},
"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"},
"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"},

View file

@ -2208,11 +2208,6 @@ msgstr "Keine Mitglieder in dieser Gruppe"
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."
@ -3972,3 +3967,8 @@ msgstr "Zeitraum"
#, elixir-autogen, elixir-format
msgid "To"
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"
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."

View file

@ -2209,11 +2209,6 @@ msgstr ""
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."
@ -3972,3 +3967,8 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "To"
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
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,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
assert html =~ member.first_name
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
describe "read_only user (Vorstand/Buchhaltung) - no cycle action buttons" do