Merge pull request 'Vereinfacht API: filter-based contact lookup, no extra required fields, country sync, and docs' (#459) from feat/vereinfacht_api into main
Some checks reported errors
continuous-integration/drone/push Build was killed

Reviewed-on: #459
This commit is contained in:
moritz 2026-03-04 21:15:06 +01:00
commit d914f5aa22
16 changed files with 208 additions and 154 deletions

View file

@ -62,6 +62,7 @@ We are building a membership management system (Mila) using the following techno
**Related documents:** **Related documents:**
- **UI / UX:** [`DESIGN_DUIDELINES.md`](../DESIGN_DUIDELINES.md) defines visual and interaction consistency: use of CoreComponents (no raw DaisyUI in views), page skeleton (`<.header>`, `mt-6 space-y-6`), **Back button left in header for edit/new forms** (§2.2), typography, buttons, forms, tables, flash/toast, and microcopy (e.g. German "du" and glossary). Follow "components first" and semantic variants instead of hard-coded colors. - **UI / UX:** [`DESIGN_DUIDELINES.md`](../DESIGN_DUIDELINES.md) defines visual and interaction consistency: use of CoreComponents (no raw DaisyUI in views), page skeleton (`<.header>`, `mt-6 space-y-6`), **Back button left in header for edit/new forms** (§2.2), typography, buttons, forms, tables, flash/toast, and microcopy (e.g. German "du" and glossary). Follow "components first" and semantic variants instead of hard-coded colors.
- **Vereinfacht API:** [`docs/vereinfacht-api.md`](docs/vereinfacht-api.md) describes the finance-contact sync (find by email filter, minimal create payload, no extra required member fields).
--- ---
@ -115,8 +116,13 @@ lib/
│ ├── membership_fees/ # Membership fee business logic │ ├── membership_fees/ # Membership fee business logic
│ │ ├── cycle_generator.ex # Cycle generation algorithm │ │ ├── cycle_generator.ex # Cycle generation algorithm
│ │ └── calendar_cycles.ex # Calendar cycle calculations │ │ └── calendar_cycles.ex # Calendar cycle calculations
│ ├── vereinfacht/ # Vereinfacht accounting API integration
│ │ ├── client.ex # HTTP client (finance-contacts: create, update, find by email)
│ │ ├── vereinfacht.ex # Business logic (sync_member, sync_members_without_contact)
│ │ ├── sync_flash.ex # Flash message helpers for sync results
│ │ └── changes/ # Ash changes (SyncContact, sync linked member)
│ ├── helpers.ex # Shared helper functions (ash_actor_opts) │ ├── helpers.ex # Shared helper functions (ash_actor_opts)
│ ├── constants.ex # Application constants (member_fields, custom_field_prefix) │ ├── constants.ex # Application constants (member_fields, custom_field_prefix, vereinfacht_required_member_fields)
│ ├── application.ex # OTP application │ ├── application.ex # OTP application
│ ├── mailer.ex # Email mailer │ ├── mailer.ex # Email mailer
│ ├── release.ex # Release tasks │ ├── release.ex # Release tasks
@ -2874,7 +2880,7 @@ Building accessible applications ensures that all users, including those with di
**Required Fields:** **Required Fields:**
Which member fields are required (asterisk, tooltip, validation) is configured in **Settings** (Memberdata section: edit a member field and set "Required"). The member create/edit form and Member resource validation both read `settings.member_field_required`. Email is always required; other fields default to optional. Which member fields are required (asterisk, tooltip, validation) is configured in **Settings** (Memberdata section: edit a member field and set "Required"). The member create/edit form and Member resource validation both read `settings.member_field_required`. Email is always required; other fields default to optional. The Vereinfacht integration does not add extra required member fields (the external API accepts a minimal payload when creating contacts and supports filter-by-email for lookup).
```heex ```heex
<!-- Mark required fields (value from settings or always true for email) --> <!-- Mark required fields (value from settings or always true for email) -->

View file

@ -247,7 +247,7 @@
- ❌ Payment records/transactions (external payment tracking) - ❌ Payment records/transactions (external payment tracking)
- ❌ Payment reminders - ❌ Payment reminders
- ❌ Invoice generation - ❌ Invoice generation
- ❌ vereinfacht.digital API integration - ✅ Memberfinance-contact sync with vereinfacht.digital API (see `docs/vereinfacht-api.md`); ❌ transaction import / full API integration
- ❌ SEPA direct debit support - ❌ SEPA direct debit support
- ❌ Payment reports - ❌ Payment reports

47
docs/vereinfacht-api.md Normal file
View file

@ -0,0 +1,47 @@
# Vereinfacht API Integration
This document describes the current integration with the Vereinfacht (verein.visuel.dev) accounting API for syncing members as finance contacts.
## Overview
- **Purpose:** Create and update external finance contacts in Vereinfacht when members are created or updated; support bulk sync for members without a contact ID.
- **Configuration:** ENV or Settings: `VEREINFACHT_API_URL`, `VEREINFACHT_API_KEY`, `VEREINFACHT_CLUB_ID`, optional `VEREINFACHT_APP_URL` for contact view links.
- **Modules:** `Mv.Vereinfacht` (business logic), `Mv.Vereinfacht.Client` (HTTP client), `Mv.Vereinfacht.Changes.SyncContact` (Ash after_transaction change).
## API Usage
### Finding an existing contact by email
The API supports filtered list requests. Use a single GET instead of paginating:
- **Endpoint:** `GET /api/v1/finance-contacts?filter[isExternal]=true&filter[email]=<email>`
- **Client:** `Mv.Vereinfacht.Client.find_contact_by_email/1` builds this URL (with encoded email) and returns `{:ok, contact_id}` if the first match exists, `{:error, :not_found}` otherwise.
- No member fields are required in the app solely for this lookup.
### Creating a contact
When creating an external finance contact, the API only requires:
- **Attributes:** `contactType` (e.g. `"person"`), `isExternal: true`
- **Relationship:** `club` (club ID from config)
Additional attributes (firstName, lastName, email, address, zipCode, city, country) are optional and are sent when present on the member so the contact is filled in. The app does **not** enforce extra required member fields for Vereinfacht; only Settings-based required fields and email apply.
- **Client:** `Mv.Vereinfacht.Client.create_contact/1` builds the JSON:API body from the member; `Mv.Constants.vereinfacht_required_member_fields/0` is an empty list.
### Updating a contact
- **Endpoint:** `PATCH /api/v1/finance-contacts/:id`
- **Client:** `Mv.Vereinfacht.Client.update_contact/2` sends current member attributes. The API may still validate presence/format of fields on update.
## Flow
1. **Member create/update:** `SyncContact` runs after the transaction. If the member has no `vereinfacht_contact_id`, the client tries `find_contact_by_email(email)`; if found, it updates that contact and stores the ID on the member; otherwise it creates a contact and stores the new ID. If the member already has a contact ID, the client updates the contact.
2. **Bulk sync:** “Sync all members without Vereinfacht contact” calls `Vereinfacht.sync_members_without_contact/0`, which loads members with nil/blank `vereinfacht_contact_id` and runs the same create/update flow per member.
## References
- **Config:** `Mv.Config` (`vereinfacht_api_url`, `vereinfacht_api_key`, `vereinfacht_club_id`, `vereinfacht_app_url`, `vereinfacht_configured?/0`).
- **Constants:** `Mv.Constants.vereinfacht_required_member_fields/0` (empty), `vereinfacht_required_field?/1` (legacy; currently unused in UI or validation).
- **Tests:** `test/mv/vereinfacht/`, `test/mv/config_vereinfacht_test.exs`; see `test/mv/vereinfacht/vereinfacht_test_README.md` for scope.
- **Roadmap:** Payment/transaction import and deeper integration are tracked in `docs/feature-roadmap.md` and `docs/membership-fee-architecture.md`.

View file

@ -550,11 +550,9 @@ defmodule Mv.Membership.Member do
end, end,
where: [action_is([:create_member, :update_member])] where: [action_is([:create_member, :update_member])]
# Validate member fields that are marked as required in settings or by Vereinfacht. # Validate member fields that are marked as required in settings.
# When settings cannot be loaded, we still enforce email + Vereinfacht-required fields. # When settings cannot be loaded, enforce only email.
validate fn changeset, _context -> validate fn changeset, _context ->
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
required_fields = required_fields =
case Mv.Membership.get_settings() do case Mv.Membership.get_settings() do
{:ok, settings} -> {:ok, settings} ->
@ -562,20 +560,17 @@ defmodule Mv.Membership.Member do
normalized = VisibilityConfig.normalize(required_config) normalized = VisibilityConfig.normalize(required_config)
Enum.filter(Mv.Constants.member_fields(), fn field -> Enum.filter(Mv.Constants.member_fields(), fn field ->
field == :email || field == :email || Map.get(normalized, field, false)
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
Map.get(normalized, field, false)
end) end)
{:error, reason} -> {:error, reason} ->
Logger.warning( Logger.warning(
"Member required-fields validation: could not load settings (#{inspect(reason)}). " <> "Member required-fields validation: could not load settings (#{inspect(reason)}). " <>
"Enforcing only email and Vereinfacht-required fields." "Enforcing only email."
) )
Enum.filter(Mv.Constants.member_fields(), fn field -> Enum.filter(Mv.Constants.member_fields(), fn field ->
field == :email || field == :email
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field))
end) end)
end end

