Compare commits

..

31 commits
1.3.0 ... main

Author SHA1 Message Date
a629bfb617 Merge pull request 'fix existing flakiness + cut runtime closes #533' (#544) from issue/mitgliederverwaltung-533 into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #544
2026-06-16 18:30:14 +02:00
84e1cf1cb8 Merge branch 'main' into issue/mitgliederverwaltung-533
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
# Conflicts:
#	test/mv_web/member_live/index_test.exs
2026-06-16 18:13:03 +02:00
6d7ece20a8 docs(changelog): record member-creation deadlock fix under Unreleased 2026-06-16 17:55:24 +02:00
3d792e8b04 docs(testing): document create_member deadlock fix and async-test-safety 2026-06-16 17:54:32 +02:00
c0f40a13ce test(member-live): keep deadlock-prone member tests synchronous
These member/group/custom-field LiveView tests stay async: false. With the
foreign keys now deferrable the create_member deadlock no longer forces it, so
the rationale is updated: they remain synchronous as a deferred scope decision,
and index_groups_url_params/member_filter_component additionally have separate
async-isolation issues that must be fixed before they can run in parallel.
2026-06-16 17:53:59 +02:00
5e84c342b7 test(repo): assert member/user foreign keys are deferrable 2026-06-16 17:53:25 +02:00
ef94d2ef10 fix(repo): make member/user foreign keys deferrable to avoid create_member deadlock
Concurrent create_member transactions took FK FOR KEY SHARE (MultiXact) locks
on shared rows across members/users/membership_fee_types and could form a
cross-transaction cycle, producing intermittent PostgreSQL deadlocks (40P01)
under load. Making the three foreign keys DEFERRABLE INITIALLY DEFERRED moves
the check to commit time and breaks the cycle, without weakening integrity
(NOT NULL and ON DELETE RESTRICT are unaffected).
2026-06-16 17:52:51 +02:00
cb54c2c46e test(member-live): build date-filter property bounds without a reject-filter
The bound-pair generator filtered out ~1/4 of generated values, so unlucky
seeds hit StreamData's FilterTooNarrowError under full property runs.
Construct an at-least-one-bound-set pair directly instead, preserving the
exact domain with no rejection.
2026-06-16 17:52:17 +02:00
655fd80524 test: wait on observable state instead of blind sleeps
Replace the fixed Process.sleep waits in the import, members-PDF and
field-visibility tests with event-based / bounded-poll waits on the
observable condition, removing a known flakiness vector.
2026-06-16 17:51:43 +02:00
ccd1f81e3e test(member-live): assert rendered behavior instead of socket internals in the index view
Replace :sys.get_state assertions on the LiveView socket with assertions on
rendered output, so the tests pin user-visible behavior rather than internal
state; the few sites with no observable equivalent are kept and annotated.
2026-06-16 17:50:57 +02:00
3bd55fbfec test(seeds): drop the dead per-process seeds-run guard 2026-06-16 17:50:24 +02:00
18fb954f73 test(membership-fees): share create_fee_type and create_cycle fixtures
Replace the create_fee_type/create_cycle helpers duplicated across 18/8
membership-fee test files with a single shared definition in Mv.Fixtures,
reconciling the divergent local signatures (including the reversed
argument order) into one superset so behavior is unchanged.
2026-06-16 17:49:50 +02:00
82effde6a1 Merge pull request 'Mechanical cleanup, quick fixes & deduplication closes #531' (#543) from issue/mitgliederverwaltung-531 into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #543
2026-06-16 16:06:52 +02:00
4f3050cc35 docs(changelog): record cleanup and quick fixes under Unreleased
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
2026-06-16 15:51:07 +02:00
a12fedcb5b ci(credo): enforce StrictModuleLayout and additional zero-violation checks 2026-06-16 15:51:07 +02:00
fe534319ee style: order module directives per StrictModuleLayout 2026-06-16 15:51:07 +02:00
3f073d4365 refactor(membership-fees): share fee-type delete handling between LiveViews 2026-06-16 15:51:07 +02:00
18bf4dab2b refactor(web): use canonical DateFormatter for all date display 2026-06-16 15:51:07 +02:00
ea105186a5 refactor(vereinfacht): reuse EmailSync.Loader for linked-member lookup 2026-06-16 15:51:07 +02:00
0cf27c95ca refactor(membership-fees): fold cycle-generation run/0 into run/1 2026-06-16 15:51:07 +02:00
ef70dd2935 refactor(settings): unify JSONB single-field update between member-field changes 2026-06-16 15:51:07 +02:00
e66fb5d3d9 refactor(email): share build/deliver skeleton across join emails 2026-06-16 15:51:07 +02:00
1adf6aa664 refactor(web): extract shared current_actor controller helper 2026-06-16 15:51:07 +02:00
561779e704 refactor(web): share member-dropdown keyboard navigation between LiveViews 2026-06-16 15:51:07 +02:00
164826d3aa refactor(authorization): unify own_data read check across linked resources 2026-06-16 15:51:07 +02:00
924dbd3bb8 refactor(oidc): drop OidcRoleSyncConfig passthrough and use Mv.Config directly 2026-06-16 15:51:07 +02:00
c4a695329c refactor(member-export): remove dead fetch/2 export chain 2026-06-16 15:51:07 +02:00
a9932776cc chore(accounts): remove orphaned UserIdentity resource file 2026-06-16 15:51:07 +02:00
2a3a152b13 perf(member): drop per-render timing log on the member-list hot path 2026-06-16 15:51:07 +02:00
7f9d9646a5 fix(auth): boot AshAuthentication children under the :mv otp_app 2026-06-16 15:51:07 +02:00
39df300735 Merge pull request 'release v1.3.0' (#542) from release-1.3.0 into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #542
2026-06-16 10:47:10 +02:00
141 changed files with 1424 additions and 1877 deletions

View file

@ -114,6 +114,7 @@
{Credo.Check.Readability.RedundantBlankLines, []}, {Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []}, {Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []}, {Credo.Check.Readability.SpaceAfterCommas, []},
{Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Readability.StringSigils, []}, {Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []}, {Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []}, {Credo.Check.Readability.TrailingWhiteSpace, []},
@ -166,13 +167,19 @@
{Credo.Check.Warning.UnusedTupleOperation, []}, {Credo.Check.Warning.UnusedTupleOperation, []},
{Credo.Check.Warning.WrongTestFileExtension, []}, {Credo.Check.Warning.WrongTestFileExtension, []},
# Module documentation check (enabled after adding @moduledoc to all modules) # Module documentation check (enabled after adding @moduledoc to all modules)
{Credo.Check.Readability.ModuleDoc, []} {Credo.Check.Readability.ModuleDoc, []},
# Promoted in the cleanup ratchet (each currently at zero violations):
{Credo.Check.Refactor.DoubleBooleanNegation, []},
{Credo.Check.Refactor.FilterReject, []},
{Credo.Check.Refactor.RejectFilter, []},
{Credo.Check.Refactor.UtcNowTruncate, []},
{Credo.Check.Warning.LazyLogging, []},
{Credo.Check.Warning.LeakyEnvironment, []},
{Credo.Check.Warning.MapGetUnsafePass, []},
{Credo.Check.Warning.MixEnv, []}
], ],
disabled: [ disabled: [
#
# Checks scheduled for next check update (opt-in for now)
{Credo.Check.Refactor.UtcNowTruncate, []},
# #
# Controversial and experimental checks (opt-in, just move the check to `:enabled` # Controversial and experimental checks (opt-in, just move the check to `:enabled`
# and be sure to use `mix credo --strict` to see low priority checks) # and be sure to use `mix credo --strict` to see low priority checks)
@ -183,6 +190,7 @@
{Credo.Check.Design.SkipTestWithoutComment, []}, {Credo.Check.Design.SkipTestWithoutComment, []},
{Credo.Check.Readability.AliasAs, []}, {Credo.Check.Readability.AliasAs, []},
{Credo.Check.Readability.BlockPipe, []}, {Credo.Check.Readability.BlockPipe, []},
# ImplTrue: ~269 violations; deferred to a follow-up.
{Credo.Check.Readability.ImplTrue, []}, {Credo.Check.Readability.ImplTrue, []},
{Credo.Check.Readability.MultiAlias, []}, {Credo.Check.Readability.MultiAlias, []},
{Credo.Check.Readability.NestedFunctionCalls, []}, {Credo.Check.Readability.NestedFunctionCalls, []},
@ -192,24 +200,20 @@
{Credo.Check.Readability.SingleFunctionToBlockPipe, []}, {Credo.Check.Readability.SingleFunctionToBlockPipe, []},
{Credo.Check.Readability.SinglePipe, []}, {Credo.Check.Readability.SinglePipe, []},
{Credo.Check.Readability.Specs, []}, {Credo.Check.Readability.Specs, []},
{Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Readability.WithCustomTaggedTuple, []}, {Credo.Check.Readability.WithCustomTaggedTuple, []},
{Credo.Check.Refactor.ABCSize, []}, {Credo.Check.Refactor.ABCSize, []},
# AppendSingleItem: ~10 violations (mostly tests); deferred to a follow-up.
{Credo.Check.Refactor.AppendSingleItem, []}, {Credo.Check.Refactor.AppendSingleItem, []},
{Credo.Check.Refactor.DoubleBooleanNegation, []}, # IoPuts: 3 violations in Mv.Release seed output; deferred to a follow-up.
{Credo.Check.Refactor.FilterReject, []},
{Credo.Check.Refactor.IoPuts, []}, {Credo.Check.Refactor.IoPuts, []},
# MapMap: ~8 violations; deferred to a follow-up.
{Credo.Check.Refactor.MapMap, []}, {Credo.Check.Refactor.MapMap, []},
{Credo.Check.Refactor.ModuleDependencies, []}, {Credo.Check.Refactor.ModuleDependencies, []},
# NegatedIsNil: ~63 violations; deferred to a follow-up.
{Credo.Check.Refactor.NegatedIsNil, []}, {Credo.Check.Refactor.NegatedIsNil, []},
{Credo.Check.Refactor.PassAsyncInTestCases, []}, {Credo.Check.Refactor.PassAsyncInTestCases, []},
{Credo.Check.Refactor.PipeChainStart, []}, {Credo.Check.Refactor.PipeChainStart, []},
{Credo.Check.Refactor.RejectFilter, []},
{Credo.Check.Refactor.VariableRebinding, []}, {Credo.Check.Refactor.VariableRebinding, []},
{Credo.Check.Warning.LazyLogging, []},
{Credo.Check.Warning.LeakyEnvironment, []},
{Credo.Check.Warning.MapGetUnsafePass, []},
{Credo.Check.Warning.MixEnv, []},
{Credo.Check.Warning.UnsafeToAtom, []} {Credo.Check.Warning.UnsafeToAtom, []}
# {Credo.Check.Refactor.MapInto, []}, # {Credo.Check.Refactor.MapInto, []},

View file

@ -24,11 +24,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Default GDPR custom field** The seeded GDPR field was shortened from "Datenschutzerklärung akzeptiert" to "DSGVO" and now ships with a default join-form description (with a placeholder link to replace). - **Default GDPR custom field** The seeded GDPR field was shortened from "Datenschutzerklärung akzeptiert" to "DSGVO" and now ships with a default join-form description (with a placeholder link to replace).
### Fixed ### Fixed
- **Authentication background workers start correctly** The token-cleanup (Expunger) and audit-log batching workers now boot under the application's real configuration instead of an unused OTP app, so they run as intended in production rather than silently doing nothing.
- **CSV date round-trip** Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors. - **CSV date round-trip** Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors.
- **CSV import fee-status columns ignored** Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists. - **CSV import fee-status columns ignored** Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists.
- **Column-header tooltips clipped** Tooltips on the members-overview column headers are no longer clipped by the sticky table header. - **Column-header tooltips clipped** Tooltips on the members-overview column headers are no longer clipped by the sticky table header.
- **Text selection opens member** Dragging to select text in a members-overview row (for example to copy an email) no longer opens the member details; a plain click still opens them. - **Text selection opens member** Dragging to select text in a members-overview row (for example to copy an email) no longer opens the member details; a plain click still opens them.
- **Sort by custom date** Sorting the member list or member export by a custom date field now orders rows chronologically instead of like text, so e.g. 29.01.1981 correctly comes before 01.03.1982. - **Sort by custom date** Sorting the member list or member export by a custom date field now orders rows chronologically instead of like text, so e.g. 29.01.1981 correctly comes before 01.03.1982.
- **Concurrent member creation no longer deadlocks** Creating members in parallel (e.g. simultaneous sign-ups, or batch operations) could intermittently fail with a PostgreSQL deadlock; the affected foreign keys are now deferrable, so concurrent member creation succeeds reliably.
## [1.2.0] - 2026-05-08 ## [1.2.0] - 2026-05-08

View file

@ -34,7 +34,7 @@
- `OIDC_ADMIN_GROUP_NAME` OIDC group name that maps to the Admin role. If unset, no role sync. - `OIDC_ADMIN_GROUP_NAME` OIDC group name that maps to the Admin role. If unset, no role sync.
- `OIDC_GROUPS_CLAIM` JWT claim name for group list (default "groups"). - `OIDC_GROUPS_CLAIM` JWT claim name for group list (default "groups").
- Module: Mv.OidcRoleSyncConfig (oidc_admin_group_name/0, oidc_groups_claim/0). - Module: Mv.Config (oidc_admin_group_name/0, oidc_groups_claim/0).
### Sign-in page (OIDC-only mode) ### Sign-in page (OIDC-only mode)

View file

@ -90,6 +90,29 @@ test/
--- ---
## 5. Concurrent `create_member` deadlock and deferrable FKs
A class of intermittent failures (PostgreSQL `deadlock_detected`, SQLSTATE `40P01`) was traced to **concurrent `create_member` transactions**, not to any single test. It surfaced as a `MatchError` on `{:ok, member} = ...` in member-heavy LiveView tests (e.g. `FormMemberSelectionTest`) and reproduced only under CPU contention (≈1 in 12 full-fast-suite runs at high `async: true` concurrency; effectively never on an idle machine).
**Root cause.** `create_member` writes a cascade in one transaction (member row, `custom_field_values`, the `user` link, fee-type defaulting, cycle generation). Concurrent inserts take FK `FOR KEY SHARE` (MultiXact) locks on shared parent rows across `members` / `users` / `membership_fee_types`; under contention these can form a cross-transaction lock cycle that Postgres resolves by aborting one transaction. It is a product-level concurrency property, **not** test-data contention, so it is not fixable by test-state isolation.
**Fix.** Migration `…_make_member_user_fks_deferrable.exs` makes the three FKs (`users.member_id`, `users.role_id`, `members.membership_fee_type_id`) `DEFERRABLE INITIALLY DEFERRED`, moving the FK check (and its lock) to commit time and breaking the cycle. Verified: **0 deadlocks in 15 full-suite runs under maximum CPU contention**, versus 1/12 before. This does **not** weaken integrity — `NOT NULL` is independent of FK deferral, a real dangling reference still aborts the commit, and `ON DELETE RESTRICT` (e.g. `users.role_id`) stays immediate regardless of deferrability. `Mv.DeferrableFkTest` asserts the constraint state as a regression guard (a deterministic in-process concurrent reproduction is infeasible under the Ecto sandbox, which serializes connections by ownership).
This deadlock is also a latent **production** risk under concurrent sign-ups; the deferrable-FK fix addresses both.
### Async-test-safety checklist (members/groups/custom fields)
Several member-creating test files historically used `async: false` with a "prevent PostgreSQL deadlocks" comment. With the deferrable-FK migration in place those files are deadlock-safe, but before flipping any such file to `async: true`:
- **Prove isolation under load, not just one green run.** Re-run the file (and the full suite) under varying `--seed` **and** CPU contention; a single green run is not evidence (the deadlock and the isolation flakes below are load-dependent).
- **Watch for separate async-isolation issues beyond the deadlock.** `index_groups_url_params_test.exs` and `member_filter_component_test.exs` showed filtered-member-leak failures (`refute html =~ name`) under concurrency that are independent of the FK deadlock — these need their own per-file isolation fix before they can run async.
### StreamData generator pitfall
`FilterTooNarrowError` appeared on unlucky seeds (e.g. 222) in a property test that built a value with a reject-filter (`StreamData.filter` discarding ~1/4 of generated pairs). Under full property-run counts this hits too many consecutive rejections. Fix: **construct the desired value directly** instead of generating-then-filtering (preserves the exact domain, no rejection). Prefer constructive generators over reject-filters in property tests.
---
## References ## References
- Testing Standards: `CODE_GUIDELINES.md` §4 - Testing Standards: `CODE_GUIDELINES.md` §4

View file

@ -8,7 +8,6 @@ defmodule Mv.Accounts.User do
extensions: [AshAuthentication], extensions: [AshAuthentication],
authorizers: [Ash.Policy.Authorizer] authorizers: [Ash.Policy.Authorizer]
require Ash.Query
import Ash.Expr import Ash.Expr
alias Ash.Resource.Preparation.Builtins alias Ash.Resource.Preparation.Builtins
@ -16,6 +15,8 @@ defmodule Mv.Accounts.User do
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor
alias Mv.OidcRoleSync alias Mv.OidcRoleSync
require Ash.Query
postgres do postgres do
table "users" table "users"
repo Mv.Repo repo Mv.Repo

View file

@ -24,12 +24,13 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
- Allow (new user will be created) - Allow (new user will be created)
""" """
use Ash.Resource.Validation use Ash.Resource.Validation
require Logger
alias Mv.Accounts.User alias Mv.Accounts.User
alias Mv.Accounts.User.Errors.PasswordVerificationRequired alias Mv.Accounts.User.Errors.PasswordVerificationRequired
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor
require Logger
@impl true @impl true
def init(opts), do: {:ok, opts} def init(opts), do: {:ok, opts}

View file

@ -1,18 +0,0 @@
defmodule Mv.Accounts.UserIdentity do
@moduledoc """
AshAuthentication specific ressource
"""
use Ash.Resource,
data_layer: AshPostgres.DataLayer,
extensions: [AshAuthentication.UserIdentity],
domain: Mv.Accounts
postgres do
table "user_identities"
repo Mv.Repo
end
user_identity do
user_resource Mv.Accounts.User
end
end

View file

@ -38,6 +38,10 @@ defmodule Mv.Membership.Email do
@min_length 5 @min_length 5
@max_length 254 @max_length 254
# These compile-time constants are referenced by the `use` options below, so
# they must be declared first; StrictModuleLayout cannot be satisfied by
# reordering here without breaking the macro expansion.
# credo:disable-for-next-line Credo.Check.Readability.StrictModuleLayout
use Ash.Type.NewType, use Ash.Type.NewType,
subtype_of: :string, subtype_of: :string,
constraints: [ constraints: [

View file

@ -39,9 +39,10 @@ defmodule Mv.Membership.Group do
data_layer: AshPostgres.DataLayer, data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer] authorizers: [Ash.Policy.Authorizer]
require Ash.Query
alias Mv.Helpers alias Mv.Helpers
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor
require Ash.Query
require Logger require Logger
postgres do postgres do

View file

@ -37,9 +37,9 @@ defmodule Mv.Membership.Member do
data_layer: AshPostgres.DataLayer, data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer] authorizers: [Ash.Policy.Authorizer]
import Bitwise
require Ash.Query
import Ash.Expr import Ash.Expr
import Bitwise
alias Ecto.Adapters.SQL, as: EctoSQL alias Ecto.Adapters.SQL, as: EctoSQL
alias Mv.Helpers alias Mv.Helpers
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor
@ -49,6 +49,7 @@ defmodule Mv.Membership.Member do
alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.Repo alias Mv.Repo
require Ash.Query
require Logger require Logger
@typedoc "An `Mv.Membership.Member` resource record." @typedoc "An `Mv.Membership.Member` resource record."

View file

@ -63,7 +63,7 @@ defmodule Mv.Membership.MemberGroup do
policies do policies do
bypass action_type(:read) do bypass action_type(:read) do
description "own_data: read only member_groups where member_id == actor.member_id" description "own_data: read only member_groups where member_id == actor.member_id"
authorize_if Mv.Authorization.Checks.MemberGroupReadLinkedForOwnData authorize_if {Mv.Authorization.Checks.ReadLinkedForOwnData, member_id_field: :member_id}
end end
policy action_type(:read) do policy action_type(:read) do

View file

@ -26,13 +26,15 @@ defmodule Mv.Membership do
use Ash.Domain, use Ash.Domain,
extensions: [AshAdmin.Domain, AshPhoenix] extensions: [AshAdmin.Domain, AshPhoenix]
require Ash.Query
import Ash.Expr import Ash.Expr
alias Ash.Error.Query.NotFound, as: NotFoundError alias Ash.Error.Query.NotFound, as: NotFoundError
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor
alias Mv.Membership.JoinRequest alias Mv.Membership.JoinRequest
alias Mv.Membership.Member alias Mv.Membership.Member
alias Mv.Membership.SettingsCache alias Mv.Membership.SettingsCache
require Ash.Query
require Logger require Logger
admin do admin do

View file

@ -65,12 +65,12 @@ defmodule Mv.Membership.Setting do
data_layer: AshPostgres.DataLayer, data_layer: AshPostgres.DataLayer,
primary_read_warning?: false primary_read_warning?: false
alias Ash.Resource.Info, as: ResourceInfo
# Used in join_form_field_ids validation (compile-time to avoid recompiling regex and list on every validation) # Used in join_form_field_ids validation (compile-time to avoid recompiling regex and list on every validation)
@uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i @uuid_pattern ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) @valid_join_form_member_fields Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
alias Ash.Resource.Info, as: ResourceInfo
postgres do postgres do
table "settings" table "settings"
repo Mv.Repo repo Mv.Repo

View file

@ -0,0 +1,98 @@
defmodule Mv.Membership.Setting.Changes.JsonbResult do
@moduledoc """
Shared normalization for the JSONB column values returned by the atomic
single-member-field settings updates.
PostgreSQL may return a JSONB column as an already-decoded map (atom or string
keys) or as a raw JSON string depending on the driver path. This helper
normalizes either form to a string-keyed map, returning `%{}` for unexpected
or undecodable input.
"""
alias Ash.Error.Invalid
alias Ecto.Adapters.SQL
require Logger
@doc """
Runs an atomic single-statement JSONB settings UPDATE inside an after_action
and maps the RETURNING row back onto the settings struct.
Shared by the single-member-field change modules, which differ only in the
SQL statement, its parameters, the row-to-settings mapping, and the error
labels. The three-branch result handling (row found / not found / SQL error)
is identical and lives here.
Options:
- `:sql` - The UPDATE statement with a RETURNING clause (required).
- `:params` - The full parameter list for the statement (required).
- `:on_row` - 1-arity function mapping the RETURNING row (a list of column
values) to the updated settings struct (required).
- `:error_field` - Ash error field for the not-found / failure errors.
- `:not_found_message` - Error message when no row matched.
- `:error_message` - Error message when the SQL statement failed.
- `:log_message` - Log prefix written on SQL failure.
"""
@spec run_update(keyword()) ::
{:ok, map()} | {:error, Exception.t()}
def run_update(opts) do
sql = Keyword.fetch!(opts, :sql)
params = Keyword.fetch!(opts, :params)
on_row = Keyword.fetch!(opts, :on_row)
error_field = Keyword.fetch!(opts, :error_field)
not_found_message = Keyword.fetch!(opts, :not_found_message)
error_message = Keyword.fetch!(opts, :error_message)
log_message = Keyword.fetch!(opts, :log_message)
case SQL.query(Mv.Repo, sql, params) do
{:ok, %{rows: [row | _]}} ->
{:ok, on_row.(row)}
{:ok, %{rows: []}} ->
{:error,
Invalid.exception(
field: error_field,
message: not_found_message
)}
{:error, error} ->
Logger.error("#{log_message}: #{inspect(error)}")
{:error,
Invalid.exception(
field: error_field,
message: error_message
)}
end
end
@doc """
Normalizes a JSONB column value to a string-keyed map.
"""
@spec normalize(map() | binary() | any()) :: map()
def normalize(updated_jsonb) do
case updated_jsonb do
map when is_map(map) ->
Enum.reduce(map, %{}, fn
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
{k, v}, acc -> Map.put(acc, k, v)
end)
binary when is_binary(binary) ->
case Jason.decode(binary) do
{:ok, decoded} when is_map(decoded) ->
decoded
{:ok, _} ->
%{}
{:error, reason} ->
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
%{}
end
_ ->
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
%{}
end
end
end

View file

@ -19,9 +19,7 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do
""" """
use Ash.Resource.Change use Ash.Resource.Change
alias Ash.Error.Invalid alias Mv.Membership.Setting.Changes.JsonbResult
alias Ecto.Adapters.SQL
require Logger
def change(changeset, _opts, _context) do def change(changeset, _opts, _context) do
with {:ok, field} <- get_and_validate_field(changeset), with {:ok, field} <- get_and_validate_field(changeset),
@ -118,62 +116,21 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do
uuid_binary = Ecto.UUID.dump!(settings.id) uuid_binary = Ecto.UUID.dump!(settings.id)
case SQL.query(Mv.Repo, sql, [field, show_in_overview, required, uuid_binary]) do JsonbResult.run_update(
{:ok, %{rows: [[updated_visibility, updated_required] | _]}} -> sql: sql,
vis = normalize_jsonb_result(updated_visibility) params: [field, show_in_overview, required, uuid_binary],
req = normalize_jsonb_result(updated_required) on_row: fn [updated_visibility, updated_required | _] ->
%{
updated_settings = %{
settings settings
| member_field_visibility: vis, | member_field_visibility: JsonbResult.normalize(updated_visibility),
member_field_required: req member_field_required: JsonbResult.normalize(updated_required)
} }
end,
{:ok, updated_settings} error_field: :member_field_required,
not_found_message: "Settings not found",
{:ok, %{rows: []}} -> error_message: "Failed to update member field settings",
{:error, log_message: "Failed to atomically update member field settings"
Invalid.exception( )
field: :member_field_required,
message: "Settings not found"
)}
{:error, error} ->
Logger.error("Failed to atomically update member field settings: #{inspect(error)}")
{:error,
Invalid.exception(
field: :member_field_required,
message: "Failed to update member field settings"
)}
end
end) end)
end end
defp normalize_jsonb_result(updated_jsonb) do
case updated_jsonb do
map when is_map(map) ->
Enum.reduce(map, %{}, fn
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
{k, v}, acc -> Map.put(acc, k, v)
end)
binary when is_binary(binary) ->
case Jason.decode(binary) do
{:ok, decoded} when is_map(decoded) ->
decoded
{:ok, _} ->
%{}
{:error, reason} ->
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
%{}
end
_ ->
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
%{}
end
end
end end

