Mechanical cleanup, quick fixes & deduplication closes #531 #543
116 changed files with 762 additions and 1144 deletions
30
.credo.exs
30
.credo.exs
|
|
@ -114,6 +114,7 @@
|
||||||
{Credo.Check.Readability.RedundantBlankLines, []},
|
{Credo.Check.Readability.RedundantBlankLines, []},
|
||||||
{Credo.Check.Readability.Semicolons, []},
|
{Credo.Check.Readability.Semicolons, []},
|
||||||
{Credo.Check.Readability.SpaceAfterCommas, []},
|
{Credo.Check.Readability.SpaceAfterCommas, []},
|
||||||
|
{Credo.Check.Readability.StrictModuleLayout, []},
|
||||||
{Credo.Check.Readability.StringSigils, []},
|
{Credo.Check.Readability.StringSigils, []},
|
||||||
{Credo.Check.Readability.TrailingBlankLine, []},
|
{Credo.Check.Readability.TrailingBlankLine, []},
|
||||||
{Credo.Check.Readability.TrailingWhiteSpace, []},
|
{Credo.Check.Readability.TrailingWhiteSpace, []},
|
||||||
|
|
@ -166,13 +167,19 @@
|
||||||
{Credo.Check.Warning.UnusedTupleOperation, []},
|
{Credo.Check.Warning.UnusedTupleOperation, []},
|
||||||
{Credo.Check.Warning.WrongTestFileExtension, []},
|
{Credo.Check.Warning.WrongTestFileExtension, []},
|
||||||
# Module documentation check (enabled after adding @moduledoc to all modules)
|
# Module documentation check (enabled after adding @moduledoc to all modules)
|
||||||
{Credo.Check.Readability.ModuleDoc, []}
|
{Credo.Check.Readability.ModuleDoc, []},
|
||||||
|
|
||||||
|
# Promoted in the cleanup ratchet (each currently at zero violations):
|
||||||
|
{Credo.Check.Refactor.DoubleBooleanNegation, []},
|
||||||
|
{Credo.Check.Refactor.FilterReject, []},
|
||||||
|
{Credo.Check.Refactor.RejectFilter, []},
|
||||||
|
{Credo.Check.Refactor.UtcNowTruncate, []},
|
||||||
|
{Credo.Check.Warning.LazyLogging, []},
|
||||||
|
{Credo.Check.Warning.LeakyEnvironment, []},
|
||||||
|
{Credo.Check.Warning.MapGetUnsafePass, []},
|
||||||
|
{Credo.Check.Warning.MixEnv, []}
|
||||||
],
|
],
|
||||||
disabled: [
|
disabled: [
|
||||||
#
|
|
||||||
# Checks scheduled for next check update (opt-in for now)
|
|
||||||
{Credo.Check.Refactor.UtcNowTruncate, []},
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
|
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
|
||||||
# and be sure to use `mix credo --strict` to see low priority checks)
|
# and be sure to use `mix credo --strict` to see low priority checks)
|
||||||
|
|
@ -183,6 +190,7 @@
|
||||||
{Credo.Check.Design.SkipTestWithoutComment, []},
|
{Credo.Check.Design.SkipTestWithoutComment, []},
|
||||||
{Credo.Check.Readability.AliasAs, []},
|
{Credo.Check.Readability.AliasAs, []},
|
||||||
{Credo.Check.Readability.BlockPipe, []},
|
{Credo.Check.Readability.BlockPipe, []},
|
||||||
|
# ImplTrue: ~269 violations; deferred to a follow-up.
|
||||||
{Credo.Check.Readability.ImplTrue, []},
|
{Credo.Check.Readability.ImplTrue, []},
|
||||||
{Credo.Check.Readability.MultiAlias, []},
|
{Credo.Check.Readability.MultiAlias, []},
|
||||||
{Credo.Check.Readability.NestedFunctionCalls, []},
|
{Credo.Check.Readability.NestedFunctionCalls, []},
|
||||||
|
|
@ -192,24 +200,20 @@
|
||||||
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
|
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
|
||||||
{Credo.Check.Readability.SinglePipe, []},
|
{Credo.Check.Readability.SinglePipe, []},
|
||||||
{Credo.Check.Readability.Specs, []},
|
{Credo.Check.Readability.Specs, []},
|
||||||
{Credo.Check.Readability.StrictModuleLayout, []},
|
|
||||||
{Credo.Check.Readability.WithCustomTaggedTuple, []},
|
{Credo.Check.Readability.WithCustomTaggedTuple, []},
|
||||||
{Credo.Check.Refactor.ABCSize, []},
|
{Credo.Check.Refactor.ABCSize, []},
|
||||||
|
# AppendSingleItem: ~10 violations (mostly tests); deferred to a follow-up.
|
||||||
{Credo.Check.Refactor.AppendSingleItem, []},
|
{Credo.Check.Refactor.AppendSingleItem, []},
|
||||||
{Credo.Check.Refactor.DoubleBooleanNegation, []},
|
# IoPuts: 3 violations in Mv.Release seed output; deferred to a follow-up.
|
||||||
{Credo.Check.Refactor.FilterReject, []},
|
|
||||||
{Credo.Check.Refactor.IoPuts, []},
|
{Credo.Check.Refactor.IoPuts, []},
|
||||||
|
# MapMap: ~8 violations; deferred to a follow-up.
|
||||||
{Credo.Check.Refactor.MapMap, []},
|
{Credo.Check.Refactor.MapMap, []},
|
||||||
{Credo.Check.Refactor.ModuleDependencies, []},
|
{Credo.Check.Refactor.ModuleDependencies, []},
|
||||||
|
# NegatedIsNil: ~63 violations; deferred to a follow-up.
|
||||||
{Credo.Check.Refactor.NegatedIsNil, []},
|
{Credo.Check.Refactor.NegatedIsNil, []},
|
||||||
{Credo.Check.Refactor.PassAsyncInTestCases, []},
|
{Credo.Check.Refactor.PassAsyncInTestCases, []},
|
||||||
{Credo.Check.Refactor.PipeChainStart, []},
|
{Credo.Check.Refactor.PipeChainStart, []},
|
||||||
{Credo.Check.Refactor.RejectFilter, []},
|
|
||||||
{Credo.Check.Refactor.VariableRebinding, []},
|
{Credo.Check.Refactor.VariableRebinding, []},
|
||||||
{Credo.Check.Warning.LazyLogging, []},
|
|
||||||
{Credo.Check.Warning.LeakyEnvironment, []},
|
|
||||||
{Credo.Check.Warning.MapGetUnsafePass, []},
|
|
||||||
{Credo.Check.Warning.MixEnv, []},
|
|
||||||
{Credo.Check.Warning.UnsafeToAtom, []}
|
{Credo.Check.Warning.UnsafeToAtom, []}
|
||||||
|
|
||||||
# {Credo.Check.Refactor.MapInto, []},
|
# {Credo.Check.Refactor.MapInto, []},
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
|
|
||||||
- `OIDC_ADMIN_GROUP_NAME` – OIDC group name that maps to the Admin role. If unset, no role sync.
|
- `OIDC_ADMIN_GROUP_NAME` – OIDC group name that maps to the Admin role. If unset, no role sync.
|
||||||
- `OIDC_GROUPS_CLAIM` – JWT claim name for group list (default "groups").
|
- `OIDC_GROUPS_CLAIM` – JWT claim name for group list (default "groups").
|
||||||
- Module: Mv.OidcRoleSyncConfig (oidc_admin_group_name/0, oidc_groups_claim/0).
|
- Module: Mv.Config (oidc_admin_group_name/0, oidc_groups_claim/0).
|
||||||
|
|
||||||
### Sign-in page (OIDC-only mode)
|
### Sign-in page (OIDC-only mode)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ defmodule Mv.Accounts.User do
|
||||||
extensions: [AshAuthentication],
|
extensions: [AshAuthentication],
|
||||||
authorizers: [Ash.Policy.Authorizer]
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
|
||||||
alias Ash.Resource.Preparation.Builtins
|
alias Ash.Resource.Preparation.Builtins
|
||||||
|
|
@ -16,6 +15,8 @@ defmodule Mv.Accounts.User do
|
||||||
alias Mv.Helpers.SystemActor
|
alias Mv.Helpers.SystemActor
|
||||||
alias Mv.OidcRoleSync
|
alias Mv.OidcRoleSync
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
table "users"
|
table "users"
|
||||||
repo Mv.Repo
|
repo Mv.Repo
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,13 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
||||||
- Allow (new user will be created)
|
- Allow (new user will be created)
|
||||||
"""
|
"""
|
||||||
use Ash.Resource.Validation
|
use Ash.Resource.Validation
|
||||||
require Logger
|
|
||||||
|
|
||||||
alias Mv.Accounts.User
|
alias Mv.Accounts.User
|
||||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||||
alias Mv.Helpers.SystemActor
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def init(opts), do: {:ok, opts}
|
def init(opts), do: {:ok, opts}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
defmodule Mv.Accounts.UserIdentity do
|
|
||||||
@moduledoc """
|
|
||||||
AshAuthentication specific ressource
|
|
||||||
"""
|
|
||||||
use Ash.Resource,
|
|
||||||
data_layer: AshPostgres.DataLayer,
|
|
||||||
extensions: [AshAuthentication.UserIdentity],
|
|
||||||
domain: Mv.Accounts
|
|
||||||
|
|
||||||
postgres do
|
|
||||||
table "user_identities"
|
|
||||||
repo Mv.Repo
|
|
||||||
end
|
|
||||||
|
|
||||||
user_identity do
|
|
||||||
user_resource Mv.Accounts.User
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -38,6 +38,10 @@ defmodule Mv.Membership.Email do
|
||||||
@min_length 5
|
@min_length 5
|
||||||
@max_length 254
|
@max_length 254
|
||||||
|
|
||||||
|
# These compile-time constants are referenced by the `use` options below, so
|
||||||
|
# they must be declared first; StrictModuleLayout cannot be satisfied by
|
||||||
|
# reordering here without breaking the macro expansion.
|
||||||
|
# credo:disable-for-next-line Credo.Check.Readability.StrictModuleLayout
|
||||||
use Ash.Type.NewType,
|
use Ash.Type.NewType,
|
||||||
subtype_of: :string,
|
subtype_of: :string,
|
||||||
constraints: [
|
constraints: [
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,10 @@ defmodule Mv.Membership.Group do
|
||||||
data_layer: AshPostgres.DataLayer,
|
data_layer: AshPostgres.DataLayer,
|
||||||
authorizers: [Ash.Policy.Authorizer]
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
alias Mv.Helpers
|
alias Mv.Helpers
|
||||||
alias Mv.Helpers.SystemActor
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,9 @@ defmodule Mv.Membership.Member do
|
||||||
data_layer: AshPostgres.DataLayer,
|
data_layer: AshPostgres.DataLayer,
|
||||||
authorizers: [Ash.Policy.Authorizer]
|
authorizers: [Ash.Policy.Authorizer]
|
||||||
|
|
||||||
import Bitwise
|
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
import Bitwise
|
||||||
|
|
||||||
alias Ecto.Adapters.SQL, as: EctoSQL
|
alias Ecto.Adapters.SQL, as: EctoSQL
|
||||||
alias Mv.Helpers
|
alias Mv.Helpers
|
||||||
alias Mv.Helpers.SystemActor
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
@ -49,6 +49,7 @@ defmodule Mv.Membership.Member do
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
alias Mv.Repo
|
alias Mv.Repo
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@typedoc "An `Mv.Membership.Member` resource record."
|
@typedoc "An `Mv.Membership.Member` resource record."
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ defmodule Mv.Membership.MemberGroup do
|
||||||
policies do
|
policies do
|
||||||
bypass action_type(:read) do
|
bypass action_type(:read) do
|
||||||
description "own_data: read only member_groups where member_id == actor.member_id"
|
description "own_data: read only member_groups where member_id == actor.member_id"
|
||||||
authorize_if Mv.Authorization.Checks.MemberGroupReadLinkedForOwnData
|
authorize_if {Mv.Authorization.Checks.ReadLinkedForOwnData, member_id_field: :member_id}
|
||||||
end
|
end
|
||||||
|
|
||||||
policy action_type(:read) do
|
policy action_type(:read) do
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,15 @@ defmodule Mv.Membership do
|
||||||
use Ash.Domain,
|
use Ash.Domain,
|
||||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
|
||||||
alias Ash.Error.Query.NotFound, as: NotFoundError
|
alias Ash.Error.Query.NotFound, as: NotFoundError
|
||||||
alias Mv.Helpers.SystemActor
|
alias Mv.Helpers.SystemActor
|
||||||
alias Mv.Membership.JoinRequest
|
alias Mv.Membership.JoinRequest
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
alias Mv.Membership.SettingsCache
|
alias Mv.Membership.SettingsCache
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
admin do
|
admin do
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
98
lib/membership/setting/changes/jsonb_result.ex
Normal file
98
lib/membership/setting/changes/jsonb_result.ex
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
defmodule Mv.Membership.Setting.Changes.JsonbResult do
|
||||||
|
@moduledoc """
|
||||||
|
Shared normalization for the JSONB column values returned by the atomic
|
||||||
|
single-member-field settings updates.
|
||||||
|
|
||||||
|
PostgreSQL may return a JSONB column as an already-decoded map (atom or string
|
||||||
|
keys) or as a raw JSON string depending on the driver path. This helper
|
||||||
|
normalizes either form to a string-keyed map, returning `%{}` for unexpected
|
||||||
|
or undecodable input.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Ash.Error.Invalid
|
||||||
|
alias Ecto.Adapters.SQL
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Runs an atomic single-statement JSONB settings UPDATE inside an after_action
|
||||||
|
and maps the RETURNING row back onto the settings struct.
|
||||||
|
|
||||||
|
Shared by the single-member-field change modules, which differ only in the
|
||||||
|
SQL statement, its parameters, the row-to-settings mapping, and the error
|
||||||
|
labels. The three-branch result handling (row found / not found / SQL error)
|
||||||
|
is identical and lives here.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `:sql` - The UPDATE statement with a RETURNING clause (required).
|
||||||
|
- `:params` - The full parameter list for the statement (required).
|
||||||
|
- `:on_row` - 1-arity function mapping the RETURNING row (a list of column
|
||||||
|
values) to the updated settings struct (required).
|
||||||
|
- `:error_field` - Ash error field for the not-found / failure errors.
|
||||||
|
- `:not_found_message` - Error message when no row matched.
|
||||||
|
- `:error_message` - Error message when the SQL statement failed.
|
||||||
|
- `:log_message` - Log prefix written on SQL failure.
|
||||||
|
"""
|
||||||
|
@spec run_update(keyword()) ::
|
||||||
|
{:ok, map()} | {:error, Exception.t()}
|
||||||
|
def run_update(opts) do
|
||||||
|
sql = Keyword.fetch!(opts, :sql)
|
||||||
|
params = Keyword.fetch!(opts, :params)
|
||||||
|
on_row = Keyword.fetch!(opts, :on_row)
|
||||||
|
error_field = Keyword.fetch!(opts, :error_field)
|
||||||
|
not_found_message = Keyword.fetch!(opts, :not_found_message)
|
||||||
|
error_message = Keyword.fetch!(opts, :error_message)
|
||||||
|
log_message = Keyword.fetch!(opts, :log_message)
|
||||||
|
|
||||||
|
case SQL.query(Mv.Repo, sql, params) do
|
||||||
|
{:ok, %{rows: [row | _]}} ->
|
||||||
|
{:ok, on_row.(row)}
|
||||||
|
|
||||||
|
{:ok, %{rows: []}} ->
|
||||||
|
{:error,
|
||||||
|
Invalid.exception(
|
||||||
|
field: error_field,
|
||||||
|
message: not_found_message
|
||||||
|
)}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
Logger.error("#{log_message}: #{inspect(error)}")
|
||||||
|
|
||||||
|
{:error,
|
||||||
|
Invalid.exception(
|
||||||
|
field: error_field,
|
||||||
|
message: error_message
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Normalizes a JSONB column value to a string-keyed map.
|
||||||
|
"""
|
||||||
|
@spec normalize(map() | binary() | any()) :: map()
|
||||||
|
def normalize(updated_jsonb) do
|
||||||
|
case updated_jsonb do
|
||||||
|
map when is_map(map) ->
|
||||||
|
Enum.reduce(map, %{}, fn
|
||||||
|
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
|
||||||
|
{k, v}, acc -> Map.put(acc, k, v)
|
||||||
|
end)
|
||||||
|
|
||||||
|
binary when is_binary(binary) ->
|
||||||
|
case Jason.decode(binary) do
|
||||||
|
{:ok, decoded} when is_map(decoded) ->
|
||||||
|
decoded
|
||||||
|
|
||||||
|
{:ok, _} ->
|
||||||
|
%{}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
|
||||||
|
%{}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
|
||||||
|
%{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -19,9 +19,7 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do
|
||||||
"""
|
"""
|
||||||
use Ash.Resource.Change
|
use Ash.Resource.Change
|
||||||
|
|
||||||
alias Ash.Error.Invalid
|
alias Mv.Membership.Setting.Changes.JsonbResult
|
||||||
alias Ecto.Adapters.SQL
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
def change(changeset, _opts, _context) do
|
def change(changeset, _opts, _context) do
|
||||||
with {:ok, field} <- get_and_validate_field(changeset),
|
with {:ok, field} <- get_and_validate_field(changeset),
|
||||||
|
|
@ -118,62 +116,21 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberField do
|
||||||
|
|
||||||
uuid_binary = Ecto.UUID.dump!(settings.id)
|
uuid_binary = Ecto.UUID.dump!(settings.id)
|
||||||
|
|
||||||
case SQL.query(Mv.Repo, sql, [field, show_in_overview, required, uuid_binary]) do
|
JsonbResult.run_update(
|
||||||
{:ok, %{rows: [[updated_visibility, updated_required] | _]}} ->
|
sql: sql,
|
||||||
vis = normalize_jsonb_result(updated_visibility)
|
params: [field, show_in_overview, required, uuid_binary],
|
||||||
req = normalize_jsonb_result(updated_required)
|
on_row: fn [updated_visibility, updated_required | _] ->
|
||||||
|
%{
|
||||||
updated_settings = %{
|
|
||||||
settings
|
settings
|
||||||
| member_field_visibility: vis,
|
| member_field_visibility: JsonbResult.normalize(updated_visibility),
|
||||||
member_field_required: req
|
member_field_required: JsonbResult.normalize(updated_required)
|
||||||
}
|
}
|
||||||
|
end,
|
||||||
{:ok, updated_settings}
|
error_field: :member_field_required,
|
||||||
|
not_found_message: "Settings not found",
|
||||||
{:ok, %{rows: []}} ->
|
error_message: "Failed to update member field settings",
|
||||||
{:error,
|
log_message: "Failed to atomically update member field settings"
|
||||||
Invalid.exception(
|
)
|
||||||
field: :member_field_required,
|
|
||||||
message: "Settings not found"
|
|
||||||
)}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
Logger.error("Failed to atomically update member field settings: #{inspect(error)}")
|
|
||||||
|
|
||||||
{:error,
|
|
||||||
Invalid.exception(
|
|
||||||
field: :member_field_required,
|
|
||||||
message: "Failed to update member field settings"
|
|
||||||
)}
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_jsonb_result(updated_jsonb) do
|
|
||||||
case updated_jsonb do
|
|
||||||
map when is_map(map) ->
|
|
||||||
Enum.reduce(map, %{}, fn
|
|
||||||
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
|
|
||||||
{k, v}, acc -> Map.put(acc, k, v)
|
|
||||||
end)
|
|
||||||
|
|
||||||
binary when is_binary(binary) ->
|
|
||||||
case Jason.decode(binary) do
|
|
||||||
{:ok, decoded} when is_map(decoded) ->
|
|
||||||
decoded
|
|
||||||
|
|
||||||
{:ok, _} ->
|
|
||||||
%{}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
|
|
||||||
%{}
|
|
||||||
end
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
|
|
||||||
%{}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,7 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
|
||||||
"""
|
"""
|
||||||
use Ash.Resource.Change
|
use Ash.Resource.Change
|
||||||
|
|
||||||
alias Ash.Error.Invalid
|
alias Mv.Membership.Setting.Changes.JsonbResult
|
||||||
alias Ecto.Adapters.SQL
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
def change(changeset, _opts, _context) do
|
def change(changeset, _opts, _context) do
|
||||||
with {:ok, field} <- get_and_validate_field(changeset),
|
with {:ok, field} <- get_and_validate_field(changeset),
|
||||||
|
|
@ -106,59 +104,17 @@ defmodule Mv.Membership.Setting.Changes.UpdateSingleMemberFieldVisibility do
|
||||||
# Convert UUID string to binary for PostgreSQL
|
# Convert UUID string to binary for PostgreSQL
|
||||||
uuid_binary = Ecto.UUID.dump!(settings.id)
|
uuid_binary = Ecto.UUID.dump!(settings.id)
|
||||||
|
|
||||||
case SQL.query(Mv.Repo, sql, [field, show_in_overview, uuid_binary]) do
|
JsonbResult.run_update(
|
||||||
{:ok, %{rows: [[updated_jsonb] | _]}} ->
|
sql: sql,
|
||||||
updated_visibility = normalize_jsonb_result(updated_jsonb)
|
params: [field, show_in_overview, uuid_binary],
|
||||||
|
on_row: fn [updated_jsonb | _] ->
|
||||||
# Update the settings struct with the new visibility
|
%{settings | member_field_visibility: JsonbResult.normalize(updated_jsonb)}
|
||||||
updated_settings = %{settings | member_field_visibility: updated_visibility}
|
end,
|
||||||
{:ok, updated_settings}
|
error_field: :member_field_visibility,
|
||||||
|
not_found_message: "Settings not found",
|
||||||
{:ok, %{rows: []}} ->
|
error_message: "Failed to update visibility",
|
||||||
{:error,
|
log_message: "Failed to atomically update member_field_visibility"
|
||||||
Invalid.exception(
|
)
|
||||||
field: :member_field_visibility,
|
|
||||||
message: "Settings not found"
|
|
||||||
)}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
Logger.error("Failed to atomically update member_field_visibility: #{inspect(error)}")
|
|
||||||
|
|
||||||
{:error,
|
|
||||||
Invalid.exception(
|
|
||||||
field: :member_field_visibility,
|
|
||||||
message: "Failed to update visibility"
|
|
||||||
)}
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_jsonb_result(updated_jsonb) do
|
|
||||||
case updated_jsonb do
|
|
||||||
map when is_map(map) ->
|
|
||||||
# Convert atom keys to strings if needed
|
|
||||||
Enum.reduce(map, %{}, fn
|
|
||||||
{k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v)
|
|
||||||
{k, v}, acc -> Map.put(acc, k, v)
|
|
||||||
end)
|
|
||||||
|
|
||||||
binary when is_binary(binary) ->
|
|
||||||
case Jason.decode(binary) do
|
|
||||||
{:ok, decoded} when is_map(decoded) ->
|
|
||||||
decoded
|
|
||||||
|
|
||||||
# Not a map after decode
|
|
||||||
{:ok, _} ->
|
|
||||||
%{}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
Logger.warning("Failed to decode JSONB: #{inspect(reason)}")
|
|
||||||
%{}
|
|
||||||
end
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}")
|
|
||||||
%{}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do
|
||||||
policies do
|
policies do
|
||||||
bypass action_type(:read) do
|
bypass action_type(:read) do
|
||||||
description "own_data: read only cycles where member_id == actor.member_id"
|
description "own_data: read only cycles where member_id == actor.member_id"
|
||||||
authorize_if Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData
|
authorize_if {Mv.Authorization.Checks.ReadLinkedForOwnData, member_id_field: :member_id}
|
||||||
end
|
end
|
||||||
|
|
||||||
policy action_type([:read, :create, :update, :destroy]) do
|
policy action_type([:read, :create, :update, :destroy]) do
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
defmodule Mix.Tasks.JoinRequests.CleanupExpired do
|
defmodule Mix.Tasks.JoinRequests.CleanupExpired do
|
||||||
|
@shortdoc "Deletes join requests in pending_confirmation with expired confirmation token"
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Hard-deletes JoinRequests in status `pending_confirmation` whose confirmation link has expired.
|
Hard-deletes JoinRequests in status `pending_confirmation` whose confirmation link has expired.
|
||||||
|
|
||||||
|
|
@ -16,12 +17,10 @@ defmodule Mix.Tasks.JoinRequests.CleanupExpired do
|
||||||
"""
|
"""
|
||||||
use Mix.Task
|
use Mix.Task
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
alias Mv.Membership.JoinRequest
|
alias Mv.Membership.JoinRequest
|
||||||
|
|
||||||
@shortdoc "Deletes join requests in pending_confirmation with expired confirmation token"
|
require Ash.Query
|
||||||
|
require Logger
|
||||||
|
|
||||||
@impl Mix.Task
|
@impl Mix.Task
|
||||||
def run(_args) do
|
def run(_args) do
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,14 @@ defmodule Mv.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||||
|
|
||||||
use MvWeb, :verified_routes
|
use MvWeb, :verified_routes
|
||||||
import Swoosh.Email
|
|
||||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
require Logger
|
import Swoosh.Email
|
||||||
|
|
||||||
alias Mv.Mailer
|
alias Mv.Mailer
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Sends a confirmation email to a new user.
|
Sends a confirmation email to a new user.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,14 @@ defmodule Mv.Accounts.User.Senders.SendPasswordResetEmail do
|
||||||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||||
|
|
||||||
use MvWeb, :verified_routes
|
use MvWeb, :verified_routes
|
||||||
import Swoosh.Email
|
|
||||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
require Logger
|
import Swoosh.Email
|
||||||
|
|
||||||
alias Mv.Mailer
|
alias Mv.Mailer
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Sends a password reset email to a user.
|
Sends a password reset email to a user.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ defmodule Mv.Application do
|
||||||
{Task.Supervisor, name: Mv.TaskSupervisor},
|
{Task.Supervisor, name: Mv.TaskSupervisor},
|
||||||
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
|
||||||
{Phoenix.PubSub, name: Mv.PubSub},
|
{Phoenix.PubSub, name: Mv.PubSub},
|
||||||
{AshAuthentication.Supervisor, otp_app: :my},
|
{AshAuthentication.Supervisor, otp_app: :mv},
|
||||||
SystemActor,
|
SystemActor,
|
||||||
# Start a worker by calling: Mv.Worker.start_link(arg)
|
# Start a worker by calling: Mv.Worker.start_link(arg)
|
||||||
# {Mv.Worker, arg},
|
# {Mv.Worker, arg},
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,10 @@ defmodule Mv.Authorization.Actor do
|
||||||
adds complexity and potential for inconsistency.
|
adds complexity and potential for inconsistency.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
alias Mv.Helpers.SystemActor
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Ensures the actor (User) has their `:role` relationship loaded.
|
Ensures the actor (User) has their `:role` relationship loaded.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,10 +79,13 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use Ash.Policy.Check
|
use Ash.Policy.Check
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
|
||||||
alias Mv.Authorization.Actor
|
alias Mv.Authorization.Actor
|
||||||
alias Mv.Authorization.PermissionSets
|
alias Mv.Authorization.PermissionSets
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
defmodule Mv.Authorization.Checks.MemberGroupReadLinkedForOwnData do
|
|
||||||
@moduledoc """
|
|
||||||
Policy check for MemberGroup read: true only when actor has permission set "own_data"
|
|
||||||
AND record.member_id == actor.member_id.
|
|
||||||
|
|
||||||
Used in a bypass so that own_data gets the linked filter (via auto_filter for list queries),
|
|
||||||
while admin with member_id does not match and gets :all from HasPermission.
|
|
||||||
|
|
||||||
- With a record (e.g. get by id): returns true only when own_data and member_id match.
|
|
||||||
- Without a record (list query): strict_check returns false; auto_filter adds filter when own_data.
|
|
||||||
"""
|
|
||||||
use Ash.Policy.Check
|
|
||||||
|
|
||||||
alias Mv.Authorization.Checks.ActorPermissionSetIs
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def type, do: :filter
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def describe(_opts),
|
|
||||||
do: "own_data can read only member_groups where member_id == actor.member_id"
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def strict_check(actor, authorizer, _opts) do
|
|
||||||
record = get_record_from_authorizer(authorizer)
|
|
||||||
is_own_data = ActorPermissionSetIs.match?(actor, authorizer, permission_set_name: "own_data")
|
|
||||||
|
|
||||||
cond do
|
|
||||||
# List query + own_data: return :unknown so authorizer applies auto_filter (keyword list)
|
|
||||||
is_nil(record) and is_own_data ->
|
|
||||||
{:ok, :unknown}
|
|
||||||
|
|
||||||
is_nil(record) ->
|
|
||||||
{:ok, false}
|
|
||||||
|
|
||||||
not is_own_data ->
|
|
||||||
{:ok, false}
|
|
||||||
|
|
||||||
record.member_id == actor.member_id ->
|
|
||||||
{:ok, true}
|
|
||||||
|
|
||||||
true ->
|
|
||||||
{:ok, false}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def auto_filter(actor, _authorizer, _opts) do
|
|
||||||
if ActorPermissionSetIs.match?(actor, nil, permission_set_name: "own_data") &&
|
|
||||||
Map.get(actor, :member_id) do
|
|
||||||
[member_id: actor.member_id]
|
|
||||||
else
|
|
||||||
[]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp get_record_from_authorizer(authorizer) do
|
|
||||||
case authorizer.subject do
|
|
||||||
%{data: data} when not is_nil(data) -> data
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
defmodule Mv.Authorization.Checks.MembershipFeeCycleReadLinkedForOwnData do
|
|
||||||
@moduledoc """
|
|
||||||
Policy check for MembershipFeeCycle read: true only when actor has permission set "own_data"
|
|
||||||
AND record.member_id == actor.member_id.
|
|
||||||
|
|
||||||
Used in a bypass so that own_data gets the linked filter (via auto_filter for list queries),
|
|
||||||
while admin with member_id does not match and gets :all from HasPermission.
|
|
||||||
|
|
||||||
- With a record (e.g. get by id): returns true only when own_data and member_id match.
|
|
||||||
- Without a record (list query): return :unknown so authorizer applies auto_filter.
|
|
||||||
"""
|
|
||||||
use Ash.Policy.Check
|
|
||||||
|
|
||||||
alias Mv.Authorization.Checks.ActorPermissionSetIs
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def type, do: :filter
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def describe(_opts),
|
|
||||||
do: "own_data can read only membership_fee_cycles where member_id == actor.member_id"
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def strict_check(actor, authorizer, _opts) do
|
|
||||||
record = get_record_from_authorizer(authorizer)
|
|
||||||
is_own_data = ActorPermissionSetIs.match?(actor, authorizer, permission_set_name: "own_data")
|
|
||||||
|
|
||||||
cond do
|
|
||||||
is_nil(record) and is_own_data ->
|
|
||||||
{:ok, :unknown}
|
|
||||||
|
|
||||||
is_nil(record) ->
|
|
||||||
{:ok, false}
|
|
||||||
|
|
||||||
not is_own_data ->
|
|
||||||
{:ok, false}
|
|
||||||
|
|
||||||
record.member_id == actor.member_id ->
|
|
||||||
{:ok, true}
|
|
||||||
|
|
||||||
true ->
|
|
||||||
{:ok, false}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def auto_filter(actor, _authorizer, _opts) do
|
|
||||||
if ActorPermissionSetIs.match?(actor, nil, permission_set_name: "own_data") &&
|
|
||||||
Map.get(actor, :member_id) do
|
|
||||||
[member_id: actor.member_id]
|
|
||||||
else
|
|
||||||
[]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp get_record_from_authorizer(authorizer) do
|
|
||||||
case authorizer.subject do
|
|
||||||
%{data: data} when not is_nil(data) -> data
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
74
lib/mv/authorization/checks/read_linked_for_own_data.ex
Normal file
74
lib/mv/authorization/checks/read_linked_for_own_data.ex
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
defmodule Mv.Authorization.Checks.ReadLinkedForOwnData do
|
||||||
|
@moduledoc """
|
||||||
|
Generic policy check for resources that link to a member via a member-id
|
||||||
|
attribute: read is allowed only when the actor has the "own_data" permission
|
||||||
|
set AND `record.<member_id_field> == actor.member_id`.
|
||||||
|
|
||||||
|
Used in a read bypass so that own_data gets the linked filter (via auto_filter
|
||||||
|
for list queries), while admin with a member_id does not match and falls
|
||||||
|
through to `HasPermission` for `:all`.
|
||||||
|
|
||||||
|
- With a record (e.g. get by id): returns true only when own_data and the
|
||||||
|
member ids match.
|
||||||
|
- Without a record (list query) + own_data: returns `:unknown` so the
|
||||||
|
authorizer applies `auto_filter`.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
- `:member_id_field` - the attribute on the resource holding the member id.
|
||||||
|
Defaults to `:member_id`.
|
||||||
|
"""
|
||||||
|
use Ash.Policy.Check
|
||||||
|
|
||||||
|
alias Mv.Authorization.Checks.ActorPermissionSetIs
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def type, do: :filter
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def describe(opts) do
|
||||||
|
"own_data can read only records where #{member_id_field(opts)} == actor.member_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def strict_check(actor, authorizer, opts) do
|
||||||
|
field = member_id_field(opts)
|
||||||
|
record = get_record_from_authorizer(authorizer)
|
||||||
|
is_own_data = ActorPermissionSetIs.match?(actor, authorizer, permission_set_name: "own_data")
|
||||||
|
|
||||||
|
cond do
|
||||||
|
is_nil(record) and is_own_data ->
|
||||||
|
{:ok, :unknown}
|
||||||
|
|
||||||
|
is_nil(record) ->
|
||||||
|
{:ok, false}
|
||||||
|
|
||||||
|
not is_own_data ->
|
||||||
|
{:ok, false}
|
||||||
|
|
||||||
|
Map.get(record, field) == actor.member_id ->
|
||||||
|
{:ok, true}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{:ok, false}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def auto_filter(actor, _authorizer, opts) do
|
||||||
|
if ActorPermissionSetIs.match?(actor, nil, permission_set_name: "own_data") &&
|
||||||
|
Map.get(actor, :member_id) do
|
||||||
|
[{member_id_field(opts), actor.member_id}]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp member_id_field(opts), do: Keyword.get(opts, :member_id_field, :member_id)
|
||||||
|
|
||||||
|
defp get_record_from_authorizer(authorizer) do
|
||||||
|
case authorizer.subject do
|
||||||
|
%{data: data} when not is_nil(data) -> data
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -6,9 +6,10 @@ defmodule Mv.EmailSync.Helpers do
|
||||||
provides clean abstractions for email updates within transactions.
|
provides clean abstractions for email updates within transactions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
require Logger
|
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Extracts the record from an Ash action result.
|
Extracts the record from an Ash action result.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,10 @@ defmodule Mv.Helpers.SystemActor do
|
||||||
|
|
||||||
use Agent
|
use Agent
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
alias Mv.Config
|
alias Mv.Config
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Starts the SystemActor Agent.
|
Starts the SystemActor Agent.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,12 @@ defmodule Mv.Mailer do
|
||||||
ENV takes priority over Settings (same pattern as OIDC and Vereinfacht).
|
ENV takes priority over Settings (same pattern as OIDC and Vereinfacht).
|
||||||
"""
|
"""
|
||||||
use Swoosh.Mailer, otp_app: :mv
|
use Swoosh.Mailer, otp_app: :mv
|
||||||
|
|
||||||
import Swoosh.Email
|
|
||||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
|
import Swoosh.Email
|
||||||
|
|
||||||
alias Mv.Smtp.ConfigBuilder
|
alias Mv.Smtp.ConfigBuilder
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
# Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation.
|
# Simple format check for test-email recipient only (e.g. allows a@b.c). Not for strict RFC validation.
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,10 @@ defmodule Mv.Membership.Import.ColumnResolver do
|
||||||
This module has no Phoenix or web dependencies.
|
This module has no Phoenix or web dependencies.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
alias Mv.Membership.Import.HeaderMapper
|
alias Mv.Membership.Import.HeaderMapper
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@preview_row_limit 3
|
@preview_row_limit 3
|
||||||
|
|
||||||
@type numbered_row :: {pos_integer(), [String.t()]}
|
@type numbered_row :: {pos_integer(), [String.t()]}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,14 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
MemberCSV.process_chunk(chunk, import_state.column_map, import_state.custom_field_map, [])
|
MemberCSV.process_chunk(chunk, import_state.column_map, import_state.custom_field_map, [])
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
alias Mv.Membership.Import.ColumnResolver
|
||||||
|
alias Mv.Membership.Import.CsvParser
|
||||||
|
alias Mv.Membership.Import.HeaderMapper
|
||||||
|
alias MvWeb.Translations.FieldTypes
|
||||||
|
|
||||||
defmodule Error do
|
defmodule Error do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Error struct for CSV import errors.
|
Error struct for CSV import errors.
|
||||||
|
|
@ -101,17 +109,6 @@ defmodule Mv.Membership.Import.MemberCSV do
|
||||||
groups_found: list(Mv.Membership.Group.t() | %{id: String.t(), name: String.t()})
|
groups_found: list(Mv.Membership.Group.t() | %{id: String.t(), name: String.t()})
|
||||||
}
|
}
|
||||||
|
|
||||||
alias Mv.Membership.Import.ColumnResolver
|
|
||||||
alias Mv.Membership.Import.CsvParser
|
|
||||||
alias Mv.Membership.Import.HeaderMapper
|
|
||||||
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
|
||||||
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
|
|
||||||
# Import FieldTypes for human-readable type labels
|
|
||||||
alias MvWeb.Translations.FieldTypes
|
|
||||||
|
|
||||||
# Configuration constants
|
# Configuration constants
|
||||||
@default_max_errors 50
|
@default_max_errors 50
|
||||||
@default_chunk_size 200
|
@default_chunk_size 200
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,6 @@ defmodule Mv.Membership.MemberExport do
|
||||||
and sends the download.
|
and sends the download.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
|
||||||
|
|
||||||
alias Mv.Membership.CustomField
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.Membership.MemberExportSort
|
|
||||||
alias MvWeb.MemberLive.Index
|
alias MvWeb.MemberLive.Index
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
|
|
||||||
|
|
@ -35,261 +29,8 @@ defmodule Mv.Membership.MemberExport do
|
||||||
["membership_fee_type", "membership_fee_status", "groups"]
|
["membership_fee_type", "membership_fee_status", "groups"]
|
||||||
@computed_export_fields ["membership_fee_status"]
|
@computed_export_fields ["membership_fee_status"]
|
||||||
@computed_insert_after "membership_fee_start_date"
|
@computed_insert_after "membership_fee_start_date"
|
||||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
|
||||||
@domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
@domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||||
|
|
||||||
@doc """
|
|
||||||
Fetches members and column specs for export.
|
|
||||||
|
|
||||||
- `actor` - Ash actor (e.g. current user)
|
|
||||||
- `parsed` - Map from controller's parse_and_validate (selected_ids, member_fields, etc.)
|
|
||||||
|
|
||||||
Returns `{:ok, members, column_specs}` or `{:error, :forbidden}`.
|
|
||||||
Column specs have `:kind`, `:key`, and for custom fields `:custom_field`;
|
|
||||||
the controller adds `:header` and optional computed columns to members before CSV export.
|
|
||||||
"""
|
|
||||||
@spec fetch(struct(), map()) ::
|
|
||||||
{:ok, [struct()], [map()]} | {:error, :forbidden}
|
|
||||||
def fetch(actor, parsed) do
|
|
||||||
custom_field_ids_union =
|
|
||||||
(parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq()
|
|
||||||
|
|
||||||
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(custom_field_ids_union, actor),
|
|
||||||
{:ok, members} <- load_members(actor, parsed, custom_fields_by_id) do
|
|
||||||
column_specs = build_column_specs(parsed, custom_fields_by_id)
|
|
||||||
{:ok, members, column_specs}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}
|
|
||||||
|
|
||||||
defp load_custom_fields_by_id(custom_field_ids, actor) do
|
|
||||||
query =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Query.filter(expr(id in ^custom_field_ids))
|
|
||||||
|> Ash.Query.select([:id, :name, :value_type])
|
|
||||||
|
|
||||||
case Ash.read(query, actor: actor) do
|
|
||||||
{:ok, custom_fields} ->
|
|
||||||
by_id = build_custom_fields_by_id(custom_field_ids, custom_fields)
|
|
||||||
{:ok, by_id}
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
|
||||||
{:error, :forbidden}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
|
|
||||||
Enum.reduce(custom_field_ids, %{}, fn id, acc ->
|
|
||||||
find_and_add_custom_field(acc, id, custom_fields)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp find_and_add_custom_field(acc, id, custom_fields) do
|
|
||||||
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
|
|
||||||
nil -> acc
|
|
||||||
cf -> Map.put(acc, id, cf)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_column_specs(parsed, custom_fields_by_id) do
|
|
||||||
member_specs = build_member_column_specs(parsed)
|
|
||||||
custom_specs = build_custom_column_specs(parsed, custom_fields_by_id)
|
|
||||||
|
|
||||||
member_specs ++ custom_specs
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_member_column_specs(parsed) do
|
|
||||||
Enum.map(parsed.member_fields, fn f ->
|
|
||||||
build_single_member_spec(f, parsed.selectable_member_fields)
|
|
||||||
end)
|
|
||||||
|> Enum.reject(&is_nil/1)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_single_member_spec(field, selectable_member_fields) do
|
|
||||||
if field in selectable_member_fields do
|
|
||||||
%{kind: :member_field, key: field}
|
|
||||||
else
|
|
||||||
build_computed_spec(field)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_computed_spec(field) do
|
|
||||||
# only allow known computed export fields to avoid crashing on unknown atoms
|
|
||||||
if field in @computed_export_fields do
|
|
||||||
%{kind: :computed, key: String.to_existing_atom(field)}
|
|
||||||
else
|
|
||||||
# ignore unknown non-selectable fields defensively
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_custom_column_specs(parsed, custom_fields_by_id) do
|
|
||||||
parsed.custom_field_ids
|
|
||||||
|> Enum.map(fn id -> Map.get(custom_fields_by_id, id) end)
|
|
||||||
|> Enum.reject(&is_nil/1)
|
|
||||||
|> Enum.map(fn cf -> %{kind: :custom_field, key: cf.id, custom_field: cf} end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp load_members(actor, parsed, custom_fields_by_id) do
|
|
||||||
query = build_members_query(parsed, custom_fields_by_id)
|
|
||||||
|
|
||||||
case Ash.read(query, actor: actor) do
|
|
||||||
{:ok, members} ->
|
|
||||||
processed_members = process_loaded_members(members, parsed, custom_fields_by_id)
|
|
||||||
{:ok, processed_members}
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
|
||||||
{:error, :forbidden}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_members_query(parsed, _custom_fields_by_id) do
|
|
||||||
select_fields =
|
|
||||||
[:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
|
|
||||||
|
|
||||||
custom_field_ids_union = parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})
|
|
||||||
|
|
||||||
need_cycles =
|
|
||||||
parsed.show_current_cycle or parsed.cycle_status_filter != nil or
|
|
||||||
parsed.computed_fields != [] or
|
|
||||||
"membership_fee_status" in parsed.member_fields
|
|
||||||
|
|
||||||
query =
|
|
||||||
Member
|
|
||||||
|> Ash.Query.new()
|
|
||||||
|> Ash.Query.select(select_fields)
|
|
||||||
|> load_custom_field_values_query(custom_field_ids_union)
|
|
||||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
|
||||||
|
|
||||||
if parsed.selected_ids != [] do
|
|
||||||
Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
|
|
||||||
else
|
|
||||||
query
|
|
||||||
|> apply_search(parsed.query)
|
|
||||||
|> then(fn q ->
|
|
||||||
{q, _sort_after_load} = maybe_sort(q, parsed.sort_field, parsed.sort_order)
|
|
||||||
q
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp process_loaded_members(members, parsed, custom_fields_by_id) do
|
|
||||||
members
|
|
||||||
|> apply_post_load_filters(parsed, custom_fields_by_id)
|
|
||||||
|> apply_post_load_sorting(parsed, custom_fields_by_id)
|
|
||||||
|> add_computed_fields(parsed.computed_fields, parsed.show_current_cycle)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_post_load_filters(members, parsed, custom_fields_by_id) do
|
|
||||||
if parsed.selected_ids == [] do
|
|
||||||
members
|
|
||||||
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|
|
||||||
|> Index.apply_boolean_custom_field_filters(
|
|
||||||
parsed.boolean_filters || %{},
|
|
||||||
Map.values(custom_fields_by_id)
|
|
||||||
)
|
|
||||||
else
|
|
||||||
members
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_post_load_sorting(members, parsed, custom_fields_by_id) do
|
|
||||||
if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
|
|
||||||
sort_members_by_custom_field(
|
|
||||||
members,
|
|
||||||
parsed.sort_field,
|
|
||||||
parsed.sort_order,
|
|
||||||
Map.values(custom_fields_by_id)
|
|
||||||
)
|
|
||||||
else
|
|
||||||
members
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp load_custom_field_values_query(query, []), do: query
|
|
||||||
|
|
||||||
defp load_custom_field_values_query(query, custom_field_ids) do
|
|
||||||
cfv_query =
|
|
||||||
Mv.Membership.CustomFieldValue
|
|
||||||
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|
|
||||||
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
|
|
||||||
|
|
||||||
Ash.Query.load(query, custom_field_values: cfv_query)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_search(query, nil), do: query
|
|
||||||
defp apply_search(query, ""), do: query
|
|
||||||
|
|
||||||
defp apply_search(query, q) when is_binary(q) do
|
|
||||||
if String.trim(q) != "" do
|
|
||||||
Member.fuzzy_search(query, %{query: q})
|
|
||||||
else
|
|
||||||
query
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp maybe_sort(query, nil, _order), do: {query, false}
|
|
||||||
defp maybe_sort(query, _field, nil), do: {query, false}
|
|
||||||
|
|
||||||
defp maybe_sort(query, field, order) when is_binary(field) do
|
|
||||||
if custom_field_sort?(field) do
|
|
||||||
{query, true}
|
|
||||||
else
|
|
||||||
field_atom = String.to_existing_atom(field)
|
|
||||||
|
|
||||||
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
|
|
||||||
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
|
|
||||||
else
|
|
||||||
{query, false}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
ArgumentError -> {query, false}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp sort_after_load?(field) when is_binary(field),
|
|
||||||
do: String.starts_with?(field, @custom_field_prefix)
|
|
||||||
|
|
||||||
defp sort_after_load?(_), do: false
|
|
||||||
|
|
||||||
defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
|
|
||||||
do: []
|
|
||||||
|
|
||||||
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
|
|
||||||
id_str = String.trim_leading(field, @custom_field_prefix)
|
|
||||||
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
|
|
||||||
if is_nil(custom_field), do: members
|
|
||||||
|
|
||||||
key_fn = fn member ->
|
|
||||||
cfv = find_cfv(member, custom_field)
|
|
||||||
raw = if cfv, do: cfv.value, else: nil
|
|
||||||
MemberExportSort.custom_field_sort_key(custom_field.value_type, raw)
|
|
||||||
end
|
|
||||||
|
|
||||||
members
|
|
||||||
|> Enum.map(fn m -> {m, key_fn.(m)} end)
|
|
||||||
|> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end)
|
|
||||||
|> Enum.map(fn {m, _} -> m end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp find_cfv(member, custom_field) do
|
|
||||||
(member.custom_field_values || [])
|
|
||||||
|> Enum.find(fn cfv ->
|
|
||||||
to_string(cfv.custom_field_id) == to_string(custom_field.id) or
|
|
||||||
(Map.get(cfv, :custom_field) &&
|
|
||||||
to_string(cfv.custom_field.id) == to_string(custom_field.id))
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)
|
|
||||||
|
|
||||||
defp maybe_load_cycles(query, false, _show_current), do: query
|
|
||||||
|
|
||||||
defp maybe_load_cycles(query, true, show_current) do
|
|
||||||
MembershipFeeStatus.load_cycles_for_members(query, show_current)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
defp apply_cycle_status_filter(members, nil, _show_current), do: members
|
||||||
|
|
||||||
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
|
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
|
||||||
|
|
@ -298,20 +39,6 @@ defmodule Mv.Membership.MemberExport do
|
||||||
|
|
||||||
defp apply_cycle_status_filter(members, _status, _show_current), do: members
|
defp apply_cycle_status_filter(members, _status, _show_current), do: members
|
||||||
|
|
||||||
defp add_computed_fields(members, computed_fields, show_current_cycle) do
|
|
||||||
computed_fields = computed_fields || []
|
|
||||||
|
|
||||||
if "membership_fee_status" in computed_fields do
|
|
||||||
Enum.map(members, fn member ->
|
|
||||||
status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
|
|
||||||
# <= Atom rein
|
|
||||||
Map.put(member, :membership_fee_status, status)
|
|
||||||
end)
|
|
||||||
else
|
|
||||||
members
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Called by controller to build parsed map from raw params (kept here so controller stays thin)
|
# Called by controller to build parsed map from raw params (kept here so controller stays thin)
|
||||||
@doc """
|
@doc """
|
||||||
Parses and validates export params (from JSON payload).
|
Parses and validates export params (from JSON payload).
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,14 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
No translations/Gettext in this module - labels come from the web layer via a function.
|
No translations/Gettext in this module - labels come from the web layer via a function.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort}
|
alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort}
|
||||||
alias MvWeb.MemberLive.Index
|
alias MvWeb.MemberLive.Index
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
|
||||||
|
|
@ -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,27 +59,7 @@ defmodule Mv.MembershipFees.CycleGenerationJob do
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@spec run() :: {:ok, CycleGenerator.results_summary()} | {:error, Ash.Error.t()}
|
@spec run() :: {:ok, CycleGenerator.results_summary()} | {:error, Ash.Error.t()}
|
||||||
def run do
|
def run, do: run([])
|
||||||
Logger.info("Starting membership fee cycle generation job")
|
|
||||||
start_time = System.monotonic_time(:millisecond)
|
|
||||||
|
|
||||||
result = CycleGenerator.generate_cycles_for_all_members()
|
|
||||||
|
|
||||||
elapsed = System.monotonic_time(:millisecond) - start_time
|
|
||||||
|
|
||||||
case result do
|
|
||||||
{:ok, stats} ->
|
|
||||||
Logger.info(
|
|
||||||
"Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total"
|
|
||||||
)
|
|
||||||
|
|
||||||
result
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
Logger.error("Cycle generation failed: #{inspect(reason)}")
|
|
||||||
result
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Runs cycle generation with custom options.
|
Runs cycle generation with custom options.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
defmodule Mv.MembershipFees.CycleGenerator do
|
defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
@typedoc "Aggregate counts returned by a batch cycle-generation run."
|
|
||||||
@type results_summary :: %{
|
|
||||||
success: non_neg_integer(),
|
|
||||||
failed: non_neg_integer(),
|
|
||||||
total: non_neg_integer()
|
|
||||||
}
|
|
||||||
|
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Module for generating membership fee cycles for members.
|
Module for generating membership fee cycles for members.
|
||||||
|
|
||||||
|
|
@ -66,6 +59,13 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
@typedoc "Aggregate counts returned by a batch cycle-generation run."
|
||||||
|
@type results_summary :: %{
|
||||||
|
success: non_neg_integer(),
|
||||||
|
failed: non_neg_integer(),
|
||||||
|
total: non_neg_integer()
|
||||||
|
}
|
||||||
|
|
||||||
@type generate_result ::
|
@type generate_result ::
|
||||||
{:ok, [MembershipFeeCycle.t()], [Ash.Notifier.Notification.t()]} | {:error, term()}
|
{:ok, [MembershipFeeCycle.t()], [Ash.Notifier.Notification.t()]} | {:error, term()}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ defmodule Mv.OidcRoleSync do
|
||||||
|
|
||||||
Used after OIDC registration (register_with_oidc) and on sign-in so that
|
Used after OIDC registration (register_with_oidc) and on sign-in so that
|
||||||
users in the configured admin group get the Admin role; others get Mitglied.
|
users in the configured admin group get the Admin role; others get Mitglied.
|
||||||
Configure via OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM (see OidcRoleSyncConfig).
|
Configure via OIDC_ADMIN_GROUP_NAME and OIDC_GROUPS_CLAIM (see Mv.Config).
|
||||||
|
|
||||||
Groups are read from user_info (ID token claims) first; if missing or empty,
|
Groups are read from user_info (ID token claims) first; if missing or empty,
|
||||||
the access_token from oauth_tokens is decoded as JWT and the groups claim is
|
the access_token from oauth_tokens is decoded as JWT and the groups claim is
|
||||||
|
|
@ -23,7 +23,7 @@ defmodule Mv.OidcRoleSync do
|
||||||
"""
|
"""
|
||||||
alias Mv.Accounts.User
|
alias Mv.Accounts.User
|
||||||
alias Mv.Authorization.Role
|
alias Mv.Authorization.Role
|
||||||
alias Mv.OidcRoleSyncConfig
|
alias Mv.Config
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Applies Admin or Mitglied role to the user based on OIDC groups claim.
|
Applies Admin or Mitglied role to the user based on OIDC groups claim.
|
||||||
|
|
@ -38,12 +38,12 @@ defmodule Mv.OidcRoleSync do
|
||||||
@spec apply_admin_role_from_user_info(User.t(), map(), map() | nil) :: :ok
|
@spec apply_admin_role_from_user_info(User.t(), map(), map() | nil) :: :ok
|
||||||
def apply_admin_role_from_user_info(user, user_info, oauth_tokens \\ nil)
|
def apply_admin_role_from_user_info(user, user_info, oauth_tokens \\ nil)
|
||||||
when is_map(user_info) do
|
when is_map(user_info) do
|
||||||
admin_group = OidcRoleSyncConfig.oidc_admin_group_name()
|
admin_group = Config.oidc_admin_group_name()
|
||||||
|
|
||||||
if is_nil(admin_group) or admin_group == "" do
|
if is_nil(admin_group) or admin_group == "" do
|
||||||
:ok
|
:ok
|
||||||
else
|
else
|
||||||
claim = OidcRoleSyncConfig.oidc_groups_claim()
|
claim = Config.oidc_groups_claim()
|
||||||
groups = groups_from_user_info(user_info, claim)
|
groups = groups_from_user_info(user_info, claim)
|
||||||
|
|
||||||
groups =
|
groups =
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
defmodule Mv.OidcRoleSyncConfig do
|
|
||||||
@moduledoc """
|
|
||||||
Runtime configuration for OIDC group → role sync (e.g. admin group → Admin role).
|
|
||||||
|
|
||||||
Reads from Mv.Config (ENV first, then Settings):
|
|
||||||
- `oidc_admin_group_name/0` – OIDC group name that maps to Admin role (optional; when nil, no sync).
|
|
||||||
- `oidc_groups_claim/0` – JWT/user_info claim name for groups (default: `"groups"`).
|
|
||||||
|
|
||||||
Set via ENV: OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM; or via Settings (Basic settings → OIDC).
|
|
||||||
"""
|
|
||||||
@doc "Returns the OIDC group name that maps to Admin role, or nil if not configured."
|
|
||||||
def oidc_admin_group_name do
|
|
||||||
Mv.Config.oidc_admin_group_name()
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc "Returns the JWT/user_info claim name for groups; defaults to \"groups\"."
|
|
||||||
def oidc_groups_claim do
|
|
||||||
Mv.Config.oidc_groups_claim()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -12,8 +12,6 @@ defmodule Mv.Release do
|
||||||
or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell
|
or ADMIN_PASSWORD_FILE). Idempotent; can be run on every deployment or via shell
|
||||||
to update the admin password without redeploying.
|
to update the admin password without redeploying.
|
||||||
"""
|
"""
|
||||||
@app :mv
|
|
||||||
|
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.Accounts.User
|
alias Mv.Accounts.User
|
||||||
alias Mv.Authorization.Role
|
alias Mv.Authorization.Role
|
||||||
|
|
@ -21,6 +19,8 @@ defmodule Mv.Release do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
@app :mv
|
||||||
|
|
||||||
def migrate do
|
def migrate do
|
||||||
_ = load_app()
|
_ = load_app()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,11 +9,9 @@ defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
|
||||||
"""
|
"""
|
||||||
use Ash.Resource.Change
|
use Ash.Resource.Change
|
||||||
|
|
||||||
|
alias Mv.EmailSync.Loader
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
alias Mv.Helpers
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
alias Mv.Membership
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def change(changeset, _opts, _context) do
|
def change(changeset, _opts, _context) do
|
||||||
|
|
@ -32,7 +30,7 @@ defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp sync_linked_member_after_transaction(_changeset, {:ok, user}) do
|
defp sync_linked_member_after_transaction(_changeset, {:ok, user}) do
|
||||||
case load_linked_member(user) do
|
case Loader.get_linked_member(user) do
|
||||||
nil ->
|
nil ->
|
||||||
{:ok, user}
|
{:ok, user}
|
||||||
|
|
||||||
|
|
@ -55,17 +53,4 @@ defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp sync_linked_member_after_transaction(_changeset, result), do: result
|
defp sync_linked_member_after_transaction(_changeset, result), do: result
|
||||||
|
|
||||||
defp load_linked_member(%{member_id: nil}), do: nil
|
|
||||||
defp load_linked_member(%{member_id: ""}), do: nil
|
|
||||||
|
|
||||||
defp load_linked_member(user) do
|
|
||||||
actor = SystemActor.get_system_actor()
|
|
||||||
opts = Helpers.ash_actor_opts(actor)
|
|
||||||
|
|
||||||
case Ash.get(Member, user.member_id, [domain: Membership] ++ opts) do
|
|
||||||
{:ok, %Member{} = member} -> member
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,15 @@ defmodule Mv.Vereinfacht do
|
||||||
the linked member's email via Ecto (e.g. user email change).
|
the linked member's email via Ecto (e.g. user email change).
|
||||||
- `sync_members_without_contact/0` – Bulk sync of members without a contact ID.
|
- `sync_members_without_contact/0` – Bulk sync of members without a contact ID.
|
||||||
"""
|
"""
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
|
||||||
alias Mv.Helpers
|
alias Mv.Helpers
|
||||||
alias Mv.Helpers.SystemActor
|
alias Mv.Helpers.SystemActor
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
alias Mv.Vereinfacht.Client
|
alias Mv.Vereinfacht.Client
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Tests the connection to the Vereinfacht API using the current configuration.
|
Tests the connection to the Vereinfacht API using the current configuration.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ defmodule MvWeb.TableComponents do
|
||||||
TableComponents that can be used in tables as components (like a button for sorting, a filter...)
|
TableComponents that can be used in tables as components (like a button for sorting, a filter...)
|
||||||
"""
|
"""
|
||||||
use Phoenix.Component
|
use Phoenix.Component
|
||||||
import MvWeb.CoreComponents
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
import MvWeb.CoreComponents
|
||||||
|
|
||||||
attr :field, :atom, required: true
|
attr :field, :atom, required: true
|
||||||
attr :label, :string, required: true
|
attr :label, :string, required: true
|
||||||
attr :sort_field, :atom, default: nil
|
attr :sort_field, :atom, default: nil
|
||||||
|
|
|
||||||
22
lib/mv_web/controllers/controller_helpers.ex
Normal file
22
lib/mv_web/controllers/controller_helpers.ex
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
defmodule MvWeb.ControllerHelpers do
|
||||||
|
@moduledoc """
|
||||||
|
Shared helpers for plug-based controllers.
|
||||||
|
|
||||||
|
The LiveView equivalent lives in `MvWeb.LiveHelpers`; this module is the
|
||||||
|
controller-side counterpart that works on a `Plug.Conn`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Mv.Authorization.Actor
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the request actor for a controller, loaded for authorization.
|
||||||
|
|
||||||
|
Reads `:current_user` from the connection assigns and ensures it is loaded via
|
||||||
|
`Mv.Authorization.Actor.ensure_loaded/1`.
|
||||||
|
"""
|
||||||
|
@spec current_actor(Plug.Conn.t()) :: Mv.Accounts.User.t() | nil
|
||||||
|
def current_actor(conn) do
|
||||||
|
conn.assigns[:current_user]
|
||||||
|
|> Actor.ensure_loaded()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -13,7 +13,8 @@ defmodule MvWeb.ImportTemplateController do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :controller
|
use MvWeb, :controller
|
||||||
|
|
||||||
alias Mv.Authorization.Actor
|
import MvWeb.ControllerHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
alias Mv.Membership.MembersCSV
|
alias Mv.Membership.MembersCSV
|
||||||
alias MvWeb.Authorization
|
alias MvWeb.Authorization
|
||||||
|
|
@ -105,11 +106,6 @@ defmodule MvWeb.ImportTemplateController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp current_actor(conn) do
|
|
||||||
conn.assigns[:current_user]
|
|
||||||
|> Actor.ensure_loaded()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp return_forbidden(conn) do
|
defp return_forbidden(conn) do
|
||||||
conn
|
conn
|
||||||
|> put_status(403)
|
|> put_status(403)
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@ defmodule MvWeb.MemberExportController do
|
||||||
Same permission and actor context as the member overview; 403 if unauthorized.
|
Same permission and actor context as the member overview; 403 if unauthorized.
|
||||||
"""
|
"""
|
||||||
use MvWeb, :controller
|
use MvWeb, :controller
|
||||||
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
import MvWeb.ControllerHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
alias Mv.Authorization.Actor
|
|
||||||
alias Mv.Membership.CustomField
|
alias Mv.Membership.CustomField
|
||||||
alias Mv.Membership.CustomFieldSort
|
alias Mv.Membership.CustomFieldSort
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
|
|
@ -18,7 +18,8 @@ defmodule MvWeb.MemberExportController do
|
||||||
alias Mv.Membership.MembersCSV
|
alias Mv.Membership.MembersCSV
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
alias MvWeb.Translations.MemberFields
|
alias MvWeb.Translations.MemberFields
|
||||||
use Gettext, backend: MvWeb.Gettext
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
|
||||||
["membership_fee_type", "groups"]
|
["membership_fee_type", "groups"]
|
||||||
|
|
@ -53,11 +54,6 @@ defmodule MvWeb.MemberExportController do
|
||||||
|> json(%{error: message})
|
|> json(%{error: message})
|
||||||
end
|
end
|
||||||
|
|
||||||
defp current_actor(conn) do
|
|
||||||
conn.assigns[:current_user]
|
|
||||||
|> Actor.ensure_loaded()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp return_forbidden(conn) do
|
defp return_forbidden(conn) do
|
||||||
conn
|
conn
|
||||||
|> put_status(403)
|
|> put_status(403)
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@ defmodule MvWeb.MemberPdfExportController do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use MvWeb, :controller
|
use MvWeb, :controller
|
||||||
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
require Logger
|
import MvWeb.ControllerHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
alias Mv.Authorization.Actor
|
|
||||||
alias Mv.Membership.{MemberExport, MemberExport.Build, MembersPDF}
|
alias Mv.Membership.{MemberExport, MemberExport.Build, MembersPDF}
|
||||||
alias MvWeb.Translations.MemberFields
|
alias MvWeb.Translations.MemberFields
|
||||||
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
require Logger
|
||||||
|
|
||||||
@payload_required_message "payload required"
|
@payload_required_message "payload required"
|
||||||
@invalid_json_message "invalid JSON"
|
@invalid_json_message "invalid JSON"
|
||||||
|
|
@ -79,13 +79,6 @@ defmodule MvWeb.MemberPdfExportController do
|
||||||
bad_request(conn, @payload_required_message)
|
bad_request(conn, @payload_required_message)
|
||||||
end
|
end
|
||||||
|
|
||||||
# --- Actor / auth ---
|
|
||||||
|
|
||||||
defp current_actor(conn) do
|
|
||||||
conn.assigns[:current_user]
|
|
||||||
|> Actor.ensure_loaded()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp forbidden(conn) do
|
defp forbidden(conn) do
|
||||||
conn
|
conn
|
||||||
|> put_status(:forbidden)
|
|> put_status(:forbidden)
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,9 @@ defmodule MvWeb.Emails.JoinAlreadyMemberEmail do
|
||||||
Used for anti-enumeration: the UI shows the same success message; only the email
|
Used for anti-enumeration: the UI shows the same success message; only the email
|
||||||
informs the recipient. Uses the unified email layout.
|
informs the recipient. Uses the unified email layout.
|
||||||
"""
|
"""
|
||||||
use Phoenix.Swoosh,
|
|
||||||
view: MvWeb.EmailsView,
|
|
||||||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
|
||||||
|
|
||||||
use MvWeb, :verified_routes
|
|
||||||
import Swoosh.Email
|
|
||||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
alias Mv.Mailer
|
alias MvWeb.Emails.JoinEmail
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Sends the "already a member" notice to the given address.
|
Sends the "already a member" notice to the given address.
|
||||||
|
|
@ -23,20 +17,6 @@ defmodule MvWeb.Emails.JoinAlreadyMemberEmail do
|
||||||
def send(email_address) when is_binary(email_address) do
|
def send(email_address) when is_binary(email_address) do
|
||||||
subject = gettext("Membership application – already a member")
|
subject = gettext("Membership application – already a member")
|
||||||
|
|
||||||
assigns = %{
|
JoinEmail.deliver(email_address, "join_already_member.html", subject)
|
||||||
subject: subject,
|
|
||||||
app_name: Mailer.mail_from() |> elem(0),
|
|
||||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
|
||||||
}
|
|
||||||
|
|
||||||
email =
|
|
||||||
new()
|
|
||||||
|> from(Mailer.mail_from())
|
|
||||||
|> to(email_address)
|
|
||||||
|> subject(subject)
|
|
||||||
|> put_view(MvWeb.EmailsView)
|
|
||||||
|> render_body("join_already_member.html", assigns)
|
|
||||||
|
|
||||||
Mailer.deliver(email, Mailer.smtp_config())
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,9 @@ defmodule MvWeb.Emails.JoinAlreadyPendingEmail do
|
||||||
Used for anti-enumeration: the UI shows the same success message; only the email
|
Used for anti-enumeration: the UI shows the same success message; only the email
|
||||||
informs the recipient. Uses the unified email layout.
|
informs the recipient. Uses the unified email layout.
|
||||||
"""
|
"""
|
||||||
use Phoenix.Swoosh,
|
|
||||||
view: MvWeb.EmailsView,
|
|
||||||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
|
||||||
|
|
||||||
use MvWeb, :verified_routes
|
|
||||||
import Swoosh.Email
|
|
||||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
alias Mv.Mailer
|
alias MvWeb.Emails.JoinEmail
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Sends the "application already under review" notice to the given address.
|
Sends the "application already under review" notice to the given address.
|
||||||
|
|
@ -24,20 +18,6 @@ defmodule MvWeb.Emails.JoinAlreadyPendingEmail do
|
||||||
def send(email_address) when is_binary(email_address) do
|
def send(email_address) when is_binary(email_address) do
|
||||||
subject = gettext("Membership application – already under review")
|
subject = gettext("Membership application – already under review")
|
||||||
|
|
||||||
assigns = %{
|
JoinEmail.deliver(email_address, "join_already_pending.html", subject)
|
||||||
subject: subject,
|
|
||||||
app_name: Mailer.mail_from() |> elem(0),
|
|
||||||
locale: Gettext.get_locale(MvWeb.Gettext)
|
|
||||||
}
|
|
||||||
|
|
||||||
email =
|
|
||||||
new()
|
|
||||||
|> from(Mailer.mail_from())
|
|
||||||
|> to(email_address)
|
|
||||||
|> subject(subject)
|
|
||||||
|> put_view(MvWeb.EmailsView)
|
|
||||||
|> render_body("join_already_pending.html", assigns)
|
|
||||||
|
|
||||||
Mailer.deliver(email, Mailer.smtp_config())
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,10 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Sends the join request confirmation email (double opt-in) using the unified email layout.
|
Sends the join request confirmation email (double opt-in) using the unified email layout.
|
||||||
"""
|
"""
|
||||||
use Phoenix.Swoosh,
|
|
||||||
view: MvWeb.EmailsView,
|
|
||||||
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
|
||||||
|
|
||||||
use MvWeb, :verified_routes
|
use MvWeb, :verified_routes
|
||||||
import Swoosh.Email
|
|
||||||
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
use Gettext, backend: MvWeb.Gettext, otp_app: :mv
|
||||||
|
|
||||||
alias Mv.Mailer
|
alias MvWeb.Emails.JoinEmail
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Sends the join confirmation email to the given address with the confirmation link.
|
Sends the join confirmation email to the given address with the confirmation link.
|
||||||
|
|
@ -31,22 +26,9 @@ defmodule MvWeb.Emails.JoinConfirmationEmail do
|
||||||
confirm_url = url(~p"/confirm_join/#{token}")
|
confirm_url = url(~p"/confirm_join/#{token}")
|
||||||
subject = gettext("Confirm your membership request")
|
subject = gettext("Confirm your membership request")
|
||||||
|
|
||||||
assigns = %{
|
JoinEmail.deliver(email_address, "join_confirmation.html", subject, %{
|
||||||
confirm_url: confirm_url,
|
confirm_url: confirm_url,
|
||||||
subject: subject,
|
|
||||||
app_name: Mailer.mail_from() |> elem(0),
|
|
||||||
locale: Gettext.get_locale(MvWeb.Gettext),
|
|
||||||
resend: Keyword.get(opts, :resend, false)
|
resend: Keyword.get(opts, :resend, false)
|
||||||
}
|
})
|
||||||
|
|
||||||
email =
|
|
||||||
new()
|
|
||||||
|> from(Mailer.mail_from())
|
|
||||||
|> to(email_address)
|
|
||||||
|> subject(subject)
|
|
||||||
|> put_view(MvWeb.EmailsView)
|
|
||||||
|> render_body("join_confirmation.html", assigns)
|
|
||||||
|
|
||||||
Mailer.deliver(email, Mailer.smtp_config())
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
54
lib/mv_web/emails/join_email.ex
Normal file
54
lib/mv_web/emails/join_email.ex
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
defmodule MvWeb.Emails.JoinEmail do
|
||||||
|
@moduledoc """
|
||||||
|
Shared build/deliver skeleton for the join-flow emails (confirmation,
|
||||||
|
already-a-member, already-pending).
|
||||||
|
|
||||||
|
Each concrete join email supplies only its subject, template, and any
|
||||||
|
email-specific assigns; this module builds the Swoosh email with the unified
|
||||||
|
layout and delivers it via `Mailer.deliver/2` with `Mailer.smtp_config/0`,
|
||||||
|
preserving the `{:ok, email}` / `{:error, reason}` contract.
|
||||||
|
"""
|
||||||
|
use Phoenix.Swoosh,
|
||||||
|
view: MvWeb.EmailsView,
|
||||||
|
layout: {MvWeb.EmailLayoutView, "layout.html"}
|
||||||
|
|
||||||
|
import Swoosh.Email
|
||||||
|
|
||||||
|
alias Mv.Mailer
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Builds and delivers a join-flow email.
|
||||||
|
|
||||||
|
- `email_address` - recipient address
|
||||||
|
- `template` - the EmailsView template to render (e.g. `"join_confirmation.html"`)
|
||||||
|
- `subject` - already-translated subject line
|
||||||
|
- `extra_assigns` - email-specific assigns merged on top of the common ones
|
||||||
|
(`subject`, `app_name`, `locale`)
|
||||||
|
|
||||||
|
Returns `{:ok, email}` on success, `{:error, reason}` on delivery failure.
|
||||||
|
"""
|
||||||
|
@spec deliver(String.t(), String.t(), String.t(), map()) ::
|
||||||
|
{:ok, Swoosh.Email.t()} | {:error, term()}
|
||||||
|
def deliver(email_address, template, subject, extra_assigns \\ %{})
|
||||||
|
when is_binary(email_address) and is_binary(template) and is_binary(subject) do
|
||||||
|
assigns =
|
||||||
|
Map.merge(
|
||||||
|
%{
|
||||||
|
subject: subject,
|
||||||
|
app_name: Mailer.mail_from() |> elem(0),
|
||||||
|
locale: Gettext.get_locale(MvWeb.Gettext)
|
||||||
|
},
|
||||||
|
extra_assigns
|
||||||
|
)
|
||||||
|
|
||||||
|
email =
|
||||||
|
new()
|
||||||
|
|> from(Mailer.mail_from())
|
||||||
|
|> to(email_address)
|
||||||
|
|> subject(subject)
|
||||||
|
|> put_view(MvWeb.EmailsView)
|
||||||
|
|> render_body(template, assigns)
|
||||||
|
|
||||||
|
Mailer.deliver(email, Mailer.smtp_config())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -9,8 +9,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
|
alias Mv.MembershipFees
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias MvWeb.Helpers.DateFormatter
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Formats a decimal amount as currency string.
|
Formats a decimal amount as currency string.
|
||||||
|
|
@ -98,8 +101,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
||||||
@spec format_cycle_range(Date.t(), :monthly | :quarterly | :half_yearly | :yearly) :: String.t()
|
@spec format_cycle_range(Date.t(), :monthly | :quarterly | :half_yearly | :yearly) :: String.t()
|
||||||
def format_cycle_range(cycle_start, interval) do
|
def format_cycle_range(cycle_start, interval) do
|
||||||
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
|
cycle_end = CalendarCycles.calculate_cycle_end(cycle_start, interval)
|
||||||
start_str = format_date(cycle_start)
|
start_str = DateFormatter.format_date(cycle_start)
|
||||||
end_str = format_date(cycle_end)
|
end_str = DateFormatter.format_date(cycle_end)
|
||||||
"#{start_str} - #{end_str}"
|
"#{start_str} - #{end_str}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -249,8 +252,68 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
|
||||||
def status_icon(:unpaid), do: "hero-x-circle"
|
def status_icon(:unpaid), do: "hero-x-circle"
|
||||||
def status_icon(:suspended), do: "hero-pause-circle"
|
def status_icon(:suspended), do: "hero-pause-circle"
|
||||||
|
|
||||||
# Private helper function for date formatting
|
@doc """
|
||||||
defp format_date(%Date{} = date) do
|
Handles a membership-fee-type "delete" event for the fee-type list and the
|
||||||
Calendar.strftime(date, "%d.%m.%Y")
|
fee-settings LiveViews.
|
||||||
|
|
||||||
|
Loads the fee type, attempts to destroy it, and returns the updated socket
|
||||||
|
with the matching flash. On success the deleted type and its member count are
|
||||||
|
dropped from the `:membership_fee_types` and `:member_counts` assigns. The
|
||||||
|
NotFound, Forbidden (delete and access), and generic error branches preserve
|
||||||
|
the exact messages both views used before they shared this block.
|
||||||
|
"""
|
||||||
|
@spec delete_fee_type(Phoenix.LiveView.Socket.t(), String.t(), term()) ::
|
||||||
|
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||||
|
def delete_fee_type(socket, id, actor) do
|
||||||
|
case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
|
||||||
|
{:ok, fee_type} ->
|
||||||
|
destroy_fee_type(socket, fee_type, id, actor)
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Query.NotFound{}} ->
|
||||||
|
{:noreply,
|
||||||
|
Phoenix.LiveView.put_flash(socket, :error, gettext("Membership fee type not found"))}
|
||||||
|
|
||||||
|
{:error, %Ash.Error.Forbidden{}} ->
|
||||||
|
{:noreply,
|
||||||
|
Phoenix.LiveView.put_flash(
|
||||||
|
socket,
|
||||||
|
:error,
|
||||||
|
gettext("You do not have permission to access this membership fee type")
|
||||||
|
)}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
{:noreply, Phoenix.LiveView.put_flash(socket, :error, fee_error_message(error))}
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,14 @@ defmodule MvWeb.LinkOidcAccountLive do
|
||||||
6. User is redirected to complete OIDC login
|
6. User is redirected to complete OIDC login
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
require Ash.Query
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
alias AshAuthentication.Strategy.Password.Actions, as: PasswordActions
|
alias AshAuthentication.Strategy.Password.Actions, as: PasswordActions
|
||||||
alias Mv.Accounts.User, as: UserResource
|
alias Mv.Accounts.User, as: UserResource
|
||||||
alias Mv.Helpers.SystemActor
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
require Logger
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, session, socket) do
|
def mount(_params, session, socket) do
|
||||||
# Use SystemActor for authorization during OIDC linking (user is not yet logged in)
|
# Use SystemActor for authorization during OIDC linking (user is not yet logged in)
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
|
||||||
alias Mv.Helpers
|
alias Mv.Helpers
|
||||||
|
|
@ -44,6 +43,8 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
alias MvWeb.Helpers.MemberHelpers
|
alias MvWeb.Helpers.MemberHelpers
|
||||||
alias MvWeb.Translations.MemberFields
|
alias MvWeb.Translations.MemberFields
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,15 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
|
||||||
import MvWeb.Authorization
|
import MvWeb.Authorization
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers
|
alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers
|
||||||
|
alias MvWeb.Live.MemberDropdownNav
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
|
@ -566,56 +567,8 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
|
def handle_event("member_dropdown_keydown", params, socket) do
|
||||||
return_if_dropdown_closed(socket, fn ->
|
MemberDropdownNav.handle_keydown(params, socket, fn -> select_focused_member(socket) end)
|
||||||
max_index = length(socket.assigns.available_members) - 1
|
|
||||||
current = socket.assigns.focused_member_index
|
|
||||||
|
|
||||||
new_index =
|
|
||||||
case current do
|
|
||||||
nil -> 0
|
|
||||||
index when index < max_index -> index + 1
|
|
||||||
_ -> current
|
|
||||||
end
|
|
||||||
|
|
||||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
|
|
||||||
return_if_dropdown_closed(socket, fn ->
|
|
||||||
current = socket.assigns.focused_member_index
|
|
||||||
|
|
||||||
new_index =
|
|
||||||
case current do
|
|
||||||
nil -> 0
|
|
||||||
0 -> 0
|
|
||||||
index -> index - 1
|
|
||||||
end
|
|
||||||
|
|
||||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
|
|
||||||
return_if_dropdown_closed(socket, fn ->
|
|
||||||
select_focused_member(socket)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
|
|
||||||
return_if_dropdown_closed(socket, fn ->
|
|
||||||
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("member_dropdown_keydown", _params, socket) do
|
|
||||||
# Ignore other keys
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -705,14 +658,6 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
defp return_if_dropdown_closed(socket, fun) do
|
|
||||||
if socket.assigns.show_member_dropdown do
|
|
||||||
fun.()
|
|
||||||
else
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp select_focused_member(socket) do
|
defp select_focused_member(socket) do
|
||||||
case socket.assigns.focused_member_index do
|
case socket.assigns.focused_member_index do
|
||||||
nil ->
|
nil ->
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
|
||||||
import MvWeb.Authorization
|
import MvWeb.Authorization
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias MvWeb.Helpers.DateFormatter
|
alias MvWeb.Helpers.DateFormatter
|
||||||
alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers
|
alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,8 @@ defmodule MvWeb.JoinRequestLive.Show do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
|
||||||
import MvWeb.Authorization
|
import MvWeb.Authorization
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
alias Mv.Constants
|
alias Mv.Constants
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
@ -26,6 +24,8 @@ defmodule MvWeb.JoinRequestLive.Show do
|
||||||
alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers
|
alias MvWeb.JoinRequestLive.Helpers, as: JoinRequestHelpers
|
||||||
alias MvWeb.Translations.MemberFields, as: MemberFieldsTranslations
|
alias MvWeb.Translations.MemberFields, as: MemberFieldsTranslations
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
if Membership.join_form_enabled?() do
|
if Membership.join_form_enabled?() do
|
||||||
|
|
|
||||||
83
lib/mv_web/live/member_dropdown_nav.ex
Normal file
83
lib/mv_web/live/member_dropdown_nav.ex
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
defmodule MvWeb.Live.MemberDropdownNav do
|
||||||
|
@moduledoc """
|
||||||
|
Shared keyboard-navigation logic for the member-search dropdown used by the
|
||||||
|
user form and the group show LiveViews.
|
||||||
|
|
||||||
|
Both views keep their own `member_dropdown_keydown` event entry point and their
|
||||||
|
own `select_focused_member/1` (the selection effect differs per view), but the
|
||||||
|
arrow/enter/escape navigation over `:focused_member_index` and the
|
||||||
|
dropdown-open guard are identical and live here.
|
||||||
|
|
||||||
|
The caller passes a zero-arity `select_focused` callback that performs the
|
||||||
|
view-specific selection of the currently focused entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 2]
|
||||||
|
|
||||||
|
@type socket :: Phoenix.LiveView.Socket.t()
|
||||||
|
@type result :: {:noreply, socket}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Handles a `member_dropdown_keydown` event for the shared dropdown.
|
||||||
|
|
||||||
|
Navigation keys move `:focused_member_index` within
|
||||||
|
`[0, length(available_members) - 1]`; Enter invokes the view-specific
|
||||||
|
`select_focused` callback; Escape closes the dropdown; any other key is a
|
||||||
|
no-op. All key handling is guarded so that keystrokes while the dropdown is
|
||||||
|
closed are ignored.
|
||||||
|
"""
|
||||||
|
@spec handle_keydown(map(), socket, (-> result)) :: result
|
||||||
|
def handle_keydown(%{"key" => "ArrowDown"}, socket, _select_focused) do
|
||||||
|
return_if_dropdown_closed(socket, fn ->
|
||||||
|
max_index = length(socket.assigns.available_members) - 1
|
||||||
|
current = socket.assigns.focused_member_index
|
||||||
|
|
||||||
|
new_index =
|
||||||
|
case current do
|
||||||
|
nil -> 0
|
||||||
|
index when index < max_index -> index + 1
|
||||||
|
_ -> current
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, assign(socket, focused_member_index: new_index)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_keydown(%{"key" => "ArrowUp"}, socket, _select_focused) do
|
||||||
|
return_if_dropdown_closed(socket, fn ->
|
||||||
|
current = socket.assigns.focused_member_index
|
||||||
|
|
||||||
|
new_index =
|
||||||
|
case current do
|
||||||
|
nil -> 0
|
||||||
|
0 -> 0
|
||||||
|
index -> index - 1
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, assign(socket, focused_member_index: new_index)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_keydown(%{"key" => "Enter"}, socket, select_focused) do
|
||||||
|
return_if_dropdown_closed(socket, select_focused)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_keydown(%{"key" => "Escape"}, socket, _select_focused) do
|
||||||
|
return_if_dropdown_closed(socket, fn ->
|
||||||
|
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_keydown(_params, socket, _select_focused) do
|
||||||
|
# Ignore other keys
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp return_if_dropdown_closed(socket, func) do
|
||||||
|
if socket.assigns.show_member_dropdown do
|
||||||
|
func.()
|
||||||
|
else
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -20,7 +20,6 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
require Logger
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
@ -32,6 +31,8 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
alias MvWeb.Helpers.MemberHelpers
|
alias MvWeb.Helpers.MemberHelpers
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
# Sort custom fields by name for display only
|
# Sort custom fields by name for display only
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
require Logger
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
|
|
@ -45,6 +43,9 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
alias MvWeb.MemberLive.Index.Formatter
|
alias MvWeb.MemberLive.Index.Formatter
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
require Logger
|
||||||
|
|
||||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||||
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
|
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
|
||||||
@group_filter_prefix Mv.Constants.group_filter_prefix()
|
@group_filter_prefix Mv.Constants.group_filter_prefix()
|
||||||
|
|
@ -1026,8 +1027,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
# Errors in handle_params are handled by Phoenix LiveView
|
# Errors in handle_params are handled by Phoenix LiveView
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
{time_microseconds, members} = :timer.tc(fn -> Ash.read!(query, actor: actor) end)
|
members = Ash.read!(query, actor: actor)
|
||||||
Logger.info("Ash.read! in load_members/1 took #{time_microseconds / 1000} ms")
|
|
||||||
|
|
||||||
# Custom field values are already filtered at the database level in load_custom_field_values/2
|
# Custom field values are already filtered at the database level in load_custom_field_values/2
|
||||||
# No need for in-memory filtering anymore
|
# No need for in-memory filtering anymore
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,13 @@ defmodule MvWeb.MemberLive.Index.DateFilter do
|
||||||
`exit_date IS NULL OR exit_date > today` — a member who left today is hidden.
|
`exit_date IS NULL OR exit_date > today` — a member who left today is hidden.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
|
||||||
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
alias MvWeb.MemberLive.Index.CustomFieldValueLookup
|
||||||
alias MvWeb.MemberLive.Index.FilterParams
|
alias MvWeb.MemberLive.Index.FilterParams
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
@join_date_from_param Mv.Constants.join_date_from_param()
|
@join_date_from_param Mv.Constants.join_date_from_param()
|
||||||
@join_date_to_param Mv.Constants.join_date_to_param()
|
@join_date_to_param Mv.Constants.join_date_to_param()
|
||||||
@exit_date_mode_param Mv.Constants.exit_date_mode_param()
|
@exit_date_mode_param Mv.Constants.exit_date_mode_param()
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
alias Mv.Membership.CustomFieldValue
|
alias Mv.Membership.CustomFieldValue
|
||||||
alias Mv.Membership.Member, as: MemberResource
|
alias Mv.Membership.Member, as: MemberResource
|
||||||
alias Mv.Vereinfacht.Client, as: VereinfachtClient
|
alias Mv.Vereinfacht.Client, as: VereinfachtClient
|
||||||
|
alias MvWeb.Helpers.DateFormatter
|
||||||
alias MvWeb.Helpers.MemberHelpers
|
alias MvWeb.Helpers.MemberHelpers
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
alias Phoenix.HTML.Engine, as: HTMLEngine
|
alias Phoenix.HTML.Engine, as: HTMLEngine
|
||||||
|
|
@ -159,12 +160,12 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
<div class="flex gap-6">
|
<div class="flex gap-6">
|
||||||
<.data_field
|
<.data_field
|
||||||
label={gettext("Join Date")}
|
label={gettext("Join Date")}
|
||||||
value={format_date(@member.join_date)}
|
value={DateFormatter.format_date(@member.join_date)}
|
||||||
class="w-28"
|
class="w-28"
|
||||||
/>
|
/>
|
||||||
<.data_field
|
<.data_field
|
||||||
label={gettext("Exit Date")}
|
label={gettext("Exit Date")}
|
||||||
value={format_date(@member.exit_date)}
|
value={DateFormatter.format_date(@member.exit_date)}
|
||||||
class="w-28"
|
class="w-28"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -719,14 +720,6 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_date(nil), do: nil
|
|
||||||
|
|
||||||
defp format_date(%Date{} = date) do
|
|
||||||
Calendar.strftime(date, "%d.%m.%Y")
|
|
||||||
end
|
|
||||||
|
|
||||||
defp format_date(date), do: to_string(date)
|
|
||||||
|
|
||||||
# Finds custom field value for a given custom field id
|
# Finds custom field value for a given custom field id
|
||||||
# Returns the value (not the CustomFieldValue struct) or nil
|
# Returns the value (not the CustomFieldValue struct) or nil
|
||||||
defp find_custom_field_value(nil, _custom_field_id), do: nil
|
defp find_custom_field_value(nil, _custom_field_id), do: nil
|
||||||
|
|
@ -760,7 +753,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_custom_field_value(%Date{} = date, :date) do
|
defp format_custom_field_value(%Date{} = date, :date) do
|
||||||
Calendar.strftime(date, "%d.%m.%Y")
|
DateFormatter.format_date(date)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_custom_field_value(value, :email) when is_binary(value) do
|
defp format_custom_field_value(value, :email) when is_binary(value) do
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,9 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_component
|
use MvWeb, :live_component
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
|
||||||
import MvWeb.Authorization, only: [can?: 3]
|
import MvWeb.Authorization, only: [can?: 3]
|
||||||
import MvWeb.Helpers.AshErrorHelpers, only: [format_error: 1]
|
import MvWeb.Helpers.AshErrorHelpers, only: [format_error: 1]
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.MembershipFees
|
alias Mv.MembershipFees
|
||||||
|
|
@ -25,6 +24,8 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,15 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
alias Mv.MembershipFees
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
@ -92,47 +91,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
actor = current_actor(socket)
|
MembershipFeeHelpers.delete_fee_type(socket, id, current_actor(socket))
|
||||||
|
|
||||||
case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
|
|
||||||
{:ok, fee_type} ->
|
|
||||||
case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do
|
|
||||||
:ok ->
|
|
||||||
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
|
|
||||||
updated_counts = Map.delete(socket.assigns.member_counts, id)
|
|
||||||
|
|
||||||
{:noreply,
|
|
||||||
socket
|
|
||||||
|> assign(:membership_fee_types, updated_types)
|
|
||||||
|> assign(:member_counts, updated_counts)
|
|
||||||
|> put_flash(:success, gettext("Membership fee type deleted"))}
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
|
||||||
{:noreply,
|
|
||||||
put_flash(
|
|
||||||
socket,
|
|
||||||
:error,
|
|
||||||
gettext("You do not have permission to delete this membership fee type")
|
|
||||||
)}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
|
||||||
end
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
|
||||||
{:noreply, put_flash(socket, :error, gettext("Membership fee type not found"))}
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
|
||||||
{:noreply,
|
|
||||||
put_flash(
|
|
||||||
socket,
|
|
||||||
:error,
|
|
||||||
gettext("You do not have permission to access this membership fee type")
|
|
||||||
)}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -465,12 +424,6 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
Map.get(member_counts, fee_type.id, 0)
|
Map.get(member_counts, fee_type.id, 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_error(%Ash.Error.Invalid{} = error) do
|
|
||||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp format_error(_error), do: gettext("An error occurred")
|
|
||||||
|
|
||||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||||
form =
|
form =
|
||||||
AshPhoenix.Form.for_update(
|
AshPhoenix.Form.for_update(
|
||||||
|
|
|
||||||
|
|
@ -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,47 +141,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", %{"id" => id}, socket) do
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
actor = current_actor(socket)
|
MembershipFeeHelpers.delete_fee_type(socket, id, current_actor(socket))
|
||||||
|
|
||||||
case Ash.get(MembershipFeeType, id, domain: MembershipFees, actor: actor) do
|
|
||||||
{:ok, fee_type} ->
|
|
||||||
case Ash.destroy(fee_type, domain: MembershipFees, actor: actor) do
|
|
||||||
:ok ->
|
|
||||||
updated_types = Enum.reject(socket.assigns.membership_fee_types, &(&1.id == id))
|
|
||||||
updated_counts = Map.delete(socket.assigns.member_counts, id)
|
|
||||||
|
|
||||||
{:noreply,
|
|
||||||
socket
|
|
||||||
|> assign(:membership_fee_types, updated_types)
|
|
||||||
|> assign(:member_counts, updated_counts)
|
|
||||||
|> put_flash(:success, gettext("Membership fee type deleted"))}
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{}} ->
|
|
||||||
{:noreply,
|
|
||||||
put_flash(
|
|
||||||
socket,
|
|
||||||
:error,
|
|
||||||
gettext("You do not have permission to delete this membership fee type")
|
|
||||||
)}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
|
||||||
end
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Query.NotFound{}} ->
|
|
||||||
{:noreply, put_flash(socket, :error, gettext("Membership fee type not found"))}
|
|
||||||
|
|
||||||
{:error, %Ash.Error.Forbidden{} = _error} ->
|
|
||||||
{:noreply,
|
|
||||||
put_flash(
|
|
||||||
socket,
|
|
||||||
:error,
|
|
||||||
gettext("You do not have permission to access this membership fee type")
|
|
||||||
)}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
{:noreply, put_flash(socket, :error, format_error(error))}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
|
|
@ -215,12 +175,6 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
||||||
Map.get(member_counts, fee_type.id, 0)
|
Map.get(member_counts, fee_type.id, 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_error(%Ash.Error.Invalid{} = error) do
|
|
||||||
Enum.map_join(error.errors, ", ", fn e -> e.message end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp format_error(_error), do: gettext("An error occurred")
|
|
||||||
|
|
||||||
# Info card explaining the membership fee type concept
|
# Info card explaining the membership fee type concept
|
||||||
defp info_card(assigns) do
|
defp info_card(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,10 @@ defmodule MvWeb.RoleLive.Form do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
alias Mv.Authorization.PermissionSets
|
|
||||||
|
|
||||||
import MvWeb.RoleLive.Helpers, only: [format_error: 1]
|
import MvWeb.RoleLive.Helpers, only: [format_error: 1]
|
||||||
|
|
||||||
|
alias Mv.Authorization.PermissionSets
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
|
||||||
|
|
@ -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,13 +6,14 @@ defmodule MvWeb.StatisticsLive do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
require Logger
|
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias Mv.Statistics
|
alias Mv.Statistics
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
# Only static assigns and fee types here; load_statistics runs once in handle_params
|
# Only static assigns and fee types here; load_statistics runs once in handle_params
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,9 @@ defmodule MvWeb.UserLive.Form do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
require Jason
|
import MvWeb.Authorization, only: [can?: 3]
|
||||||
|
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
|
||||||
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
|
|
||||||
alias Mv.Accounts
|
alias Mv.Accounts
|
||||||
alias Mv.Accounts.User, as: UserResource
|
alias Mv.Accounts.User, as: UserResource
|
||||||
|
|
@ -43,10 +45,9 @@ defmodule MvWeb.UserLive.Form do
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.Membership.Member, as: MemberResource
|
alias Mv.Membership.Member, as: MemberResource
|
||||||
alias MvWeb.Helpers.MemberHelpers
|
alias MvWeb.Helpers.MemberHelpers
|
||||||
|
alias MvWeb.Live.MemberDropdownNav
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
require Jason
|
||||||
import MvWeb.Authorization, only: [can?: 3]
|
|
||||||
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
|
|
@ -571,56 +572,8 @@ defmodule MvWeb.UserLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
|
def handle_event("member_dropdown_keydown", params, socket) do
|
||||||
return_if_dropdown_closed(socket, fn ->
|
MemberDropdownNav.handle_keydown(params, socket, fn -> select_focused_member(socket) end)
|
||||||
max_index = length(socket.assigns.available_members) - 1
|
|
||||||
current = socket.assigns.focused_member_index
|
|
||||||
|
|
||||||
new_index =
|
|
||||||
case current do
|
|
||||||
nil -> 0
|
|
||||||
index when index < max_index -> index + 1
|
|
||||||
_ -> current
|
|
||||||
end
|
|
||||||
|
|
||||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
|
|
||||||
return_if_dropdown_closed(socket, fn ->
|
|
||||||
current = socket.assigns.focused_member_index
|
|
||||||
|
|
||||||
new_index =
|
|
||||||
case current do
|
|
||||||
nil -> 0
|
|
||||||
0 -> 0
|
|
||||||
index -> index - 1
|
|
||||||
end
|
|
||||||
|
|
||||||
{:noreply, assign(socket, focused_member_index: new_index)}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
|
|
||||||
return_if_dropdown_closed(socket, fn ->
|
|
||||||
select_focused_member(socket)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
|
|
||||||
return_if_dropdown_closed(socket, fn ->
|
|
||||||
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def handle_event("member_dropdown_keydown", _params, socket) do
|
|
||||||
# Ignore other keys
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -778,17 +731,6 @@ defmodule MvWeb.UserLive.Form do
|
||||||
@spec notify_parent(any()) :: {module(), any()}
|
@spec notify_parent(any()) :: {module(), any()}
|
||||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||||
|
|
||||||
# Helper to ignore keyboard events when dropdown is closed
|
|
||||||
@spec return_if_dropdown_closed(Phoenix.LiveView.Socket.t(), function()) ::
|
|
||||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
|
||||||
defp return_if_dropdown_closed(socket, func) do
|
|
||||||
if socket.assigns.show_member_dropdown do
|
|
||||||
func.()
|
|
||||||
else
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Select the currently focused member from the dropdown
|
# Select the currently focused member from the dropdown
|
||||||
@spec select_focused_member(Phoenix.LiveView.Socket.t()) ::
|
@spec select_focused_member(Phoenix.LiveView.Socket.t()) ::
|
||||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ defmodule MvWeb.LiveUserAuth do
|
||||||
Helpers for authenticating users in LiveViews.
|
Helpers for authenticating users in LiveViews.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import Phoenix.Component
|
|
||||||
use MvWeb, :verified_routes
|
use MvWeb, :verified_routes
|
||||||
|
|
||||||
|
import Phoenix.Component
|
||||||
|
|
||||||
alias AshAuthentication.Phoenix.LiveSession
|
alias AshAuthentication.Phoenix.LiveSession
|
||||||
alias Phoenix.LiveView
|
alias Phoenix.LiveView
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -193,8 +193,7 @@ msgid "An account with this email already exists. Please verify your password to
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/helpers/ash_error_helpers.ex
|
#: lib/mv_web/helpers/ash_error_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
|
||||||
#: lib/mv_web/live/role_live/helpers.ex
|
#: lib/mv_web/live/role_live/helpers.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "An error occurred"
|
msgid "An error occurred"
|
||||||
|
|
@ -2289,14 +2288,12 @@ msgstr ""
|
||||||
msgid "Membership fee start"
|
msgid "Membership fee start"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Membership fee type deleted"
|
msgid "Membership fee type deleted"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Membership fee type not found"
|
msgid "Membership fee type not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -3956,8 +3953,7 @@ msgstr ""
|
||||||
msgid "You do not have permission to %{action} members."
|
msgid "You do not have permission to %{action} members."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "You do not have permission to access this membership fee type"
|
msgid "You do not have permission to access this membership fee type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -3973,8 +3969,7 @@ msgstr ""
|
||||||
msgid "You do not have permission to delete this member"
|
msgid "You do not have permission to delete this member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "You do not have permission to delete this membership fee type"
|
msgid "You do not have permission to delete this membership fee type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@ defmodule Mv.Membership.GroupDatabaseConstraintsTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
import Ash.Expr
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@ defmodule Mv.Membership.GroupTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
|
import Ash.Expr
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,14 @@ defmodule Mv.Membership.JoinRequestApprovalDomainTest do
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
alias Mv.Fixtures
|
alias Mv.Fixtures
|
||||||
alias Mv.Helpers.SystemActor
|
alias Mv.Helpers.SystemActor
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
defp member_count do
|
defp member_count do
|
||||||
actor = SystemActor.get_system_actor()
|
actor = SystemActor.get_system_actor()
|
||||||
{:ok, members} = Membership.list_members(actor: actor)
|
{:ok, members} = Membership.list_members(actor: actor)
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,14 @@ defmodule Mv.Membership.JoinRequestTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
|
||||||
alias Mv.Fixtures
|
alias Mv.Fixtures
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.Membership.JoinRequest
|
alias Mv.Membership.JoinRequest
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
# Valid minimal attributes for submit (email required; confirmation_token optional for tests)
|
# Valid minimal attributes for submit (email required; confirmation_token optional for tests)
|
||||||
@valid_submit_attrs %{
|
@valid_submit_attrs %{
|
||||||
email: "join#{System.unique_integer([:positive])}@example.com"
|
email: "join#{System.unique_integer([:positive])}@example.com"
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@ defmodule Mv.Membership.MemberGroupTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
|
import Ash.Expr
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ defmodule Mv.Membership.MemberGroupsRelationshipTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
|
import Ash.Expr
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
|
||||||
58
test/mv/application_test.exs
Normal file
58
test/mv/application_test.exs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
defmodule Mv.ApplicationTest do
|
||||||
|
@moduledoc """
|
||||||
|
Guards the AshAuthentication supervisor wiring in `Mv.Application`.
|
||||||
|
|
||||||
|
The auth children (token Expunger, audit-log batcher/expunger) resolve their
|
||||||
|
configuration via `Spark.sparks(otp_app, Ash.Resource)`. With the wrong
|
||||||
|
`otp_app` they silently start against an empty resource set: the token
|
||||||
|
Expunger then runs against no token resources and becomes a no-op. This test
|
||||||
|
pins the corrected `:mv` wiring against the *running* application tree: the
|
||||||
|
supervisor and all three children are present, and the token Expunger booted
|
||||||
|
live and resolved the real `:mv` token resource.
|
||||||
|
|
||||||
|
The two audit-log children legitimately report an `:undefined` pid because no
|
||||||
|
`AuditLogResource` resources exist under `:mv` (their init returns `:ignore`);
|
||||||
|
that is a successful start, not a crash, so they are only asserted present.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias AshAuthentication.TokenResource.Expunger
|
||||||
|
|
||||||
|
test "AshAuthentication children boot under the :mv otp_app and resolve real config" do
|
||||||
|
# Locate the running AshAuthentication.Supervisor inside the live app tree.
|
||||||
|
auth_sup =
|
||||||
|
Mv.Supervisor
|
||||||
|
|> Supervisor.which_children()
|
||||||
|
|> Enum.find_value(fn
|
||||||
|
{AshAuthentication.Supervisor, pid, _type, _mods} when is_pid(pid) -> pid
|
||||||
|
_ -> nil
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert is_pid(auth_sup), "AshAuthentication.Supervisor is not running in Mv.Supervisor"
|
||||||
|
|
||||||
|
children = Supervisor.which_children(auth_sup)
|
||||||
|
child_ids = children |> Enum.map(&elem(&1, 0)) |> Enum.sort()
|
||||||
|
|
||||||
|
# All three auth children are present in the supervision tree.
|
||||||
|
assert child_ids ==
|
||||||
|
Enum.sort([
|
||||||
|
AshAuthentication.TokenResource.Expunger,
|
||||||
|
AshAuthentication.AuditLogResource.Batcher,
|
||||||
|
AshAuthentication.AuditLogResource.Expunger
|
||||||
|
])
|
||||||
|
|
||||||
|
# The token Expunger booted as a live process and resolved the real :mv
|
||||||
|
# token resource — proving the children run against non-empty :mv config
|
||||||
|
# rather than the empty set the wrong otp_app produced.
|
||||||
|
expunger_pid =
|
||||||
|
Enum.find_value(children, fn
|
||||||
|
{Expunger, pid, _, _} when is_pid(pid) -> pid
|
||||||
|
_ -> nil
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert is_pid(expunger_pid), "token Expunger did not boot as a live process under :mv"
|
||||||
|
assert Process.alive?(expunger_pid)
|
||||||
|
assert %{otp_app: :mv, resources: resources} = :sys.get_state(expunger_pid)
|
||||||
|
assert Map.has_key?(resources, Mv.Accounts.Token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -12,10 +12,10 @@ defmodule Mv.Authorization.Checks.HasPermissionFailClosedTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
alias Mv.Authorization.Checks.HasPermission
|
|
||||||
|
|
||||||
import Mv.Fixtures
|
import Mv.Fixtures
|
||||||
|
|
||||||
|
alias Mv.Authorization.Checks.HasPermission
|
||||||
|
|
||||||
test "auto_filter deny-filter matches no records (regression for NOT IN [] allow-all bug)" do
|
test "auto_filter deny-filter matches no records (regression for NOT IN [] allow-all bug)" do
|
||||||
# Arrange: create some members in DB
|
# Arrange: create some members in DB
|
||||||
_m1 = member_fixture()
|
_m1 = member_fixture()
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
defmodule Mv.OidcRoleSyncConfigTest do
|
|
||||||
@moduledoc """
|
|
||||||
Tests for OIDC role sync configuration (OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM).
|
|
||||||
Reads via Mv.Config (ENV first, then Settings).
|
|
||||||
"""
|
|
||||||
use Mv.DataCase, async: false
|
|
||||||
|
|
||||||
alias Mv.OidcRoleSyncConfig
|
|
||||||
|
|
||||||
describe "oidc_admin_group_name/0" do
|
|
||||||
test "returns nil when OIDC_ADMIN_GROUP_NAME is not configured" do
|
|
||||||
restore = clear_env("OIDC_ADMIN_GROUP_NAME")
|
|
||||||
on_exit(restore)
|
|
||||||
|
|
||||||
assert OidcRoleSyncConfig.oidc_admin_group_name() == nil
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns configured admin group name when set via ENV" do
|
|
||||||
restore = set_env("OIDC_ADMIN_GROUP_NAME", "mila-admin")
|
|
||||||
on_exit(restore)
|
|
||||||
|
|
||||||
assert OidcRoleSyncConfig.oidc_admin_group_name() == "mila-admin"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "oidc_groups_claim/0" do
|
|
||||||
test "returns default \"groups\" when OIDC_GROUPS_CLAIM is not configured" do
|
|
||||||
restore = clear_env("OIDC_GROUPS_CLAIM")
|
|
||||||
on_exit(restore)
|
|
||||||
|
|
||||||
assert OidcRoleSyncConfig.oidc_groups_claim() == "groups"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns configured claim name when OIDC_GROUPS_CLAIM is set via ENV" do
|
|
||||||
restore = set_env("OIDC_GROUPS_CLAIM", "ak_groups")
|
|
||||||
on_exit(restore)
|
|
||||||
|
|
||||||
assert OidcRoleSyncConfig.oidc_groups_claim() == "ak_groups"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp set_env(key, value) do
|
|
||||||
previous = System.get_env(key)
|
|
||||||
System.put_env(key, value)
|
|
||||||
|
|
||||||
fn ->
|
|
||||||
if previous, do: System.put_env(key, previous), else: System.delete_env(key)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp clear_env(key) do
|
|
||||||
previous = System.get_env(key)
|
|
||||||
System.delete_env(key)
|
|
||||||
|
|
||||||
fn ->
|
|
||||||
if previous, do: System.put_env(key, previous)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -4,7 +4,6 @@ defmodule Mv.StatisticsTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
|
|
@ -13,6 +12,8 @@ defmodule Mv.StatisticsTest do
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
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}
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
||||||
# async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
|
# async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.Membership.CustomField
|
alias Mv.Membership.CustomField
|
||||||
|
|
||||||
# Helper to create a boolean custom field (uses system_actor - only admin can create)
|
# Helper to create a boolean custom field (uses system_actor - only admin can create)
|
||||||
|
|
|
||||||
|
|
@ -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,10 +15,11 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
use MvWeb.ConnCase, async: true
|
use MvWeb.ConnCase, async: true
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
admin_role = Mv.Fixtures.role_fixture("admin")
|
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,11 @@ defmodule MvWeb.CustomFieldLive.FormTest do
|
||||||
use MvWeb.ConnCase, async: true
|
use MvWeb.ConnCase, async: true
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
alias Mv.Membership.CustomField
|
alias Mv.Membership.CustomField
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
admin_role = Mv.Fixtures.role_fixture("admin")
|
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ defmodule MvWeb.GroupLive.FormTest do
|
||||||
- Security
|
- Security
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.Fixtures
|
alias Mv.Fixtures
|
||||||
|
|
||||||
describe "create form" do
|
describe "create form" do
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ defmodule MvWeb.GroupLive.IndexTest do
|
||||||
- Edge cases
|
- Edge cases
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.Fixtures
|
alias Mv.Fixtures
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,16 @@ defmodule MvWeb.GroupLive.IntegrationTest do
|
||||||
- URL persistence
|
- URL persistence
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
require Ash.Query
|
|
||||||
import Ash.Expr
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
import Ash.Expr
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.Fixtures
|
alias Mv.Fixtures
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
describe "complete workflow" do
|
describe "complete workflow" do
|
||||||
test "create → view via slug → edit → view via slug (slug unchanged)", %{
|
test "create → view via slug → edit → view via slug (slug unchanged)", %{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.Fixtures
|
alias Mv.Fixtures
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
import MvWeb.GroupLiveHelpers
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
import MvWeb.GroupLiveHelpers
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.Fixtures
|
alias Mv.Fixtures
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.Fixtures
|
alias Mv.Fixtures
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.Fixtures
|
alias Mv.Fixtures
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.Fixtures
|
alias Mv.Fixtures
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.Fixtures
|
alias Mv.Fixtures
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.Fixtures
|
alias Mv.Fixtures
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,15 @@ defmodule MvWeb.GroupLive.ShowTest do
|
||||||
- Delete functionality
|
- Delete functionality
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
require Ash.Query
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.Fixtures
|
alias Mv.Fixtures
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
describe "mount and display" do
|
describe "mount and display" do
|
||||||
test "page renders successfully", %{conn: conn} do
|
test "page renders successfully", %{conn: conn} do
|
||||||
group = Fixtures.group_fixture()
|
group = Fixtures.group_fixture()
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@ defmodule MvWeb.MemberLive.DeactivateTest do
|
||||||
driven through the parent LiveView (the DeactivateComponent is stateful).
|
driven through the parent LiveView (the DeactivateComponent is stateful).
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: true
|
use MvWeb.ConnCase, async: true
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.Fixtures
|
alias Mv.Fixtures
|
||||||
|
|
||||||
defp reload_member(member) do
|
defp reload_member(member) do
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,15 @@ defmodule MvWeb.RoleLive.ShowTest do
|
||||||
- Delete functionality
|
- Delete functionality
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
require Ash.Query
|
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.Authorization
|
alias Mv.Authorization
|
||||||
alias Mv.Authorization.Role
|
alias Mv.Authorization.Role
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
# Helper to create a role (authorize?: false for test data setup)
|
# Helper to create a role (authorize?: false for test data setup)
|
||||||
defp create_role(attrs \\ %{}) do
|
defp create_role(attrs \\ %{}) do
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
|
|
|
||||||
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