View file

@ -28,15 +28,17 @@ defmodule Mv.Constants do
@email_validator_checks [:html_input, :pow] @email_validator_checks [:html_input, :pow]
# Member fields that are required when Vereinfacht integration is active (contact sync) # No member fields are required solely for Vereinfacht; API accepts minimal payload
@vereinfacht_required_member_fields [:first_name, :last_name, :street, :postal_code, :city] # (contactType + isExternal) when creating external contacts and supports filter by email for lookup.
@vereinfacht_required_member_fields []
def member_fields, do: @member_fields def member_fields, do: @member_fields
@doc """ @doc """
Returns member fields that are always required when Vereinfacht integration is configured. Returns member fields that are always required when Vereinfacht integration is configured.
Used for validation, member form required indicators, and settings UI (checkbox disabled). Currently empty: the Vereinfacht API only requires contactType (e.g. "person") when creating
external contacts; lookup uses filter[email] so no extra required fields in the app.
""" """
def vereinfacht_required_member_fields, do: @vereinfacht_required_member_fields def vereinfacht_required_member_fields, do: @vereinfacht_required_member_fields

View file

@ -9,7 +9,7 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do
(Mv.Config.vereinfacht_configured?/0). (Mv.Config.vereinfacht_configured?/0).
Only runs when relevant data changed: on create always; on update only when Only runs when relevant data changed: on create always; on update only when
first_name, last_name, email, street, house_number, postal_code, or city changed, first_name, last_name, email, street, house_number, postal_code, city, or country changed,
or when the member has no vereinfacht_contact_id yet (to avoid unnecessary API calls). or when the member has no vereinfacht_contact_id yet (to avoid unnecessary API calls).
""" """
use Ash.Resource.Change use Ash.Resource.Change
@ -26,7 +26,8 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do
:street, :street,
:house_number, :house_number,
:postal_code, :postal_code,
:city :city,
:country
] ]
@impl true @impl true