View file

@ -19,9 +19,7 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
""" """
use Ash.Resource.Change use Ash.Resource.Change
alias Ash.Error.Invalid alias Mv.Membership.Setting.Changes.JsonbResult
alias Ecto.Adapters.SQL
require Logger
def change(changeset, _opts, _context) do def change(changeset, _opts, _context) do
with {:ok, field} <- get_and_validate_field(changeset), with {:ok, field} <- get_and_validate_field(changeset),
@ -106,59 +104,17 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
# Convert UUID string to binary for PostgreSQL # Convert UUID string to binary for PostgreSQL
uuid_binary = Ecto.UUID.dump!(settings.id) uuid_binary = Ecto.UUID.dump!(settings.id)
case SQL.query(Mv.Repo, sql, [field, show_in_overview, uuid_binary]) do JsonbResult.run_update(
{:ok, %{rows: [[updated_jsonb] | _]}} -> sql: sql,
updated_visibility = normalize_jsonb_result(updated_jsonb) params: [field, show_in_overview, uuid_binary],
on_row: fn [updated_jsonb | _] ->
# Update the settings struct with the new visibility %{settings | member_field_visibility: JsonbResult.normalize(updated_jsonb)}
updated_settings = %{settings | member_field_visibility: updated_visibility} end,
{:ok, updated_settings} error_field: :member_field_visibility,
not_found_message: "Settings not found",
{:ok, %{rows: []}} -> error_message: "Failed to update visibility",
{:error, log_message: "Failed to atomically update member_field_visibility"
Invalid.exception( )
field: :member_field_visibility,
message: "Settings not found"
)}
{:error, error} ->
Logger.error("Failed to atomically update member_field_visibility: #{inspect(error)}")
{:error,
Invalid.exception(
field: :member_field_visibility,
message: "Failed to update visibility"
)}
end
end) end)
end end
defp normalize_jsonb_result(updated_jsonb) do
case updated_jsonb do
map when is_map(map) ->
# Convert atom keys to strings if needed
Enum.reduce(map, %{}, fn
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
{k, v}, acc -> Map.put(acc, k, v)
end)
binary when is_binary(binary) ->
case Jason.decode(binary) do
{:ok, decoded} when is_map(decoded) ->
decoded
# Not a map after decode
{:ok, _} ->
%{}
{:error, reason} ->
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
%{}
end
_ ->
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
%{}
end
end
end end

View file

@ -88,7 +88,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
policies do policies do
bypass action_type(:read) do bypass action_type(:read) do
description "own_data: read only cycles where member_id == actor.member_id" description "own_data: read only cycles where member_id == actor.member_id"
authorize_if Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData authorize_if {Mv.Authorization.Checks.ReadLinkedForOwnData, member_id_field: :member_id}
end end
policy action_type([:read, :create, :update, :destroy]) do policy action_type([:read, :create, :update, :destroy]) do

View file

@ -1,4 +1,5 @@
defmodule Mix.Tasks.JoinRequests.CleanupExpired do defmodule Mix.Tasks.JoinRequests.CleanupExpired do
@shortdoc "Deletes join requests in pending_confirmation with expired confirmation token"
@moduledoc """ @moduledoc """
Hard-deletes JoinRequests in status `pending_confirmation` whose confirmation link has expired. Hard-deletes JoinRequests in status `pending_confirmation` whose confirmation link has expired.
@ -16,12 +17,10 @@ defmodule Mix.Tasks.JoinRequests.CleanupExpired do
""" """
use Mix.Task use Mix.Task
require Ash.Query
require Logger
alias Mv.Membership.JoinRequest alias Mv.Membership.JoinRequest
@shortdoc "Deletes join requests in pending_confirmation with expired confirmation token" require Ash.Query
require Logger
@impl Mix.Task @impl Mix.Task
def run(_args) do def run(_args) do

View file

@ -13,13 +13,14 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
layout: {MvWeb.EmailLayoutView, "layout.html"} layout: {MvWeb.EmailLayoutView, "layout.html"}
use MvWeb, :verified_routes use MvWeb, :verified_routes
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv use Gettext, backend: MvWeb.Gettext, otp_app: :mv
require Logger import Swoosh.Email
alias Mv.Mailer alias Mv.Mailer
require Logger
@doc """ @doc """
Sends a confirmation email to a new user. Sends a confirmation email to a new user.

View file

@ -13,13 +13,14 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
layout: {MvWeb.EmailLayoutView, "layout.html"} layout: {MvWeb.EmailLayoutView, "layout.html"}
use MvWeb, :verified_routes use MvWeb, :verified_routes
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv use Gettext, backend: MvWeb.Gettext, otp_app: :mv
require Logger import Swoosh.Email
alias Mv.Mailer alias Mv.Mailer
require Logger
@doc """ @doc """
Sends a password reset email to a user. Sends a password reset email to a user.

View file

@ -32,7 +32,7 @@ defmodule Mv.Application do
{Task.Supervisor, name: Mv.TaskSupervisor}, {Task.Supervisor, name: Mv.TaskSupervisor},
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore}, {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Mv.PubSub}, {Phoenix.PubSub, name: Mv.PubSub},
{AshAuthentication.Supervisor, otp_app: :my}, {AshAuthentication.Supervisor, otp_app: :mv},
SystemActor, SystemActor,
# Start a worker by calling: Mv.Worker.start_link(arg) # Start a worker by calling: Mv.Worker.start_link(arg)
# {Mv.Worker, arg}, # {Mv.Worker, arg},

View file

@ -49,10 +49,10 @@ defmodule Mv.Authorization.Actor do
adds complexity and potential for inconsistency. adds complexity and potential for inconsistency.
""" """
require Logger
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor
require Logger
@doc """ @doc """
Ensures the actor (User) has their `:role` relationship loaded. Ensures the actor (User) has their `:role` relationship loaded.

View file

@ -79,10 +79,13 @@ defmodule Mv.Authorization.Checks.HasPermission do
""" """
use Ash.Policy.Check use Ash.Policy.Check
require Ash.Query
import Ash.Expr import Ash.Expr
alias Mv.Authorization.Actor alias Mv.Authorization.Actor
alias Mv.Authorization.PermissionSets alias Mv.Authorization.PermissionSets
require Ash.Query
require Logger require Logger
@impl true @impl true

View file

@ -1,63 +0,0 @@
defmodule Mv.Authorization.Checks.MemberGroupReadLinkedForOwnData do
@moduledoc """
Policy check for MemberGroup read: true only when actor has permission set "own_data"
AND record.member_id == actor.member_id.
Used in a bypass so that own_data gets the linked filter (via auto_filter for list queries),
while admin with member_id does not match and gets :all from HasPermission.
- With a record (e.g. get by id): returns true only when own_data and member_id match.
- Without a record (list query): strict_check returns false; auto_filter adds filter when own_data.
"""
use Ash.Policy.Check
alias Mv.Authorization.Checks.ActorPermissionSetIs
@impl true
def type, do: :filter
@impl true
def describe(_opts),
do: "own_data can read only member_groups where member_id == actor.member_id"
@impl true
def strict_check(actor, authorizer, _opts) do
record = get_record_from_authorizer(authorizer)
is_own_data = ActorPermissionSetIs.match?(actor, authorizer, permission_set_name: "own_data")
cond do
# List query + own_data: return :unknown so authorizer applies auto_filter (keyword list)
is_nil(record) and is_own_data ->
{:ok, :unknown}
is_nil(record) ->
{:ok, false}
not is_own_data ->
{:ok, false}
record.member_id == actor.member_id ->
{:ok, true}
true ->
{:ok, false}
end
end
@impl true
def auto_filter(actor, _authorizer, _opts) do
if ActorPermissionSetIs.match?(actor, nil, permission_set_name: "own_data") &&
Map.get(actor, :member_id) do
[member_id: actor.member_id]
else
[]
end
end
defp get_record_from_authorizer(authorizer) do
case authorizer.subject do
%{data: data} when not is_nil(data) -> data
_ -> nil
end
end
end

View file

@ -1,62 +0,0 @@
defmodule Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData do
@moduledoc """
Policy check for MembershipFeeCycle read: true only when actor has permission set "own_data"
AND record.member_id == actor.member_id.
Used in a bypass so that own_data gets the linked filter (via auto_filter for list queries),
while admin with member_id does not match and gets :all from HasPermission.
- With a record (e.g. get by id): returns true only when own_data and member_id match.
- Without a record (list query): return :unknown so authorizer applies auto_filter.
"""
use Ash.Policy.Check
alias Mv.Authorization.Checks.ActorPermissionSetIs
@impl true
def type, do: :filter
@impl true
def describe(_opts),
do: "own_data can read only membership_fee_cycles where member_id == actor.member_id"
@impl true
def strict_check(actor, authorizer, _opts) do
record = get_record_from_authorizer(authorizer)
is_own_data = ActorPermissionSetIs.match?(actor, authorizer, permission_set_name: "own_data")
cond do
is_nil(record) and is_own_data ->
{:ok, :unknown}
is_nil(record) ->
{:ok, false}
not is_own_data ->
{:ok, false}
record.member_id == actor.member_id ->
{:ok, true}
true ->
{:ok, false}
end
end
@impl true
def auto_filter(actor, _authorizer, _opts) do
if ActorPermissionSetIs.match?(actor, nil, permission_set_name: "own_data") &&
Map.get(actor, :member_id) do
[member_id: actor.member_id]
else
[]
end
end
defp get_record_from_authorizer(authorizer) do
case authorizer.subject do
%{data: data} when not is_nil(data) -> data
_ -> nil
end
end
end

View file

@ -0,0 +1,74 @@
defmodule Mv.Authorization.Checks.ReadLinkedForOwnData do
@moduledoc """
Generic policy check for resources that link to a member via a member-id
attribute: read is allowed only when the actor has the "own_data" permission
set AND `record.<member_id_field> == actor.member_id`.
Used in a read bypass so that own_data gets the linked filter (via auto_filter
for list queries), while admin with a member_id does not match and falls
through to `HasPermission` for `:all`.
- With a record (e.g. get by id): returns true only when own_data and the
member ids match.
- Without a record (list query) + own_data: returns `:unknown` so the
authorizer applies `auto_filter`.
## Options
- `:member_id_field` - the attribute on the resource holding the member id.
Defaults to `:member_id`.
"""
use Ash.Policy.Check
alias Mv.Authorization.Checks.ActorPermissionSetIs
@impl true
def type, do: :filter
@impl true
def describe(opts) do
"own_data can read only records where #{member_id_field(opts)} == actor.member_id"
end
@impl true
def strict_check(actor, authorizer, opts) do
field = member_id_field(opts)
record = get_record_from_authorizer(authorizer)
is_own_data = ActorPermissionSetIs.match?(actor, authorizer, permission_set_name: "own_data")
cond do
is_nil(record) and is_own_data ->
{:ok, :unknown}
is_nil(record) ->
{:ok, false}
not is_own_data ->
{:ok, false}
Map.get(record, field) == actor.member_id ->
{:ok, true}
true ->
{:ok, false}
end
end
@impl true
def auto_filter(actor, _authorizer, opts) do
if ActorPermissionSetIs.match?(actor, nil, permission_set_name: "own_data") &&
Map.get(actor, :member_id) do
[{member_id_field(opts), actor.member_id}]
else
[]
end
end
defp member_id_field(opts), do: Keyword.get(opts, :member_id_field, :member_id)
defp get_record_from_authorizer(authorizer) do
case authorizer.subject do
%{data: data} when not is_nil(data) -> data
_ -> nil
end
end
end

View file

@ -6,9 +6,10 @@ defmodule Mv.EmailSync.Helpers do
provides clean abstractions for email updates within transactions. provides clean abstractions for email updates within transactions.
""" """
require Logger
import Ecto.Changeset import Ecto.Changeset
require Logger
@doc """ @doc """
Extracts the record from an Ash action result. Extracts the record from an Ash action result.

View file

@ -48,10 +48,10 @@ defmodule Mv.Helpers.SystemActor do
use Agent use Agent
require Ash.Query
alias Mv.Config alias Mv.Config
require Ash.Query
@doc """ @doc """
Starts the SystemActor Agent. Starts the SystemActor Agent.

View file

@ -27,11 +27,12 @@ defmodule Mv.Mailer do
ENV takes priority over Settings (same pattern as OIDC and Vereinfacht). ENV takes priority over Settings (same pattern as OIDC and Vereinfacht).
""" """
use Swoosh.Mailer, otp_app: :mv use Swoosh.Mailer, otp_app: :mv
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv use Gettext, backend: MvWeb.Gettext, otp_app: :mv
import Swoosh.Email
alias Mv.Smtp.ConfigBuilder alias Mv.Smtp.ConfigBuilder
require Logger require Logger
# Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation. # Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation.

View file

@ -15,10 +15,10 @@ defmodule Mv.Membership.Import.ColumnResolver do
This module has no Phoenix or web dependencies. This module has no Phoenix or web dependencies.
""" """
require Logger
alias Mv.Membership.Import.HeaderMapper alias Mv.Membership.Import.HeaderMapper
require Logger
@preview_row_limit 3 @preview_row_limit 3
@type numbered_row :: {pos_integer(), [String.t()]} @type numbered_row :: {pos_integer(), [String.t()]}

View file

@ -53,6 +53,14 @@ defmodule Mv.Membership.Import.MemberCSV do
MemberCSV.process_chunk(chunk, import_state.column_map, import_state.custom_field_map, []) MemberCSV.process_chunk(chunk, import_state.column_map, import_state.custom_field_map, [])
""" """
use Gettext, backend: MvWeb.Gettext
alias Mv.Helpers.SystemActor
alias Mv.Membership.Import.ColumnResolver
alias Mv.Membership.Import.CsvParser
alias Mv.Membership.Import.HeaderMapper
alias MvWeb.Translations.FieldTypes
defmodule Error do defmodule Error do
@moduledoc """ @moduledoc """
Error struct for CSV import errors. Error struct for CSV import errors.
@ -101,17 +109,6 @@ defmodule Mv.Membership.Import.MemberCSV do
groups_found: list(Mv.Membership.Group.t() | %{id: String.t(), name: String.t()}) groups_found: list(Mv.Membership.Group.t() | %{id: String.t(), name: String.t()})
} }
alias Mv.Membership.Import.ColumnResolver
alias Mv.Membership.Import.CsvParser
alias Mv.Membership.Import.HeaderMapper
use Gettext, backend: MvWeb.Gettext
alias Mv.Helpers.SystemActor
# Import FieldTypes for human-readable type labels
alias MvWeb.Translations.FieldTypes
# Configuration constants # Configuration constants
@default_max_errors 50 @default_max_errors 50
@default_chunk_size 200 @default_chunk_size 200

View file

