Compare commits
No commits in common. "main" and "1.3.0" have entirely different histories.
141 changed files with 1875 additions and 1422 deletions
30
.credo.exs
30
.credo.exs
|
|
@ -114,7 +114,6 @@
|
||||||
{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, []},
|
||||||
|
|
@ -167,19 +166,13 @@
|
||||||
{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)
|
||||||
|
|
@ -190,7 +183,6 @@
|
||||||
{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, []},
|
||||||
|
|
@ -200,20 +192,24 @@
|
||||||
{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, []},
|
||||||
# IoPuts: 3 violations in Mv.Release seed output; deferred to a follow-up.
|
{Credo.Check.Refactor.DoubleBooleanNegation, []},
|
||||||
|
{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, []},
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,11 @@ 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.Config (oidc_admin_group_name/0, oidc_groups_claim/0).
|
- Module: Mv.OidcRoleSyncConfig (oidc_admin_group_name/0, oidc_groups_claim/0).
|
||||||
|
|
||||||
### Sign-in page (OIDC-only mode)
|
### Sign-in page (OIDC-only mode)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,29 +90,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ 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
|
||||||
|
|
@ -15,8 +16,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,12 @@ 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}
|
||||||
|
|
||||||
|
|
|
||||||
18
lib/accounts/user_identity.exs
Normal file
18
lib/accounts/user_identity.exs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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
|
||||||
|
|
@ -38,10 +38,6 @@ 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: [
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,9 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -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 Ash.Expr
|
|
||||||
import Bitwise
|
import Bitwise
|
||||||
|
require Ash.Query
|
||||||
|
import Ash.Expr
|
||||||
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,7 +49,6 @@ 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."
|
||||||
|
|
|
||||||
|
|
@ -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.ReadLinkedForOwnData, member_id_field: :member_id}
|
authorize_if Mv.Authorization.Checks.MemberGroupReadLinkedForOwnData
|
||||||
end
|
end
|
||||||
|
|
||||||
policy action_type(:read) do
|
policy action_type(:read) do
|
||||||
|
|
|
||||||
|
|
@ -26,15 +26,13 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -19,7 +19,9 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do
|
||||||
"""
|
"""
|
||||||
use Ash.Resource.Change
|
use Ash.Resource.Change
|
||||||
|
|
||||||
alias Mv.Membership.Setting.Changes.JsonbResult
|
alias Ash.Error.Invalid
|
||||||
|
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),
|
||||||
|
|
@ -116,21 +118,62 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do
|
||||||
|
|
||||||
uuid_binary = Ecto.UUID.dump!(settings.id)
|
uuid_binary = Ecto.UUID.dump!(settings.id)
|
||||||
|
|
||||||
JsonbResult.run_update(
|
case SQL.query(Mv.Repo, sql, [field, show_in_overview, required, uuid_binary]) do
|
||||||
sql: sql,
|
{:ok, %{rows: [[updated_visibility, updated_required] | _]}} ->
|
||||||
params: [field, show_in_overview, required, uuid_binary],
|
vis = normalize_jsonb_result(updated_visibility)
|
||||||
on_row: fn [updated_visibility, updated_required | _] ->
|
req = normalize_jsonb_result(updated_required)
|
||||||
%{
|
|
||||||
|
updated_settings = %{
|
||||||
settings
|
settings
|
||||||
| member_field_visibility: JsonbResult.normalize(updated_visibility),
|
| member_field_visibility: vis,
|
||||||
member_field_required: JsonbResult.normalize(updated_required)
|
member_field_required: req
|
||||||
}
|
}
|
||||||
end,
|
|
||||||
error_field: :member_field_required,
|
{:ok, updated_settings}
|
||||||
not_found_message: "Settings not found",
|
|
||||||
error_message: "Failed to update member field settings",
|
{:ok, %{rows: []}} ->
|
||||||
log_message: "Failed to atomically update member field settings"
|
{:error,
|
||||||
)
|
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
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
|
||||||
"""
|
"""
|
||||||
use Ash.Resource.Change
|
use Ash.Resource.Change
|
||||||
|
|
||||||
alias Mv.Membership.Setting.Changes.JsonbResult
|
alias Ash.Error.Invalid
|
||||||
|
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),
|
||||||
|
|
@ -104,17 +106,59 @@ 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)
|
||||||
|
|
||||||
JsonbResult.run_update(
|
case SQL.query(Mv.Repo, sql, [field, show_in_overview, uuid_binary]) do
|
||||||
sql: sql,
|
{:ok, %{rows: [[updated_jsonb] | _]}} ->
|
||||||
params: [field, show_in_overview, uuid_binary],
|
updated_visibility = normalize_jsonb_result(updated_jsonb)
|
||||||
on_row: fn [updated_jsonb | _] ->
|
|
||||||
%{settings | member_field_visibility: JsonbResult.normalize(updated_jsonb)}
|
# Update the settings struct with the new visibility
|
||||||
end,
|
updated_settings = %{settings | member_field_visibility: updated_visibility}
|
||||||
error_field: :member_field_visibility,
|
{:ok, updated_settings}
|
||||||
not_found_message: "Settings not found",
|
|
||||||
error_message: "Failed to update visibility",
|
{:ok, %{rows: []}} ->
|
||||||
log_message: "Failed to atomically update member_field_visibility"
|
{:error,
|
||||||
)
|
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
|
||||||
|
|
|
||||||
|
|
@ -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.ReadLinkedForOwnData, member_id_field: :member_id}
|
authorize_if Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData
|
||||||
end
|
end
|
||||||
|
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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.
|
||||||
|
|
||||||
|
|
@ -17,11 +16,13 @@ defmodule Mix.Tasks.JoinRequests.CleanupExpired do
|
||||||
"""
|
"""
|
||||||
use Mix.Task
|
use Mix.Task
|
||||||
|
|
||||||
alias Mv.Membership.JoinRequest
|
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
alias Mv.Membership.JoinRequest
|
||||||
|
|
||||||
|
@shortdoc "Deletes join requests in pending_confirmation with expired confirmation token"
|
||||||
|
|
||||||
@impl Mix.Task
|
@impl Mix.Task
|
||||||
def run(_args) do
|
def run(_args) do
|
||||||
Mix.Task.run("app.start")
|
Mix.Task.run("app.start")
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,13 @@ 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
|
||||||
|
|
||||||
import Swoosh.Email
|
require Logger
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,13 @@ 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
|
||||||
|
|
||||||
import Swoosh.Email
|
require Logger
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: :mv},
|
{AshAuthentication.Supervisor, otp_app: :my},
|
||||||
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},
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,10 @@ defmodule Mv.Authorization.Actor do
|
||||||
adds complexity and potential for inconsistency.
|
adds complexity and potential for inconsistency.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Ensures the actor (User) has their `:role` relationship loaded.
|
Ensures the actor (User) has their `:role` relationship loaded.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,13 +79,10 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
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
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -6,9 +6,8 @@ defmodule Mv.EmailSync.Helpers do
|
||||||
provides clean abstractions for email updates within transactions.
|
provides clean abstractions for email updates within transactions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import Ecto.Changeset
|
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Extracts the record from an Ash action result.
|
Extracts the record from an Ash action result.
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,10 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
|
|
||||||
use Agent
|
use Agent
|
||||||
|
|
||||||
alias Mv.Config
|
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
alias Mv.Config
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Starts the SystemActor Agent.
|
Starts the SystemActor Agent.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,11 @@ 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
|
||||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
|
||||||
|
|
||||||
import Swoosh.Email
|
import Swoosh.Email
|
||||||
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Mv.Membership.Import.HeaderMapper
|
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
alias Mv.Membership.Import.HeaderMapper
|
||||||
|
|
||||||
@preview_row_limit 3
|
@preview_row_limit 3
|
||||||
|
|
||||||
@type numbered_row :: {pos_integer(), [String.t()]}
|
@type numbered_row :: {pos_integer(), [String.t()]}
|
||||||
|
|
|
||||||
|
|
@ -53,14 +53,6 @@ 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.
|
||||||
|
|
@ -109,6 +101,17 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,12 @@ 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
|
||||||
|
|
||||||
|
|
@ -29,8 +35,261 @@ 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
|
||||||
|
|
@ -39,6 +298,20 @@ 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).
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,13 @@ 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 """
|
||||||
|
|
|
||||||
|
|
@ -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 """
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,27 @@ 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: run([])
|
def run do
|
||||||
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
defmodule Mv.MembershipFees.CycleGenerator do
|
defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
|
@typedoc "Aggregate counts returned by a batch cycle-generation run."
|
||||||
|
@type results_summary :: %{
|
||||||
|
success: non_neg_integer(),
|
||||||
|
failed: non_neg_integer(),
|
||||||
|
total: non_neg_integer()
|
||||||
|
}
|
||||||
|
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Module for generating membership fee cycles for members.
|
Module for generating membership fee cycles for members.
|
||||||
|
|
||||||
|
|
@ -59,13 +66,6 @@ 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()}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 Mv.Config).
|
Configure via OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM (see OidcRoleSyncConfig).
|
||||||
|
|
||||||
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.Config
|
alias Mv.OidcRoleSyncConfig
|
||||||
|
|
||||||
@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 = Config.oidc_admin_group_name()
|
admin_group = OidcRoleSyncConfig.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 = Config.oidc_groups_claim()
|
claim = OidcRoleSyncConfig.oidc_groups_claim()
|
||||||
groups = groups_from_user_info(user_info, claim)
|
groups = groups_from_user_info(user_info, claim)
|
||||||
|
|
||||||
groups =
|
groups =
|
||||||
|
|
|
||||||
20
lib/mv/oidc_role_sync_config.ex
Normal file
20
lib/mv/oidc_role_sync_config.ex
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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
|
||||||
|
|
@ -12,6 +12,8 @@ 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
|
||||||
|
|
@ -19,8 +21,6 @@ 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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ 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
|
||||||
|
|
@ -30,7 +32,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 Loader.get_linked_member(user) do
|
case load_linked_member(user) do
|
||||||
nil ->
|
nil ->
|
||||||
{:ok, user}
|
{:ok, user}
|
||||||
|
|
||||||
|
|
@ -53,4 +55,17 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,13 @@ 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,8 @@ 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
|
||||||
use Gettext, backend: MvWeb.Gettext
|
|
||||||
|
|
||||||
import MvWeb.CoreComponents
|
import MvWeb.CoreComponents
|
||||||
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
attr :field, :atom, required: true
|
attr :field, :atom, required: true
|
||||||
attr :label, :string, required: true
|
attr :label, :string, required: true
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -13,8 +13,7 @@ defmodule MvWeb.ImportTemplateController do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :controller
|
use MvWeb, :controller
|
||||||
|
|
||||||
import MvWeb.ControllerHelpers, only: [current_actor: 1]
|
alias Mv.Authorization.Actor
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
alias Mv.Membership.MembersCSV
|
alias Mv.Membership.MembersCSV
|
||||||
alias MvWeb.Authorization
|
alias MvWeb.Authorization
|
||||||
|
|
@ -106,6 +105,11 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -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,8 +18,7 @@ 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"]
|
||||||
|
|
@ -54,6 +53,11 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@ defmodule MvWeb.MemberPdfExportController do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use MvWeb, :controller
|
use MvWeb, :controller
|
||||||
use Gettext, backend: MvWeb.Gettext
|
|
||||||
|
|
||||||
import MvWeb.ControllerHelpers, only: [current_actor: 1]
|
require Logger
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
require Logger
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
@payload_required_message "payload required"
|
@payload_required_message "payload required"
|
||||||
@invalid_json_message "invalid JSON"
|
@invalid_json_message "invalid JSON"
|
||||||
|
|
@ -79,6 +79,13 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,15 @@ 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 MvWeb.Emails.JoinEmail
|
alias Mv.Mailer
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Sends the "already a member" notice to the given address.
|
Sends the "already a member" notice to the given address.
|
||||||
|
|
@ -17,6 +23,20 @@ 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")
|
||||||
|
|
||||||
JoinEmail.deliver(email_address, "join_already_member.html", subject)
|
assigns = %{
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,15 @@ 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 MvWeb.Emails.JoinEmail
|
alias Mv.Mailer
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Sends the "application already under review" notice to the given address.
|
Sends the "application already under review" notice to the given address.
|
||||||
|
|
@ -18,6 +24,20 @@ 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")
|
||||||
|
|
||||||
JoinEmail.deliver(email_address, "join_already_pending.html", subject)
|
assigns = %{
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,15 @@ 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 MvWeb.Emails.JoinEmail
|
alias Mv.Mailer
|
||||||
|
|
||||||
@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.
|
||||||
|
|
@ -26,9 +31,22 @@ 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")
|
||||||
|
|
||||||
JoinEmail.deliver(email_address, "join_confirmation.html", subject, %{
|
assigns = %{
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -9,11 +9,8 @@ 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.
|
||||||
|
|
@ -101,8 +98,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 = DateFormatter.format_date(cycle_start)
|
start_str = format_date(cycle_start)
|
||||||
end_str = DateFormatter.format_date(cycle_end)
|
end_str = format_date(cycle_end)
|
||||||
"#{start_str} - #{end_str}"
|
"#{start_str} - #{end_str}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -252,68 +249,8 @@ 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"
|
||||||
|
|
||||||
@doc """
|
# Private helper function for date formatting
|
||||||
Handles a membership-fee-type "delete" event for the fee-type list and the
|
defp format_date(%Date{} = date) do
|
||||||
fee-settings LiveViews.
|
Calendar.strftime(date, "%d.%m.%Y")
|
||||||
|
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,13 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ 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
|
||||||
|
|
@ -43,8 +44,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,14 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
import MvWeb.Authorization
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
import MvWeb.Authorization
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -567,8 +566,56 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("member_dropdown_keydown", params, socket) do
|
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
|
||||||
MemberDropdownNav.handle_keydown(params, socket, fn -> select_focused_member(socket) end)
|
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
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
@ -658,6 +705,14 @@ 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 ->
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,15 @@ defmodule MvWeb.JoinRequestLive.Index do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
import MvWeb.Authorization
|
require Logger
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
import MvWeb.Authorization
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,10 @@ defmodule MvWeb.JoinRequestLive.Show do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
import MvWeb.Authorization
|
require Logger
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
import MvWeb.Authorization
|
||||||
|
|
||||||
alias Mv.Constants
|
alias Mv.Constants
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
@ -24,8 +26,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -20,6 +20,7 @@ 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
|
||||||
|
|
@ -31,8 +32,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ 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]
|
||||||
|
|
||||||
|
|
@ -43,9 +45,6 @@ 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()
|
||||||
|
|
@ -1027,7 +1026,8 @@ 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)
|
||||||
members = Ash.read!(query, actor: actor)
|
{time_microseconds, members} = :timer.tc(fn -> Ash.read!(query, actor: actor) end)
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,12 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ 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
|
||||||
|
|
@ -160,12 +159,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={DateFormatter.format_date(@member.join_date)}
|
value={format_date(@member.join_date)}
|
||||||
class="w-28"
|
class="w-28"
|
||||||
/>
|
/>
|
||||||
<.data_field
|
<.data_field
|
||||||
label={gettext("Exit Date")}
|
label={gettext("Exit Date")}
|
||||||
value={DateFormatter.format_date(@member.exit_date)}
|
value={format_date(@member.exit_date)}
|
||||||
class="w-28"
|
class="w-28"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -720,6 +719,14 @@ 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
|
||||||
|
|
@ -753,7 +760,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
|
||||||
DateFormatter.format_date(date)
|
Calendar.strftime(date, "%d.%m.%Y")
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ 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
|
||||||
|
|
@ -24,8 +25,6 @@ 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"""
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,16 @@ 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)
|
||||||
|
|
@ -91,7 +92,47 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
MembershipFeeHelpers.delete_fee_type(socket, id, current_actor(socket))
|
actor = 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
|
||||||
|
|
@ -424,6 +465,12 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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,7 +141,47 @@ 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
|
||||||
MembershipFeeHelpers.delete_fee_type(socket, id, current_actor(socket))
|
actor = 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
|
||||||
|
|
@ -175,6 +215,12 @@ 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"""
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,10 @@ defmodule MvWeb.RoleLive.Form do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
import MvWeb.RoleLive.Helpers, only: [format_error: 1]
|
|
||||||
|
|
||||||
alias Mv.Authorization.PermissionSets
|
alias Mv.Authorization.PermissionSets
|
||||||
|
|
||||||
|
import MvWeb.RoleLive.Helpers, only: [format_error: 1]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,13 @@ defmodule MvWeb.StatisticsLive do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
require Logger
|
||||||
|
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
import MvWeb.Authorization, only: [can?: 3]
|
require Jason
|
||||||
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
|
||||||
|
|
@ -45,9 +43,10 @@ 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
|
|
||||||
|
|
||||||
require Jason
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
|
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
|
||||||
|
|
@ -572,8 +571,56 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("member_dropdown_keydown", params, socket) do
|
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
|
||||||
MemberDropdownNav.handle_keydown(params, socket, fn -> select_focused_member(socket) end)
|
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
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
@ -731,6 +778,17 @@ 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()}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,8 @@ defmodule MvWeb.LiveUserAuth do
|
||||||
Helpers for authenticating users in LiveViews.
|
Helpers for authenticating users in LiveViews.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use MvWeb, :verified_routes
|
|
||||||
|
|
||||||
import Phoenix.Component
|
import Phoenix.Component
|
||||||
|
use MvWeb, :verified_routes
|
||||||
|
|
||||||
alias AshAuthentication.Phoenix.LiveSession
|
alias AshAuthentication.Phoenix.LiveSession
|
||||||
alias Phoenix.LiveView
|
alias Phoenix.LiveView
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,8 @@ 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/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/live/membership_fee_settings_live.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"
|
||||||
|
|
@ -2288,12 +2289,14 @@ msgstr ""
|
||||||
msgid "Membership fee start"
|
msgid "Membership fee start"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/live/membership_fee_settings_live.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/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/live/membership_fee_settings_live.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 ""
|
||||||
|
|
@ -3953,7 +3956,8 @@ msgstr ""
|
||||||
msgid "You do not have permission to %{action} members."
|
msgid "You do not have permission to %{action} members."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/live/membership_fee_settings_live.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 ""
|
||||||
|
|
@ -3969,7 +3973,8 @@ 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/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/live/membership_fee_settings_live.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 ""
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -5,11 +5,10 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,10 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,13 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,13 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,30 @@ 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 = %{
|
||||||
|
|
@ -26,6 +41,23 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,10 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,10 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,9 @@ 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
|
||||||
|
|
||||||
|
|
@ -16,6 +15,21 @@ 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 = %{
|
||||||
|
|
@ -30,6 +44,23 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,29 @@ 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 = %{
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,8 @@ 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
|
||||||
|
|
||||||
|
|
@ -15,6 +14,21 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,29 @@ 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 = %{
|
||||||
|
|
@ -26,6 +40,22 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ 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
|
||||||
|
|
@ -17,6 +15,21 @@ 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 = %{
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -12,10 +12,10 @@ defmodule Mv.Authorization.Checks.HasPermissionFailClosedTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
import Mv.Fixtures
|
|
||||||
|
|
||||||
alias Mv.Authorization.Checks.HasPermission
|
alias Mv.Authorization.Checks.HasPermission
|
||||||
|
|
||||||
|
import Mv.Fixtures
|
||||||
|
|
||||||
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()
|
||||||
|
|
|
||||||
|
|
@ -274,38 +274,17 @@ defmodule Mv.Membership.MembersPDFTest do
|
||||||
|
|
||||||
assert {:ok, _pdf_binary} = result
|
assert {:ok, _pdf_binary} = result
|
||||||
|
|
||||||
count_export_dirs = fn ->
|
# Wait a bit for cleanup (async cleanup might take a moment)
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,9 @@ 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
|
||||||
|
|
||||||
|
|
@ -24,6 +23,21 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,9 @@ 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
|
||||||
|
|
||||||
|
|
@ -16,6 +15,21 @@ 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 = %{
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -34,15 +32,41 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
|
||||||
member
|
member
|
||||||
end
|
end
|
||||||
|
|
||||||
defp fee_type_fixture do
|
defp create_fee_type_fixture do
|
||||||
create_fee_type(%{amount: Decimal.new("10.00"), description: "Test"})
|
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
|
||||||
|
{: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 cycle_fixture do
|
defp create_cycle_fixture do
|
||||||
create_cycle(create_member_fixture(), fee_type_fixture(), %{
|
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
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
|
||||||
|
|
@ -50,7 +74,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 = fee_type_fixture()
|
fee_type = create_fee_type_fixture()
|
||||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||||
|
|
||||||
user =
|
user =
|
||||||
|
|
@ -106,7 +130,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 = cycle_fixture()
|
cycle = create_cycle_fixture()
|
||||||
%{actor: actor, user: user, cycle: cycle}
|
%{actor: actor, user: user, cycle: cycle}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -132,7 +156,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 = fee_type_fixture()
|
fee_type = create_fee_type_fixture()
|
||||||
|
|
||||||
assert {:error, %Ash.Error.Forbidden{}} =
|
assert {:error, %Ash.Error.Forbidden{}} =
|
||||||
MembershipFees.create_membership_fee_cycle(
|
MembershipFees.create_membership_fee_cycle(
|
||||||
|
|
@ -156,7 +180,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 = cycle_fixture()
|
cycle = create_cycle_fixture()
|
||||||
%{actor: actor, user: user, cycle: cycle}
|
%{actor: actor, user: user, cycle: cycle}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -186,7 +210,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 = fee_type_fixture()
|
fee_type = create_fee_type_fixture()
|
||||||
|
|
||||||
assert {:ok, created} =
|
assert {:ok, created} =
|
||||||
MembershipFees.create_membership_fee_cycle(
|
MembershipFees.create_membership_fee_cycle(
|
||||||
|
|
@ -211,7 +235,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 = cycle_fixture()
|
cycle = create_cycle_fixture()
|
||||||
%{actor: actor, user: user, cycle: cycle}
|
%{actor: actor, user: user, cycle: cycle}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -246,7 +270,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 = fee_type_fixture()
|
fee_type = create_fee_type_fixture()
|
||||||
|
|
||||||
assert {:ok, created} =
|
assert {:ok, created} =
|
||||||
MembershipFees.create_membership_fee_cycle(
|
MembershipFees.create_membership_fee_cycle(
|
||||||
|
|
|
||||||
59
test/mv/oidc_role_sync_config_test.exs
Normal file
59
test/mv/oidc_role_sync_config_test.exs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
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
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -4,21 +4,36 @@ 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]})
|
||||||
|
|
@ -116,7 +131,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(%{amount: Decimal.new("50.00")}, actor)
|
fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")})
|
||||||
|
|
||||||
# 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 =
|
||||||
|
|
@ -156,8 +171,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(%{amount: Decimal.new("30.00")}, actor)
|
fee_type_a = create_fee_type(actor, %{amount: Decimal.new("30.00")})
|
||||||
fee_type_b = create_fee_type(%{amount: Decimal.new("70.00")}, actor)
|
fee_type_b = create_fee_type(actor, %{amount: Decimal.new("70.00")})
|
||||||
|
|
||||||
_m1 =
|
_m1 =
|
||||||
Mv.Fixtures.member_fixture(%{
|
Mv.Fixtures.member_fixture(%{
|
||||||
|
|
@ -192,7 +207,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(%{amount: Decimal.new("50.00")}, actor)
|
fee_type = create_fee_type(actor, %{amount: Decimal.new("50.00")})
|
||||||
|
|
||||||
_member =
|
_member =
|
||||||
Mv.Fixtures.member_fixture(%{
|
Mv.Fixtures.member_fixture(%{
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,11 @@ 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
|
||||||
"""
|
"""
|
||||||
# Kept async: false. The deferrable-FK migration removed the concurrent
|
# async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
|
||||||
# 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
|
||||||
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Mv.Membership.CustomField
|
alias Mv.Membership.CustomField
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,10 @@ 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")
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,10 @@ 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
Loading…
Add table
Add a link
Reference in a new issue