View file

@ -166,12 +166,12 @@ defmodule Mv.Vereinfacht.Client do
end end
@doc """ @doc """
Finds a finance contact by email (GET /finance-contacts, then match in response). Finds a finance contact by email using the API filter.
The Vereinfacht API does not allow filter by email on this endpoint, so we Uses GET /finance-contacts?filter[isExternal]=true&filter[email]=... so the API
fetch the first page and find the contact client-side. Returns {:ok, contact_id} returns only matching external contacts. Returns {:ok, contact_id} if a contact
if a contact with that email exists, {:error, :not_found} if none, or exists, {:error, :not_found} if none, or {:error, reason} on API/network failure.
{:error, reason} on API/network failure. Used before create for idempotency. Used before create for idempotency.
""" """
@spec find_contact_by_email(String.t()) :: {:ok, String.t()} | {:error, :not_found | term()} @spec find_contact_by_email(String.t()) :: {:ok, String.t()} | {:error, :not_found | term()}
def find_contact_by_email(email) when is_binary(email) do def find_contact_by_email(email) when is_binary(email) do
@ -182,25 +182,18 @@ defmodule Mv.Vereinfacht.Client do
end end
end end
@find_contact_page_size 100
@find_contact_max_pages 100
defp do_find_contact_by_email(email) do defp do_find_contact_by_email(email) do
normalized = String.trim(email) |> String.downcase() normalized_email = email |> String.trim() |> String.downcase()
do_find_contact_by_email_page(1, normalized)
end
defp do_find_contact_by_email_page(page, _normalized) when page > @find_contact_max_pages do
{:error, :not_found}
end
defp do_find_contact_by_email_page(page, normalized) do
base = base_url() |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts") base = base_url() |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts")
url = base <> "?page[size]=#{@find_contact_page_size}&page[number]=#{page}" encoded_email = URI.encode_www_form(normalized_email)
url = "#{base}?filter[isExternal]=true&filter[email]=#{encoded_email}"
case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do
{:ok, %{status: 200, body: body}} when is_map(body) -> {:ok, %{status: 200, body: body}} when is_map(body) ->
handle_find_contact_page_response(body, page, normalized) case get_first_contact_id_from_list(body) do
nil -> {:error, :not_found}
id -> {:ok, id}
end
{:ok, %{status: status, body: body}} -> {:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}} {:error, {:http, status, extract_error_message(body)}}
@ -210,48 +203,20 @@ defmodule Mv.Vereinfacht.Client do
end end
end end
defp handle_find_contact_page_response(body, page, normalized) do defp get_first_contact_id_from_list(%{"data" => data} = _body) when is_list(data) do
case find_contact_id_by_email_in_list(body, normalized) do if length(data) > 1 do
id when is_binary(id) -> {:ok, id} Logger.warning(
nil -> maybe_find_contact_next_page(body, page, normalized) "Vereinfacht find_contact_by_email: API returned multiple contacts for same email (count: #{length(data)}), using first. Check for duplicate or inconsistent data."
)
end
case data do
[%{"id" => id} | _] -> normalize_contact_id(id)
[] -> nil
end end
end end
defp maybe_find_contact_next_page(body, page, normalized) do defp get_first_contact_id_from_list(_), do: nil
data = Map.get(body, "data") || []
if length(data) < @find_contact_page_size,
do: {:error, :not_found},
else: do_find_contact_by_email_page(page + 1, normalized)
end
defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do
Enum.find_value(list, fn
%{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => true}}
when is_binary(att_email) ->
if att_email |> String.trim() |> String.downcase() == normalized do
normalize_contact_id(id)
else
nil
end
%{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => "true"}}
when is_binary(att_email) ->
if att_email |> String.trim() |> String.downcase() == normalized do
normalize_contact_id(id)
else
nil
end
%{"id" => _id, "attributes" => _} ->
nil
_ ->
nil
end)
end
defp find_contact_id_by_email_in_list(_, _), do: nil
defp normalize_contact_id(id) when is_binary(id), do: id defp normalize_contact_id(id) when is_binary(id), do: id
defp normalize_contact_id(id) when is_integer(id), do: to_string(id) defp normalize_contact_id(id) when is_integer(id), do: to_string(id)
@ -389,6 +354,7 @@ defmodule Mv.Vereinfacht.Client do
|> put_attr("address", address) |> put_attr("address", address)
|> put_attr("zipCode", member |> Map.get(:postal_code)) |> put_attr("zipCode", member |> Map.get(:postal_code))
|> put_attr("city", member |> Map.get(:city)) |> put_attr("city", member |> Map.get(:city))
|> put_attr("country", member |> Map.get(:country))
|> Map.put("contactType", "person") |> Map.put("contactType", "person")
|> Map.put("isExternal", true) |> Map.put("isExternal", true)
|> Enum.reject(fn {_k, v} -> is_nil(v) end) |> Enum.reject(fn {_k, v} -> is_nil(v) end)

View file

@ -33,7 +33,6 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
assigns assigns
|> assign(:field_attributes, get_field_attributes(assigns.member_field)) |> assign(:field_attributes, get_field_attributes(assigns.member_field))
|> assign(:is_email_field?, assigns.member_field == :email) |> assign(:is_email_field?, assigns.member_field == :email)
|> assign(:vereinfacht_required_field?, vereinfacht_required_field?(assigns))
|> assign(:field_label, MemberFields.label(assigns.member_field)) |> assign(:field_label, MemberFields.label(assigns.member_field))
~H""" ~H"""
@ -120,22 +119,12 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
<%!-- Line break before Required / Show in overview block --%> <%!-- Line break before Required / Show in overview block --%>
<div class="mt-4"> <div class="mt-4">
<%!-- Required: disabled for email (always required) or Vereinfacht-required fields when integration is active --%> <%!-- Required: disabled for email (always required); else configurable in settings --%>
<div <div
:if={@is_email_field? or @vereinfacht_required_field?} :if={@is_email_field?}
class="tooltip tooltip-right" class="tooltip tooltip-right"
data-tip={ data-tip={gettext("This is a technical field and cannot be changed")}
if(@is_email_field?, aria-label={gettext("This is a technical field and cannot be changed")}
do: gettext("This is a technical field and cannot be changed"),
else: gettext("Required for Vereinfacht integration and cannot be disabled.")
)
}
aria-label={
if(@is_email_field?,
do: gettext("This is a technical field and cannot be changed"),
else: gettext("Required for Vereinfacht integration and cannot be disabled.")
)
}
> >
<fieldset class="mb-2 fieldset"> <fieldset class="mb-2 fieldset">
<label> <label>
@ -164,7 +153,7 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
</fieldset> </fieldset>
</div> </div>
<.input <.input
:if={not @is_email_field? and not @vereinfacht_required_field?} :if={not @is_email_field?}
field={@form[:required]} field={@form[:required]}
type="checkbox" type="checkbox"
label={gettext("Required")} label={gettext("Required")}
@ -211,7 +200,6 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
end end
required = required =
socket.assigns.vereinfacht_required_field? ||
if Map.has_key?(member_field_params, "required") do if Map.has_key?(member_field_params, "required") do
TypeParsers.parse_boolean(member_field_params["required"]) TypeParsers.parse_boolean(member_field_params["required"])
else else
@ -247,7 +235,6 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
end end
required = required =
socket.assigns.vereinfacht_required_field? ||
if Map.has_key?(member_field_params, "required") do if Map.has_key?(member_field_params, "required") do
TypeParsers.parse_boolean(member_field_params["required"]) TypeParsers.parse_boolean(member_field_params["required"])
else else
@ -292,20 +279,10 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
normalized_visibility = VisibilityConfig.normalize(visibility_config) normalized_visibility = VisibilityConfig.normalize(visibility_config)
normalized_required = VisibilityConfig.normalize(required_config) normalized_required = VisibilityConfig.normalize(required_config)
show_in_overview = Map.get(normalized_visibility, member_field, true) show_in_overview = Map.get(normalized_visibility, member_field, true)
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
# Persist in socket so validate/save can enforce server-side without relying on render assigns
socket =
assign(
socket,
:vereinfacht_required_field?,
vereinfacht_required_field?(%{member_field: member_field})
)
# Email always required; Vereinfacht-required fields when integration active; else from settings # Email always required; else from settings
required = required =
member_field == :email || member_field == :email || Map.get(normalized_required, member_field, false)
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(member_field)) ||
Map.get(normalized_required, member_field, false)
form_data = %{ form_data = %{
"name" => MemberFields.label(member_field), "name" => MemberFields.label(member_field),
@ -338,9 +315,4 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
defp format_error(error) do defp format_error(error) do
inspect(error) inspect(error)
end end
defp vereinfacht_required_field?(assigns) do
Mv.Config.vereinfacht_configured?() &&
Mv.Constants.vereinfacht_required_field?(assigns.member_field)
end
end end

View file

@ -172,19 +172,15 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
member_fields = Mv.Constants.member_fields() member_fields = Mv.Constants.member_fields()
visibility_config = settings.member_field_visibility || %{} visibility_config = settings.member_field_visibility || %{}
required_config = settings.member_field_required || %{} required_config = settings.member_field_required || %{}
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
normalized_visibility = VisibilityConfig.normalize(visibility_config) normalized_visibility = VisibilityConfig.normalize(visibility_config)
normalized_required = VisibilityConfig.normalize(required_config) normalized_required = VisibilityConfig.normalize(required_config)
Enum.map(member_fields, fn field -> Enum.map(member_fields, fn field ->
show_in_overview = Map.get(normalized_visibility, field, true) show_in_overview = Map.get(normalized_visibility, field, true)
# Email always required; Vereinfacht-required fields when integration active; else from settings # Email always required; else from settings
required = required =
field == :email || field == :email || Map.get(normalized_required, field, false)
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
Map.get(normalized_required, field, false)
attribute = Info.attribute(Mv.Membership.Member, field) attribute = Info.attribute(Mv.Membership.Member, field)

View file

@ -398,8 +398,6 @@ defmodule MvWeb.MemberLive.Form do
end end
defp get_member_field_required_map do defp get_member_field_required_map do
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
case Membership.get_settings() do case Membership.get_settings() do
{:ok, settings} -> {:ok, settings} ->
required_config = settings.member_field_required || %{} required_config = settings.member_field_required || %{}
@ -407,20 +405,15 @@ defmodule MvWeb.MemberLive.Form do
Mv.Constants.member_fields() Mv.Constants.member_fields()
|> Enum.map(fn field -> |> Enum.map(fn field ->
required = required = field == :email || Map.get(normalized, field, false)
field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
Map.get(normalized, field, false)
{field, required} {field, required}
end) end)
|> Map.new() |> Map.new()
{:error, _} -> {:error, _} ->
# Email always required; Vereinfacht fields when integration active # When settings cannot be loaded, only email is required
Map.new(Mv.Constants.member_fields(), fn f -> Map.new(Mv.Constants.member_fields(), fn f ->
{f, {f, f == :email}
f == :email || (vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(f))}
end) end)
end end
end end

View file

@ -75,6 +75,7 @@ defmodule Mv.MixProject do
{:bandit, "~> 1.5"}, {:bandit, "~> 1.5"},
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false}, {:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
{:bypass, "~> 2.1", only: [:dev, :test]},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:picosat_elixir, "~> 0.1"}, {:picosat_elixir, "~> 0.1"},
{:ecto_commons, "~> 0.3"}, {:ecto_commons, "~> 0.3"},

View file

@ -10,11 +10,15 @@
"bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"}, "cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
"credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"},
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"}, "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
@ -65,8 +69,10 @@
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"}, "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_cowboy": {:hex, :plug_cowboy, "2.8.0", "07789e9c03539ee51bb14a07839cc95aa96999fd8846ebfd28c97f0b50c7b612", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9cbfaaf17463334ca31aed38ea7e08a68ee37cabc077b1e9be6d2fb68e0171d0"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"},
"reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"}, "reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},

View file

@ -2820,11 +2820,6 @@ msgstr "Okt."
msgid "Sep." msgid "Sep."
msgstr "Sep." msgstr "Sep."
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert werden."
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex #: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format

View file

@ -2820,11 +2820,6 @@ msgstr ""
msgid "Sep." msgid "Sep."
msgstr "" msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex #: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format

View file

@ -2820,11 +2820,6 @@ msgstr ""
msgid "Sep." msgid "Sep."
msgstr "" msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr "Required for Vereinfacht integration and cannot be disabled."
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex #: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format

View file

@ -2,8 +2,8 @@ defmodule Mv.Vereinfacht.ClientTest do
@moduledoc """ @moduledoc """
Tests for Mv.Vereinfacht.Client. Tests for Mv.Vereinfacht.Client.
Only tests the "not configured" path; no real HTTP calls. Config reads from "Not configured" path: no HTTP. When configured we use Bypass to stub the API
ENV first, then from Settings (DB), so we use DataCase so get_settings() is available. and assert on request (query params) and response parsing.
""" """
use Mv.DataCase, async: false use Mv.DataCase, async: false
@ -30,6 +30,84 @@ defmodule Mv.Vereinfacht.ClientTest do
end end
end end
describe "find_contact_by_email/1" do
test "returns {:error, :not_configured} when Vereinfacht is not configured" do
assert Client.find_contact_by_email("kayley.becker@example.com") ==
{:error, :not_configured}
end
@tag :bypass
test "sends filter[isExternal]=true and filter[email]=<encoded> and returns :not_found when data is empty" do
bypass = Bypass.open()
base = "http://127.0.0.1:#{bypass.port}"
set_vereinfacht_env(base)
Bypass.expect_once(bypass, "GET", "/finance-contacts", fn conn ->
qs = conn.query_string || ""
assert qs =~ "filter[isExternal]=true",
"expected query to contain filter[isExternal]=true, got: #{inspect(qs)}"
assert qs =~ "filter[email]=",
"expected query to contain filter[email]=..., got: #{inspect(qs)}"
# Email should be encoded (e.g. @ as %40)
assert qs =~ "filter[email]=test%40example.com",
"expected filter[email] to be URL-encoded (downcased), got: #{inspect(qs)}"
body = Jason.encode!(%{"jsonapi" => %{"version" => "1.0"}, "data" => []})
conn
|> Plug.Conn.put_resp_content_type("application/vnd.api+json")
|> Plug.Conn.send_resp(200, body)
end)
assert Client.find_contact_by_email(" Test@Example.com ") == {:error, :not_found}
end
@tag :bypass
test "returns {:ok, id} when API returns one contact (string id)" do
bypass = Bypass.open()
base = "http://127.0.0.1:#{bypass.port}"
set_vereinfacht_env(base)
Bypass.expect_once(bypass, "GET", "/finance-contacts", fn conn ->
body =
Jason.encode!(%{
"jsonapi" => %{"version" => "1.0"},
"data" => [%{"type" => "finance-contacts", "id" => "123", "attributes" => %{}}]
})
conn
|> Plug.Conn.put_resp_content_type("application/vnd.api+json")
|> Plug.Conn.send_resp(200, body)
end)
assert Client.find_contact_by_email("user@example.com") == {:ok, "123"}
end
@tag :bypass
test "returns {:ok, id} when API returns one contact (integer id)" do
bypass = Bypass.open()
base = "http://127.0.0.1:#{bypass.port}"
set_vereinfacht_env(base)
Bypass.expect_once(bypass, "GET", "/finance-contacts", fn conn ->
body =
Jason.encode!(%{
"jsonapi" => %{"version" => "1.0"},
"data" => [%{"type" => "finance-contacts", "id" => 456, "attributes" => %{}}]
})
conn
|> Plug.Conn.put_resp_content_type("application/vnd.api+json")
|> Plug.Conn.send_resp(200, body)
end)
assert Client.find_contact_by_email("other@example.com") == {:ok, "456"}
end
end
defp build_member_struct do defp build_member_struct do
%{ %{
first_name: "Test", first_name: "Test",
@ -42,6 +120,12 @@ defmodule Mv.Vereinfacht.ClientTest do
} }
end end
defp set_vereinfacht_env(base_url) do
System.put_env("VEREINFACHT_API_URL", base_url)
System.put_env("VEREINFACHT_API_KEY", "test-key")
System.put_env("VEREINFACHT_CLUB_ID", "2")
end
defp clear_vereinfacht_env do defp clear_vereinfacht_env do
System.delete_env("VEREINFACHT_API_URL") System.delete_env("VEREINFACHT_API_URL")
System.delete_env("VEREINFACHT_API_KEY") System.delete_env("VEREINFACHT_API_KEY")