@ -7,12 +7,6 @@ defmodule Mv.Membership.MemberExport do
and sends the download. and sends the download.
""" """
require Ash.Query
import Ash.Expr
alias Mv.Membership.CustomField
alias Mv.Membership.Member
alias Mv.Membership.MemberExportSort
alias MvWeb.MemberLive.Index alias MvWeb.MemberLive.Index
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
@ -35,261 +29,8 @@ defmodule Mv.Membership.MemberExport do
["membership_fee_type", "membership_fee_status", "groups"] ["membership_fee_type", "membership_fee_status", "groups"]
@computed_export_fields ["membership_fee_status"] @computed_export_fields ["membership_fee_status"]
@computed_insert_after "membership_fee_start_date" @computed_insert_after "membership_fee_start_date"
@custom_field_prefix Mv.Constants.custom_field_prefix()
@domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) @domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
@doc """
Fetches members and column specs for export.
- `actor` - Ash actor (e.g. current user)
- `parsed` - Map from controller's parse_and_validate (selected_ids, member_fields, etc.)
Returns `{:ok, members, column_specs}` or `{:error, :forbidden}`.
Column specs have `:kind`, `:key`, and for custom fields `:custom_field`;
the controller adds `:header` and optional computed columns to members before CSV export.
"""
@spec fetch(struct(), map()) ::
{:ok, [struct()], [map()]} | {:error, :forbidden}
def fetch(actor, parsed) do
custom_field_ids_union =
(parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq()
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(custom_field_ids_union, actor),
{:ok, members} <- load_members(actor, parsed, custom_fields_by_id) do
column_specs = build_column_specs(parsed, custom_fields_by_id)
{:ok, members, column_specs}
end
end
defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
defp load_custom_fields_by_id(custom_field_ids, actor) do
query =
CustomField
|> Ash.Query.filter(expr(id in ^custom_field_ids))
|> Ash.Query.select([:id, :name, :value_type])
case Ash.read(query, actor: actor) do
{:ok, custom_fields} ->
by_id = build_custom_fields_by_id(custom_field_ids, custom_fields)
{:ok, by_id}
{:error, %Ash.Error.Forbidden{}} ->
{:error, :forbidden}
end
end
defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
find_and_add_custom_field(acc, id, custom_fields)
end)
end
defp find_and_add_custom_field(acc, id, custom_fields) do
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
nil -> acc
cf -> Map.put(acc, id, cf)
end
end
defp build_column_specs(parsed, custom_fields_by_id) do
member_specs = build_member_column_specs(parsed)
custom_specs = build_custom_column_specs(parsed, custom_fields_by_id)
member_specs ++ custom_specs
end
defp build_member_column_specs(parsed) do
Enum.map(parsed.member_fields, fn f ->
build_single_member_spec(f, parsed.selectable_member_fields)
end)
|> Enum.reject(&is_nil/1)
end
defp build_single_member_spec(field, selectable_member_fields) do
if field in selectable_member_fields do
%{kind: :member_field, key: field}
else
build_computed_spec(field)
end
end
defp build_computed_spec(field) do
# only allow known computed export fields to avoid crashing on unknown atoms
if field in @computed_export_fields do
%{kind: :computed, key: String.to_existing_atom(field)}
else
# ignore unknown non-selectable fields defensively
nil
end
end
defp build_custom_column_specs(parsed, custom_fields_by_id) do
parsed.custom_field_ids
|> Enum.map(fn id -> Map.get(custom_fields_by_id, id) end)
|> Enum.reject(&is_nil/1)
|> Enum.map(fn cf -> %{kind: :custom_field, key: cf.id, custom_field: cf} end)
end
defp load_members(actor, parsed, custom_fields_by_id) do
query = build_members_query(parsed, custom_fields_by_id)
case Ash.read(query, actor: actor) do
{:ok, members} ->
processed_members = process_loaded_members(members, parsed, custom_fields_by_id)
{:ok, processed_members}
{:error, %Ash.Error.Forbidden{}} ->
{:error, :forbidden}
end
end
defp build_members_query(parsed, _custom_fields_by_id) do
select_fields =
[:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
custom_field_ids_union = parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})
need_cycles =
parsed.show_current_cycle or parsed.cycle_status_filter != nil or
parsed.computed_fields != [] or
"membership_fee_status" in parsed.member_fields
query =
Member
|> Ash.Query.new()
|> Ash.Query.select(select_fields)
|> load_custom_field_values_query(custom_field_ids_union)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
if parsed.selected_ids != [] do
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
else
query
|> apply_search(parsed.query)
|> then(fn q ->
{q, _sort_after_load} = maybe_sort(q, parsed.sort_field, parsed.sort_order)
q
end)
end
end
defp process_loaded_members(members, parsed, custom_fields_by_id) do
members
|> apply_post_load_filters(parsed, custom_fields_by_id)
|> apply_post_load_sorting(parsed, custom_fields_by_id)
|> add_computed_fields(parsed.computed_fields, parsed.show_current_cycle)
end
defp apply_post_load_filters(members, parsed, custom_fields_by_id) do
if parsed.selected_ids == [] do
members
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|> Index.apply_boolean_custom_field_filters(
parsed.boolean_filters || %{},
Map.values(custom_fields_by_id)
)
else
members
end
end
defp apply_post_load_sorting(members, parsed, custom_fields_by_id) do
if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
sort_members_by_custom_field(
members,
parsed.sort_field,
parsed.sort_order,
Map.values(custom_fields_by_id)
)
else
members
end
end
defp load_custom_field_values_query(query, []), do: query
defp load_custom_field_values_query(query, custom_field_ids) do
cfv_query =
Mv.Membership.CustomFieldValue
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
Ash.Query.load(query, custom_field_values: cfv_query)
end
defp apply_search(query, nil), do: query
defp apply_search(query, ""), do: query
defp apply_search(query, q) when is_binary(q) do
if String.trim(q) != "" do
Member.fuzzy_search(query, %{query: q})
else
query
end
end
defp maybe_sort(query, nil, _order), do: {query, false}
defp maybe_sort(query, _field, nil), do: {query, false}
defp maybe_sort(query, field, order) when is_binary(field) do
if custom_field_sort?(field) do
{query, true}
else
field_atom = String.to_existing_atom(field)
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
{query, false}
end
end
rescue
ArgumentError -> {query, false}
end
defp sort_after_load?(field) when is_binary(field),
do: String.starts_with?(field, @custom_field_prefix)
defp sort_after_load?(_), do: false
defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
do: []
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
id_str = String.trim_leading(field, @custom_field_prefix)
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
if is_nil(custom_field), do: members
key_fn = fn member ->
cfv = find_cfv(member, custom_field)
raw = if cfv, do: cfv.value, else: nil
MemberExportSort.custom_field_sort_key(custom_field.value_type, raw)
end
members
|> Enum.map(fn m -> {m, key_fn.(m)} end)
|> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end)
|> Enum.map(fn {m, _} -> m end)
end
defp find_cfv(member, custom_field) do
(member.custom_field_values || [])
|> Enum.find(fn cfv ->
to_string(cfv.custom_field_id) == to_string(custom_field.id) or
(Map.get(cfv, :custom_field) &&
to_string(cfv.custom_field.id) == to_string(custom_field.id))
end)
end
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
defp maybe_load_cycles(query, false, _show_current), do: query
defp maybe_load_cycles(query, true, show_current) do
MembershipFeeStatus.load_cycles_for_members(query, show_current)
end
defp apply_cycle_status_filter(members, nil, _show_current), do: members defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
@ -298,20 +39,6 @@ defmodule Mv.Membership.MemberExport do
defp apply_cycle_status_filter(members, _status, _show_current), do: members defp apply_cycle_status_filter(members, _status, _show_current), do: members
defp add_computed_fields(members, computed_fields, show_current_cycle) do
computed_fields = computed_fields || []
if "membership_fee_status" in computed_fields do
Enum.map(members, fn member ->
status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
# <= Atom rein
Map.put(member, :membership_fee_status, status)
end)
else
members
end
end
# Called by controller to build parsed map from raw params (kept here so controller stays thin) # Called by controller to build parsed map from raw params (kept here so controller stays thin)
@doc """ @doc """
Parses and validates export params (from JSON payload). Parses and validates export params (from JSON payload).

View file

@ -17,13 +17,14 @@ defmodule Mv.Membership.MemberExport.Build do
No translations/Gettext in this module - labels come from the web layer via a function. No translations/Gettext in this module - labels come from the web layer via a function.
""" """
require Ash.Query
import Ash.Expr import Ash.Expr
alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort} alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort}
alias MvWeb.MemberLive.Index alias MvWeb.MemberLive.Index
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
require Ash.Query
@custom_field_prefix Mv.Constants.custom_field_prefix() @custom_field_prefix Mv.Constants.custom_field_prefix()
@doc """ @doc """

View file

@ -12,12 +12,12 @@ defmodule Mv.Membership.MembersPDF do
to avoid symlink issues and ensure isolation. to avoid symlink issues and ensure isolation.
""" """
require Logger
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
alias Mv.Config alias Mv.Config
require Logger
@template_filename "members_export.typ" @template_filename "members_export.typ"
@doc """ @doc """

View file

@ -59,27 +59,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
""" """
@spec run() :: {:ok, CycleGenerator.results_summary()} | {:error, Ash.Error.t()} @spec run() :: {:ok, CycleGenerator.results_summary()} | {:error, Ash.Error.t()}
def run do def run, do: run([])
Logger.info("Starting membership fee cycle generation job")
start_time = System.monotonic_time(:millisecond)
result = CycleGenerator.generate_cycles_for_all_members()
elapsed = System.monotonic_time(:millisecond) - start_time
case result do
{:ok, stats} ->
Logger.info(
"Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total"
)
result
{:error, reason} ->
Logger.error("Cycle generation failed: #{inspect(reason)}")
result
end
end
@doc """ @doc """
Runs cycle generation with custom options. Runs cycle generation with custom options.

View file

@ -1,11 +1,4 @@
defmodule Mv.MembershipFees.CycleGenerator do defmodule Mv.MembershipFees.CycleGenerator do
@typedoc "Aggregate counts returned by a batch cycle-generation run."
@type results_summary :: %{
success: non_neg_integer(),
failed: non_neg_integer(),
total: non_neg_integer()
}
@moduledoc """ @moduledoc """
Module for generating membership fee cycles for members. Module for generating membership fee cycles for members.
@ -66,6 +59,13 @@ defmodule Mv.MembershipFees.CycleGenerator do
require Ash.Query require Ash.Query
require Logger require Logger
@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()
}
@type generate_result :: @type generate_result ::
{:ok, [MembershipFeeCycle.t()], [Ash.Notifier.Notification.t()]} | {:error, term()} {:ok, [MembershipFeeCycle.t()], [Ash.Notifier.Notification.t()]} | {:error, term()}

View file

@ -4,7 +4,7 @@ defmodule Mv.OidcRoleSync do
Used after OIDC registration (register_with_oidc) and on sign-in so that Used after OIDC registration (register_with_oidc) and on sign-in so that
users in the configured admin group get the Admin role; others get Mitglied. users in the configured admin group get the Admin role; others get Mitglied.
Configure via OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM (see OidcRoleSyncConfig). Configure via OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM (see Mv.Config).
Groups are read from user_info (ID token claims) first; if missing or empty, Groups are read from user_info (ID token claims) first; if missing or empty,
the access_token from oauth_tokens is decoded as JWT and the groups claim is the access_token from oauth_tokens is decoded as JWT and the groups claim is
@ -23,7 +23,7 @@ defmodule Mv.OidcRoleSync do
""" """
alias Mv.Accounts.User alias Mv.Accounts.User
alias Mv.Authorization.Role alias Mv.Authorization.Role
alias Mv.OidcRoleSyncConfig alias Mv.Config
@doc """ @doc """
Applies Admin or Mitglied role to the user based on OIDC groups claim. Applies Admin or Mitglied role to the user based on OIDC groups claim.
@ -38,12 +38,12 @@ defmodule Mv.OidcRoleSync do
@spec apply_admin_role_from_user_info(User.t(), map(), map() | nil) :: :ok @spec apply_admin_role_from_user_info(User.t(), map(), map() | nil) :: :ok
def apply_admin_role_from_user_info(user, user_info, oauth_tokens \\ nil) def apply_admin_role_from_user_info(user, user_info, oauth_tokens \\ nil)
when is_map(user_info) do when is_map(user_info) do
admin_group = OidcRoleSyncConfig.oidc_admin_group_name() admin_group = Config.oidc_admin_group_name()
if is_nil(admin_group) or admin_group == "" do if is_nil(admin_group) or admin_group == "" do
:ok :ok
else else
claim = OidcRoleSyncConfig.oidc_groups_claim() claim = Config.oidc_groups_claim()
groups = groups_from_user_info(user_info, claim) groups = groups_from_user_info(user_info, claim)
groups = groups =

View file

@ -1,20 +0,0 @@
defmodule Mv.OidcRoleSyncConfig do
@moduledoc """
Runtime configuration for OIDC group role sync (e.g. admin group Admin role).
Reads from Mv.Config (ENV first, then Settings):
- `oidc_admin_group_name/0` OIDC group name that maps to Admin role (optional; when nil, no sync).
- `oidc_groups_claim/0` JWT/user_info claim name for groups (default: `"groups"`).
Set via ENV: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM; or via Settings (Basic settings OIDC).
"""
@doc "Returns the OIDC group name that maps to Admin role, or nil if not configured."
def oidc_admin_group_name do
Mv.Config.oidc_admin_group_name()
end
@doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
def oidc_groups_claim do
Mv.Config.oidc_groups_claim()
end
end

View file

@ -12,8 +12,6 @@ defmodule Mv.Release do
or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell
to update the admin password without redeploying. to update the admin password without redeploying.
""" """
@app :mv
alias Mv.Accounts alias Mv.Accounts
alias Mv.Accounts.User alias Mv.Accounts.User
alias Mv.Authorization.Role alias Mv.Authorization.Role
@ -21,6 +19,8 @@ defmodule Mv.Release do
require Ash.Query require Ash.Query
require Logger require Logger
@app :mv
def migrate do def migrate do
_ = load_app() _ = load_app()

View file

@ -7,15 +7,15 @@ defmodule Mv.Statistics do
to Ash reads so that policies are enforced. to Ash reads so that policies are enforced.
""" """
require Ash.Query
import Ash.Expr import Ash.Expr
require Logger
alias Mv.Membership.Member alias Mv.Membership.Member
alias Mv.MembershipFees alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeCycle
require Ash.Query
require Logger
@doc """ @doc """
Returns the earliest year in which any member has a join_date. Returns the earliest year in which any member has a join_date.

View file

@ -9,11 +9,9 @@ defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
""" """
use Ash.Resource.Change use Ash.Resource.Change
alias Mv.EmailSync.Loader
require Logger require Logger
alias Mv.Helpers
alias Mv.Helpers.SystemActor
alias Mv.Membership
alias Mv.Membership.Member
@impl true @impl true
def change(changeset, _opts, _context) do def change(changeset, _opts, _context) do
@ -32,7 +30,7 @@ defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
end end
defp sync_linked_member_after_transaction(_changeset, {:ok, user}) do defp sync_linked_member_after_transaction(_changeset, {:ok, user}) do
case load_linked_member(user) do case Loader.get_linked_member(user) do
nil -> nil ->
{:ok, user} {:ok, user}
@ -55,17 +53,4 @@ defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
end end
defp sync_linked_member_after_transaction(_changeset, result), do: result defp sync_linked_member_after_transaction(_changeset, result), do: result
defp load_linked_member(%{member_id: nil}), do: nil
defp load_linked_member(%{member_id: ""}), do: nil
defp load_linked_member(user) do
actor = SystemActor.get_system_actor()
opts = Helpers.ash_actor_opts(actor)
case Ash.get(Member, user.member_id, [domain: Membership] ++ opts) do
{:ok, %Member{} = member} -> member
_ -> nil
end
end
end end

View file

@ -7,13 +7,15 @@ defmodule Mv.Vereinfacht do
the linked member's email via Ecto (e.g. user email change). the linked member's email via Ecto (e.g. user email change).
- `sync_members_without_contact/0` Bulk sync of members without a contact ID. - `sync_members_without_contact/0` Bulk sync of members without a contact ID.
""" """
require Ash.Query
import Ash.Expr import Ash.Expr
alias Mv.Helpers alias Mv.Helpers
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor
alias Mv.Membership.Member alias Mv.Membership.Member
alias Mv.Vereinfacht.Client alias Mv.Vereinfacht.Client
require Ash.Query
@doc """ @doc """
Tests the connection to the Vereinfacht API using the current configuration. Tests the connection to the Vereinfacht API using the current configuration.

View file

@ -3,9 +3,10 @@ defmodule MvWeb.TableComponents do
TableComponents that can be used in tables as components (like a button for sorting, a filter...) TableComponents that can be used in tables as components (like a button for sorting, a filter...)
""" """
use Phoenix.Component use Phoenix.Component
import MvWeb.CoreComponents
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
import MvWeb.CoreComponents
attr :field, :atom, required: true attr :field, :atom, required: true
attr :label, :string, required: true attr :label, :string, required: true
attr :sort_field, :atom, default: nil attr :sort_field, :atom, default: nil

View file

@ -0,0 +1,22 @@
defmodule MvWeb.ControllerHelpers do
@moduledoc """
Shared helpers for plug-based controllers.
The LiveView equivalent lives in `MvWeb.LiveHelpers`; this module is the
controller-side counterpart that works on a `Plug.Conn`.
"""
alias Mv.Authorization.Actor
@doc """
Returns the request actor for a controller, loaded for authorization.
Reads `:current_user` from the connection assigns and ensures it is loaded via
`Mv.Authorization.Actor.ensure_loaded/1`.
"""
@spec current_actor(Plug.Conn.t()) :: Mv.Accounts.User.t() | nil
def current_actor(conn) do
conn.assigns[:current_user]
|> Actor.ensure_loaded()
end
end

View file

@ -13,7 +13,8 @@ defmodule MvWeb.ImportTemplateController do
""" """
use MvWeb, :controller use MvWeb, :controller
alias Mv.Authorization.Actor import MvWeb.ControllerHelpers, only: [current_actor: 1]
alias Mv.Membership.Member alias Mv.Membership.Member
alias Mv.Membership.MembersCSV alias Mv.Membership.MembersCSV
alias MvWeb.Authorization alias MvWeb.Authorization
@ -105,11 +106,6 @@ defmodule MvWeb.ImportTemplateController do
end end
end end
defp current_actor(conn) do
conn.assigns[:current_user]
|> Actor.ensure_loaded()
end
defp return_forbidden(conn) do defp return_forbidden(conn) do
conn conn
|> put_status(403) |> put_status(403)

View file

@ -6,11 +6,11 @@ defmodule MvWeb.MemberExportController do
Same permission and actor context as the member overview; 403 if unauthorized. Same permission and actor context as the member overview; 403 if unauthorized.
""" """
use MvWeb, :controller use MvWeb, :controller
use Gettext, backend: MvWeb.Gettext
require Ash.Query
import Ash.Expr import Ash.Expr
import MvWeb.ControllerHelpers, only: [current_actor: 1]
alias Mv.Authorization.Actor
alias Mv.Membership.CustomField alias Mv.Membership.CustomField
alias Mv.Membership.CustomFieldSort alias Mv.Membership.CustomFieldSort
alias Mv.Membership.Member alias Mv.Membership.Member
@ -18,7 +18,8 @@ defmodule MvWeb.MemberExportController do
alias Mv.Membership.MembersCSV alias Mv.Membership.MembersCSV
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.Translations.MemberFields alias MvWeb.Translations.MemberFields
use Gettext, backend: MvWeb.Gettext
require Ash.Query
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
["membership_fee_type", "groups"] ["membership_fee_type", "groups"]
@ -53,11 +54,6 @@ defmodule MvWeb.MemberExportController do
|> json(%{error: message}) |> json(%{error: message})
end end
defp current_actor(conn) do
conn.assigns[:current_user]
|> Actor.ensure_loaded()
end
defp return_forbidden(conn) do defp return_forbidden(conn) do
conn conn
|> put_status(403) |> put_status(403)

View file

@ -7,14 +7,14 @@ defmodule MvWeb.MemberPdfExportController do
""" """
use MvWeb, :controller use MvWeb, :controller
use Gettext, backend: MvWeb.Gettext
require Logger import MvWeb.ControllerHelpers, only: [current_actor: 1]
alias Mv.Authorization.Actor
alias Mv.Membership.{MemberExport, MemberExport.Build, MembersPDF} alias Mv.Membership.{MemberExport, MemberExport.Build, MembersPDF}
alias MvWeb.Translations.MemberFields alias MvWeb.Translations.MemberFields
use Gettext, backend: MvWeb.Gettext require Logger
@payload_required_message "payload required" @payload_required_message "payload required"
@invalid_json_message "invalid JSON" @invalid_json_message "invalid JSON"
@ -79,13 +79,6 @@ defmodule MvWeb.MemberPdfExportController do
bad_request(conn, @payload_required_message) bad_request(conn, @payload_required_message)
end end
# --- Actor / auth ---
defp current_actor(conn) do
conn.assigns[:current_user]
|> Actor.ensure_loaded()
end
defp forbidden(conn) do defp forbidden(conn) do
conn conn
|> put_status(:forbidden) |> put_status(:forbidden)

View file

@ -5,15 +5,9 @@ defmodule MvWeb.Emails.JoinAlreadyMemberEmail do
Used for anti-enumeration: the UI shows the same success message; only the email Used for anti-enumeration: the UI shows the same success message; only the email
informs the recipient. Uses the unified email layout. informs the recipient. Uses the unified email layout.
""" """
use Phoenix.Swoosh,
view: MvWeb.EmailsView,
layout: {MvWeb.EmailLayoutView, "layout.html"}
use MvWeb, :verified_routes
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv use Gettext, backend: MvWeb.Gettext, otp_app: :mv
alias Mv.Mailer alias MvWeb.Emails.JoinEmail
@doc """ @doc """
Sends the "already a member" notice to the given address. Sends the "already a member" notice to the given address.
@ -23,20 +17,6 @@ defmodule MvWeb.Emails.JoinAlreadyMemberEmail do
def send(email_address) when is_binary(email_address) do def send(email_address) when is_binary(email_address) do
subject = gettext("Membership application already a member") subject = gettext("Membership application already a member")
assigns = %{ JoinEmail.deliver(email_address, "join_already_member.html", subject)
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
email =
new()
|> from(Mailer.mail_from())
|> to(email_address)
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("join_already_member.html", assigns)
Mailer.deliver(email, Mailer.smtp_config())
end end
end end

View file

@ -6,15 +6,9 @@ defmodule MvWeb.Emails.JoinAlreadyPendingEmail do
Used for anti-enumeration: the UI shows the same success message; only the email Used for anti-enumeration: the UI shows the same success message; only the email
informs the recipient. Uses the unified email layout. informs the recipient. Uses the unified email layout.
""" """
use Phoenix.Swoosh,
view: MvWeb.EmailsView,
layout: {MvWeb.EmailLayoutView, "layout.html"}
use MvWeb, :verified_routes
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv use Gettext, backend: MvWeb.Gettext, otp_app: :mv
alias Mv.Mailer alias MvWeb.Emails.JoinEmail
@doc """ @doc """
Sends the "application already under review" notice to the given address. Sends the "application already under review" notice to the given address.
@ -24,20 +18,6 @@ defmodule MvWeb.Emails.JoinAlreadyPendingEmail do
def send(email_address) when is_binary(email_address) do def send(email_address) when is_binary(email_address) do
subject = gettext("Membership application already under review") subject = gettext("Membership application already under review")
assigns = %{ JoinEmail.deliver(email_address, "join_already_pending.html", subject)
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
}
email =
new()
|> from(Mailer.mail_from())
|> to(email_address)
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("join_already_pending.html", assigns)
Mailer.deliver(email, Mailer.smtp_config())
end end
end end

View file

@ -2,15 +2,10 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
@moduledoc """ @moduledoc """
Sends the join request confirmation email (double opt-in) using the unified email layout. Sends the join request confirmation email (double opt-in) using the unified email layout.
""" """
use Phoenix.Swoosh,
view: MvWeb.EmailsView,
layout: {MvWeb.EmailLayoutView, "layout.html"}
use MvWeb, :verified_routes use MvWeb, :verified_routes
import Swoosh.Email
use Gettext, backend: MvWeb.Gettext, otp_app: :mv use Gettext, backend: MvWeb.Gettext, otp_app: :mv
alias Mv.Mailer alias MvWeb.Emails.JoinEmail
@doc """ @doc """
Sends the join confirmation email to the given address with the confirmation link. Sends the join confirmation email to the given address with the confirmation link.
@ -31,22 +26,9 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
confirm_url = url(~p"/confirm_join/#{token}") confirm_url = url(~p"/confirm_join/#{token}")
subject = gettext("Confirm your membership request") subject = gettext("Confirm your membership request")
assigns = %{ JoinEmail.deliver(email_address, "join_confirmation.html", subject, %{
confirm_url: confirm_url, confirm_url: confirm_url,
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext),
resend: Keyword.get(opts, :resend, false) resend: Keyword.get(opts, :resend, false)
} })
email =
new()
|> from(Mailer.mail_from())
|> to(email_address)
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body("join_confirmation.html", assigns)
Mailer.deliver(email, Mailer.smtp_config())
end end
end end

View file

@ -0,0 +1,54 @@
defmodule MvWeb.Emails.JoinEmail do
@moduledoc """
Shared build/deliver skeleton for the join-flow emails (confirmation,
already-a-member, already-pending).
Each concrete join email supplies only its subject, template, and any
email-specific assigns; this module builds the Swoosh email with the unified
layout and delivers it via `Mailer.deliver/2` with `Mailer.smtp_config/0`,
preserving the `{:ok, email}` / `{:error, reason}` contract.
"""
use Phoenix.Swoosh,
view: MvWeb.EmailsView,
layout: {MvWeb.EmailLayoutView, "layout.html"}
import Swoosh.Email
alias Mv.Mailer
@doc """
Builds and delivers a join-flow email.
- `email_address` - recipient address
- `template` - the EmailsView template to render (e.g. `"join_confirmation.html"`)
- `subject` - already-translated subject line
- `extra_assigns` - email-specific assigns merged on top of the common ones
(`subject`, `app_name`, `locale`)
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure.
"""
@spec deliver(String.t(), String.t(), String.t(), map()) ::
{:ok, Swoosh.Email.t()} | {:error, term()}
def deliver(email_address, template, subject, extra_assigns \\ %{})
when is_binary(email_address) and is_binary(template) and is_binary(subject) do
assigns =
Map.merge(
%{
subject: subject,
app_name: Mailer.mail_from() |> elem(0),
locale: Gettext.get_locale(MvWeb.Gettext)
},
extra_assigns
)
email =
new()
|> from(Mailer.mail_from())
|> to(email_address)
|> subject(subject)
|> put_view(MvWeb.EmailsView)
|> render_body(template, assigns)
Mailer.deliver(email, Mailer.smtp_config())
end
end

View file

