62 changed files with 589 additions and 619 deletions
11
.dialyzer_ignore.exs
Normal file
11
.dialyzer_ignore.exs
Normal 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
184
.drone.jsonnet
Normal 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',
|
||||
],
|
||||
}],
|
||||
},
|
||||
]
|
||||
298
.drone.yml
298
.drone.yml
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
15
Justfile
15
Justfile
|
|
@ -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 5–15 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
|
||||
|
|
|
|||
|
|
@ -17,16 +17,10 @@ defmodule Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist do
|
|||
form_data = Ash.Changeset.get_attribute(changeset, :form_data) || %{}
|
||||
|
||||
allowlist_ids =
|
||||
case Membership.get_join_form_allowlist() do
|
||||
list when is_list(list) ->
|
||||
list
|
||||
|> Enum.map(fn item -> item.id end)
|
||||
|> MapSet.new()
|
||||
|> MapSet.difference(MapSet.new(@typed_fields))
|
||||
|
||||
_ ->
|
||||
MapSet.new()
|
||||
end
|
||||
Membership.get_join_form_allowlist()
|
||||
|> Enum.map(fn item -> item.id end)
|
||||
|> MapSet.new()
|
||||
|> MapSet.difference(MapSet.new(@typed_fields))
|
||||
|
||||
filtered =
|
||||
form_data
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -37,9 +37,10 @@ defmodule Mv.Membership.Member.Changes.UnrelateUserWhenArgumentNil do
|
|||
{:ok, %{user: user}} when not is_nil(user) ->
|
||||
# User's :update action only accepts [:email]; use :update_user so
|
||||
# manage_relationship(:member, ..., on_missing: :unrelate) runs and clears member_id.
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts)
|
||||
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false)
|
||||
_ =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update_user, %{member: nil}, domain: Mv.Accounts)
|
||||
|> Ash.update(domain: Mv.Accounts, actor: actor, authorize?: false)
|
||||
|
||||
changeset
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -207,8 +207,6 @@ defmodule Mv.Config do
|
|||
end
|
||||
end
|
||||
|
||||
defp derive_app_url_from_api_url(_), do: nil
|
||||
|
||||
@doc """
|
||||
Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set).
|
||||
"""
|
||||
|
|
@ -251,7 +249,6 @@ defmodule Mv.Config do
|
|||
case System.get_env(key) do
|
||||
nil -> false
|
||||
v when is_binary(v) -> String.trim(v) != ""
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -270,9 +267,6 @@ defmodule Mv.Config do
|
|||
value when is_binary(value) ->
|
||||
v = String.trim(value) |> String.downcase()
|
||||
v in ["true", "1", "yes"]
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -328,7 +322,6 @@ defmodule Mv.Config do
|
|||
|
||||
defp present?(nil), do: false
|
||||
defp present?(s) when is_binary(s), do: String.trim(s) != ""
|
||||
defp present?(_), do: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OIDC authentication
|
||||
|
|
@ -409,7 +402,7 @@ defmodule Mv.Config do
|
|||
@doc """
|
||||
Returns the OIDC groups claim name (default "groups"). ENV first, then Settings.
|
||||
"""
|
||||
@spec oidc_groups_claim() :: String.t() | nil
|
||||
@spec oidc_groups_claim() :: String.t()
|
||||
def oidc_groups_claim do
|
||||
case env_or_setting("OIDC_GROUPS_CLAIM", :oidc_groups_claim) do
|
||||
nil -> "groups"
|
||||
|
|
@ -492,7 +485,7 @@ defmodule Mv.Config do
|
|||
- ENV-only mode (`SMTP_HOST` set): read from ENV `SMTP_PORT`
|
||||
- Settings mode: read from Settings only
|
||||
"""
|
||||
@spec smtp_port() :: non_neg_integer() | nil
|
||||
@spec smtp_port() :: pos_integer() | nil
|
||||
def smtp_port do
|
||||
if smtp_env_mode?() do
|
||||
parse_smtp_port_env(System.get_env("SMTP_PORT"))
|
||||
|
|
@ -638,9 +631,15 @@ defmodule Mv.Config do
|
|||
"""
|
||||
@spec mail_from_name() :: String.t()
|
||||
def mail_from_name do
|
||||
case System.get_env("MAIL_FROM_NAME") do
|
||||
nil -> get_from_settings(:smtp_from_name) || "Mila"
|
||||
value -> trim_nil(value) || "Mila"
|
||||
name =
|
||||
case System.get_env("MAIL_FROM_NAME") do
|
||||
nil -> get_from_settings(:smtp_from_name)
|
||||
value -> trim_nil(value)
|
||||
end
|
||||
|
||||
case name do
|
||||
nil -> "Mila"
|
||||
name -> name
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -225,7 +225,10 @@ defmodule Mv.Helpers.SystemActor do
|
|||
# This allows configuration via SYSTEM_ACTOR_EMAIL env var
|
||||
@spec system_user_email_config() :: String.t()
|
||||
defp system_user_email_config do
|
||||
System.get_env("SYSTEM_ACTOR_EMAIL") || "system@mila.local"
|
||||
case System.get_env("SYSTEM_ACTOR_EMAIL") do
|
||||
nil -> "system@mila.local"
|
||||
email -> email
|
||||
end
|
||||
end
|
||||
|
||||
# Loads the system actor from the database
|
||||
|
|
@ -257,7 +260,7 @@ defmodule Mv.Helpers.SystemActor do
|
|||
end
|
||||
|
||||
# Handles database error when loading system user
|
||||
@spec handle_system_user_error(term()) :: Mv.Accounts.User.t() | no_return()
|
||||
@spec handle_system_user_error({:error, Ash.Error.t()}) :: Mv.Accounts.User.t() | no_return()
|
||||
defp handle_system_user_error(error) do
|
||||
case load_admin_user_fallback() do
|
||||
{:ok, admin_user} ->
|
||||
|
|
@ -393,15 +396,18 @@ defmodule Mv.Helpers.SystemActor do
|
|||
# 1. Only creates system user with known email
|
||||
# 2. Only called during system actor initialization (bootstrap)
|
||||
# 3. Once created, all subsequent operations use proper authorization
|
||||
Accounts.create_user!(%{email: system_user_email_config()},
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email,
|
||||
authorize?: false
|
||||
)
|
||||
|> Ash.Changeset.for_update(:update_internal, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||
user =
|
||||
Accounts.create_user!(%{email: system_user_email_config()},
|
||||
upsert?: true,
|
||||
upsert_identity: :unique_email,
|
||||
authorize?: false
|
||||
)
|
||||
|> Ash.Changeset.for_update(:update_internal, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> Ash.load!(:role, domain: Mv.Accounts, authorize?: false)
|
||||
|
||||
%Accounts.User{} = user
|
||||
end
|
||||
|
||||
# Finds a user by email address
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) != ""))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) ||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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, ".")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ defmodule Mv.Release do
|
|||
require Logger
|
||||
|
||||
def migrate do
|
||||
load_app()
|
||||
_ = load_app()
|
||||
|
||||
for repo <- repos() do
|
||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
|
||||
|
|
@ -75,14 +75,14 @@ defmodule Mv.Release do
|
|||
dev_path = Path.join(priv, "repo/seeds_dev.exs")
|
||||
|
||||
prev = Code.compiler_options()
|
||||
Code.compiler_options(ignore_module_conflict: true)
|
||||
_ = Code.compiler_options(ignore_module_conflict: true)
|
||||
|
||||
try do
|
||||
Code.eval_file(bootstrap_path)
|
||||
_ = Code.eval_file(bootstrap_path)
|
||||
IO.puts("✅ Bootstrap seeds completed.")
|
||||
|
||||
if System.get_env("RUN_DEV_SEEDS") == "true" do
|
||||
Code.eval_file(dev_path)
|
||||
_ = Code.eval_file(dev_path)
|
||||
IO.puts("✅ Dev seeds completed.")
|
||||
end
|
||||
after
|
||||
|
|
@ -92,7 +92,7 @@ defmodule Mv.Release do
|
|||
end
|
||||
|
||||
def rollback(repo, version) do
|
||||
load_app()
|
||||
_ = load_app()
|
||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||
end
|
||||
|
||||
|
|
@ -139,10 +139,11 @@ defmodule Mv.Release do
|
|||
{:ok, %Role{} = admin_role} ->
|
||||
case get_user_by_email(email) do
|
||||
{:ok, %User{} = user} ->
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
_ =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
|
||||
:ok
|
||||
|
||||
|
|
@ -189,15 +190,16 @@ defmodule Mv.Release do
|
|||
defp create_admin_user(email, password, admin_role) do
|
||||
case Accounts.create_user(%{email: email}, authorize?: false) do
|
||||
{:ok, user} ->
|
||||
user
|
||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> then(fn u ->
|
||||
u
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
_ =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||
|> Ash.update!(authorize?: false)
|
||||
end)
|
||||
|> then(fn u ->
|
||||
u
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
end)
|
||||
|
||||
:ok
|
||||
|
||||
|
|
@ -207,15 +209,16 @@ defmodule Mv.Release do
|
|||
end
|
||||
|
||||
defp update_admin_user(user, password, admin_role) do
|
||||
user
|
||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||
|> Ash.update!(authorize?: false)
|
||||
|> then(fn u ->
|
||||
u
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
_ =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:admin_set_password, %{password: password})
|
||||
|> Ash.update!(authorize?: false)
|
||||
end)
|
||||
|> then(fn u ->
|
||||
u
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update!(authorize?: false)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -37,9 +37,10 @@ defmodule Mv.Vereinfacht.SyncFlash do
|
|||
def create_table! do
|
||||
# :public so any process can write (SyncContact runs in LiveView/Ash transaction process,
|
||||
# not the process that created the table). :protected would restrict writes to the creating process.
|
||||
if :ets.whereis(@table) == :undefined do
|
||||
:ets.new(@table, [:set, :public, :named_table])
|
||||
end
|
||||
_ =
|
||||
if :ets.whereis(@table) == :undefined do
|
||||
:ets.new(@table, [:set, :public, :named_table])
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -25,31 +25,33 @@ defmodule MvWeb.MemberExportController do
|
|||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
|
||||
def export(conn, params) do
|
||||
actor = current_actor(conn)
|
||||
if is_nil(actor), do: return_forbidden(conn)
|
||||
|
||||
case params["payload"] do
|
||||
nil ->
|
||||
conn
|
||||
|> put_status(400)
|
||||
|> put_resp_content_type("application/json")
|
||||
|> json(%{error: "payload required"})
|
||||
|
||||
payload when is_binary(payload) ->
|
||||
case Jason.decode(payload) do
|
||||
{:ok, decoded} when is_map(decoded) ->
|
||||
parsed = parse_and_validate(decoded)
|
||||
run_export(conn, actor, parsed)
|
||||
|
||||
_ ->
|
||||
conn
|
||||
|> put_status(400)
|
||||
|> put_resp_content_type("application/json")
|
||||
|> json(%{error: "invalid JSON"})
|
||||
end
|
||||
case current_actor(conn) do
|
||||
nil -> return_forbidden(conn)
|
||||
actor -> export_with_actor(conn, actor, params["payload"])
|
||||
end
|
||||
end
|
||||
|
||||
defp export_with_actor(conn, actor, payload) when is_binary(payload) do
|
||||
case Jason.decode(payload) do
|
||||
{:ok, decoded} when is_map(decoded) ->
|
||||
run_export(conn, actor, parse_and_validate(decoded))
|
||||
|
||||
_ ->
|
||||
json_error(conn, "invalid JSON")
|
||||
end
|
||||
end
|
||||
|
||||
defp export_with_actor(conn, _actor, _payload) do
|
||||
json_error(conn, "payload required")
|
||||
end
|
||||
|
||||
defp json_error(conn, message) do
|
||||
conn
|
||||
|> put_status(400)
|
||||
|> put_resp_content_type("application/json")
|
||||
|> json(%{error: message})
|
||||
end
|
||||
|
||||
defp current_actor(conn) do
|
||||
conn.assigns[:current_user]
|
||||
|> Actor.ensure_loaded()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,32 +266,33 @@ defmodule MvWeb.ImportLive do
|
|||
actor: actor
|
||||
]
|
||||
|
||||
if Config.sql_sandbox?() do
|
||||
run_chunk_with_locale(
|
||||
locale,
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
else
|
||||
Task.Supervisor.start_child(
|
||||
Mv.TaskSupervisor,
|
||||
fn ->
|
||||
run_chunk_with_locale(
|
||||
locale,
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
end
|
||||
)
|
||||
end
|
||||
_ =
|
||||
if Config.sql_sandbox?() do
|
||||
run_chunk_with_locale(
|
||||
locale,
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
else
|
||||
Task.Supervisor.start_child(
|
||||
Mv.TaskSupervisor,
|
||||
fn ->
|
||||
run_chunk_with_locale(
|
||||
locale,
|
||||
chunk,
|
||||
import_state.column_map,
|
||||
import_state.custom_field_map,
|
||||
opts,
|
||||
live_view_pid,
|
||||
idx
|
||||
)
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
|
@ -378,7 +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
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# -------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 """
|
||||
|
|
|
|||
|
|
@ -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"))}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
19
mix.exs
|
|
@ -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
|
||||
|
|
|
|||
2
mix.lock
2
mix.lock
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
33
test/mv/membership/import/import_runner_test.exs
Normal file
33
test/mv/membership/import/import_runner_test.exs
Normal 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
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
35
test/mv_web/live_user_auth_test.exs
Normal file
35
test/mv_web/live_user_auth_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue