defmodule Mv.Config do @moduledoc """ Configuration helper functions for the application. Provides centralized access to configuration values to avoid magic strings/atoms scattered throughout the codebase. """ @doc """ Returns whether SQL sandbox mode is enabled. SQL sandbox mode is typically enabled in test environments to allow concurrent database access in tests. ## Returns - `true` if SQL sandbox is enabled - `false` otherwise """ @spec sql_sandbox?() :: boolean() def sql_sandbox? do Application.get_env(:mv, :sql_sandbox, false) end @doc """ Returns the maximum file size for CSV imports in bytes. Reads the `max_file_size_mb` value from the CSV import configuration and converts it to bytes. ## Returns - Maximum file size in bytes (default: 10_485_760 bytes = 10 MB) ## Examples iex> Mv.Config.csv_import_max_file_size_bytes() 10_485_760 """ @spec csv_import_max_file_size_bytes() :: non_neg_integer() def csv_import_max_file_size_bytes do max_file_size_mb = get_csv_import_config(:max_file_size_mb, 10) max_file_size_mb * 1024 * 1024 end @doc """ Returns the maximum number of rows allowed in CSV imports. Reads the `max_rows` value from the CSV import configuration. ## Returns - Maximum number of rows (default: 1000) ## Examples iex> Mv.Config.csv_import_max_rows() 1000 """ @spec csv_import_max_rows() :: pos_integer() def csv_import_max_rows do get_csv_import_config(:max_rows, 1000) end @doc """ Returns the maximum file size for CSV imports in megabytes. Reads the `max_file_size_mb` value from the CSV import configuration. ## Returns - Maximum file size in megabytes (default: 10) ## Examples iex> Mv.Config.csv_import_max_file_size_mb() 10 """ @spec csv_import_max_file_size_mb() :: pos_integer() def csv_import_max_file_size_mb do get_csv_import_config(:max_file_size_mb, 10) end # Helper function to get CSV import config values defp get_csv_import_config(key, default) do Application.get_env(:mv, :csv_import, []) |> Keyword.get(key, default) |> parse_and_validate_integer(default) end # Parses and validates integer configuration values. # # Accepts: # - Integer values (passed through) # - String integers (e.g., "1000") - parsed to integer # - Invalid values (e.g., "abc", nil) - falls back to default # # Always clamps the result to a minimum of 1 to ensure positive values. # # Note: We don't log warnings for unparseable values because: # - These functions may be called frequently (e.g., on every request) # - Logging would create excessive log spam # - The fallback to default provides a safe behavior # - Configuration errors should be caught during deployment/testing defp parse_and_validate_integer(value, _default) when is_integer(value) do max(1, value) end defp parse_and_validate_integer(value, default) when is_binary(value) do case Integer.parse(value) do {int, _remainder} -> max(1, int) :error -> default end end defp parse_and_validate_integer(_value, default) do default end @doc """ Returns the maximum number of rows allowed in PDF exports. Reads the `row_limit` value from the PDF export configuration. ## Returns - Maximum number of rows (default: 5000) ## Examples iex> Mv.Config.pdf_export_row_limit() 5000 """ @spec pdf_export_row_limit() :: pos_integer() def pdf_export_row_limit do get_pdf_export_config(:row_limit, 5000) end # Helper function to get PDF export config values defp get_pdf_export_config(key, default) do Application.get_env(:mv, :pdf_export, []) |> Keyword.get(key, default) |> parse_and_validate_integer(default) end # --------------------------------------------------------------------------- # Vereinfacht accounting software integration # ENV variables take priority; fallback to Settings from database. # --------------------------------------------------------------------------- @doc """ Returns the Vereinfacht API base URL. Reads from `VEREINFACHT_API_URL` env first, then from Settings. """ @spec vereinfacht_api_url() :: String.t() | nil def vereinfacht_api_url do env_or_setting("VEREINFACHT_API_URL", :vereinfacht_api_url) end @doc """ Returns the Vereinfacht API key (Bearer token). Reads from `VEREINFACHT_API_KEY` env first, then from Settings. """ @spec vereinfacht_api_key() :: String.t() | nil def vereinfacht_api_key do env_or_setting("VEREINFACHT_API_KEY", :vereinfacht_api_key) end @doc """ Returns the Vereinfacht club ID for multi-tenancy. Reads from `VEREINFACHT_CLUB_ID` env first, then from Settings. """ @spec vereinfacht_club_id() :: String.t() | nil def vereinfacht_club_id do env_or_setting("VEREINFACHT_CLUB_ID", :vereinfacht_club_id) end @doc """ Returns the Vereinfacht app base URL for contact view links (frontend, not API). Reads from `VEREINFACHT_APP_URL` env first, then from Settings. Used to build links like https://app.verein.visuel.dev/en/admin/finances/contacts/{id}. If not set, derived from API URL by replacing host \"api.\" with \"app.\" when possible. """ @spec vereinfacht_app_url() :: String.t() | nil def vereinfacht_app_url do env_or_setting("VEREINFACHT_APP_URL", :vereinfacht_app_url) || derive_app_url_from_api_url(vereinfacht_api_url()) end defp derive_app_url_from_api_url(nil), do: nil defp derive_app_url_from_api_url(api_url) when is_binary(api_url) do api_url = String.trim(api_url) uri = URI.parse(api_url) host = uri.host || "" if String.starts_with?(host, "api.") do app_host = "app." <> String.slice(host, 4..-1//1) scheme = uri.scheme || "https" "#{scheme}://#{app_host}" else nil end end defp derive_app_url_from_api_url(_), do: nil @doc """ Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set). """ @spec vereinfacht_configured?() :: boolean() def vereinfacht_configured? do present?(vereinfacht_api_url()) and present?(vereinfacht_api_key()) and present?(vereinfacht_club_id()) end @doc """ Returns true if any Vereinfacht ENV variable is set (used to show hint in Settings UI). """ @spec vereinfacht_env_configured?() :: boolean() def vereinfacht_env_configured? do vereinfacht_api_url_env_set?() or vereinfacht_api_key_env_set?() or vereinfacht_club_id_env_set?() end @doc """ Returns true if VEREINFACHT_API_URL is set (field is read-only in Settings). """ def vereinfacht_api_url_env_set?, do: env_set?("VEREINFACHT_API_URL") @doc """ Returns true if VEREINFACHT_API_KEY is set (field is read-only in Settings). """ def vereinfacht_api_key_env_set?, do: env_set?("VEREINFACHT_API_KEY") @doc """ Returns true if VEREINFACHT_CLUB_ID is set (field is read-only in Settings). """ def vereinfacht_club_id_env_set?, do: env_set?("VEREINFACHT_CLUB_ID") @doc """ Returns true if VEREINFACHT_APP_URL is set (field is read-only in Settings). """ def vereinfacht_app_url_env_set?, do: env_set?("VEREINFACHT_APP_URL") defp env_set?(key) do case System.get_env(key) do nil -> false v when is_binary(v) -> String.trim(v) != "" _ -> false end end defp env_or_setting(env_key, setting_key) do case System.get_env(env_key) do nil -> get_vereinfacht_from_settings(setting_key) value -> trim_nil(value) end end defp get_vereinfacht_from_settings(key) do get_from_settings(key) end defp get_from_settings(key) do case Mv.Membership.get_settings() do {:ok, settings} -> settings |> Map.get(key) |> trim_nil() {:error, _} -> nil end end defp trim_nil(nil), do: nil defp trim_nil(s) when is_binary(s) do t = String.trim(s) if t == "", do: nil, else: t end @doc """ Returns the URL to view a finance contact in the Vereinfacht app (frontend). Uses the configured app base URL (or derived from API URL) and appends /en/admin/finances/contacts/{id}. Returns nil if no app URL can be determined. """ @spec vereinfacht_contact_view_url(String.t()) :: String.t() | nil def vereinfacht_contact_view_url(contact_id) when is_binary(contact_id) do base = vereinfacht_app_url() if present?(base) do base |> String.trim_trailing("/") |> then(&"#{&1}/en/admin/finances/contacts/#{contact_id}") else nil end end defp present?(nil), do: false defp present?(s) when is_binary(s), do: String.trim(s) != "" defp present?(_), do: false # --------------------------------------------------------------------------- # OIDC authentication # ENV variables take priority; fallback to Settings from database. # --------------------------------------------------------------------------- @doc """ Returns the OIDC client ID. ENV first, then Settings. """ @spec oidc_client_id() :: String.t() | nil def oidc_client_id do env_or_setting("OIDC_CLIENT_ID", :oidc_client_id) end @doc """ Returns the OIDC provider base URL. ENV first, then Settings. """ @spec oidc_base_url() :: String.t() | nil def oidc_base_url do env_or_setting("OIDC_BASE_URL", :oidc_base_url) end @doc """ Returns the OIDC redirect URI. ENV first, then Settings. """ @spec oidc_redirect_uri() :: String.t() | nil def oidc_redirect_uri do env_or_setting("OIDC_REDIRECT_URI", :oidc_redirect_uri) end @doc """ Returns the OIDC client secret. ENV first, then Settings. """ @spec oidc_client_secret() :: String.t() | nil def oidc_client_secret do env_or_setting("OIDC_CLIENT_SECRET", :oidc_client_secret) end @doc """ Returns the OIDC admin group name (for role sync). ENV first, then Settings. """ @spec oidc_admin_group_name() :: String.t() | nil def oidc_admin_group_name do env_or_setting("OIDC_ADMIN_GROUP_NAME", :oidc_admin_group_name) end @doc """ Returns the OIDC groups claim name (default "groups"). ENV first, then Settings. """ @spec oidc_groups_claim() :: String.t() | nil def oidc_groups_claim do case env_or_setting("OIDC_GROUPS_CLAIM", :oidc_groups_claim) do nil -> "groups" v -> v end end @doc """ Returns true if any OIDC ENV variable is set (used to show hint in Settings UI). """ @spec oidc_env_configured?() :: boolean() def oidc_env_configured? do oidc_client_id_env_set?() or oidc_base_url_env_set?() or oidc_redirect_uri_env_set?() or oidc_client_secret_env_set?() or oidc_admin_group_name_env_set?() or oidc_groups_claim_env_set?() end def oidc_client_id_env_set?, do: env_set?("OIDC_CLIENT_ID") def oidc_base_url_env_set?, do: env_set?("OIDC_BASE_URL") def oidc_redirect_uri_env_set?, do: env_set?("OIDC_REDIRECT_URI") def oidc_client_secret_env_set?, do: env_set?("OIDC_CLIENT_SECRET") def oidc_admin_group_name_env_set?, do: env_set?("OIDC_ADMIN_GROUP_NAME") def oidc_groups_claim_env_set?, do: env_set?("OIDC_GROUPS_CLAIM") end