@ -9,8 +9,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
alias Mv.Membership.Member alias Mv.Membership.Member
alias Mv.MembershipFees
alias Mv.MembershipFees.CalendarCycles alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.DateFormatter
@doc """ @doc """
Formats a decimal amount as currency string. Formats a decimal amount as currency string.
@ -98,8 +101,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
@spec format_cycle_range(Date.t(), :monthly | :quarterly | :half_yearly | :yearly) :: String.t() @spec format_cycle_range(Date.t(), :monthly | :quarterly | :half_yearly | :yearly) :: String.t()
def format_cycle_range(cycle_start, interval) do def format_cycle_range(cycle_start, interval) do
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval) cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
start_str = format_date(cycle_start) start_str = DateFormatter.format_date(cycle_start)
end_str = format_date(cycle_end) end_str = DateFormatter.format_date(cycle_end)
"#{start_str} - #{end_str}" "#{start_str} - #{end_str}"
end end
@ -249,8 +252,68 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
def status_icon(:unpaid), do: "hero-x-circle" def status_icon(:unpaid), do: "hero-x-circle"
def status_icon(:suspended), do: "hero-pause-circle" def status_icon(:suspended), do: "hero-pause-circle"
# Private helper function for date formatting @doc """
defp format_date(%Date{} = date) do Handles a membership-fee-type "delete" event for the fee-type list and the
Calendar.strftime(date, "%d.%m.%Y") fee-settings LiveViews.
Loads the fee type, attempts to destroy it, and returns the updated socket
with the matching flash. On success the deleted type and its member count are
dropped from the `:membership_fee_types` and `:member_counts` assigns. The
NotFound, Forbidden (delete and access), and generic error branches preserve
the exact messages both views used before they shared this block.
"""
@spec delete_fee_type(Phoenix.LiveView.Socket.t(), String.t(), term()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
def delete_fee_type(socket, id, actor) do
case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
{:ok, fee_type} ->
destroy_fee_type(socket, fee_type, id, actor)
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply,
Phoenix.LiveView.put_flash(socket, :error, gettext("Membership fee type not found"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
Phoenix.LiveView.put_flash(
socket,
:error,
gettext("You do not have permission to access this membership fee type")
)}
{:error, error} ->
{:noreply, Phoenix.LiveView.put_flash(socket, :error, fee_error_message(error))}
end end
end end
defp destroy_fee_type(socket, fee_type, id, actor) do
case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do
:ok ->
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
updated_counts = Map.delete(socket.assigns.member_counts, id)
{:noreply,
socket
|> Phoenix.Component.assign(:membership_fee_types, updated_types)
|> Phoenix.Component.assign(:member_counts, updated_counts)
|> Phoenix.LiveView.put_flash(:success, gettext("Membership fee type deleted"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
Phoenix.LiveView.put_flash(
socket,
:error,
gettext("You do not have permission to delete this membership fee type")
)}
{:error, error} ->
{:noreply, Phoenix.LiveView.put_flash(socket, :error, fee_error_message(error))}
end
end
defp fee_error_message(%Ash.Error.Invalid{} = error) do
Enum.map_join(error.errors, ", ", fn e -> e.message end)
end
defp fee_error_message(_error), do: gettext("An error occurred")
end

View file

@ -15,13 +15,14 @@ defmodule MvWeb.LinkOidcAccountLive do
6. User is redirected to complete OIDC login 6. User is redirected to complete OIDC login
""" """
use MvWeb, :live_view use MvWeb, :live_view
require Ash.Query
require Logger
alias AshAuthentication.Strategy.Password.Actions, as: PasswordActions alias AshAuthentication.Strategy.Password.Actions, as: PasswordActions
alias Mv.Accounts.User, as: UserResource alias Mv.Accounts.User, as: UserResource
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor
require Ash.Query
require Logger
@impl true @impl true
def mount(_params, session, socket) do def mount(_params, session, socket) do
# Use SystemActor for authorization during OIDC linking (user is not yet logged in) # Use SystemActor for authorization during OIDC linking (user is not yet logged in)

View file

@ -34,7 +34,6 @@ defmodule MvWeb.GlobalSettingsLive do
""" """
use MvWeb, :live_view use MvWeb, :live_view
require Ash.Query
import Ash.Expr import Ash.Expr
alias Mv.Helpers alias Mv.Helpers
@ -44,6 +43,8 @@ defmodule MvWeb.GlobalSettingsLive do
alias MvWeb.Helpers.MemberHelpers alias MvWeb.Helpers.MemberHelpers
alias MvWeb.Translations.MemberFields alias MvWeb.Translations.MemberFields
require Ash.Query
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded} on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
@impl true @impl true

View file

@ -15,14 +15,15 @@ defmodule MvWeb.GroupLive.Show do
""" """
use MvWeb, :live_view use MvWeb, :live_view
require Logger
import Ash.Expr import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization import MvWeb.Authorization
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership alias Mv.Membership
alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers
alias MvWeb.Live.MemberDropdownNav
require Logger
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -566,56 +567,8 @@ defmodule MvWeb.GroupLive.Show do
end end
@impl true @impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do def handle_event("member_dropdown_keydown", params, socket) do
return_if_dropdown_closed(socket, fn -> MemberDropdownNav.handle_keydown(params, socket, fn -> select_focused_member(socket) end)
max_index = length(socket.assigns.available_members) - 1
current = socket.assigns.focused_member_index
new_index =
case current do
nil -> 0
index when index < max_index -> index + 1
_ -> current
end
{:noreply, assign(socket, focused_member_index: new_index)}
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
return_if_dropdown_closed(socket, fn ->
current = socket.assigns.focused_member_index
new_index =
case current do
nil -> 0
0 -> 0
index -> index - 1
end
{:noreply, assign(socket, focused_member_index: new_index)}
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
return_if_dropdown_closed(socket, fn ->
select_focused_member(socket)
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
return_if_dropdown_closed(socket, fn ->
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end)
end
@impl true
def handle_event("member_dropdown_keydown", _params, socket) do
# Ignore other keys
{:noreply, socket}
end end
@impl true @impl true
@ -705,14 +658,6 @@ defmodule MvWeb.GroupLive.Show do
end end
# Helper functions # Helper functions
defp return_if_dropdown_closed(socket, fun) do
if socket.assigns.show_member_dropdown do
fun.()
else
{:noreply, socket}
end
end
defp select_focused_member(socket) do defp select_focused_member(socket) do
case socket.assigns.focused_member_index do case socket.assigns.focused_member_index do
nil -> nil ->

View file

@ -7,13 +7,13 @@ defmodule MvWeb.ImportLive.Components do
use Phoenix.Component use Phoenix.Component
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
import MvWeb.CoreComponents
use Phoenix.VerifiedRoutes, use Phoenix.VerifiedRoutes,
endpoint: MvWeb.Endpoint, endpoint: MvWeb.Endpoint,
router: MvWeb.Router, router: MvWeb.Router,
statics: MvWeb.static_paths() statics: MvWeb.static_paths()
import MvWeb.CoreComponents
@doc """ @doc """
Renders the info box explaining that data fields must exist before import Renders the info box explaining that data fields must exist before import
and linking to Manage Member Data (custom fields). and linking to Manage Member Data (custom fields).

View file

@ -13,15 +13,15 @@ defmodule MvWeb.JoinRequestLive.Index do
""" """
use MvWeb, :live_view use MvWeb, :live_view
require Logger
import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization import MvWeb.Authorization
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership alias Mv.Membership
alias MvWeb.Helpers.DateFormatter alias MvWeb.Helpers.DateFormatter
alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers
require Logger
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
actor = current_actor(socket) actor = current_actor(socket)

View file

@ -14,10 +14,8 @@ defmodule MvWeb.JoinRequestLive.Show do
""" """
use MvWeb, :live_view use MvWeb, :live_view
require Logger
import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization import MvWeb.Authorization
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Constants alias Mv.Constants
alias Mv.Membership alias Mv.Membership
@ -26,6 +24,8 @@ defmodule MvWeb.JoinRequestLive.Show do
alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers
alias MvWeb.Translations.MemberFields, as: MemberFieldsTranslations alias MvWeb.Translations.MemberFields, as: MemberFieldsTranslations
require Logger
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
if Membership.join_form_enabled?() do if Membership.join_form_enabled?() do

View file

@ -0,0 +1,83 @@
defmodule MvWeb.Live.MemberDropdownNav do
@moduledoc """
Shared keyboard-navigation logic for the member-search dropdown used by the
user form and the group show LiveViews.
Both views keep their own `member_dropdown_keydown` event entry point and their
own `select_focused_member/1` (the selection effect differs per view), but the
arrow/enter/escape navigation over `:focused_member_index` and the
dropdown-open guard are identical and live here.
The caller passes a zero-arity `select_focused` callback that performs the
view-specific selection of the currently focused entry.
"""
import Phoenix.Component, only: [assign: 2]
@type socket :: Phoenix.LiveView.Socket.t()
@type result :: {:noreply, socket}
@doc """
Handles a `member_dropdown_keydown` event for the shared dropdown.
Navigation keys move `:focused_member_index` within
`[0, length(available_members) - 1]`; Enter invokes the view-specific
`select_focused` callback; Escape closes the dropdown; any other key is a
no-op. All key handling is guarded so that keystrokes while the dropdown is
closed are ignored.
"""
@spec handle_keydown(map(), socket, (-> result)) :: result
def handle_keydown(%{"key" => "ArrowDown"}, socket, _select_focused) do
return_if_dropdown_closed(socket, fn ->
max_index = length(socket.assigns.available_members) - 1
current = socket.assigns.focused_member_index
new_index =
case current do
nil -> 0
index when index < max_index -> index + 1
_ -> current
end
{:noreply, assign(socket, focused_member_index: new_index)}
end)
end
def handle_keydown(%{"key" => "ArrowUp"}, socket, _select_focused) do
return_if_dropdown_closed(socket, fn ->
current = socket.assigns.focused_member_index
new_index =
case current do
nil -> 0
0 -> 0
index -> index - 1
end
{:noreply, assign(socket, focused_member_index: new_index)}
end)
end
def handle_keydown(%{"key" => "Enter"}, socket, select_focused) do
return_if_dropdown_closed(socket, select_focused)
end
def handle_keydown(%{"key" => "Escape"}, socket, _select_focused) do
return_if_dropdown_closed(socket, fn ->
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end)
end
def handle_keydown(_params, socket, _select_focused) do
# Ignore other keys
{:noreply, socket}
end
defp return_if_dropdown_closed(socket, func) do
if socket.assigns.show_member_dropdown do
func.()
else
{:noreply, socket}
end
end
end

View file

@ -20,7 +20,6 @@ defmodule MvWeb.MemberLive.Form do
""" """
use MvWeb, :live_view use MvWeb, :live_view
require Logger
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3] import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
alias Mv.Membership alias Mv.Membership
@ -32,6 +31,8 @@ defmodule MvWeb.MemberLive.Form do
alias MvWeb.Helpers.MemberHelpers alias MvWeb.Helpers.MemberHelpers
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
require Logger
@impl true @impl true
def render(assigns) do def render(assigns) do
# Sort custom fields by name for display only # Sort custom fields by name for display only

View file

@ -26,8 +26,6 @@ defmodule MvWeb.MemberLive.Index do
""" """
use MvWeb, :live_view use MvWeb, :live_view
require Ash.Query
require Logger
import Ash.Expr import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
@ -45,6 +43,9 @@ defmodule MvWeb.MemberLive.Index do
alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
require Ash.Query
require Logger
@custom_field_prefix Mv.Constants.custom_field_prefix() @custom_field_prefix Mv.Constants.custom_field_prefix()
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix() @boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
@group_filter_prefix Mv.Constants.group_filter_prefix() @group_filter_prefix Mv.Constants.group_filter_prefix()
@ -1026,8 +1027,7 @@ defmodule MvWeb.MemberLive.Index do
# Errors in handle_params are handled by Phoenix LiveView # Errors in handle_params are handled by Phoenix LiveView
actor = current_actor(socket) actor = current_actor(socket)
{time_microseconds, members} = :timer.tc(fn -> Ash.read!(query, actor: actor) end) members = Ash.read!(query, actor: actor)
Logger.info("Ash.read! in load_members/1 took #{time_microseconds / 1000} ms")
# Custom field values are already filtered at the database level in load_custom_field_values/2 # Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore # No need for in-memory filtering anymore

View file

@ -28,12 +28,13 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
`exit_date IS NULL OR exit_date > today` a member who left today is hidden. `exit_date IS NULL OR exit_date > today` a member who left today is hidden.
""" """
require Ash.Query
import Ash.Expr import Ash.Expr
alias MvWeb.MemberLive.Index.CustomFieldValueLookup alias MvWeb.MemberLive.Index.CustomFieldValueLookup
alias MvWeb.MemberLive.Index.FilterParams alias MvWeb.MemberLive.Index.FilterParams
require Ash.Query
@join_date_from_param Mv.Constants.join_date_from_param() @join_date_from_param Mv.Constants.join_date_from_param()
@join_date_to_param Mv.Constants.join_date_to_param() @join_date_to_param Mv.Constants.join_date_to_param()
@exit_date_mode_param Mv.Constants.exit_date_mode_param() @exit_date_mode_param Mv.Constants.exit_date_mode_param()

View file

@ -28,6 +28,7 @@ defmodule MvWeb.MemberLive.Show do
alias Mv.Membership.CustomFieldValue alias Mv.Membership.CustomFieldValue
alias Mv.Membership.Member, as: MemberResource alias Mv.Membership.Member, as: MemberResource
alias Mv.Vereinfacht.Client, as: VereinfachtClient alias Mv.Vereinfacht.Client, as: VereinfachtClient
alias MvWeb.Helpers.DateFormatter
alias MvWeb.Helpers.MemberHelpers alias MvWeb.Helpers.MemberHelpers
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
alias Phoenix.HTML.Engine, as: HTMLEngine alias Phoenix.HTML.Engine, as: HTMLEngine
@ -159,12 +160,12 @@ defmodule MvWeb.MemberLive.Show do
<div class="flex gap-6"> <div class="flex gap-6">
<.data_field <.data_field
label={gettext("Join Date")} label={gettext("Join Date")}
value={format_date(@member.join_date)} value={DateFormatter.format_date(@member.join_date)}
class="w-28" class="w-28"
/> />
<.data_field <.data_field
label={gettext("Exit Date")} label={gettext("Exit Date")}
value={format_date(@member.exit_date)} value={DateFormatter.format_date(@member.exit_date)}
class="w-28" class="w-28"
/> />
</div> </div>
@ -719,14 +720,6 @@ defmodule MvWeb.MemberLive.Show do
end end
end end
defp format_date(nil), do: nil
defp format_date(%Date{} = date) do
Calendar.strftime(date, "%d.%m.%Y")
end
defp format_date(date), do: to_string(date)
# Finds custom field value for a given custom field id # Finds custom field value for a given custom field id
# Returns the value (not the CustomFieldValue struct) or nil # Returns the value (not the CustomFieldValue struct) or nil
defp find_custom_field_value(nil, _custom_field_id), do: nil defp find_custom_field_value(nil, _custom_field_id), do: nil
@ -760,7 +753,7 @@ defmodule MvWeb.MemberLive.Show do
end end
defp format_custom_field_value(%Date{} = date, :date) do defp format_custom_field_value(%Date{} = date, :date) do
Calendar.strftime(date, "%d.%m.%Y") DateFormatter.format_date(date)
end end
defp format_custom_field_value(value, :email) when is_binary(value) do defp format_custom_field_value(value, :email) when is_binary(value) do

View file

@ -12,10 +12,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
""" """
use MvWeb, :live_component use MvWeb, :live_component
require Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization, only: [can?: 3] import MvWeb.Authorization, only: [can?: 3]
import MvWeb.Helpers.AshErrorHelpers, only: [format_error: 1] import MvWeb.Helpers.AshErrorHelpers, only: [format_error: 1]
import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership alias Mv.Membership
alias Mv.MembershipFees alias Mv.MembershipFees
@ -25,6 +24,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
require Ash.Query
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""

View file

@ -9,16 +9,15 @@ defmodule MvWeb.MembershipFeeSettingsLive do
""" """
use MvWeb, :live_view use MvWeb, :live_view
require Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.Member alias Mv.Membership.Member
alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
require Ash.Query
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
actor = current_actor(socket) actor = current_actor(socket)
@ -92,47 +91,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
actor = current_actor(socket) MembershipFeeHelpers.delete_fee_type(socket, id, current_actor(socket))
case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
{:ok, fee_type} ->
case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do
:ok ->
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
updated_counts = Map.delete(socket.assigns.member_counts, id)
{:noreply,
socket
|> assign(:membership_fee_types, updated_types)
|> assign(:member_counts, updated_counts)
|> put_flash(:success, gettext("Membership fee type deleted"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this membership fee type")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("Membership fee type not found"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to access this membership fee type")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end end
@impl true @impl true
@ -465,12 +424,6 @@ defmodule MvWeb.MembershipFeeSettingsLive do
Map.get(member_counts, fee_type.id, 0) Map.get(member_counts, fee_type.id, 0)
end end
defp format_error(%Ash.Error.Invalid{} = error) do
Enum.map_join(error.errors, ", ", fn e -> e.message end)
end
defp format_error(_error), do: gettext("An error occurred")
defp assign_form(%{assigns: %{settings: settings}} = socket) do defp assign_form(%{assigns: %{settings: settings}} = socket) do
form = form =
AshPhoenix.Form.for_update( AshPhoenix.Form.for_update(

View file

@ -15,13 +15,13 @@ defmodule MvWeb.MembershipFeeTypeLive.Form do
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3] import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
require Ash.Query
alias Mv.Membership.Member alias Mv.Membership.Member
alias Mv.MembershipFees alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
require Ash.Query
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""

View file

@ -16,14 +16,14 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
require Ash.Query
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.Member alias Mv.Membership.Member
alias Mv.MembershipFees alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
require Ash.Query
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
actor = current_actor(socket) actor = current_actor(socket)
@ -141,47 +141,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
actor = current_actor(socket) MembershipFeeHelpers.delete_fee_type(socket, id, current_actor(socket))
case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
{:ok, fee_type} ->
case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do
:ok ->
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
updated_counts = Map.delete(socket.assigns.member_counts, id)
{:noreply,
socket
|> assign(:membership_fee_types, updated_types)
|> assign(:member_counts, updated_counts)
|> put_flash(:success, gettext("Membership fee type deleted"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this membership fee type")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("Membership fee type not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to access this membership fee type")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end end
# Helper functions # Helper functions
@ -215,12 +175,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
Map.get(member_counts, fee_type.id, 0) Map.get(member_counts, fee_type.id, 0)
end end
defp format_error(%Ash.Error.Invalid{} = error) do
Enum.map_join(error.errors, ", ", fn e -> e.message end)
end
defp format_error(_error), do: gettext("An error occurred")
# Info card explaining the membership fee type concept # Info card explaining the membership fee type concept
defp info_card(assigns) do defp info_card(assigns) do
~H""" ~H"""

View file

@ -13,10 +13,10 @@ defmodule MvWeb.RoleLive.Form do
""" """
use MvWeb, :live_view use MvWeb, :live_view
alias Mv.Authorization.PermissionSets
import MvWeb.RoleLive.Helpers, only: [format_error: 1] import MvWeb.RoleLive.Helpers, only: [format_error: 1]
alias Mv.Authorization.PermissionSets
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""

View file

@ -13,13 +13,13 @@ defmodule MvWeb.RoleLive.Index do
""" """
use MvWeb, :live_view use MvWeb, :live_view
import MvWeb.RoleLive.Helpers, only: [permission_set_badge_variant: 1]
alias Mv.Accounts alias Mv.Accounts
alias Mv.Authorization alias Mv.Authorization
require Ash.Query require Ash.Query
import MvWeb.RoleLive.Helpers, only: [permission_set_badge_variant: 1]
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
actor = socket.assigns[:current_user] actor = socket.assigns[:current_user]

View file

@ -12,13 +12,13 @@ defmodule MvWeb.RoleLive.Show do
""" """
use MvWeb, :live_view use MvWeb, :live_view
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3]
alias Mv.Accounts alias Mv.Accounts
require Ash.Query require Ash.Query
import MvWeb.RoleLive.Helpers,
only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3]
@impl true @impl true
def mount(%{"id" => id}, _session, socket) do def mount(%{"id" => id}, _session, socket) do
case Ash.get( case Ash.get(

View file

@ -6,13 +6,14 @@ defmodule MvWeb.StatisticsLive do
""" """
use MvWeb, :live_view use MvWeb, :live_view
require Logger
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias Mv.Statistics alias Mv.Statistics
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
require Logger
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
# Only static assigns and fee types here; load_statistics runs once in handle_params # Only static assigns and fee types here; load_statistics runs once in handle_params

View file

@ -33,7 +33,9 @@ defmodule MvWeb.UserLive.Form do
""" """
use MvWeb, :live_view use MvWeb, :live_view
require Jason import MvWeb.Authorization, only: [can?: 3]
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
alias Mv.Accounts alias Mv.Accounts
alias Mv.Accounts.User, as: UserResource alias Mv.Accounts.User, as: UserResource
@ -43,10 +45,9 @@ defmodule MvWeb.UserLive.Form do
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.Member, as: MemberResource alias Mv.Membership.Member, as: MemberResource
alias MvWeb.Helpers.MemberHelpers alias MvWeb.Helpers.MemberHelpers
alias MvWeb.Live.MemberDropdownNav
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3] require Jason
import MvWeb.Authorization, only: [can?: 3]
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
@impl true @impl true
def render(assigns) do def render(assigns) do
@ -571,56 +572,8 @@ defmodule MvWeb.UserLive.Form do
end end
@impl true @impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do def handle_event("member_dropdown_keydown", params, socket) do
return_if_dropdown_closed(socket, fn -> MemberDropdownNav.handle_keydown(params, socket, fn -> select_focused_member(socket) end)
max_index = length(socket.assigns.available_members) - 1
current = socket.assigns.focused_member_index
new_index =
case current do
nil -> 0
index when index < max_index -> index + 1
_ -> current
end
{:noreply, assign(socket, focused_member_index: new_index)}
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
return_if_dropdown_closed(socket, fn ->
current = socket.assigns.focused_member_index
new_index =
case current do
nil -> 0
0 -> 0
index -> index - 1
end
{:noreply, assign(socket, focused_member_index: new_index)}
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
return_if_dropdown_closed(socket, fn ->
select_focused_member(socket)
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
return_if_dropdown_closed(socket, fn ->
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end)
end
@impl true
def handle_event("member_dropdown_keydown", _params, socket) do
# Ignore other keys
{:noreply, socket}
end end
@impl true @impl true
@ -778,17 +731,6 @@ defmodule MvWeb.UserLive.Form do
@spec notify_parent(any()) :: {module(), any()} @spec notify_parent(any()) :: {module(), any()}
defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
# Helper to ignore keyboard events when dropdown is closed
@spec return_if_dropdown_closed(Phoenix.LiveView.Socket.t(), function()) ::
{:noreply, Phoenix.LiveView.Socket.t()}
defp return_if_dropdown_closed(socket, func) do
if socket.assigns.show_member_dropdown do
func.()
else
{:noreply, socket}
end
end
# Select the currently focused member from the dropdown # Select the currently focused member from the dropdown
@spec select_focused_member(Phoenix.LiveView.Socket.t()) :: @spec select_focused_member(Phoenix.LiveView.Socket.t()) ::
{:noreply, Phoenix.LiveView.Socket.t()} {:noreply, Phoenix.LiveView.Socket.t()}

View file

@ -3,9 +3,10 @@ defmodule MvWeb.LiveUserAuth do
Helpers for authenticating users in LiveViews. Helpers for authenticating users in LiveViews.
""" """
import Phoenix.Component
use MvWeb, :verified_routes use MvWeb, :verified_routes
import Phoenix.Component
alias AshAuthentication.Phoenix.LiveSession alias AshAuthentication.Phoenix.LiveSession
alias Phoenix.LiveView alias Phoenix.LiveView

View file

@ -193,8 +193,7 @@ msgid "An account with this email already exists. Please verify your password to
msgstr "" msgstr ""
#: lib/mv_web/helpers/ash_error_helpers.ex #: lib/mv_web/helpers/ash_error_helpers.ex
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#: lib/mv_web/live/role_live/helpers.ex #: lib/mv_web/live/role_live/helpers.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "An error occurred" msgid "An error occurred"
@ -2289,14 +2288,12 @@ msgstr ""
msgid "Membership fee start" msgid "Membership fee start"
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Membership fee type deleted" msgid "Membership fee type deleted"
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Membership fee type not found" msgid "Membership fee type not found"
msgstr "" msgstr ""
@ -3956,8 +3953,7 @@ msgstr ""
msgid "You do not have permission to %{action} members." msgid "You do not have permission to %{action} members."
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You do not have permission to access this membership fee type" msgid "You do not have permission to access this membership fee type"
msgstr "" msgstr ""
@ -3973,8 +3969,7 @@ msgstr ""
msgid "You do not have permission to delete this member" msgid "You do not have permission to delete this member"
msgstr "" msgstr ""
#: lib/mv_web/live/membership_fee_settings_live.ex #: lib/mv_web/helpers/membership_fee_helpers.ex
#: lib/mv_web/live/membership_fee_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "You do not have permission to delete this membership fee type" msgid "You do not have permission to delete this membership fee type"
msgstr "" msgstr ""

View file

@ -0,0 +1,32 @@
defmodule Mv.Repo.Migrations.MakeMemberUserFksDeferrable do
@moduledoc """
Makes the members/users foreign keys DEFERRABLE INITIALLY DEFERRED.
Concurrent `create_member` transactions take FK `FOR KEY SHARE` (MultiXact)
locks on these foreign keys at statement time and can form a cross-transaction
lock cycle, producing a PostgreSQL `deadlock_detected` (40P01). Deferring the
FK checks to commit time breaks the cycle.
Constraint deferrability is not tracked by AshPostgres resource snapshots, so
this is a hand-written migration with raw `execute/2`. Do not regenerate it
via `mix ash_postgres.generate_migrations`.
"""
use Ecto.Migration
def change do
execute(
"ALTER TABLE users ALTER CONSTRAINT users_member_id_fkey DEFERRABLE INITIALLY DEFERRED",
"ALTER TABLE users ALTER CONSTRAINT users_member_id_fkey NOT DEFERRABLE"
)
execute(
"ALTER TABLE users ALTER CONSTRAINT users_role_id_fkey DEFERRABLE INITIALLY DEFERRED",
"ALTER TABLE users ALTER CONSTRAINT users_role_id_fkey NOT DEFERRABLE"
)
execute(
"ALTER TABLE members ALTER CONSTRAINT members_membership_fee_type_id_fkey DEFERRABLE INITIALLY DEFERRED",
"ALTER TABLE members ALTER CONSTRAINT members_membership_fee_type_id_fkey NOT DEFERRABLE"
)
end
end

View file

@ -5,10 +5,11 @@ defmodule Mv.Membership.GroupDatabaseConstraintsTest do
""" """
use Mv.DataCase, async: false use Mv.DataCase, async: false
import Ash.Expr
alias Mv.Membership alias Mv.Membership
require Ash.Query require Ash.Query
import Ash.Expr
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()

View file

@ -5,10 +5,11 @@ defmodule Mv.Membership.GroupTest do
""" """
use Mv.DataCase, async: true use Mv.DataCase, async: true
import Ash.Expr
alias Mv.Membership alias Mv.Membership
require Ash.Query require Ash.Query
import Ash.Expr
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()

View file

@ -8,13 +8,14 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do
use Mv.DataCase, async: true use Mv.DataCase, async: true
import Ash.Expr import Ash.Expr
require Ash.Query
alias Mv.Fixtures alias Mv.Fixtures
alias Mv.Helpers.SystemActor alias Mv.Helpers.SystemActor
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.Member alias Mv.Membership.Member
require Ash.Query
defp member_count do defp member_count do
actor = SystemActor.get_system_actor() actor = SystemActor.get_system_actor()
{:ok, members} = Membership.list_members(actor: actor) {:ok, members} = Membership.list_members(actor: actor)

View file

@ -12,13 +12,14 @@ defmodule Mv.Membership.JoinRequestTest do
""" """
use Mv.DataCase, async: true use Mv.DataCase, async: true
require Ash.Query
import Ash.Expr import Ash.Expr
alias Mv.Fixtures alias Mv.Fixtures
alias Mv.Membership alias Mv.Membership
alias Mv.Membership.JoinRequest alias Mv.Membership.JoinRequest
require Ash.Query
# Valid minimal attributes for submit (email required; confirmation_token optional for tests) # Valid minimal attributes for submit (email required; confirmation_token optional for tests)
@valid_submit_attrs %{ @valid_submit_attrs %{
email: "join#{System.unique_integer([:positive])}@example.com" email: "join#{System.unique_integer([:positive])}@example.com"

View file

@ -4,30 +4,15 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
""" """
use Mv.DataCase, async: true use Mv.DataCase, async: true
import Mv.Fixtures, only: [create_fee_type: 2, create_cycle: 4]
alias Mv.MembershipFees.CalendarCycles alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor} %{actor: system_actor}
end end
# Helper to create a membership fee type
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: actor)
end
# Helper to create a member # Helper to create a member
defp create_member(attrs, actor) do defp create_member(attrs, actor) do
default_attrs = %{ default_attrs = %{
@ -41,23 +26,6 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
member member
end end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs, actor) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: actor)
end
describe "current_cycle_status" do describe "current_cycle_status" do
test "returns status of current cycle for member with active cycle", %{actor: actor} do test "returns status of current cycle for member with active cycle", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor) fee_type = create_fee_type(%{interval: :yearly}, actor)

View file

@ -5,10 +5,11 @@ defmodule Mv.Membership.MemberGroupTest do
""" """
use Mv.DataCase, async: true use Mv.DataCase, async: true
import Ash.Expr
alias Mv.Membership alias Mv.Membership
require Ash.Query require Ash.Query
import Ash.Expr
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()

View file

@ -4,10 +4,11 @@ defmodule Mv.Membership.MemberGroupsRelationshipTest do
""" """
use Mv.DataCase, async: true use Mv.DataCase, async: true
import Ash.Expr
alias Mv.Membership alias Mv.Membership
require Ash.Query require Ash.Query
import Ash.Expr
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()

View file

@ -4,9 +4,10 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
""" """
use Mv.DataCase, async: true use Mv.DataCase, async: true
import Mv.Fixtures, only: [create_fee_type: 2, create_cycle: 4]
alias Mv.MembershipFees.CalendarCycles alias Mv.MembershipFees.CalendarCycles
alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
require Ash.Query require Ash.Query
@ -15,21 +16,6 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
%{actor: system_actor} %{actor: system_actor}
end end
# Helper to create a membership fee type
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: actor)
end
# Helper to create a member # Helper to create a member
defp create_member(attrs, actor) do defp create_member(attrs, actor) do
default_attrs = %{ default_attrs = %{
@ -44,23 +30,6 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
member member
end end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs, actor) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id,
status: :unpaid
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: actor)
end
describe "type change cycle regeneration" do describe "type change cycle regeneration" do
test "future unpaid cycles are regenerated with new amount", %{actor: actor} do test "future unpaid cycles are regenerated with new amount", %{actor: actor} do
today = Date.utc_today() today = Date.utc_today()

View file

@ -4,29 +4,15 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
""" """
use Mv.DataCase, async: true use Mv.DataCase, async: true
import Mv.Fixtures, only: [create_fee_type: 2]
alias Mv.MembershipFees.Changes.ValidateSameInterval alias Mv.MembershipFees.Changes.ValidateSameInterval
alias Mv.MembershipFees.MembershipFeeType
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor} %{actor: system_actor}
end end
# Helper to create a membership fee type
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: actor)
end
# Helper to create a member # Helper to create a member
defp create_member(attrs, actor) do defp create_member(attrs, actor) do
default_attrs = %{ default_attrs = %{

View file

@ -4,8 +4,9 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
""" """
use Mv.DataCase, async: false use Mv.DataCase, async: false
import Mv.Fixtures, only: [create_fee_type: 2]
alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
require Ash.Query require Ash.Query
@ -14,21 +15,6 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
%{actor: system_actor} %{actor: system_actor}
end end
# Helper to create a membership fee type
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: actor)
end
# Helper to set up settings # Helper to set up settings
defp setup_settings(include_joining_cycle, actor) do defp setup_settings(include_joining_cycle, actor) do
{:ok, settings} = Mv.Membership.get_settings() {:ok, settings} = Mv.Membership.get_settings()

View file

@ -4,29 +4,15 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
""" """
use Mv.DataCase, async: true use Mv.DataCase, async: true
import Mv.Fixtures, only: [create_fee_type: 2, create_cycle: 4]
alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor} %{actor: system_actor}
end end
# Helper to create a membership fee type
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: actor)
end
# Helper to create a member # Helper to create a member
defp create_member(attrs, actor) do defp create_member(attrs, actor) do
default_attrs = %{ default_attrs = %{
@ -40,22 +26,6 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
member member
end end
# Helper to create a cycle
defp create_cycle(member, fee_type, attrs, actor) do
default_attrs = %{
cycle_start: ~D[2024-01-01],
amount: Decimal.new("50.00"),
member_id: member.id,
membership_fee_type_id: fee_type.id
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeCycle
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: actor)
end
describe "status defaults" do describe "status defaults" do
test "status defaults to :unpaid when creating a cycle", %{actor: actor} do test "status defaults to :unpaid when creating a cycle", %{actor: actor} do
fee_type = create_fee_type(%{interval: :yearly}, actor) fee_type = create_fee_type(%{interval: :yearly}, actor)

View file

@ -4,6 +4,8 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
""" """
use Mv.DataCase, async: false use Mv.DataCase, async: false
import Mv.Fixtures, only: [create_fee_type: 2]
alias Mv.Membership.Member alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
@ -15,21 +17,6 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
%{actor: system_actor} %{actor: system_actor}
end end
# Helper to create a membership fee type
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: actor)
end
describe "admin can create membership fee type" do describe "admin can create membership fee type" do
test "creates type with all fields", %{actor: actor} do test "creates type with all fields", %{actor: actor} do
attrs = %{ attrs = %{

View file

@ -0,0 +1,58 @@
defmodule Mv.ApplicationTest do
@moduledoc """
Guards the AshAuthentication supervisor wiring in `Mv.Application`.
The auth children (token Expunger, audit-log batcher/expunger) resolve their
configuration via `Spark.sparks(otp_app, Ash.Resource)`. With the wrong
`otp_app` they silently start against an empty resource set: the token
Expunger then runs against no token resources and becomes a no-op. This test
pins the corrected `:mv` wiring against the *running* application tree: the
supervisor and all three children are present, and the token Expunger booted
live and resolved the real `:mv` token resource.
The two audit-log children legitimately report an `:undefined` pid because no
`AuditLogResource` resources exist under `:mv` (their init returns `:ignore`);
that is a successful start, not a crash, so they are only asserted present.
"""
use ExUnit.Case, async: false
alias AshAuthentication.TokenResource.Expunger
test "AshAuthentication children boot under the :mv otp_app and resolve real config" do
# Locate the running AshAuthentication.Supervisor inside the live app tree.
auth_sup =
Mv.Supervisor
|> Supervisor.which_children()
|> Enum.find_value(fn
{AshAuthentication.Supervisor, pid, _type, _mods} when is_pid(pid) -> pid
_ -> nil
end)
assert is_pid(auth_sup), "AshAuthentication.Supervisor is not running in Mv.Supervisor"
children = Supervisor.which_children(auth_sup)
child_ids = children |> Enum.map(&elem(&1, 0)) |> Enum.sort()
# All three auth children are present in the supervision tree.
assert child_ids ==
Enum.sort([
AshAuthentication.TokenResource.Expunger,
AshAuthentication.AuditLogResource.Batcher,
AshAuthentication.AuditLogResource.Expunger
])
# The token Expunger booted as a live process and resolved the real :mv
# token resource — proving the children run against non-empty :mv config
# rather than the empty set the wrong otp_app produced.
expunger_pid =
Enum.find_value(children, fn
{Expunger, pid, _, _} when is_pid(pid) -> pid
_ -> nil
end)
assert is_pid(expunger_pid), "token Expunger did not boot as a live process under :mv"
assert Process.alive?(expunger_pid)
assert %{otp_app: :mv, resources: resources} = :sys.get_state(expunger_pid)
assert Map.has_key?(resources, Mv.Accounts.Token)
end
end

View file

@ -12,10 +12,10 @@ defmodule Mv.Authorization.Checks.HasPermissionFailClosedTest do
""" """
use Mv.DataCase, async: true use Mv.DataCase, async: true
alias Mv.Authorization.Checks.HasPermission
import Mv.Fixtures import Mv.Fixtures
alias Mv.Authorization.Checks.HasPermission
test "auto_filter deny-filter matches no records (regression for NOT IN [] allow-all bug)" do test "auto_filter deny-filter matches no records (regression for NOT IN [] allow-all bug)" do
# Arrange: create some members in DB # Arrange: create some members in DB
_m1 = member_fixture() _m1 = member_fixture()

View file

@ -274,17 +274,38 @@ defmodule Mv.Membership.MembersPDFTest do
assert {:ok, _pdf_binary} = result assert {:ok, _pdf_binary} = result
# Wait a bit for cleanup (async cleanup might take a moment) count_export_dirs = fn ->
Process.sleep(100)
# Count temp directories after
after_count =
temp_base temp_base
|> File.ls!() |> File.ls!()
|> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end) |> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end)
end
# Poll the observable cleanup condition (temp-dir count returns to the baseline)
# with a bounded deadline instead of a fixed sleep, so the test waits no longer
# than the cleanup actually needs and still fails if cleanup never runs.
after_count = poll_until_cleaned(count_export_dirs, before_count, 100)
# Should have same or fewer temp dirs (cleanup should have run) # Should have same or fewer temp dirs (cleanup should have run)
assert after_count <= before_count + 1 assert after_count <= before_count + 1
end end
end end
# Bounded poll: returns the export-dir count once it drops back to the baseline
# (cleanup done), or the last observed count when the attempt budget is exhausted
# (so the caller's assertion reports the real state on a genuine cleanup stall).
defp poll_until_cleaned(count_fun, baseline, attempts_left) do
current = count_fun.()
cond do
current <= baseline ->
current
attempts_left <= 0 ->
current
true ->
Process.sleep(10)
poll_until_cleaned(count_fun, baseline, attempts_left - 1)
end
end
end end

View file

@ -12,9 +12,10 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
""" """
use Mv.DataCase, async: false use Mv.DataCase, async: false
import Mv.Fixtures, only: [create_fee_type: 2]
alias Mv.MembershipFees.CycleGenerator alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
require Ash.Query require Ash.Query
@ -23,21 +24,6 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
%{actor: system_actor} %{actor: system_actor}
end end
# Helper to create a membership fee type
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: actor)
end
# Helper to create a member. Note: If membership_fee_type_id is provided, # Helper to create a member. Note: If membership_fee_type_id is provided,
# cycles will be auto-generated during creation in test environment. # cycles will be auto-generated during creation in test environment.
defp create_member(attrs, actor) do defp create_member(attrs, actor) do

View file

@ -4,9 +4,10 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
""" """
use Mv.DataCase, async: false use Mv.DataCase, async: false
import Mv.Fixtures, only: [create_fee_type: 2]
alias Mv.MembershipFees.CycleGenerator alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
require Ash.Query require Ash.Query
@ -15,21 +16,6 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
%{actor: system_actor} %{actor: system_actor}
end end
# Helper to create a membership fee type
defp create_fee_type(attrs, actor) do
default_attrs = %{
name: "Test Fee Type #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
}
attrs = Map.merge(default_attrs, attrs)
MembershipFeeType
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(actor: actor)
end
# Helper to create a member without triggering cycle generation # Helper to create a member without triggering cycle generation
defp create_member_without_cycles(attrs, actor) do defp create_member_without_cycles(attrs, actor) do
default_attrs = %{ default_attrs = %{

View file

@ -8,6 +8,8 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
""" """
use Mv.DataCase, async: false use Mv.DataCase, async: false
import Mv.Fixtures, only: [create_fee_type: 1, create_cycle: 3]
alias Mv.Membership alias Mv.Membership
alias Mv.MembershipFees alias Mv.MembershipFees
@ -32,41 +34,15 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
member member
end end
defp create_fee_type_fixture do defp fee_type_fixture do
admin = Mv.Fixtures.user_with_role_fixture("admin") create_fee_type(%{amount: Decimal.new("10.00"), description: "Test"})
{:ok, fee_type} =
MembershipFees.create_membership_fee_type(
%{
name: "Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("10.00"),
interval: :yearly,
description: "Test"
},
actor: admin
)
fee_type
end end
defp create_cycle_fixture do defp cycle_fixture do
admin = Mv.Fixtures.user_with_role_fixture("admin") create_cycle(create_member_fixture(), fee_type_fixture(), %{
member = create_member_fixture()
fee_type = create_fee_type_fixture()
{:ok, cycle} =
MembershipFees.create_membership_fee_cycle(
%{
member_id: member.id,
membership_fee_type_id: fee_type.id,
cycle_start: Date.utc_today(), cycle_start: Date.utc_today(),
amount: Decimal.new("10.00"), amount: Decimal.new("10.00")
status: :unpaid })
},
actor: admin
)
cycle
end end
describe "own_data permission set" do describe "own_data permission set" do
@ -74,7 +50,7 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
user = Mv.Fixtures.user_with_role_fixture("own_data") user = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_member_fixture() linked_member = create_member_fixture()
other_member = create_member_fixture() other_member = create_member_fixture()
fee_type = create_fee_type_fixture() fee_type = fee_type_fixture()
admin = Mv.Fixtures.user_with_role_fixture("admin") admin = Mv.Fixtures.user_with_role_fixture("admin")
user = user =
@ -130,7 +106,7 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
describe "read_only permission set" do describe "read_only permission set" do
setup %{actor: actor} do setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("read_only") user = Mv.Fixtures.user_with_role_fixture("read_only")
cycle = create_cycle_fixture() cycle = cycle_fixture()
%{actor: actor, user: user, cycle: cycle} %{actor: actor, user: user, cycle: cycle}
end end
@ -156,7 +132,7 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
test "cannot create cycle (returns forbidden)", %{user: user, actor: _actor} do test "cannot create cycle (returns forbidden)", %{user: user, actor: _actor} do
member = create_member_fixture() member = create_member_fixture()
fee_type = create_fee_type_fixture() fee_type = fee_type_fixture()
assert {:error, %Ash.Error.Forbidden{}} = assert {:error, %Ash.Error.Forbidden{}} =
MembershipFees.create_membership_fee_cycle( MembershipFees.create_membership_fee_cycle(
@ -180,7 +156,7 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
describe "normal_user permission set" do describe "normal_user permission set" do
setup %{actor: actor} do setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("normal_user") user = Mv.Fixtures.user_with_role_fixture("normal_user")
cycle = create_cycle_fixture() cycle = cycle_fixture()
%{actor: actor, user: user, cycle: cycle} %{actor: actor, user: user, cycle: cycle}
end end
@ -210,7 +186,7 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
test "can create cycle", %{user: user, actor: _actor} do test "can create cycle", %{user: user, actor: _actor} do
member = create_member_fixture() member = create_member_fixture()
fee_type = create_fee_type_fixture() fee_type = fee_type_fixture()
assert {:ok, created} = assert {:ok, created} =
MembershipFees.create_membership_fee_cycle( MembershipFees.create_membership_fee_cycle(
@ -235,7 +211,7 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
describe "admin permission set" do describe "admin permission set" do
setup %{actor: actor} do setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("admin") user = Mv.Fixtures.user_with_role_fixture("admin")
cycle = create_cycle_fixture() cycle = cycle_fixture()
%{actor: actor, user: user, cycle: cycle} %{actor: actor, user: user, cycle: cycle}
end end
@ -270,7 +246,7 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
test "can create cycle", %{user: user, actor: _actor} do test "can create cycle", %{user: user, actor: _actor} do
member = create_member_fixture() member = create_member_fixture()
fee_type = create_fee_type_fixture() fee_type = fee_type_fixture()
assert {:ok, created} = assert {:ok, created} =
MembershipFees.create_membership_fee_cycle( MembershipFees.create_membership_fee_cycle(

View file

@ -1,59 +0,0 @@
defmodule Mv.OidcRoleSyncConfigTest do
@moduledoc """
Tests for OIDC role sync configuration (OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM).
Reads via Mv.Config (ENV first, then Settings).
"""
use Mv.DataCase, async: false
alias Mv.OidcRoleSyncConfig
describe "oidc_admin_group_name/0" do
test "returns nil when OIDC_ADMIN_GROUP_NAME is not configured" do
restore = clear_env("OIDC_ADMIN_GROUP_NAME")
on_exit(restore)
assert OidcRoleSyncConfig.oidc_admin_group_name() == nil
end
test "returns configured admin group name when set via ENV" do
restore = set_env("OIDC_ADMIN_GROUP_NAME", "mila-admin")
on_exit(restore)
assert OidcRoleSyncConfig.oidc_admin_group_name() == "mila-admin"
end
end
describe "oidc_groups_claim/0" do
test "returns default \"groups\" when OIDC_GROUPS_CLAIM is not configured" do
restore = clear_env("OIDC_GROUPS_CLAIM")
on_exit(restore)
assert OidcRoleSyncConfig.oidc_groups_claim() == "groups"
end
test "returns configured claim name when OIDC_GROUPS_CLAIM is set via ENV" do
restore = set_env("OIDC_GROUPS_CLAIM", "ak_groups")
on_exit(restore)
assert OidcRoleSyncConfig.oidc_groups_claim() == "ak_groups"
end
end
defp set_env(key, value) do
previous = System.get_env(key)
System.put_env(key, value)
fn ->
if previous, do: System.put_env(key, previous), else: System.delete_env(key)
end
end
defp clear_env(key) do
previous = System.get_env(key)
System.delete_env(key)
fn ->
if previous, do: System.put_env(key, previous)
end
end
end

View file

@ -0,0 +1,37 @@
defmodule Mv.DeferrableFkTest do
@moduledoc """
Regression guard for the deferrable-FK migration.
Asserts the schema property directly (`condeferred = true`) for the three
members/users foreign keys. A multi-connection deadlock reproduction cannot
be made deterministic under the Ecto sandbox (ownership serializes
connections), so the structural assertion is the guard here; see the
migration moduledoc for the full FOR-KEY-SHARE/MultiXact deadlock rationale.
"""
use Mv.DataCase, async: true
@deferrable_fks ~w(
users_member_id_fkey
users_role_id_fkey
members_membership_fee_type_id_fkey
)
test "member/user foreign keys are DEFERRABLE INITIALLY DEFERRED" do
query = """
SELECT conname, condeferrable, condeferred
FROM pg_constraint
WHERE conname = ANY($1)
"""
{:ok, %{rows: rows}} = Mv.Repo.query(query, [@deferrable_fks])
found = Map.new(rows, fn [name, deferrable, deferred] -> {name, {deferrable, deferred}} end)
for fk <- @deferrable_fks do
assert Map.has_key?(found, fk), "expected foreign key #{fk} to exist"
assert found[fk] == {true, true},
"expected #{fk} to be DEFERRABLE INITIALLY DEFERRED, got #{inspect(found[fk])}"
end
end
end

View file

@ -4,36 +4,21 @@ defmodule Mv.StatisticsTest do
""" """
use Mv.DataCase, async: true use Mv.DataCase, async: true
require Ash.Query
import Ash.Expr import Ash.Expr
import Mv.Fixtures, only: [create_fee_type: 2]
alias Mv.Membership.Member alias Mv.Membership.Member
alias Mv.MembershipFees alias Mv.MembershipFees
alias Mv.MembershipFees.MembershipFeeCycle alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Statistics alias Mv.Statistics
require Ash.Query
setup do setup do
actor = Mv.Helpers.SystemActor.get_system_actor() actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: actor} %{actor: actor}
end end
defp create_fee_type(actor, attrs) do
MembershipFeeType
|> Ash.Changeset.for_create(
:create,
Map.merge(
%{
name: "Test Fee #{System.unique_integer([:positive])}",
amount: Decimal.new("50.00"),
interval: :yearly
},
attrs
)
)
|> Ash.create!(actor: actor)
end
describe "first_join_year/1" do describe "first_join_year/1" do
test "returns the year of the earliest join_date", %{actor: actor} do test "returns the year of the earliest join_date", %{actor: actor} do
Mv.Fixtures.member_fixture(%{join_date: ~D[2019-03-15]}) Mv.Fixtures.member_fixture(%{join_date: ~D[2019-03-15]})
@ -131,7 +116,7 @@ defmodule Mv.StatisticsTest do
end end
test "returns totals by status for cycles in that year", %{actor: actor} do test "returns totals by status for cycles in that year", %{actor: actor} do
fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")}) fee_type = create_fee_type(%{amount: Decimal.new("50.00")}, actor)
# Creating members with fee type triggers cycle generation (2020..today). We use 2024 cycles. # Creating members with fee type triggers cycle generation (2020..today). We use 2024 cycles.
_member1 = _member1 =
@ -171,8 +156,8 @@ defmodule Mv.StatisticsTest do
test "when fee_type_id is passed in opts, returns only cycles of that fee type", %{ test "when fee_type_id is passed in opts, returns only cycles of that fee type", %{
actor: actor actor: actor
} do } do
fee_type_a = create_fee_type(actor, %{amount: Decimal.new("30.00")}) fee_type_a = create_fee_type(%{amount: Decimal.new("30.00")}, actor)
fee_type_b = create_fee_type(actor, %{amount: Decimal.new("70.00")}) fee_type_b = create_fee_type(%{amount: Decimal.new("70.00")}, actor)
_m1 = _m1 =
Mv.Fixtures.member_fixture(%{ Mv.Fixtures.member_fixture(%{
@ -207,7 +192,7 @@ defmodule Mv.StatisticsTest do
end end
test "returns sum of amount for all unpaid cycles", %{actor: actor} do test "returns sum of amount for all unpaid cycles", %{actor: actor} do
fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")}) fee_type = create_fee_type(%{amount: Decimal.new("50.00")}, actor)
_member = _member =
Mv.Fixtures.member_fixture(%{ Mv.Fixtures.member_fixture(%{

View file

@ -8,12 +8,16 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
- Button label and badge logic - Button label and badge logic
- Filtering to show only boolean custom fields - Filtering to show only boolean custom fields
""" """
# async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB # Kept async: false. The deferrable-FK migration removed the concurrent
# create_member deadlock, but this file additionally showed an async-isolation
# failure under load (filtered members from a parallel test leaking in), so it
# is not trivially async-safe; resolving that is a separate follow-up.
use MvWeb.ConnCase, async: false use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
import Phoenix.LiveViewTest
alias Mv.Membership.CustomField alias Mv.Membership.CustomField
# Helper to create a boolean custom field (uses system_actor - only admin can create) # Helper to create a boolean custom field (uses system_actor - only admin can create)

View file

@ -4,11 +4,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
""" """
use Mv.DataCase, async: true use Mv.DataCase, async: true
require Ash.Query
alias Mv.MembershipFees.CalendarCycles alias Mv.MembershipFees.CalendarCycles
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
require Ash.Query
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor} %{actor: system_actor}

View file

@ -15,10 +15,11 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
use MvWeb.ConnCase, async: true use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, CustomFieldValue, Member} alias Mv.Membership.{CustomField, CustomFieldValue, Member}
require Ash.Query
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
admin_role = Mv.Fixtures.role_fixture("admin") admin_role = Mv.Fixtures.role_fixture("admin")

View file

@ -8,10 +8,11 @@ defmodule MvWeb.CustomFieldLive.FormTest do
use MvWeb.ConnCase, async: true use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.CustomField alias Mv.Membership.CustomField
require Ash.Query
setup do setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
admin_role = Mv.Fixtures.role_fixture("admin") admin_role = Mv.Fixtures.role_fixture("admin")

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