Compare commits
1 commit
3c64f33aa4
...
41eb4da2ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41eb4da2ea |
104 changed files with 1719 additions and 2909 deletions
|
|
@ -82,14 +82,8 @@
|
||||||
# You can customize the priority of any check
|
# You can customize the priority of any check
|
||||||
# Priority values are: `low, normal, high, higher`
|
# Priority values are: `low, normal, high, higher`
|
||||||
#
|
#
|
||||||
# AliasUsage only for lib and support; test files excluded (many nested module refs by design)
|
|
||||||
{Credo.Check.Design.AliasUsage,
|
{Credo.Check.Design.AliasUsage,
|
||||||
[
|
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
|
||||||
priority: :low,
|
|
||||||
if_nested_deeper_than: 2,
|
|
||||||
if_called_more_often_than: 0,
|
|
||||||
files: %{excluded: ["test/"]}
|
|
||||||
]},
|
|
||||||
{Credo.Check.Design.TagFIXME, []},
|
{Credo.Check.Design.TagFIXME, []},
|
||||||
# You can also customize the exit_status of each check.
|
# You can also customize the exit_status of each check.
|
||||||
# If you don't want TODO comments to cause `mix credo` to fail, just
|
# If you don't want TODO comments to cause `mix credo` to fail, just
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ steps:
|
||||||
# Check for dependencies that are not maintained anymore
|
# Check for dependencies that are not maintained anymore
|
||||||
- mix hex.audit
|
- mix hex.audit
|
||||||
# Provide hints for improving code quality
|
# Provide hints for improving code quality
|
||||||
- mix credo --strict
|
- mix credo
|
||||||
# Check that translations are up to date
|
# Check that translations are up to date
|
||||||
- mix gettext.extract --check-up-to-date
|
- mix gettext.extract --check-up-to-date
|
||||||
|
|
||||||
|
|
@ -159,7 +159,7 @@ steps:
|
||||||
# Check for dependencies that are not maintained anymore
|
# Check for dependencies that are not maintained anymore
|
||||||
- mix hex.audit
|
- mix hex.audit
|
||||||
# Provide hints for improving code quality
|
# Provide hints for improving code quality
|
||||||
- mix credo --strict
|
- mix credo
|
||||||
# Check that translations are up to date
|
# Check that translations are up to date
|
||||||
- mix gettext.extract --check-up-to-date
|
- mix gettext.extract --check-up-to-date
|
||||||
|
|
||||||
|
|
@ -273,7 +273,7 @@ environment:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: renovate
|
- name: renovate
|
||||||
image: renovate/renovate:43.55
|
image: renovate/renovate:43.51
|
||||||
environment:
|
environment:
|
||||||
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
||||||
RENOVATE_TOKEN:
|
RENOVATE_TOKEN:
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,6 @@ 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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -116,13 +115,8 @@ 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, vereinfacht_required_member_fields)
|
│ ├── constants.ex # Application constants (member_fields, custom_field_prefix)
|
||||||
│ ├── application.ex # OTP application
|
│ ├── application.ex # OTP application
|
||||||
│ ├── mailer.ex # Email mailer
|
│ ├── mailer.ex # Email mailer
|
||||||
│ ├── release.ex # Release tasks
|
│ ├── release.ex # Release tasks
|
||||||
|
|
@ -275,16 +269,6 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.2.1 Database Seeds
|
|
||||||
|
|
||||||
Seeds are split into **bootstrap** and **dev**:
|
|
||||||
|
|
||||||
- **`priv/repo/seeds.exs`** – Entrypoint. Runs `seeds_bootstrap.exs` always; runs `seeds_dev.exs` only when `Mix.env()` is `:dev` or `:test`.
|
|
||||||
- **`priv/repo/seeds_bootstrap.exs`** – Creates only data required for system startup: membership fee types, custom fields, roles, admin user, system user, global settings (including default membership fee type). No members, no groups. Used in all environments (dev, test, prod).
|
|
||||||
- **`priv/repo/seeds_dev.exs`** – Creates 20 sample members, groups, and optional custom field values. Run only in dev and test.
|
|
||||||
|
|
||||||
In production, running `mix run priv/repo/seeds.exs` executes only the bootstrap part (no dev seeds).
|
|
||||||
|
|
||||||
### 1.3 Domain-Driven Design
|
### 1.3 Domain-Driven Design
|
||||||
|
|
||||||
**Use Ash Domains for Context Boundaries:**
|
**Use Ash Domains for Context Boundaries:**
|
||||||
|
|
@ -2880,7 +2864,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. 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).
|
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.
|
||||||
|
|
||||||
```heex
|
```heex
|
||||||
<!-- Mark required fields (value from settings or always true for email) -->
|
<!-- Mark required fields (value from settings or always true for email) -->
|
||||||
|
|
|
||||||
2
Justfile
2
Justfile
|
|
@ -31,7 +31,7 @@ gettext:
|
||||||
lint:
|
lint:
|
||||||
mix format --check-formatted
|
mix format --check-formatted
|
||||||
mix compile --warnings-as-errors
|
mix compile --warnings-as-errors
|
||||||
mix credo --strict
|
mix credo
|
||||||
# Check that all German translations are filled (UI must be in German)
|
# Check that all German translations are filled (UI must be in German)
|
||||||
@bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
|
@bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done'
|
||||||
mix gettext.extract --check-up-to-date
|
mix gettext.extract --check-up-to-date
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,10 @@ Most membership tools for clubs are either:
|
||||||
|
|
||||||
Our philosophy: **software should help people spend less time on administration and more time on their community.**
|
Our philosophy: **software should help people spend less time on administration and more time on their community.**
|
||||||
|
|
||||||
## User Documentation (German)
|
## 📸 Screenshots
|
||||||
|
|
||||||
You can find our documentation for users here: https://wiki.local-it.org/s/mila-user-dokumentation
|
|
||||||
|
|
||||||
|

|
||||||
|
*This is how Mila might look in action.*
|
||||||
|
|
||||||
## 🔑 Features
|
## 🔑 Features
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,8 @@
|
||||||
word-spacing: inherit;
|
word-spacing: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* WCAG 2 AA: success/error/warning text. Light theme: dark tones on light bg; dark theme: light tones on dark bg. */
|
/* WCAG 2 AA: success/error text on light backgrounds (e.g. base-200). Use instead of
|
||||||
|
text-success/text-error when contrast ratio of theme colors is insufficient. */
|
||||||
.text-success-aa {
|
.text-success-aa {
|
||||||
color: oklch(0.35 0.12 165);
|
color: oklch(0.35 0.12 165);
|
||||||
}
|
}
|
||||||
|
|
@ -117,22 +118,6 @@
|
||||||
color: oklch(0.45 0.2 25);
|
color: oklch(0.45 0.2 25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-warning-aa {
|
|
||||||
color: oklch(0.45 0.14 75);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .text-success-aa {
|
|
||||||
color: oklch(0.72 0.12 165);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .text-error-aa {
|
|
||||||
color: oklch(0.75 0.18 25);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .text-warning-aa {
|
|
||||||
color: oklch(0.78 0.14 75);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* WCAG 2.2 AA: Badge contrast. DaisyUI .badge-outline uses transparent bg; we use
|
/* WCAG 2.2 AA: Badge contrast. DaisyUI .badge-outline uses transparent bg; we use
|
||||||
Core Component <.badge style="outline"> which adds .bg-base-100. This rule ensures
|
Core Component <.badge style="outline"> which adds .bg-base-100. This rule ensures
|
||||||
outline badges always have a visible background in both themes. */
|
outline badges always have a visible background in both themes. */
|
||||||
|
|
@ -563,96 +548,4 @@
|
||||||
--color-secondary-content: oklch(98% 0 0);
|
--color-secondary-content: oklch(98% 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
WCAG 2.2 AA: Tab list inactive tab text contrast (4.5:1)
|
|
||||||
============================================ */
|
|
||||||
#member-tablist .tab:not(.tab-active) {
|
|
||||||
color: oklch(0.35 0.02 285);
|
|
||||||
}
|
|
||||||
[data-theme="dark"] #member-tablist .tab:not(.tab-active) {
|
|
||||||
color: oklch(0.72 0.02 257);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
WCAG 2.2 AA: Link contrast - primary and accent
|
|
||||||
============================================ */
|
|
||||||
[data-theme="light"] .link.link-primary {
|
|
||||||
color: oklch(0.45 0.15 35);
|
|
||||||
}
|
|
||||||
[data-theme="light"] .link.link-primary:hover {
|
|
||||||
color: oklch(0.38 0.14 35);
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .link.link-primary {
|
|
||||||
color: oklch(0.82 0.14 45);
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .link.link-primary:hover {
|
|
||||||
color: oklch(0.88 0.12 45);
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .link.link-accent {
|
|
||||||
color: oklch(0.82 0.18 292);
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .link.link-accent:hover {
|
|
||||||
color: oklch(0.88 0.16 292);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
WCAG 2.2 AA: Danger zone heading contrast (dark theme)
|
|
||||||
============================================ */
|
|
||||||
[data-theme="dark"] #danger-zone-heading.text-error {
|
|
||||||
color: oklch(0.78 0.18 25);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
WCAG 2.2 AA: Blue link contrast in dark theme
|
|
||||||
============================================ */
|
|
||||||
[data-theme="dark"] a.text-blue-700,
|
|
||||||
[data-theme="dark"] a.text-blue-600,
|
|
||||||
[data-theme="dark"] a.hover\:text-blue-800 {
|
|
||||||
color: oklch(0.72 0.16 255);
|
|
||||||
}
|
|
||||||
[data-theme="dark"] a.text-blue-700:hover,
|
|
||||||
[data-theme="dark"] a.text-blue-600:hover {
|
|
||||||
color: oklch(0.82 0.14 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================
|
|
||||||
WCAG 2.2 AA: Password / form label on light box in dark theme
|
|
||||||
============================================ */
|
|
||||||
[data-theme="dark"] .bg-gray-50 {
|
|
||||||
background-color: var(--color-base-200);
|
|
||||||
color: var(--color-base-content);
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .bg-gray-50 .label,
|
|
||||||
[data-theme="dark"] .bg-gray-50 .mb-1.label,
|
|
||||||
[data-theme="dark"] .bg-gray-50 .text-gray-600,
|
|
||||||
[data-theme="dark"] .bg-gray-50 .text-gray-700,
|
|
||||||
[data-theme="dark"] .bg-gray-50 strong,
|
|
||||||
[data-theme="dark"] .bg-gray-50 p,
|
|
||||||
[data-theme="dark"] .bg-gray-50 li {
|
|
||||||
color: var(--color-base-content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode: orange/red info boxes (admin note, OIDC warning) – dark bg, light text */
|
|
||||||
[data-theme="dark"] .bg-orange-50 {
|
|
||||||
background-color: oklch(0.32 0.06 55);
|
|
||||||
border-color: oklch(0.42 0.08 55);
|
|
||||||
color: var(--color-base-content);
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .bg-orange-50 .text-orange-800,
|
|
||||||
[data-theme="dark"] .bg-orange-50 p,
|
|
||||||
[data-theme="dark"] .bg-orange-50 strong {
|
|
||||||
color: var(--color-base-content);
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .bg-red-50 {
|
|
||||||
background-color: oklch(0.32 0.08 25);
|
|
||||||
border-color: oklch(0.42 0.12 25);
|
|
||||||
color: var(--color-base-content);
|
|
||||||
}
|
|
||||||
[data-theme="dark"] .bg-red-50 .text-red-800,
|
|
||||||
[data-theme="dark"] .bg-red-50 .text-red-700,
|
|
||||||
[data-theme="dark"] .bg-red-50 p,
|
|
||||||
[data-theme="dark"] .bg-red-50 strong {
|
|
||||||
color: var(--color-base-content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* This file is for your main application CSS */
|
/* This file is for your main application CSS */
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Feature Roadmap & Implementation Plan
|
# Feature Roadmap & Implementation Plan
|
||||||
|
|
||||||
**Project:** Mila - Membership Management System
|
**Project:** Mila - Membership Management System
|
||||||
**Last Updated:** 2026-03-03
|
**Last Updated:** 2026-01-27
|
||||||
**Status:** Active Development
|
**Status:** Active Development
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -247,7 +247,7 @@
|
||||||
- ❌ Payment records/transactions (external payment tracking)
|
- ❌ Payment records/transactions (external payment tracking)
|
||||||
- ❌ Payment reminders
|
- ❌ Payment reminders
|
||||||
- ❌ Invoice generation
|
- ❌ Invoice generation
|
||||||
- ✅ Member–finance-contact sync with vereinfacht.digital API (see `docs/vereinfacht-api.md`); ❌ transaction import / full API integration
|
- ❌ vereinfacht.digital API integration
|
||||||
- ❌ SEPA direct debit support
|
- ❌ SEPA direct debit support
|
||||||
- ❌ Payment reports
|
- ❌ Payment reports
|
||||||
|
|
||||||
|
|
@ -371,7 +371,6 @@
|
||||||
- ✅ Production Dockerfile
|
- ✅ Production Dockerfile
|
||||||
- ✅ Drone CI/CD pipeline
|
- ✅ Drone CI/CD pipeline
|
||||||
- ✅ Renovate for dependency updates
|
- ✅ Renovate for dependency updates
|
||||||
- ✅ Database seeds split into bootstrap (all envs) and dev-only seeds (20 members, groups; 2026-03-03)
|
|
||||||
- ⚠️ No staging environment
|
- ⚠️ No staging environment
|
||||||
|
|
||||||
**Open Issues:**
|
**Open Issues:**
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
# 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`.
|
|
||||||
|
|
@ -11,11 +11,6 @@ defmodule Mv.Accounts.User do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
|
||||||
alias Ash.Resource.Preparation.Builtins
|
|
||||||
alias Mv.Authorization.Role, as: RoleResource
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
alias Mv.OidcRoleSync
|
|
||||||
|
|
||||||
postgres do
|
postgres do
|
||||||
table "users"
|
table "users"
|
||||||
repo Mv.Repo
|
repo Mv.Repo
|
||||||
|
|
@ -287,20 +282,20 @@ defmodule Mv.Accounts.User do
|
||||||
|
|
||||||
# Sync role from OIDC groups after sign-in (e.g. admin group → Admin role)
|
# Sync role from OIDC groups after sign-in (e.g. admin group → Admin role)
|
||||||
# get? true can return nil, a single %User{}, or a list; normalize to list for Enum.each
|
# get? true can return nil, a single %User{}, or a list; normalize to list for Enum.each
|
||||||
prepare Builtins.after_action(fn query, result, _context ->
|
prepare Ash.Resource.Preparation.Builtins.after_action(fn query, result, _context ->
|
||||||
user_info = Ash.Query.get_argument(query, :user_info) || %{}
|
user_info = Ash.Query.get_argument(query, :user_info) || %{}
|
||||||
oauth_tokens = Ash.Query.get_argument(query, :oauth_tokens) || %{}
|
oauth_tokens = Ash.Query.get_argument(query, :oauth_tokens) || %{}
|
||||||
|
|
||||||
users =
|
users =
|
||||||
case result do
|
case result do
|
||||||
nil -> []
|
nil -> []
|
||||||
u when is_struct(u, __MODULE__) -> [u]
|
u when is_struct(u, User) -> [u]
|
||||||
list when is_list(list) -> list
|
list when is_list(list) -> list
|
||||||
_ -> []
|
_ -> []
|
||||||
end
|
end
|
||||||
|
|
||||||
Enum.each(users, fn user ->
|
Enum.each(users, fn user ->
|
||||||
OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
|
Mv.OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
{:ok, result}
|
{:ok, result}
|
||||||
|
|
@ -488,10 +483,10 @@ defmodule Mv.Accounts.User do
|
||||||
|> Enum.map(& &1.id)
|
|> Enum.map(& &1.id)
|
||||||
|
|
||||||
# Count only non-system users with admin role (system user is for internal ops)
|
# Count only non-system users with admin role (system user is for internal ops)
|
||||||
system_email = SystemActor.system_user_email()
|
system_email = Mv.Helpers.SystemActor.system_user_email()
|
||||||
|
|
||||||
count =
|
count =
|
||||||
__MODULE__
|
Mv.Accounts.User
|
||||||
|> Ash.Query.for_read(:read)
|
|> Ash.Query.for_read(:read)
|
||||||
|> Ash.Query.filter(expr(role_id in ^admin_role_ids))
|
|> Ash.Query.filter(expr(role_id in ^admin_role_ids))
|
||||||
|> Ash.Query.filter(expr(email != ^system_email))
|
|> Ash.Query.filter(expr(email != ^system_email))
|
||||||
|
|
@ -517,7 +512,7 @@ defmodule Mv.Accounts.User do
|
||||||
# Prevent modification of the system actor user (required for internal operations).
|
# Prevent modification of the system actor user (required for internal operations).
|
||||||
# Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.
|
# Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests.
|
||||||
validate fn changeset, _context ->
|
validate fn changeset, _context ->
|
||||||
if SystemActor.system_user?(changeset.data) do
|
if Mv.Helpers.SystemActor.system_user?(changeset.data) do
|
||||||
{:error,
|
{:error,
|
||||||
field: :email,
|
field: :email,
|
||||||
message:
|
message:
|
||||||
|
|
@ -646,8 +641,8 @@ defmodule Mv.Accounts.User do
|
||||||
case Process.get({__MODULE__, :default_role_id}) do
|
case Process.get({__MODULE__, :default_role_id}) do
|
||||||
nil ->
|
nil ->
|
||||||
role_id =
|
role_id =
|
||||||
case RoleResource.get_mitglied_role() do
|
case Mv.Authorization.Role.get_mitglied_role() do
|
||||||
{:ok, %RoleResource{id: id}} -> id
|
{:ok, %Mv.Authorization.Role{id: id}} -> id
|
||||||
_ -> nil
|
_ -> nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,7 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
||||||
use Ash.Resource.Validation
|
use Ash.Resource.Validation
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias Mv.Accounts.User
|
|
||||||
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
alias Mv.Accounts.User.Errors.PasswordVerificationRequired
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def init(opts), do: {:ok, opts}
|
def init(opts), do: {:ok, opts}
|
||||||
|
|
@ -45,10 +43,10 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
||||||
# Check if a user with this oidc_id already exists
|
# Check if a user with this oidc_id already exists
|
||||||
# If yes, this will be an upsert (email update), not a new registration
|
# If yes, this will be an upsert (email update), not a new registration
|
||||||
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
|
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
existing_oidc_user =
|
existing_oidc_user =
|
||||||
case User
|
case Mv.Accounts.User
|
||||||
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|
|> Ash.Query.filter(oidc_id == ^to_string(oidc_id))
|
||||||
|> Ash.read_one(actor: system_actor) do
|
|> Ash.read_one(actor: system_actor) do
|
||||||
{:ok, user} -> user
|
{:ok, user} -> user
|
||||||
|
|
@ -64,7 +62,7 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
||||||
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user, system_actor) do
|
defp check_email_collision(email, new_oidc_id, user_info, existing_oidc_user, system_actor) do
|
||||||
# Find existing user with this email
|
# Find existing user with this email
|
||||||
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
|
# Use SystemActor for authorization during OIDC registration (no logged-in actor)
|
||||||
case User
|
case Mv.Accounts.User
|
||||||
|> Ash.Query.filter(email == ^to_string(email))
|
|> Ash.Query.filter(email == ^to_string(email))
|
||||||
|> Ash.read_one(actor: system_actor) do
|
|> Ash.read_one(actor: system_actor) do
|
||||||
{:ok, nil} ->
|
{:ok, nil} ->
|
||||||
|
|
@ -166,7 +164,7 @@ defmodule Mv.Accounts.User.Validations.OidcEmailCollision do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def atomic?, do: false
|
def atomic?(), do: false
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def describe(_opts) do
|
def describe(_opts) do
|
||||||
|
|
|
||||||
|
|
@ -37,20 +37,13 @@ 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
|
require Ash.Query
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
alias Ecto.Adapters.SQL, as: EctoSQL
|
|
||||||
alias Mv.Helpers
|
alias Mv.Helpers
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
alias Mv.Membership.Helpers.VisibilityConfig
|
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
|
||||||
alias Mv.MembershipFees.CycleGenerator
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.Repo
|
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
alias Mv.Membership.Helpers.VisibilityConfig
|
||||||
|
|
||||||
# Module constants
|
# Module constants
|
||||||
@member_search_limit 10
|
@member_search_limit 10
|
||||||
|
|
||||||
|
|
@ -550,9 +543,11 @@ 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.
|
# Validate member fields that are marked as required in settings or by Vereinfacht.
|
||||||
# When settings cannot be loaded, enforce only email.
|
# When settings cannot be loaded, we still enforce email + Vereinfacht-required fields.
|
||||||
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} ->
|
||||||
|
|
@ -560,17 +555,20 @@ 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 || Map.get(normalized, field, false)
|
field == :email ||
|
||||||
|
(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."
|
"Enforcing only email and Vereinfacht-required fields."
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -815,7 +813,7 @@ defmodule Mv.Membership.Member do
|
||||||
case Map.get(cycle, :membership_fee_type) do
|
case Map.get(cycle, :membership_fee_type) do
|
||||||
%{interval: interval} ->
|
%{interval: interval} ->
|
||||||
cycle_end =
|
cycle_end =
|
||||||
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||||
|
|
||||||
Date.compare(cycle.cycle_start, today) in [:lt, :eq] and
|
Date.compare(cycle.cycle_start, today) in [:lt, :eq] and
|
||||||
Date.compare(today, cycle_end) in [:lt, :eq]
|
Date.compare(today, cycle_end) in [:lt, :eq]
|
||||||
|
|
@ -849,7 +847,7 @@ defmodule Mv.Membership.Member do
|
||||||
case Map.get(cycle, :membership_fee_type) do
|
case Map.get(cycle, :membership_fee_type) do
|
||||||
%{interval: interval} ->
|
%{interval: interval} ->
|
||||||
cycle_end =
|
cycle_end =
|
||||||
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||||
|
|
||||||
Date.compare(today, cycle_end) == :gt
|
Date.compare(today, cycle_end) == :gt
|
||||||
|
|
||||||
|
|
@ -865,7 +863,7 @@ defmodule Mv.Membership.Member do
|
||||||
cycles,
|
cycles,
|
||||||
fn cycle ->
|
fn cycle ->
|
||||||
interval = Map.get(cycle, :membership_fee_type).interval
|
interval = Map.get(cycle, :membership_fee_type).interval
|
||||||
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||||
end,
|
end,
|
||||||
{:desc, Date}
|
{:desc, Date}
|
||||||
)
|
)
|
||||||
|
|
@ -892,7 +890,7 @@ defmodule Mv.Membership.Member do
|
||||||
case Map.get(cycle, :membership_fee_type) do
|
case Map.get(cycle, :membership_fee_type) do
|
||||||
%{interval: interval} ->
|
%{interval: interval} ->
|
||||||
cycle_end =
|
cycle_end =
|
||||||
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||||
|
|
||||||
cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt
|
cycle.status == :unpaid and Date.compare(today, cycle_end) == :gt
|
||||||
|
|
||||||
|
|
@ -902,25 +900,6 @@ defmodule Mv.Membership.Member do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns a deterministic 64-bit key for pg_advisory_xact_lock from a member id (UUID string).
|
|
||||||
# Reduces collision risk vs phash2 when multiple members are locked.
|
|
||||||
@doc false
|
|
||||||
def advisory_lock_key_for_member_id(member_id) when is_binary(member_id) do
|
|
||||||
hex = String.replace(member_id, "-", "")
|
|
||||||
|
|
||||||
if String.length(hex) >= 16 do
|
|
||||||
first_8_hex = String.slice(hex, 0, 16)
|
|
||||||
bin = Base.decode16!(first_8_hex, case: :lower)
|
|
||||||
decoded = :binary.decode_unsigned(bin, :big)
|
|
||||||
# Postgres bigint is signed 64-bit; keep in non-negative range
|
|
||||||
rem(decoded, 1 <<< 63)
|
|
||||||
else
|
|
||||||
:erlang.phash2(member_id)
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
ArgumentError -> :erlang.phash2(member_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Regenerates cycles when membership fee type changes
|
# Regenerates cycles when membership fee type changes
|
||||||
# Deletes future unpaid cycles and regenerates them with the new type/amount
|
# Deletes future unpaid cycles and regenerates them with the new type/amount
|
||||||
# Uses advisory lock to prevent concurrent modifications
|
# Uses advisory lock to prevent concurrent modifications
|
||||||
|
|
@ -929,12 +908,15 @@ defmodule Mv.Membership.Member do
|
||||||
@doc false
|
@doc false
|
||||||
# Uses system actor for cycle regeneration (mandatory side effect)
|
# Uses system actor for cycle regeneration (mandatory side effect)
|
||||||
def regenerate_cycles_on_type_change(member, _opts \\ []) do
|
def regenerate_cycles_on_type_change(member, _opts \\ []) do
|
||||||
|
alias Mv.Helpers
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
lock_key = advisory_lock_key_for_member_id(member.id)
|
lock_key = :erlang.phash2(member.id)
|
||||||
|
|
||||||
# Use advisory lock to prevent concurrent deletion and regeneration
|
# Use advisory lock to prevent concurrent deletion and regeneration
|
||||||
# This ensures atomicity when multiple updates happen simultaneously
|
# This ensures atomicity when multiple updates happen simultaneously
|
||||||
if Repo.in_transaction?() do
|
if Mv.Repo.in_transaction?() do
|
||||||
regenerate_cycles_in_transaction(member, today, lock_key)
|
regenerate_cycles_in_transaction(member, today, lock_key)
|
||||||
else
|
else
|
||||||
regenerate_cycles_new_transaction(member, today, lock_key)
|
regenerate_cycles_new_transaction(member, today, lock_key)
|
||||||
|
|
@ -944,15 +926,15 @@ defmodule Mv.Membership.Member do
|
||||||
# Already in transaction: use advisory lock directly
|
# Already in transaction: use advisory lock directly
|
||||||
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
# Returns {:ok, notifications} - notifications should be returned to after_action hook
|
||||||
defp regenerate_cycles_in_transaction(member, today, lock_key) do
|
defp regenerate_cycles_in_transaction(member, today, lock_key) do
|
||||||
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||||
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
|
do_regenerate_cycles_on_type_change(member, today, skip_lock?: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Not in transaction: start new transaction with advisory lock
|
# Not in transaction: start new transaction with advisory lock
|
||||||
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
# Returns {:ok, notifications} - notifications should be sent by caller (e.g., via after_action)
|
||||||
defp regenerate_cycles_new_transaction(member, today, lock_key) do
|
defp regenerate_cycles_new_transaction(member, today, lock_key) do
|
||||||
Repo.transaction(fn ->
|
Mv.Repo.transaction(fn ->
|
||||||
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
Ecto.Adapters.SQL.query!(Mv.Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||||
|
|
||||||
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
|
case do_regenerate_cycles_on_type_change(member, today, skip_lock?: true) do
|
||||||
{:ok, notifications} ->
|
{:ok, notifications} ->
|
||||||
|
|
@ -960,7 +942,7 @@ defmodule Mv.Membership.Member do
|
||||||
notifications
|
notifications
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
Repo.rollback(reason)
|
Mv.Repo.rollback(reason)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|> case do
|
|> case do
|
||||||
|
|
@ -974,6 +956,9 @@ defmodule Mv.Membership.Member do
|
||||||
# notifications are collected to be sent after transaction commits
|
# notifications are collected to be sent after transaction commits
|
||||||
# Uses system actor for all operations
|
# Uses system actor for all operations
|
||||||
defp do_regenerate_cycles_on_type_change(member, today, opts) do
|
defp do_regenerate_cycles_on_type_change(member, today, opts) do
|
||||||
|
alias Mv.Helpers
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||||
|
|
@ -983,7 +968,7 @@ defmodule Mv.Membership.Member do
|
||||||
# Find all unpaid cycles for this member
|
# Find all unpaid cycles for this member
|
||||||
# We need to check cycle_end for each cycle using its own interval
|
# We need to check cycle_end for each cycle using its own interval
|
||||||
all_unpaid_cycles_query =
|
all_unpaid_cycles_query =
|
||||||
MembershipFeeCycle
|
Mv.MembershipFees.MembershipFeeCycle
|
||||||
|> Ash.Query.filter(member_id == ^member.id)
|
|> Ash.Query.filter(member_id == ^member.id)
|
||||||
|> Ash.Query.filter(status == :unpaid)
|
|> Ash.Query.filter(status == :unpaid)
|
||||||
|> Ash.Query.load([:membership_fee_type])
|
|> Ash.Query.load([:membership_fee_type])
|
||||||
|
|
@ -1012,7 +997,7 @@ defmodule Mv.Membership.Member do
|
||||||
case cycle.membership_fee_type do
|
case cycle.membership_fee_type do
|
||||||
%{interval: interval} ->
|
%{interval: interval} ->
|
||||||
cycle_end =
|
cycle_end =
|
||||||
CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle.cycle_start, interval)
|
||||||
|
|
||||||
Date.compare(today, cycle_end) in [:lt, :eq]
|
Date.compare(today, cycle_end) in [:lt, :eq]
|
||||||
|
|
||||||
|
|
@ -1040,17 +1025,18 @@ defmodule Mv.Membership.Member do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise.
|
# Deletes cycles and returns :ok if all succeeded, {:error, reason} otherwise
|
||||||
# Returns the first error for debugging; uses system actor for authorization.
|
# Uses system actor for authorization to ensure deletion always works
|
||||||
defp delete_cycles(cycles_to_delete, actor_opts) do
|
defp delete_cycles(cycles_to_delete, actor_opts) do
|
||||||
delete_results =
|
delete_results =
|
||||||
Enum.map(cycles_to_delete, fn cycle ->
|
Enum.map(cycles_to_delete, fn cycle ->
|
||||||
Ash.destroy(cycle, actor_opts)
|
Ash.destroy(cycle, actor_opts)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
case Enum.find(delete_results, &match?({:error, _}, &1)) do
|
if Enum.any?(delete_results, &match?({:error, _}, &1)) do
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, :deletion_failed}
|
||||||
nil -> :ok
|
else
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -1061,7 +1047,7 @@ defmodule Mv.Membership.Member do
|
||||||
defp regenerate_cycles(member_id, today, opts) do
|
defp regenerate_cycles(member_id, today, opts) do
|
||||||
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
skip_lock? = Keyword.get(opts, :skip_lock?, false)
|
||||||
|
|
||||||
case CycleGenerator.generate_cycles_for_member(
|
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||||
member_id,
|
member_id,
|
||||||
today: today,
|
today: today,
|
||||||
skip_lock?: skip_lock?
|
skip_lock?: skip_lock?
|
||||||
|
|
@ -1092,7 +1078,7 @@ defmodule Mv.Membership.Member do
|
||||||
|
|
||||||
# Runs cycle generation synchronously (for test environment)
|
# Runs cycle generation synchronously (for test environment)
|
||||||
defp handle_cycle_generation_sync(member, initiator) do
|
defp handle_cycle_generation_sync(member, initiator) do
|
||||||
case CycleGenerator.generate_cycles_for_member(
|
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(
|
||||||
member.id,
|
member.id,
|
||||||
today: Date.utc_today(),
|
today: Date.utc_today(),
|
||||||
initiator: initiator
|
initiator: initiator
|
||||||
|
|
@ -1113,7 +1099,7 @@ defmodule Mv.Membership.Member do
|
||||||
# Runs cycle generation asynchronously (for production environment)
|
# Runs cycle generation asynchronously (for production environment)
|
||||||
defp handle_cycle_generation_async(member, initiator) do
|
defp handle_cycle_generation_async(member, initiator) do
|
||||||
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
|
Task.Supervisor.async_nolink(Mv.TaskSupervisor, fn ->
|
||||||
case CycleGenerator.generate_cycles_for_member(member.id,
|
case Mv.MembershipFees.CycleGenerator.generate_cycles_for_member(member.id,
|
||||||
initiator: initiator
|
initiator: initiator
|
||||||
) do
|
) do
|
||||||
{:ok, cycles, notifications} ->
|
{:ok, cycles, notifications} ->
|
||||||
|
|
|
||||||
|
|
@ -5,28 +5,22 @@ defmodule Mv.Application do
|
||||||
|
|
||||||
use Application
|
use Application
|
||||||
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
alias Mv.Repo
|
|
||||||
alias Mv.Vereinfacht.SyncFlash
|
|
||||||
alias MvWeb.Endpoint
|
|
||||||
alias MvWeb.Telemetry
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
SyncFlash.create_table!()
|
Mv.Vereinfacht.SyncFlash.create_table!()
|
||||||
|
|
||||||
children = [
|
children = [
|
||||||
Telemetry,
|
MvWeb.Telemetry,
|
||||||
Repo,
|
Mv.Repo,
|
||||||
{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: :my},
|
||||||
SystemActor,
|
Mv.Helpers.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},
|
||||||
# Start to serve requests, typically the last entry
|
# Start to serve requests, typically the last entry
|
||||||
Endpoint
|
MvWeb.Endpoint
|
||||||
]
|
]
|
||||||
|
|
||||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,9 @@ defmodule Mv.Authorization.Checks.ActorIsSystemUser do
|
||||||
"""
|
"""
|
||||||
use Ash.Policy.SimpleCheck
|
use Ash.Policy.SimpleCheck
|
||||||
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def describe(_opts), do: "actor is the system user"
|
def describe(_opts), do: "actor is the system user"
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def match?(actor, _context, _opts), do: SystemActor.system_user?(actor)
|
def match?(actor, _context, _opts), do: Mv.Helpers.SystemActor.system_user?(actor)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do
|
||||||
end
|
end
|
||||||
"""
|
"""
|
||||||
use Ash.Policy.Check
|
use Ash.Policy.Check
|
||||||
alias Mv.Authorization.Actor
|
|
||||||
alias Mv.Authorization.PermissionSets
|
alias Mv.Authorization.PermissionSets
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -68,5 +67,5 @@ defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp ensure_role_loaded(actor), do: Actor.ensure_loaded(actor)
|
defp ensure_role_loaded(actor), do: Mv.Authorization.Actor.ensure_loaded(actor)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,6 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
use Ash.Policy.Check
|
use Ash.Policy.Check
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
alias Mv.Authorization.Actor
|
|
||||||
alias Mv.Authorization.PermissionSets
|
alias Mv.Authorization.PermissionSets
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|
@ -398,6 +397,6 @@ defmodule Mv.Authorization.Checks.HasPermission do
|
||||||
# Fallback: Load role if not loaded (in case on_mount didn't run)
|
# Fallback: Load role if not loaded (in case on_mount didn't run)
|
||||||
# Delegates to centralized Actor helper
|
# Delegates to centralized Actor helper
|
||||||
defp ensure_role_loaded(actor) do
|
defp ensure_role_loaded(actor) do
|
||||||
Actor.ensure_loaded(actor)
|
Mv.Authorization.Actor.ensure_loaded(actor)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -94,16 +94,14 @@ defmodule Mv.Authorization.Role do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
alias Mv.Authorization.PermissionSets
|
|
||||||
|
|
||||||
validations do
|
validations do
|
||||||
validate one_of(
|
validate one_of(
|
||||||
:permission_set_name,
|
:permission_set_name,
|
||||||
PermissionSets.all_permission_sets()
|
Mv.Authorization.PermissionSets.all_permission_sets()
|
||||||
|> Enum.map(&Atom.to_string/1)
|
|> Enum.map(&Atom.to_string/1)
|
||||||
),
|
),
|
||||||
message:
|
message:
|
||||||
"must be one of: #{PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}"
|
"must be one of: #{Mv.Authorization.PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}"
|
||||||
|
|
||||||
validate fn changeset, _context ->
|
validate fn changeset, _context ->
|
||||||
if changeset.data.is_system_role do
|
if changeset.data.is_system_role do
|
||||||
|
|
|
||||||
|
|
@ -28,17 +28,15 @@ defmodule Mv.Constants do
|
||||||
|
|
||||||
@email_validator_checks [:html_input, :pow]
|
@email_validator_checks [:html_input, :pow]
|
||||||
|
|
||||||
# No member fields are required solely for Vereinfacht; API accepts minimal payload
|
# Member fields that are required when Vereinfacht integration is active (contact sync)
|
||||||
# (contactType + isExternal) when creating external contacts and supports filter by email for lookup.
|
@vereinfacht_required_member_fields [:first_name, :last_name, :street, :postal_code, :city]
|
||||||
@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.
|
||||||
|
|
||||||
Currently empty: the Vereinfacht API only requires contactType (e.g. "person") when creating
|
Used for validation, member form required indicators, and settings UI (checkbox disabled).
|
||||||
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ defmodule Mv.Membership.MemberExport do
|
||||||
alias Mv.Membership.CustomField
|
alias Mv.Membership.CustomField
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
alias Mv.Membership.MemberExportSort
|
alias Mv.Membership.MemberExportSort
|
||||||
alias MvWeb.MemberLive.Index
|
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
|
|
||||||
@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)) ++
|
||||||
|
|
@ -170,7 +169,7 @@ defmodule Mv.Membership.MemberExport do
|
||||||
if parsed.selected_ids == [] do
|
if parsed.selected_ids == [] do
|
||||||
members
|
members
|
||||||
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|
||||||
|> Index.apply_boolean_custom_field_filters(
|
|> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||||
parsed.boolean_filters || %{},
|
parsed.boolean_filters || %{},
|
||||||
Map.values(custom_fields_by_id)
|
Map.values(custom_fields_by_id)
|
||||||
)
|
)
|
||||||
|
|
@ -378,38 +377,6 @@ defmodule Mv.Membership.MemberExport do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
|
||||||
Applies export filters (cycle status and boolean custom field filters) when exporting "all" (no selected_ids).
|
|
||||||
|
|
||||||
Used by the CSV export controller so that "Export (all)" with active filters exports only the filtered members,
|
|
||||||
matching PDF export behavior.
|
|
||||||
|
|
||||||
- `members` - Loaded members (must have cycle data loaded when cycle_status_filter is used).
|
|
||||||
- `opts` - Map with `:selected_ids`, `:cycle_status_filter`, `:show_current_cycle`, `:boolean_filters`.
|
|
||||||
- `custom_fields_by_id` - Map of custom field id => custom field struct (for boolean filter resolution).
|
|
||||||
|
|
||||||
When `opts.selected_ids` is not empty, returns `members` unchanged (selected_ids
|
|
||||||
override filters). Otherwise applies cycle status filter and boolean custom field filters.
|
|
||||||
|
|
||||||
Uses `Map.get(opts, :selected_ids, [])` so that `nil` or a missing key is treated as
|
|
||||||
"export all" and filters are applied.
|
|
||||||
"""
|
|
||||||
@spec apply_export_filters([struct()], map(), map()) :: [struct()]
|
|
||||||
def apply_export_filters(members, opts, custom_fields_by_id) do
|
|
||||||
selected_ids = Map.get(opts, :selected_ids, [])
|
|
||||||
|
|
||||||
if Enum.empty?(selected_ids) do
|
|
||||||
members
|
|
||||||
|> apply_cycle_status_filter(opts[:cycle_status_filter], opts[:show_current_cycle])
|
|
||||||
|> Index.apply_boolean_custom_field_filters(
|
|
||||||
Map.get(opts, :boolean_filters, %{}),
|
|
||||||
Map.values(custom_fields_by_id)
|
|
||||||
)
|
|
||||||
else
|
|
||||||
members
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp extract_list(params, key) do
|
defp extract_list(params, key) do
|
||||||
case Map.get(params, key) do
|
case Map.get(params, key) do
|
||||||
list when is_list(list) -> list
|
list when is_list(list) -> list
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
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.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
|
|
||||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||||
|
|
@ -170,7 +169,7 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
if parsed.selected_ids == [] do
|
if parsed.selected_ids == [] do
|
||||||
members
|
members
|
||||||
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|
|> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
|
||||||
|> Index.apply_boolean_custom_field_filters(
|
|> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||||
parsed.boolean_filters || %{},
|
parsed.boolean_filters || %{},
|
||||||
Map.values(custom_fields_by_id)
|
Map.values(custom_fields_by_id)
|
||||||
)
|
)
|
||||||
|
|
@ -520,9 +519,11 @@ defmodule Mv.Membership.MemberExport.Build do
|
||||||
defp key_to_atom(k) when is_atom(k), do: k
|
defp key_to_atom(k) when is_atom(k), do: k
|
||||||
|
|
||||||
defp key_to_atom(k) when is_binary(k) do
|
defp key_to_atom(k) when is_binary(k) do
|
||||||
String.to_existing_atom(k)
|
try do
|
||||||
rescue
|
String.to_existing_atom(k)
|
||||||
ArgumentError -> k
|
rescue
|
||||||
|
ArgumentError -> k
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_cfv_by_id(member, id) do
|
defp get_cfv_by_id(member, id) do
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,11 @@ defmodule Mv.Membership.MembersCSV do
|
||||||
defp key_to_atom(k) when is_atom(k), do: k
|
defp key_to_atom(k) when is_atom(k), do: k
|
||||||
|
|
||||||
defp key_to_atom(k) when is_binary(k) do
|
defp key_to_atom(k) when is_binary(k) do
|
||||||
String.to_existing_atom(k)
|
try do
|
||||||
rescue
|
String.to_existing_atom(k)
|
||||||
ArgumentError -> k
|
rescue
|
||||||
|
ArgumentError -> k
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_cfv_by_id(member, id) do
|
defp get_cfv_by_id(member, id) do
|
||||||
|
|
|
||||||
|
|
@ -299,9 +299,11 @@ defmodule Mv.Membership.MembersPDF do
|
||||||
defp date_column?(_), do: false
|
defp date_column?(_), do: false
|
||||||
|
|
||||||
defp key_to_atom_safe(key) when is_binary(key) do
|
defp key_to_atom_safe(key) when is_binary(key) do
|
||||||
String.to_existing_atom(key)
|
try do
|
||||||
rescue
|
String.to_existing_atom(key)
|
||||||
ArgumentError -> key
|
rescue
|
||||||
|
ArgumentError -> key
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp key_to_atom_safe(key), do: key
|
defp key_to_atom_safe(key), do: key
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,6 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
alias Mv.Repo
|
alias Mv.Repo
|
||||||
|
|
||||||
alias Ecto.Adapters.SQL, as: EctoSQL
|
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|
@ -112,10 +110,10 @@ defmodule Mv.MembershipFees.CycleGenerator do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_generate_cycles_with_lock(member, today, false, opts) do
|
defp do_generate_cycles_with_lock(member, today, false, opts) do
|
||||||
lock_key = Member.advisory_lock_key_for_member_id(member.id)
|
lock_key = :erlang.phash2(member.id)
|
||||||
|
|
||||||
Repo.transaction(fn ->
|
Repo.transaction(fn ->
|
||||||
EctoSQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
Ecto.Adapters.SQL.query!(Repo, "SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||||
|
|
||||||
case do_generate_cycles(member, today, opts) do
|
case do_generate_cycles(member, today, opts) do
|
||||||
{:ok, cycles, notifications} ->
|
{:ok, cycles, notifications} ->
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,11 @@ defmodule Mv.OidcRoleSync do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp safe_get_atom(map, key) when is_binary(key) do
|
defp safe_get_atom(map, key) when is_binary(key) do
|
||||||
Map.get(map, String.to_existing_atom(key))
|
try do
|
||||||
rescue
|
Map.get(map, String.to_existing_atom(key))
|
||||||
ArgumentError -> nil
|
rescue
|
||||||
|
ArgumentError -> nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp safe_get_atom(_map, _key), do: nil
|
defp safe_get_atom(_map, _key), do: nil
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,11 @@ 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, city, or country changed,
|
first_name, last_name, email, street, house_number, postal_code, or city 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
|
||||||
|
|
||||||
alias Mv.Vereinfacht
|
|
||||||
alias Mv.Vereinfacht.SyncFlash
|
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@synced_attributes [
|
@synced_attributes [
|
||||||
|
|
@ -26,8 +23,7 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do
|
||||||
:street,
|
:street,
|
||||||
:house_number,
|
:house_number,
|
||||||
:postal_code,
|
:postal_code,
|
||||||
:city,
|
:city
|
||||||
:country
|
|
||||||
]
|
]
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -64,13 +60,13 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do
|
||||||
|
|
||||||
# Ash calls after_transaction with (changeset, result) only - 2 args.
|
# Ash calls after_transaction with (changeset, result) only - 2 args.
|
||||||
defp sync_after_transaction(_changeset, {:ok, member}) do
|
defp sync_after_transaction(_changeset, {:ok, member}) do
|
||||||
case Vereinfacht.sync_member(member) do
|
case Mv.Vereinfacht.sync_member(member) do
|
||||||
:ok ->
|
:ok ->
|
||||||
SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.")
|
Mv.Vereinfacht.SyncFlash.store(to_string(member.id), :ok, "Synced to Vereinfacht.")
|
||||||
{:ok, member}
|
{:ok, member}
|
||||||
|
|
||||||
{:ok, member_updated} ->
|
{:ok, member_updated} ->
|
||||||
SyncFlash.store(
|
Mv.Vereinfacht.SyncFlash.store(
|
||||||
to_string(member_updated.id),
|
to_string(member_updated.id),
|
||||||
:ok,
|
:ok,
|
||||||
"Synced to Vereinfacht."
|
"Synced to Vereinfacht."
|
||||||
|
|
@ -81,10 +77,10 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
Logger.warning("Vereinfacht sync failed for member #{member.id}: #{inspect(reason)}")
|
Logger.warning("Vereinfacht sync failed for member #{member.id}: #{inspect(reason)}")
|
||||||
|
|
||||||
SyncFlash.store(
|
Mv.Vereinfacht.SyncFlash.store(
|
||||||
to_string(member.id),
|
to_string(member.id),
|
||||||
:warning,
|
:warning,
|
||||||
Vereinfacht.format_error(reason)
|
Mv.Vereinfacht.format_error(reason)
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, member}
|
{:ok, member}
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ defmodule Mv.Vereinfacht.Changes.SyncLinkedMemberAfterUserChange do
|
||||||
use Ash.Resource.Change
|
use Ash.Resource.Change
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
alias Mv.Helpers
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
alias Mv.Membership
|
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
|
alias Mv.Membership
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
alias Mv.Helpers
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def change(changeset, _opts, _context) do
|
def change(changeset, _opts, _context) do
|
||||||
|
|
|
||||||
|
|
@ -166,12 +166,12 @@ defmodule Mv.Vereinfacht.Client do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Finds a finance contact by email using the API filter.
|
Finds a finance contact by email (GET /finance-contacts, then match in response).
|
||||||
|
|
||||||
Uses GET /finance-contacts?filter[isExternal]=true&filter[email]=... so the API
|
The Vereinfacht API does not allow filter by email on this endpoint, so we
|
||||||
returns only matching external contacts. Returns {:ok, contact_id} if a contact
|
fetch the first page and find the contact client-side. Returns {:ok, contact_id}
|
||||||
exists, {:error, :not_found} if none, or {:error, reason} on API/network failure.
|
if a contact with that email exists, {:error, :not_found} if none, or
|
||||||
Used before create for idempotency.
|
{:error, reason} on API/network failure. 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,18 +182,25 @@ 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_email = email |> String.trim() |> String.downcase()
|
normalized = String.trim(email) |> 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")
|
||||||
encoded_email = URI.encode_www_form(normalized_email)
|
url = base <> "?page[size]=#{@find_contact_page_size}&page[number]=#{page}"
|
||||||
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) ->
|
||||||
case get_first_contact_id_from_list(body) do
|
handle_find_contact_page_response(body, page, normalized)
|
||||||
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)}}
|
||||||
|
|
@ -203,20 +210,48 @@ defmodule Mv.Vereinfacht.Client do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_first_contact_id_from_list(%{"data" => data} = _body) when is_list(data) do
|
defp handle_find_contact_page_response(body, page, normalized) do
|
||||||
if length(data) > 1 do
|
case find_contact_id_by_email_in_list(body, normalized) do
|
||||||
Logger.warning(
|
id when is_binary(id) -> {:ok, id}
|
||||||
"Vereinfacht find_contact_by_email: API returned multiple contacts for same email (count: #{length(data)}), using first. Check for duplicate or inconsistent data."
|
nil -> maybe_find_contact_next_page(body, page, normalized)
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
case data do
|
|
||||||
[%{"id" => id} | _] -> normalize_contact_id(id)
|
|
||||||
[] -> nil
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_first_contact_id_from_list(_), do: nil
|
defp maybe_find_contact_next_page(body, page, normalized) do
|
||||||
|
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)
|
||||||
|
|
@ -354,7 +389,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ defmodule Mv.Vereinfacht do
|
||||||
"""
|
"""
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
alias Mv.Helpers
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.Vereinfacht.Client
|
alias Mv.Vereinfacht.Client
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
alias Mv.Helpers
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Tests the connection to the Vereinfacht API using the current configuration.
|
Tests the connection to the Vereinfacht API using the current configuration.
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,8 @@ defmodule MvWeb do
|
||||||
import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2]
|
import MvWeb.Authorization, only: [can?: 3, can_access_page?: 2]
|
||||||
|
|
||||||
# Common modules used in templates
|
# Common modules used in templates
|
||||||
alias MvWeb.Layouts
|
|
||||||
alias Phoenix.LiveView.JS
|
alias Phoenix.LiveView.JS
|
||||||
|
alias MvWeb.Layouts
|
||||||
|
|
||||||
# Routes generation with the ~p sigil
|
# Routes generation with the ~p sigil
|
||||||
unquote(verified_routes())
|
unquote(verified_routes())
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,10 @@ defmodule MvWeb.AuthOverrides do
|
||||||
set :root_class, "md:min-w-md"
|
set :root_class, "md:min-w-md"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Replace banner logo with text (no image in light or dark so link has discernible text)
|
# Replace banner logo with text
|
||||||
override AshAuthentication.Phoenix.Components.Banner do
|
override AshAuthentication.Phoenix.Components.Banner do
|
||||||
set :text, "Mitgliederverwaltung"
|
set :text, "Mitgliederverwaltung"
|
||||||
set :image_url, nil
|
set :image_url, nil
|
||||||
set :dark_image_url, nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Translate the "or" in the horizontal rule (between password form and SSO).
|
# Translate the "or" in the horizontal rule (between password form and SSO).
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ defmodule MvWeb.CoreComponents do
|
||||||
use Phoenix.Component
|
use Phoenix.Component
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Phoenix.HTML.Form, as: HTMLForm
|
|
||||||
alias Phoenix.LiveView.JS
|
alias Phoenix.LiveView.JS
|
||||||
|
|
||||||
# WCAG 2.4.7 / 2.4.11: Shared focus ring for buttons and dropdown (trigger + items)
|
# WCAG 2.4.7 / 2.4.11: Shared focus ring for buttons and dropdown (trigger + items)
|
||||||
|
|
@ -562,17 +561,13 @@ defmodule MvWeb.CoreComponents do
|
||||||
phx-target={@phx_target}
|
phx-target={@phx_target}
|
||||||
>
|
>
|
||||||
<%= if @checkboxes do %>
|
<%= if @checkboxes do %>
|
||||||
<%!-- Visual-only indicator: do not nest an interactive control (checkbox) inside the button for screen reader and focus correctness (WCAG 2.1.2). --%>
|
<input
|
||||||
<span
|
type="checkbox"
|
||||||
class={
|
checked={Map.get(@selected, item.value, true)}
|
||||||
if Map.get(@selected, item.value, true),
|
class="checkbox checkbox-sm checkbox-primary pointer-events-none"
|
||||||
do: "text-primary",
|
tabindex="-1"
|
||||||
else: "text-base-300"
|
|
||||||
}
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
/>
|
||||||
<.icon name="hero-check" class="size-4 shrink-0" />
|
|
||||||
</span>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -674,7 +669,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
def input(%{type: "checkbox"} = assigns) do
|
def input(%{type: "checkbox"} = assigns) do
|
||||||
assigns =
|
assigns =
|
||||||
assign_new(assigns, :checked, fn ->
|
assign_new(assigns, :checked, fn ->
|
||||||
HTMLForm.normalize_value("checkbox", assigns[:value])
|
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||||
end)
|
end)
|
||||||
|
|
||||||
# For checkboxes, we don't use HTML required attribute (means "must be checked")
|
# For checkboxes, we don't use HTML required attribute (means "must be checked")
|
||||||
|
|
@ -741,7 +736,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
{@rest}
|
{@rest}
|
||||||
>
|
>
|
||||||
<option :if={@prompt} value="">{@prompt}</option>
|
<option :if={@prompt} value="">{@prompt}</option>
|
||||||
{HTMLForm.options_for_select(@options, @value)}
|
{Phoenix.HTML.Form.options_for_select(@options, @value)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<.error :for={msg <- @errors}>{msg}</.error>
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
|
|
@ -770,7 +765,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
@errors != [] && (@error_class || "textarea-error")
|
@errors != [] && (@error_class || "textarea-error")
|
||||||
]}
|
]}
|
||||||
{@rest}
|
{@rest}
|
||||||
>{HTMLForm.normalize_value("textarea", @value)}</textarea>
|
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
|
||||||
</label>
|
</label>
|
||||||
<.error :for={msg <- @errors}>{msg}</.error>
|
<.error :for={msg <- @errors}>{msg}</.error>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
@ -795,7 +790,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
type={@type}
|
type={@type}
|
||||||
name={@name}
|
name={@name}
|
||||||
id={@id}
|
id={@id}
|
||||||
value={HTMLForm.normalize_value(@type, @value)}
|
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||||
class={[
|
class={[
|
||||||
@class || "w-full input",
|
@class || "w-full input",
|
||||||
@errors != [] && (@error_class || "input-error")
|
@errors != [] && (@error_class || "input-error")
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,9 @@ defmodule MvWeb.MemberExportController do
|
||||||
alias Mv.Authorization.Actor
|
alias Mv.Authorization.Actor
|
||||||
alias Mv.Membership.CustomField
|
alias Mv.Membership.CustomField
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
alias Mv.Membership.MemberExport
|
|
||||||
alias Mv.Membership.MembersCSV
|
alias Mv.Membership.MembersCSV
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
|
||||||
alias MvWeb.Translations.MemberFields
|
alias MvWeb.Translations.MemberFields
|
||||||
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
@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)) ++
|
||||||
|
|
@ -66,9 +65,6 @@ defmodule MvWeb.MemberExportController do
|
||||||
defp parse_and_validate(params) do
|
defp parse_and_validate(params) do
|
||||||
member_fields = filter_allowed_member_fields(extract_list(params, "member_fields"))
|
member_fields = filter_allowed_member_fields(extract_list(params, "member_fields"))
|
||||||
{selectable_member_fields, computed_fields} = split_member_fields(member_fields)
|
{selectable_member_fields, computed_fields} = split_member_fields(member_fields)
|
||||||
custom_field_ids = filter_valid_uuids(extract_list(params, "custom_field_ids"))
|
|
||||||
boolean_filters = extract_boolean_filters(params)
|
|
||||||
custom_field_ids_union = (custom_field_ids ++ Map.keys(boolean_filters)) |> Enum.uniq()
|
|
||||||
|
|
||||||
%{
|
%{
|
||||||
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
|
||||||
|
|
@ -76,51 +72,14 @@ defmodule MvWeb.MemberExportController do
|
||||||
selectable_member_fields: selectable_member_fields,
|
selectable_member_fields: selectable_member_fields,
|
||||||
computed_fields:
|
computed_fields:
|
||||||
computed_fields ++ filter_existing_atoms(extract_list(params, "computed_fields")),
|
computed_fields ++ filter_existing_atoms(extract_list(params, "computed_fields")),
|
||||||
custom_field_ids: custom_field_ids,
|
custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
|
||||||
custom_field_ids_union: custom_field_ids_union,
|
|
||||||
query: extract_string(params, "query"),
|
query: extract_string(params, "query"),
|
||||||
sort_field: extract_string(params, "sort_field"),
|
sort_field: extract_string(params, "sort_field"),
|
||||||
sort_order: extract_sort_order(params),
|
sort_order: extract_sort_order(params),
|
||||||
show_current_cycle: extract_boolean(params, "show_current_cycle"),
|
show_current_cycle: extract_boolean(params, "show_current_cycle")
|
||||||
cycle_status_filter: extract_cycle_status_filter(params),
|
|
||||||
boolean_filters: boolean_filters
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Only paid and unpaid are supported for list/export filter. :suspended exists in the
|
|
||||||
# domain (e.g. membership fee status display) but is not used as a filter in the member index.
|
|
||||||
defp extract_cycle_status_filter(params) do
|
|
||||||
case Map.get(params, "cycle_status_filter") do
|
|
||||||
"paid" -> :paid
|
|
||||||
"unpaid" -> :unpaid
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Normalizes values so that "true"/"false" from query/form encoding are accepted as well as JSON booleans.
|
|
||||||
defp extract_boolean_filters(params) do
|
|
||||||
case Map.get(params, "boolean_filters") do
|
|
||||||
map when is_map(map) ->
|
|
||||||
map
|
|
||||||
|> Enum.filter(fn {k, v} ->
|
|
||||||
is_binary(k) and match?({:ok, _}, Ecto.UUID.cast(k)) and boolean_value?(v)
|
|
||||||
end)
|
|
||||||
|> Enum.map(fn {k, v} -> {k, normalize_boolean_value(v)} end)
|
|
||||||
|> Enum.into(%{})
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
%{}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp boolean_value?(v) when is_boolean(v), do: true
|
|
||||||
defp boolean_value?(v) when v in ["true", "false"], do: true
|
|
||||||
defp boolean_value?(_), do: false
|
|
||||||
|
|
||||||
defp normalize_boolean_value(v) when is_boolean(v), do: v
|
|
||||||
defp normalize_boolean_value("true"), do: true
|
|
||||||
defp normalize_boolean_value("false"), do: false
|
|
||||||
|
|
||||||
defp split_member_fields(member_fields) do
|
defp split_member_fields(member_fields) do
|
||||||
domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||||
selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
|
selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
|
||||||
|
|
@ -146,10 +105,12 @@ defmodule MvWeb.MemberExportController do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp atom_exists?(name) do
|
defp atom_exists?(name) do
|
||||||
_ = String.to_existing_atom(name)
|
try do
|
||||||
true
|
_ = String.to_existing_atom(name)
|
||||||
rescue
|
true
|
||||||
ArgumentError -> false
|
rescue
|
||||||
|
ArgumentError -> false
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp extract_list(params, key) do
|
defp extract_list(params, key) do
|
||||||
|
|
@ -197,8 +158,7 @@ defmodule MvWeb.MemberExportController do
|
||||||
parsed
|
parsed
|
||||||
|> ensure_sort_custom_field_loaded()
|
|> ensure_sort_custom_field_loaded()
|
||||||
|
|
||||||
with {:ok, custom_fields_by_id} <-
|
with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(parsed.custom_field_ids, actor),
|
||||||
load_custom_fields_by_id(parsed.custom_field_ids_union, actor),
|
|
||||||
{:ok, members} <- load_members_for_export(actor, parsed, custom_fields_by_id) do
|
{:ok, members} <- load_members_for_export(actor, parsed, custom_fields_by_id) do
|
||||||
columns = build_columns(conn, parsed, custom_fields_by_id)
|
columns = build_columns(conn, parsed, custom_fields_by_id)
|
||||||
csv_iodata = MembersCSV.export(members, columns)
|
csv_iodata = MembersCSV.export(members, columns)
|
||||||
|
|
@ -216,19 +176,13 @@ defmodule MvWeb.MemberExportController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp ensure_sort_custom_field_loaded(
|
defp ensure_sort_custom_field_loaded(%{custom_field_ids: ids, sort_field: sort_field} = parsed) do
|
||||||
%{custom_field_ids: ids, custom_field_ids_union: union, sort_field: sort_field} = parsed
|
|
||||||
) do
|
|
||||||
case extract_sort_custom_field_id(sort_field) do
|
case extract_sort_custom_field_id(sort_field) do
|
||||||
nil ->
|
nil ->
|
||||||
parsed
|
parsed
|
||||||
|
|
||||||
id ->
|
id ->
|
||||||
%{
|
%{parsed | custom_field_ids: Enum.uniq([id | ids])}
|
||||||
parsed
|
|
||||||
| custom_field_ids: Enum.uniq([id | ids]),
|
|
||||||
custom_field_ids_union: Enum.uniq([id | union])
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -281,8 +235,7 @@ defmodule MvWeb.MemberExportController do
|
||||||
select_fields = [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
|
select_fields = [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)
|
||||||
|
|
||||||
need_cycles =
|
need_cycles =
|
||||||
(parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields) or
|
parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields
|
||||||
parsed.cycle_status_filter != nil
|
|
||||||
|
|
||||||
need_groups = "groups" in parsed.member_fields
|
need_groups = "groups" in parsed.member_fields
|
||||||
|
|
||||||
|
|
@ -294,7 +247,7 @@ defmodule MvWeb.MemberExportController do
|
||||||
Member
|
Member
|
||||||
|> Ash.Query.new()
|
|> Ash.Query.new()
|
||||||
|> Ash.Query.select(select_fields)
|
|> Ash.Query.select(select_fields)
|
||||||
|> load_custom_field_values_query(parsed.custom_field_ids_union)
|
|> load_custom_field_values_query(parsed.custom_field_ids)
|
||||||
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|
||||||
|> maybe_load_groups(need_groups)
|
|> maybe_load_groups(need_groups)
|
||||||
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
|> maybe_load_membership_fee_type(need_membership_fee_type)
|
||||||
|
|
@ -325,10 +278,6 @@ defmodule MvWeb.MemberExportController do
|
||||||
members
|
members
|
||||||
end
|
end
|
||||||
|
|
||||||
# When exporting "all" (no selected_ids), apply same filters as PDF: cycle status and boolean custom fields
|
|
||||||
members =
|
|
||||||
MemberExport.apply_export_filters(members, parsed, custom_fields_by_id)
|
|
||||||
|
|
||||||
# Calculate membership_fee_status for computed fields
|
# Calculate membership_fee_status for computed fields
|
||||||
members = add_computed_fields(members, parsed.computed_fields, parsed.show_current_cycle)
|
members = add_computed_fields(members, parsed.computed_fields, parsed.show_current_cycle)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,19 +18,15 @@ defmodule MvWeb.LinkOidcAccountLive do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias AshAuthentication.Strategy.Password.Actions, as: PasswordActions
|
|
||||||
alias Mv.Accounts.User, as: UserResource
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
|
|
||||||
@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)
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
with user_id when not is_nil(user_id) <- Map.get(session, "oidc_linking_user_id"),
|
with user_id when not is_nil(user_id) <- Map.get(session, "oidc_linking_user_id"),
|
||||||
oidc_user_info when not is_nil(oidc_user_info) <-
|
oidc_user_info when not is_nil(oidc_user_info) <-
|
||||||
Map.get(session, "oidc_linking_user_info"),
|
Map.get(session, "oidc_linking_user_info"),
|
||||||
{:ok, user} <- Ash.get(UserResource, user_id, actor: system_actor) do
|
{:ok, user} <- Ash.get(Mv.Accounts.User, user_id, actor: system_actor) do
|
||||||
# Check if user is passwordless
|
# Check if user is passwordless
|
||||||
if passwordless?(user) do
|
if passwordless?(user) do
|
||||||
# Auto-link passwordless user immediately
|
# Auto-link passwordless user immediately
|
||||||
|
|
@ -54,9 +50,9 @@ defmodule MvWeb.LinkOidcAccountLive do
|
||||||
|
|
||||||
defp reload_user!(user_id) do
|
defp reload_user!(user_id) 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)
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
UserResource
|
Mv.Accounts.User
|
||||||
|> Ash.Query.filter(id == ^user_id)
|
|> Ash.Query.filter(id == ^user_id)
|
||||||
|> Ash.read_one!(actor: system_actor)
|
|> Ash.read_one!(actor: system_actor)
|
||||||
end
|
end
|
||||||
|
|
@ -69,7 +65,7 @@ defmodule MvWeb.LinkOidcAccountLive do
|
||||||
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
|
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
|
||||||
|
|
||||||
# Use SystemActor for authorization (passwordless user auto-linking)
|
# Use SystemActor for authorization (passwordless user auto-linking)
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
case user.id
|
case user.id
|
||||||
|> reload_user!()
|
|> reload_user!()
|
||||||
|
|
@ -180,11 +176,11 @@ defmodule MvWeb.LinkOidcAccountLive do
|
||||||
|
|
||||||
defp verify_password(email, password) do
|
defp verify_password(email, password) do
|
||||||
# Use AshAuthentication password strategy to verify
|
# Use AshAuthentication password strategy to verify
|
||||||
strategies = AshAuthentication.Info.authentication_strategies(UserResource)
|
strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User)
|
||||||
password_strategy = Enum.find(strategies, fn s -> s.name == :password end)
|
password_strategy = Enum.find(strategies, fn s -> s.name == :password end)
|
||||||
|
|
||||||
if password_strategy do
|
if password_strategy do
|
||||||
PasswordActions.sign_in(
|
AshAuthentication.Strategy.Password.Actions.sign_in(
|
||||||
password_strategy,
|
password_strategy,
|
||||||
%{
|
%{
|
||||||
"email" => email,
|
"email" => email,
|
||||||
|
|
@ -201,7 +197,7 @@ defmodule MvWeb.LinkOidcAccountLive do
|
||||||
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
|
oidc_id = Map.get(oidc_user_info, "sub") || Map.get(oidc_user_info, "id")
|
||||||
|
|
||||||
# Use SystemActor for authorization (user just verified password but is not yet logged in)
|
# Use SystemActor for authorization (user just verified password but is not yet logged in)
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Update the user with the OIDC ID
|
# Update the user with the OIDC ID
|
||||||
case user.id
|
case user.id
|
||||||
|
|
|
||||||
|
|
@ -54,15 +54,13 @@ defmodule MvWeb.SignInLive do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<main
|
<div
|
||||||
id="sign-in-page"
|
id="sign-in-page"
|
||||||
role="main"
|
|
||||||
class={@root_class}
|
class={@root_class}
|
||||||
data-oidc-configured={to_string(@oidc_configured)}
|
data-oidc-configured={to_string(@oidc_configured)}
|
||||||
data-oidc-only={to_string(@oidc_only)}
|
data-oidc-only={to_string(@oidc_only)}
|
||||||
data-locale={@locale}
|
data-locale={@locale}
|
||||||
>
|
>
|
||||||
<h1 class="sr-only">{dgettext("auth", "Sign in")}</h1>
|
|
||||||
<%!-- Language selector --%>
|
<%!-- Language selector --%>
|
||||||
<nav
|
<nav
|
||||||
aria-label={dgettext("auth", "Language selection")}
|
aria-label={dgettext("auth", "Language selection")}
|
||||||
|
|
@ -97,7 +95,7 @@ defmodule MvWeb.SignInLive do
|
||||||
context={@context}
|
context={@context}
|
||||||
gettext_fn={@gettext_fn}
|
gettext_fn={@gettext_fn}
|
||||||
/>
|
/>
|
||||||
</main>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,9 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||||
<.icon name="hero-arrow-left" class="size-4" />
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
{gettext("Back")}
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
<h2 class="card-title text-xl">
|
<h3 class="card-title">
|
||||||
{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")}
|
{if @custom_field, do: gettext("Edit Data Field"), else: gettext("New Data Field")}
|
||||||
</h2>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<.form
|
<.form
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,11 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_component
|
use MvWeb, :live_component
|
||||||
|
|
||||||
alias MvWeb.Translations.FieldTypes
|
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
assigns = assign(assigns, :field_type_label, &FieldTypes.label/1)
|
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div id={@id}>
|
<div id={@id}>
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
|
||||||
alias Mv.Helpers
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.Membership.Member, as: MemberResource
|
|
||||||
alias MvWeb.Helpers.MemberHelpers
|
|
||||||
|
|
||||||
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
|
||||||
|
|
||||||
|
|
@ -304,20 +300,22 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<.input
|
<label class="label cursor-pointer justify-start gap-2">
|
||||||
field={@form[:oidc_only]}
|
<.input
|
||||||
type="checkbox"
|
field={@form[:oidc_only]}
|
||||||
class="checkbox checkbox-sm"
|
type="checkbox"
|
||||||
disabled={@oidc_only_env_set or not @oidc_configured}
|
class="checkbox checkbox-sm"
|
||||||
label={
|
disabled={@oidc_only_env_set or not @oidc_configured}
|
||||||
if @oidc_only_env_set do
|
/>
|
||||||
gettext("Only OIDC sign-in (hide password login)") <>
|
<span class="label-text">
|
||||||
" (" <> gettext("From OIDC_ONLY") <> ")"
|
{gettext("Only OIDC sign-in (hide password login)")}
|
||||||
else
|
<%= if @oidc_only_env_set do %>
|
||||||
gettext("Only OIDC sign-in (hide password login)")
|
<span class="label-text-alt text-base-content/70 ml-1">
|
||||||
end
|
({gettext("From OIDC_ONLY")})
|
||||||
}
|
</span>
|
||||||
/>
|
<% end %>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<p class="label-text-alt text-base-content/70 mt-1">
|
<p class="label-text-alt text-base-content/70 mt-1">
|
||||||
{gettext(
|
{gettext(
|
||||||
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
"When enabled and OIDC is configured, the sign-in page shows only the Single Sign-On button."
|
||||||
|
|
@ -553,13 +551,13 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp fetch_member_names_by_ids(ids) do
|
defp fetch_member_names_by_ids(ids) do
|
||||||
actor = SystemActor.get_system_actor()
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
opts = Helpers.ash_actor_opts(actor)
|
opts = Mv.Helpers.ash_actor_opts(actor)
|
||||||
query = Ash.Query.filter(MemberResource, expr(id in ^ids))
|
query = Ash.Query.filter(Mv.Membership.Member, expr(id in ^ids))
|
||||||
|
|
||||||
case Ash.read(query, opts) do
|
case Ash.read(query, opts) do
|
||||||
{:ok, members} ->
|
{:ok, members} ->
|
||||||
Map.new(members, fn m -> {m.id, MemberHelpers.display_name(m)} end)
|
Map.new(members, fn m -> {m.id, MvWeb.Helpers.MemberHelpers.display_name(m)} end)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
%{}
|
%{}
|
||||||
|
|
|
||||||
|
|
@ -88,17 +88,16 @@ defmodule MvWeb.ImportLive.Components do
|
||||||
phx-submit="start_import"
|
phx-submit="start_import"
|
||||||
data-testid="csv-upload-form"
|
data-testid="csv-upload-form"
|
||||||
>
|
>
|
||||||
<fieldset class="mb-2 fieldset w-md" aria-labelledby="csv_file_label">
|
<fieldset class="mb-2 fieldset w-md">
|
||||||
<label id="csv_file_label" for="csv_file" class="label block">
|
<label for="csv_file">
|
||||||
<span class="mb-1 label text-base-content">{gettext("CSV File")}</span>
|
<span class="mb-1 label">{gettext("CSV File")}</span>
|
||||||
<.live_file_input
|
|
||||||
upload={@uploads.csv_file}
|
|
||||||
id="csv_file"
|
|
||||||
class="file-input file-input-bordered block"
|
|
||||||
aria-describedby="csv_file_help"
|
|
||||||
aria-label={gettext("CSV File")}
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
|
<.live_file_input
|
||||||
|
upload={@uploads.csv_file}
|
||||||
|
id="csv_file"
|
||||||
|
class="file-input file-input-bordered"
|
||||||
|
aria-describedby="csv_file_help"
|
||||||
|
/>
|
||||||
<p class="text-sm text-base-content/60 mt-2" id="csv_file_help">
|
<p class="text-sm text-base-content/60 mt-2" id="csv_file_help">
|
||||||
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
|
{gettext("CSV files only, maximum %{size} MB", size: @csv_import_max_file_size_mb)}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ 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"""
|
||||||
|
|
@ -49,9 +50,9 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
||||||
<.icon name="hero-arrow-left" class="size-4" />
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
{gettext("Back")}
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
<h2 class="card-title text-xl">
|
<h3 class="card-title">
|
||||||
{gettext("Edit Field: %{field}", field: @field_label)}
|
{gettext("Edit Field: %{field}", field: @field_label)}
|
||||||
</h2>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<.form
|
<.form
|
||||||
|
|
@ -119,12 +120,22 @@ 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); else configurable in settings --%>
|
<%!-- Required: disabled for email (always required) or Vereinfacht-required fields when integration is active --%>
|
||||||
<div
|
<div
|
||||||
:if={@is_email_field?}
|
:if={@is_email_field? or @vereinfacht_required_field?}
|
||||||
class="tooltip tooltip-right"
|
class="tooltip tooltip-right"
|
||||||
data-tip={gettext("This is a technical field and cannot be changed")}
|
data-tip={
|
||||||
aria-label={gettext("This is a technical field and cannot be changed")}
|
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.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
|
@ -153,7 +164,7 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<.input
|
<.input
|
||||||
:if={not @is_email_field?}
|
:if={not @is_email_field? and not @vereinfacht_required_field?}
|
||||||
field={@form[:required]}
|
field={@form[:required]}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
label={gettext("Required")}
|
label={gettext("Required")}
|
||||||
|
|
@ -200,11 +211,12 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
required =
|
required =
|
||||||
if Map.has_key?(member_field_params, "required") do
|
socket.assigns.vereinfacht_required_field? ||
|
||||||
TypeParsers.parse_boolean(member_field_params["required"])
|
if Map.has_key?(member_field_params, "required") do
|
||||||
else
|
TypeParsers.parse_boolean(member_field_params["required"])
|
||||||
form.source["required"]
|
else
|
||||||
end
|
form.source["required"]
|
||||||
|
end
|
||||||
|
|
||||||
# Merge so we keep name/value_type and have current checkbox state; use as new form source
|
# Merge so we keep name/value_type and have current checkbox state; use as new form source
|
||||||
merged_source =
|
merged_source =
|
||||||
|
|
@ -235,11 +247,12 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
required =
|
required =
|
||||||
if Map.has_key?(member_field_params, "required") do
|
socket.assigns.vereinfacht_required_field? ||
|
||||||
TypeParsers.parse_boolean(member_field_params["required"])
|
if Map.has_key?(member_field_params, "required") do
|
||||||
else
|
TypeParsers.parse_boolean(member_field_params["required"])
|
||||||
form.source["required"]
|
else
|
||||||
end
|
form.source["required"]
|
||||||
|
end
|
||||||
|
|
||||||
field_string = Atom.to_string(socket.assigns.member_field)
|
field_string = Atom.to_string(socket.assigns.member_field)
|
||||||
|
|
||||||
|
|
@ -279,10 +292,20 @@ 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; else from settings
|
# Email always required; Vereinfacht-required fields when integration active; else from settings
|
||||||
required =
|
required =
|
||||||
member_field == :email || Map.get(normalized_required, member_field, false)
|
member_field == :email ||
|
||||||
|
(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),
|
||||||
|
|
@ -315,4 +338,9 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -172,15 +172,19 @@ 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; else from settings
|
# Email always required; Vereinfacht-required fields when integration active; else from settings
|
||||||
required =
|
required =
|
||||||
field == :email || Map.get(normalized_required, field, false)
|
field == :email ||
|
||||||
|
(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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,8 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.Membership.Helpers.VisibilityConfig
|
alias Mv.Membership.Helpers.VisibilityConfig
|
||||||
alias Mv.Membership.Member, as: MemberResource
|
|
||||||
alias Mv.MembershipFees
|
alias Mv.MembershipFees
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias Mv.Vereinfacht.SyncFlash
|
|
||||||
alias MvWeb.Helpers.MemberHelpers
|
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -54,10 +51,15 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
<%= if @member do %>
|
<%= if @member do %>
|
||||||
{MemberHelpers.display_name(@member)}
|
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||||
<% else %>
|
<% else %>
|
||||||
{gettext("New Member")}
|
{gettext("New Member")}
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<:actions>
|
||||||
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
|
{gettext("Save")}
|
||||||
|
</.button>
|
||||||
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<div class="mt-6 space-y-6">
|
<div class="mt-6 space-y-6">
|
||||||
|
|
@ -215,16 +217,14 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
<.form_section title={gettext("Membership Fee")}>
|
<.form_section title={gettext("Membership Fee")}>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for={@form[:membership_fee_type_id].id} class="label">
|
<label class="label">
|
||||||
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id={@form[:membership_fee_type_id].id}
|
|
||||||
class="select select-bordered w-full"
|
class="select select-bordered w-full"
|
||||||
name={@form[:membership_fee_type_id].name}
|
name={@form[:membership_fee_type_id].name}
|
||||||
phx-change="validate"
|
phx-change="validate"
|
||||||
value={@form[:membership_fee_type_id].value || ""}
|
value={@form[:membership_fee_type_id].value || ""}
|
||||||
aria-label={gettext("Membership Fee Type")}
|
|
||||||
>
|
>
|
||||||
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
|
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
|
||||||
<option value="">{gettext("Select a membership fee type")}</option>
|
<option value="">{gettext("Select a membership fee type")}</option>
|
||||||
|
|
@ -289,7 +289,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
data-testid="member-delete"
|
data-testid="member-delete"
|
||||||
aria-label={
|
aria-label={
|
||||||
gettext("Delete member %{name}",
|
gettext("Delete member %{name}",
|
||||||
name: MemberHelpers.display_name(@member)
|
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -316,7 +316,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
<p class="py-4">
|
<p class="py-4">
|
||||||
{gettext(
|
{gettext(
|
||||||
"Are you sure you want to delete %{name}? This action cannot be undone.",
|
"Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||||
name: MemberHelpers.display_name(@member)
|
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
|
|
@ -371,7 +371,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
member =
|
member =
|
||||||
case params["id"] do
|
case params["id"] do
|
||||||
nil -> nil
|
nil -> nil
|
||||||
id -> Ash.get!(MemberResource, id, load: [:membership_fee_type], actor: actor)
|
id -> Ash.get!(Mv.Membership.Member, id, load: [:membership_fee_type], actor: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
page_title =
|
page_title =
|
||||||
|
|
@ -398,6 +398,8 @@ 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 || %{}
|
||||||
|
|
@ -405,15 +407,20 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|
|
||||||
Mv.Constants.member_fields()
|
Mv.Constants.member_fields()
|
||||||
|> Enum.map(fn field ->
|
|> Enum.map(fn field ->
|
||||||
required = field == :email || Map.get(normalized, field, false)
|
required =
|
||||||
|
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, _} ->
|
||||||
# When settings cannot be loaded, only email is required
|
# Email always required; Vereinfacht fields when integration active
|
||||||
Map.new(Mv.Constants.member_fields(), fn f ->
|
Map.new(Mv.Constants.member_fields(), fn f ->
|
||||||
{f, f == :email}
|
{f,
|
||||||
|
f == :email || (vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(f))}
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -439,21 +446,23 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("save", %{"member" => member_params}, socket) do
|
def handle_event("save", %{"member" => member_params}, socket) do
|
||||||
actor = current_actor(socket)
|
try do
|
||||||
|
actor = current_actor(socket)
|
||||||
|
|
||||||
case submit_form(socket.assigns.form, member_params, actor) do
|
case submit_form(socket.assigns.form, member_params, actor) do
|
||||||
{:ok, member} ->
|
{:ok, member} ->
|
||||||
handle_save_success(socket, member)
|
handle_save_success(socket, member)
|
||||||
|
|
||||||
{:error, form} ->
|
{:error, form} ->
|
||||||
handle_save_error(socket, form)
|
handle_save_error(socket, form)
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
_e in [Ash.Error.Forbidden, Ash.Error.Forbidden.Policy] ->
|
||||||
|
handle_save_forbidden(socket)
|
||||||
|
|
||||||
|
e ->
|
||||||
|
handle_save_exception(socket, e)
|
||||||
end
|
end
|
||||||
rescue
|
|
||||||
_e in [Ash.Error.Forbidden, Ash.Error.Forbidden.Policy] ->
|
|
||||||
handle_save_forbidden(socket)
|
|
||||||
|
|
||||||
e ->
|
|
||||||
handle_save_exception(socket, e)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -555,7 +564,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_put_vereinfacht_sync_flash(socket, member_id) do
|
defp maybe_put_vereinfacht_sync_flash(socket, member_id) do
|
||||||
case SyncFlash.take(to_string(member_id)) do
|
case Mv.Vereinfacht.SyncFlash.take(to_string(member_id)) do
|
||||||
{:warning, message} ->
|
{:warning, message} ->
|
||||||
put_flash(socket, :warning, translate_vereinfacht_flash(message))
|
put_flash(socket, :warning, translate_vereinfacht_flash(message))
|
||||||
|
|
||||||
|
|
@ -681,9 +690,11 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
|
|
||||||
# Extracts message from struct error using Ash.ErrorKind protocol
|
# Extracts message from struct error using Ash.ErrorKind protocol
|
||||||
defp extract_struct_error_message(error) do
|
defp extract_struct_error_message(error) do
|
||||||
Ash.ErrorKind.message(error)
|
try do
|
||||||
rescue
|
Ash.ErrorKind.message(error)
|
||||||
Protocol.UndefinedError -> gettext("Failed to save member. Please try again.")
|
rescue
|
||||||
|
Protocol.UndefinedError -> gettext("Failed to save member. Please try again.")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Checks if form has any errors
|
# Checks if form has any errors
|
||||||
|
|
@ -760,7 +771,7 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
AshPhoenix.Form.for_create(
|
AshPhoenix.Form.for_create(
|
||||||
MemberResource,
|
Mv.Membership.Member,
|
||||||
:create_member,
|
:create_member,
|
||||||
api: Mv.Membership,
|
api: Mv.Membership,
|
||||||
as: "member",
|
as: "member",
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.Membership.Member, as: MemberResource
|
|
||||||
alias MvWeb.Helpers.DateFormatter
|
alias MvWeb.Helpers.DateFormatter
|
||||||
alias MvWeb.MemberLive.Index.FieldSelection
|
alias MvWeb.MemberLive.Index.FieldSelection
|
||||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||||
|
|
@ -709,9 +708,11 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp to_sort_id(field) when is_binary(field) do
|
defp to_sort_id(field) when is_binary(field) do
|
||||||
String.to_existing_atom("sort_#{field}")
|
try do
|
||||||
rescue
|
String.to_existing_atom("sort_#{field}")
|
||||||
ArgumentError -> :"sort_#{field}"
|
rescue
|
||||||
|
ArgumentError -> :"sort_#{field}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp to_sort_id(field) when is_atom(field), do: :"sort_#{field}"
|
defp to_sort_id(field) when is_atom(field), do: :"sort_#{field}"
|
||||||
|
|
@ -1013,7 +1014,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
defp apply_search_filter(query, search_query) do
|
defp apply_search_filter(query, search_query) do
|
||||||
if search_query && String.trim(search_query) != "" do
|
if search_query && String.trim(search_query) != "" do
|
||||||
query
|
query
|
||||||
|> MemberResource.fuzzy_search(%{query: search_query})
|
|> Mv.Membership.Member.fuzzy_search(%{query: search_query})
|
||||||
else
|
else
|
||||||
query
|
query
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -54,36 +54,33 @@
|
||||||
boolean_filters={@boolean_custom_field_filters}
|
boolean_filters={@boolean_custom_field_filters}
|
||||||
member_count={length(@members)}
|
member_count={length(@members)}
|
||||||
/>
|
/>
|
||||||
<.tooltip
|
<.button
|
||||||
content={
|
type="button"
|
||||||
gettext(
|
variant="secondary"
|
||||||
"Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
|
class={["gap-2", @show_current_cycle && "btn-active"]}
|
||||||
|
phx-click="toggle_cycle_view"
|
||||||
|
data-testid="toggle-cycle-view"
|
||||||
|
aria-label={
|
||||||
|
if(@show_current_cycle,
|
||||||
|
do: gettext("Current Cycle Payment Status"),
|
||||||
|
else: gettext("Last Cycle Payment Status")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
if(@show_current_cycle,
|
||||||
|
do: gettext("Current Cycle Payment Status"),
|
||||||
|
else: gettext("Last Cycle Payment Status")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
position="top"
|
|
||||||
>
|
>
|
||||||
<.button
|
<.icon name="hero-arrow-path" class="h-5 w-5" />
|
||||||
type="button"
|
<span class="hidden sm:inline">
|
||||||
variant="secondary"
|
{if(@show_current_cycle,
|
||||||
class={["gap-2", @show_current_cycle && "btn-active"]}
|
do: gettext("Current Cycle Payment Status"),
|
||||||
phx-click="toggle_cycle_view"
|
else: gettext("Last Cycle Payment Status")
|
||||||
data-testid="toggle-cycle-view"
|
)}
|
||||||
aria-label={
|
</span>
|
||||||
if(@show_current_cycle,
|
</.button>
|
||||||
do: gettext("Current payment cycle"),
|
|
||||||
else: gettext("Last payment cycle")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<.icon name="hero-arrow-path" class="h-5 w-5" />
|
|
||||||
<span class="hidden sm:inline">
|
|
||||||
{if(@show_current_cycle,
|
|
||||||
do: gettext("Current payment cycle"),
|
|
||||||
else: gettext("Last payment cycle")
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</.button>
|
|
||||||
</.tooltip>
|
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
||||||
id="field-visibility-dropdown"
|
id="field-visibility-dropdown"
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
import Ash.Query
|
import Ash.Query
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
alias Mv.Membership.CustomField
|
|
||||||
alias Mv.Membership.CustomFieldValue
|
|
||||||
alias Mv.Membership.Member, as: MemberResource
|
|
||||||
alias Mv.Vereinfacht.Client, as: VereinfachtClient
|
|
||||||
alias MvWeb.Helpers.MemberHelpers
|
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
alias Phoenix.HTML.Engine, as: HTMLEngine
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
|
|
@ -47,7 +41,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
{gettext("Back")}
|
{gettext("Back")}
|
||||||
</.button>
|
</.button>
|
||||||
</:leading>
|
</:leading>
|
||||||
{MemberHelpers.display_name(@member)}
|
{MvWeb.Helpers.MemberHelpers.display_name(@member)}
|
||||||
<:actions>
|
<:actions>
|
||||||
<%= if can?(@current_user, :update, @member) do %>
|
<%= if can?(@current_user, :update, @member) do %>
|
||||||
<.button
|
<.button
|
||||||
|
|
@ -335,7 +329,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
data-testid="member-delete"
|
data-testid="member-delete"
|
||||||
aria-label={
|
aria-label={
|
||||||
gettext("Delete member %{name}",
|
gettext("Delete member %{name}",
|
||||||
name: MemberHelpers.display_name(@member)
|
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -361,7 +355,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
</h3>
|
</h3>
|
||||||
<p class="py-4">
|
<p class="py-4">
|
||||||
{gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
{gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||||
name: MemberHelpers.display_name(@member)
|
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
|
|
@ -408,13 +402,13 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
# Load custom fields once using assign_new to avoid repeated queries
|
# Load custom fields once using assign_new to avoid repeated queries
|
||||||
socket =
|
socket =
|
||||||
assign_new(socket, :custom_fields, fn ->
|
assign_new(socket, :custom_fields, fn ->
|
||||||
CustomField
|
Mv.Membership.CustomField
|
||||||
|> Ash.Query.sort(name: :asc)
|
|> Ash.Query.sort(name: :asc)
|
||||||
|> Ash.read!(actor: actor)
|
|> Ash.read!(actor: actor)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
query =
|
query =
|
||||||
MemberResource
|
Mv.Membership.Member
|
||||||
|> filter(id == ^id)
|
|> filter(id == ^id)
|
||||||
|> load([
|
|> load([
|
||||||
:user,
|
:user,
|
||||||
|
|
@ -533,7 +527,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
|
|
||||||
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
|
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
|
||||||
response =
|
response =
|
||||||
case VereinfachtClient.get_contact_with_receipts(contact_id) do
|
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
|
||||||
{:ok, receipts} -> {:ok, receipts}
|
{:ok, receipts} -> {:ok, receipts}
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason} -> {:error, reason}
|
||||||
end
|
end
|
||||||
|
|
@ -723,7 +717,7 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
# Handles both CustomFieldValue structs and direct values
|
# Handles both CustomFieldValue structs and direct values
|
||||||
defp format_custom_field_value(nil, _type), do: render_empty_value()
|
defp format_custom_field_value(nil, _type), do: render_empty_value()
|
||||||
|
|
||||||
defp format_custom_field_value(%CustomFieldValue{} = cfv, value_type) do
|
defp format_custom_field_value(%Mv.Membership.CustomFieldValue{} = cfv, value_type) do
|
||||||
format_custom_field_value(cfv.value, value_type)
|
format_custom_field_value(cfv.value, value_type)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -765,6 +759,6 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
# Returns safe HTML so it can be used from helpers without LiveView assigns.
|
# Returns safe HTML so it can be used from helpers without LiveView assigns.
|
||||||
defp render_empty_value do
|
defp render_empty_value do
|
||||||
text = gettext("Not set")
|
text = gettext("Not set")
|
||||||
{:safe, ["<span class=\"sr-only\">", HTMLEngine.html_escape(text), "</span>"]}
|
{:safe, ["<span class=\"sr-only\">", Phoenix.HTML.Engine.html_escape(text), "</span>"]}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,10 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias Mv.MembershipFees
|
alias Mv.MembershipFees
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
|
||||||
alias Mv.MembershipFees.CycleGenerator
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
alias Mv.MembershipFees.CycleGenerator
|
||||||
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -151,22 +151,24 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
</:actions>
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<%!-- One card: default setting + fee types table --%>
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<%!-- Settings Form --%>
|
||||||
<div class="card-body space-y-6">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<%!-- Default setting: one row, clear section title and split hints --%>
|
<div class="card-body">
|
||||||
<.form
|
<h2 class="card-title">
|
||||||
for={@form}
|
<.icon name="hero-cog-6-tooth" class="size-5" />
|
||||||
phx-change="validate"
|
{gettext("Global Settings")}
|
||||||
phx-submit="save"
|
|
||||||
class="space-y-2"
|
|
||||||
>
|
|
||||||
<h2 class="text-base font-semibold text-base-content">
|
|
||||||
{gettext("Default settings")}
|
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex flex-wrap items-end gap-6">
|
|
||||||
<fieldset class="fieldset flex-1 min-w-[200px] max-w-md">
|
<.form
|
||||||
<label for="default_membership_fee_type_id" class="label py-0">
|
for={@form}
|
||||||
|
phx-change="validate"
|
||||||
|
phx-submit="save"
|
||||||
|
class="space-y-6"
|
||||||
|
>
|
||||||
|
<%!-- Default Membership Fee Type --%>
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<label for="default_membership_fee_type_id" class="label">
|
||||||
<span class="label-text font-semibold">
|
<span class="label-text font-semibold">
|
||||||
{gettext("Default Membership Fee Type")}
|
{gettext("Default Membership Fee Type")}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -175,7 +177,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
id="default_membership_fee_type_id"
|
id="default_membership_fee_type_id"
|
||||||
name="settings[default_membership_fee_type_id]"
|
name="settings[default_membership_fee_type_id]"
|
||||||
class={[
|
class={[
|
||||||
"select select-bordered w-full",
|
"select select-bordered",
|
||||||
if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "")
|
if(@form.errors[:default_membership_fee_type_id], do: "select-error", else: "")
|
||||||
]}
|
]}
|
||||||
phx-debounce="blur"
|
phx-debounce="blur"
|
||||||
|
|
@ -198,10 +200,16 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
<p class="text-error text-sm mt-1">{msg}</p>
|
<p class="text-error text-sm mt-1">{msg}</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<p class="text-sm text-base-content/60 mt-2">
|
||||||
|
{gettext(
|
||||||
|
"This membership fee type is automatically assigned to all new members. Can be changed individually per member."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="fieldset flex-shrink-0">
|
<%!-- Include Joining Cycle --%>
|
||||||
<label class="label cursor-pointer justify-start gap-3 py-0 min-h-0">
|
<fieldset class="fieldset">
|
||||||
|
<label class="label cursor-pointer justify-start gap-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="settings[include_joining_cycle]"
|
name="settings[include_joining_cycle]"
|
||||||
|
|
@ -216,184 +224,195 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
||||||
<%= if @form.errors[:include_joining_cycle] do %>
|
<%= if @form.errors[:include_joining_cycle] do %>
|
||||||
<%= for error <- List.wrap(@form.errors[:include_joining_cycle]) do %>
|
<%= for error <- List.wrap(@form.errors[:include_joining_cycle]) do %>
|
||||||
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
||||||
<p class="text-error text-sm mt-1">{msg}</p>
|
<p class="text-error text-sm ml-9 mt-1">{msg}</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<div class="ml-9 space-y-2">
|
||||||
|
<p class="text-sm text-base-content/60">
|
||||||
|
{gettext("When active: Members pay from the cycle of their joining.")}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-base-content/60">
|
||||||
|
{gettext("When inactive: Members pay from the next full cycle after joining.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="flex-shrink-0 ml-auto border-l border-base-300 pl-6">
|
<div class="divider"></div>
|
||||||
<.button type="submit" variant="primary">
|
|
||||||
<.icon name="hero-check" class="size-5" />
|
<.button type="submit" variant="primary" class="w-full">
|
||||||
{gettext("Save Settings")}
|
<.icon name="hero-check" class="size-5" />
|
||||||
</.button>
|
{gettext("Save Settings")}
|
||||||
|
</.button>
|
||||||
|
</.form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Examples Card (collapsible) --%>
|
||||||
|
<div class="card bg-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<details class="group">
|
||||||
|
<summary class="card-title cursor-pointer list-none flex items-center gap-2">
|
||||||
|
<.icon name="hero-chevron-right" class="size-5 transition group-open:rotate-90" />
|
||||||
|
<.icon name="hero-light-bulb" class="size-5" />
|
||||||
|
{gettext("Examples")}
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="pt-4 space-y-4">
|
||||||
|
<.example_section
|
||||||
|
title={gettext("Yearly Interval - Joining Cycle Included")}
|
||||||
|
joining_date="15.03.2023"
|
||||||
|
include_joining={true}
|
||||||
|
start_date="01.01.2023"
|
||||||
|
periods={["2023", "2024", "2025"]}
|
||||||
|
note={gettext("Member pays for the year they joined")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<.example_section
|
||||||
|
title={gettext("Yearly Interval - Joining Cycle Excluded")}
|
||||||
|
joining_date="15.03.2023"
|
||||||
|
include_joining={false}
|
||||||
|
start_date="01.01.2024"
|
||||||
|
periods={["2024", "2025"]}
|
||||||
|
note={gettext("Member pays from the next full year")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<.example_section
|
||||||
|
title={gettext("Quarterly Interval - Joining Cycle Excluded")}
|
||||||
|
joining_date="15.05.2024"
|
||||||
|
include_joining={false}
|
||||||
|
start_date="01.07.2024"
|
||||||
|
periods={["Q3/2024", "Q4/2024", "Q1/2025"]}
|
||||||
|
note={gettext("Member pays from the next full quarter")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<.example_section
|
||||||
|
title={gettext("Monthly Interval - Joining Cycle Included")}
|
||||||
|
joining_date="15.03.2024"
|
||||||
|
include_joining={true}
|
||||||
|
start_date="01.03.2024"
|
||||||
|
periods={["03/2024", "04/2024", "05/2024", "..."]}
|
||||||
|
note={gettext("Member pays from the joining month")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul class="text-sm text-base-content/60 list-disc list-inside space-y-0.5">
|
<%!-- Fee Types Table --%>
|
||||||
<li>{gettext("Default type: Assigned to new members; can be changed per member.")}</li>
|
<div class="mt-8">
|
||||||
<li>
|
<h2 class="text-lg font-semibold mb-4">{gettext("Membership Fee Types")}</h2>
|
||||||
{gettext(
|
<.table
|
||||||
"Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle."
|
id="membership_fee_types"
|
||||||
)}
|
rows={@membership_fee_types}
|
||||||
</li>
|
row_id={fn mft -> "mft-#{mft.id}" end}
|
||||||
</ul>
|
>
|
||||||
</.form>
|
<:col :let={mft} label={gettext("Name")}>
|
||||||
|
<span class="font-medium">{mft.name}</span>
|
||||||
|
<p :if={mft.description} class="text-sm text-base-content/70">{mft.description}</p>
|
||||||
|
</:col>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<:col :let={mft} label={gettext("Amount")}>
|
||||||
|
<span class="font-mono">{MembershipFeeHelpers.format_currency(mft.amount)}</span>
|
||||||
|
</:col>
|
||||||
|
|
||||||
<%!-- Fee types table: row click opens edit --%>
|
<:col :let={mft} label={gettext("Interval")}>
|
||||||
<h2 class="text-lg font-semibold">{gettext("Membership Fee Types")}</h2>
|
<.badge variant="neutral" style="outline">
|
||||||
<.table
|
{MembershipFeeHelpers.format_interval(mft.interval)}
|
||||||
id="membership_fee_types"
|
</.badge>
|
||||||
rows={@membership_fee_types}
|
</:col>
|
||||||
row_id={fn mft -> "mft-#{mft.id}" end}
|
|
||||||
row_click={
|
|
||||||
fn mft ->
|
|
||||||
Phoenix.LiveView.JS.navigate(~p"/membership_fee_settings/#{mft.id}/edit_fee_type")
|
|
||||||
end
|
|
||||||
}
|
|
||||||
row_tooltip={gettext("Click to edit membership fee type")}
|
|
||||||
>
|
|
||||||
<:col :let={mft} label={gettext("Name")}>
|
|
||||||
<span class="font-medium">{mft.name}</span>
|
|
||||||
<p :if={mft.description} class="text-sm text-base-content/70">{mft.description}</p>
|
|
||||||
</:col>
|
|
||||||
|
|
||||||
<:col :let={mft} label={gettext("Amount")}>
|
<:col :let={mft} label={gettext("Members")}>
|
||||||
<span class="font-mono">{MembershipFeeHelpers.format_currency(mft.amount)}</span>
|
<span class="text-sm">{get_member_count(mft, @member_counts)}</span>
|
||||||
</:col>
|
</:col>
|
||||||
|
|
||||||
<:col :let={mft} label={gettext("Interval")}>
|
<:action :let={mft}>
|
||||||
<.badge variant="neutral" style="outline">
|
<.tooltip content={gettext("Edit membership fee type")} position="left">
|
||||||
{MembershipFeeHelpers.format_interval(mft.interval)}
|
<.button
|
||||||
</.badge>
|
variant="ghost"
|
||||||
</:col>
|
size="sm"
|
||||||
|
navigate={~p"/membership_fee_settings/#{mft.id}/edit_fee_type"}
|
||||||
|
aria-label={gettext("Edit membership fee type")}
|
||||||
|
>
|
||||||
|
<.icon name="hero-pencil" class="size-4" />
|
||||||
|
</.button>
|
||||||
|
</.tooltip>
|
||||||
|
</:action>
|
||||||
|
|
||||||
<:col :let={mft} label={gettext("Members")}>
|
<:action :let={mft}>
|
||||||
<span class="text-sm">{get_member_count(mft, @member_counts)}</span>
|
<.tooltip
|
||||||
</:col>
|
:if={get_member_count(mft, @member_counts) > 0}
|
||||||
|
content={
|
||||||
<:action :let={mft}>
|
gettext("Cannot delete - %{count} member(s) assigned",
|
||||||
<.tooltip
|
count: get_member_count(mft, @member_counts)
|
||||||
:if={get_member_count(mft, @member_counts) > 0}
|
)
|
||||||
content={
|
}
|
||||||
|
position="left"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
phx-click="delete"
|
||||||
|
phx-value-id={mft.id}
|
||||||
|
data-confirm={gettext("Are you sure?")}
|
||||||
|
class="btn btn-ghost btn-xs text-error opacity-50 cursor-not-allowed"
|
||||||
|
aria-label={
|
||||||
gettext("Cannot delete - %{count} member(s) assigned",
|
gettext("Cannot delete - %{count} member(s) assigned",
|
||||||
count: get_member_count(mft, @member_counts)
|
count: get_member_count(mft, @member_counts)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
position="left"
|
disabled={true}
|
||||||
>
|
|
||||||
<button
|
|
||||||
phx-click="delete"
|
|
||||||
phx-value-id={mft.id}
|
|
||||||
data-confirm={gettext("Are you sure?")}
|
|
||||||
class="btn btn-ghost btn-xs text-error opacity-50 cursor-not-allowed"
|
|
||||||
aria-label={
|
|
||||||
gettext("Cannot delete - %{count} member(s) assigned",
|
|
||||||
count: get_member_count(mft, @member_counts)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={true}
|
|
||||||
>
|
|
||||||
<.icon name="hero-trash" class="size-4" />
|
|
||||||
</button>
|
|
||||||
</.tooltip>
|
|
||||||
<.button
|
|
||||||
:if={get_member_count(mft, @member_counts) == 0}
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
phx-click="delete"
|
|
||||||
phx-value-id={mft.id}
|
|
||||||
data-confirm={gettext("Are you sure?")}
|
|
||||||
aria-label={gettext("Delete Membership Fee Type")}
|
|
||||||
>
|
>
|
||||||
<.icon name="hero-trash" class="size-4" />
|
<.icon name="hero-trash" class="size-4" />
|
||||||
</.button>
|
</button>
|
||||||
</:action>
|
</.tooltip>
|
||||||
</.table>
|
<.button
|
||||||
</div>
|
:if={get_member_count(mft, @member_counts) == 0}
|
||||||
</div>
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
phx-click="delete"
|
||||||
|
phx-value-id={mft.id}
|
||||||
|
data-confirm={gettext("Are you sure?")}
|
||||||
|
aria-label={gettext("Delete Membership Fee Type")}
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
</.button>
|
||||||
|
</:action>
|
||||||
|
</.table>
|
||||||
|
|
||||||
<%!-- About membership fee types (info above examples) --%>
|
<details class="mt-6 card bg-base-200">
|
||||||
<div class="mt-6 rounded-lg bg-base-200 p-4 prose prose-sm max-w-none">
|
<summary class="card-body cursor-pointer list-none card-title">
|
||||||
<p>
|
<.icon name="hero-information-circle" class="size-5" />
|
||||||
{gettext(
|
{gettext("About Membership Fee Types")}
|
||||||
"Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
</summary>
|
||||||
)}
|
<div class="card-body pt-0 prose prose-sm max-w-none">
|
||||||
</p>
|
<p>
|
||||||
<ul>
|
{gettext(
|
||||||
<li>
|
"Membership fee types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
||||||
<strong>{gettext("Name & Amount")}</strong>
|
)}
|
||||||
- {gettext("Can be changed at any time. Amount changes affect future periods only.")}
|
</p>
|
||||||
</li>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<strong>{gettext("Interval")}</strong>
|
<strong>{gettext("Name & Amount")}</strong>
|
||||||
- {gettext(
|
- {gettext("Can be changed at any time. Amount changes affect future periods only.")}
|
||||||
"Fixed after creation. Members can only switch between types with the same interval."
|
</li>
|
||||||
)}
|
<li>
|
||||||
</li>
|
<strong>{gettext("Interval")}</strong>
|
||||||
<li>
|
- {gettext(
|
||||||
<strong>{gettext("Deletion")}</strong>
|
"Fixed after creation. Members can only switch between types with the same interval."
|
||||||
- {gettext("Only possible if no members are assigned to this type.")}
|
)}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
<li>
|
||||||
</div>
|
<strong>{gettext("Deletion")}</strong>
|
||||||
|
- {gettext("Only possible if no members are assigned to this type.")}
|
||||||
<%!-- Examples (collapsible) --%>
|
</li>
|
||||||
<div class="mt-6 card bg-base-200">
|
</ul>
|
||||||
<div class="card-body">
|
</div>
|
||||||
<details class="group">
|
</details>
|
||||||
<summary class="card-title cursor-pointer list-none flex items-center gap-2">
|
|
||||||
<.icon name="hero-chevron-right" class="size-5 transition group-open:rotate-90" />
|
|
||||||
<.icon name="hero-light-bulb" class="size-5" />
|
|
||||||
{gettext("Examples")}
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<div class="pt-4 space-y-4">
|
|
||||||
<.example_section
|
|
||||||
title={gettext("Yearly Interval - Joining Cycle Included")}
|
|
||||||
joining_date="15.03.2023"
|
|
||||||
include_joining={true}
|
|
||||||
start_date="01.01.2023"
|
|
||||||
periods={["2023", "2024", "2025"]}
|
|
||||||
note={gettext("Member pays for the year they joined")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<.example_section
|
|
||||||
title={gettext("Yearly Interval - Joining Cycle Excluded")}
|
|
||||||
joining_date="15.03.2023"
|
|
||||||
include_joining={false}
|
|
||||||
start_date="01.01.2024"
|
|
||||||
periods={["2024", "2025"]}
|
|
||||||
note={gettext("Member pays from the next full year")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<.example_section
|
|
||||||
title={gettext("Quarterly Interval - Joining Cycle Excluded")}
|
|
||||||
joining_date="15.05.2024"
|
|
||||||
include_joining={false}
|
|
||||||
start_date="01.07.2024"
|
|
||||||
periods={["Q3/2024", "Q4/2024", "Q1/2025"]}
|
|
||||||
note={gettext("Member pays from the next full quarter")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<.example_section
|
|
||||||
title={gettext("Monthly Interval - Joining Cycle Included")}
|
|
||||||
joining_date="15.03.2024"
|
|
||||||
include_joining={true}
|
|
||||||
start_date="01.03.2024"
|
|
||||||
periods={["03/2024", "04/2024", "05/2024", "..."]}
|
|
||||||
note={gettext("Member pays from the joining month")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -21,47 +21,49 @@ defmodule MvWeb.RoleLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
case Ash.get(
|
try do
|
||||||
Mv.Authorization.Role,
|
case Ash.get(
|
||||||
id,
|
Mv.Authorization.Role,
|
||||||
domain: Mv.Authorization,
|
id,
|
||||||
actor: socket.assigns[:current_user]
|
domain: Mv.Authorization,
|
||||||
) do
|
actor: socket.assigns[:current_user]
|
||||||
{:ok, role} ->
|
) do
|
||||||
user_count = load_user_count(role, socket.assigns[:current_user])
|
{:ok, role} ->
|
||||||
|
user_count = load_user_count(role, socket.assigns[:current_user])
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Show Role"))
|
|> assign(:page_title, gettext("Show Role"))
|
||||||
|> assign(:role, role)
|
|> assign(:role, role)
|
||||||
|> assign(:user_count, user_count)
|
|> assign(:user_count, user_count)
|
||||||
|> assign(:show_delete_modal, false)}
|
|> assign(:show_delete_modal, false)}
|
||||||
|
|
||||||
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
|
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
|
||||||
{:ok,
|
|
||||||
socket
|
|
||||||
|> put_flash(:error, gettext("Role not found."))
|
|
||||||
|> redirect(to: ~p"/admin/roles")}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
{:ok,
|
|
||||||
socket
|
|
||||||
|> put_flash(:error, format_error(error))
|
|
||||||
|> redirect(to: ~p"/admin/roles")}
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
e in [Ash.Error.Invalid] ->
|
|
||||||
# Handle exceptions that Ash.get might throw (e.g., policy violations)
|
|
||||||
case e do
|
|
||||||
%Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]} ->
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:error, gettext("Role not found."))
|
|> put_flash(:error, gettext("Role not found."))
|
||||||
|> redirect(to: ~p"/admin/roles")}
|
|> redirect(to: ~p"/admin/roles")}
|
||||||
|
|
||||||
_ ->
|
{:error, error} ->
|
||||||
reraise e, __STACKTRACE__
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, format_error(error))
|
||||||
|
|> redirect(to: ~p"/admin/roles")}
|
||||||
end
|
end
|
||||||
|
rescue
|
||||||
|
e in [Ash.Error.Invalid] ->
|
||||||
|
# Handle exceptions that Ash.get might throw (e.g., policy violations)
|
||||||
|
case e do
|
||||||
|
%Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]} ->
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, gettext("Role not found."))
|
||||||
|
|> redirect(to: ~p"/admin/roles")}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
reraise e, __STACKTRACE__
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ defmodule MvWeb.StatisticsLive do
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
|
||||||
alias Mv.Statistics
|
alias Mv.Statistics
|
||||||
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -35,14 +35,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
require Jason
|
require Jason
|
||||||
|
|
||||||
alias Mv.Accounts
|
|
||||||
alias Mv.Accounts.User, as: UserResource
|
|
||||||
alias Mv.Authorization
|
alias Mv.Authorization
|
||||||
alias Mv.Authorization.Role, as: RoleResource
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
alias Mv.Membership
|
|
||||||
alias Mv.Membership.Member, as: MemberResource
|
|
||||||
alias MvWeb.Helpers.MemberHelpers
|
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
|
||||||
import MvWeb.Authorization, only: [can?: 3]
|
import MvWeb.Authorization, only: [can?: 3]
|
||||||
|
|
@ -310,7 +303,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%!-- Danger zone: canonical pattern (same as member form) --%>
|
<%!-- Danger zone: canonical pattern (same as member form) --%>
|
||||||
<%= if @user && can?(@current_user, :destroy, @user) && !SystemActor.system_user?(@user) do %>
|
<%= if @user && can?(@current_user, :destroy, @user) && !Mv.Helpers.SystemActor.system_user?(@user) do %>
|
||||||
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
{gettext("Danger zone")}
|
{gettext("Danger zone")}
|
||||||
|
|
@ -409,9 +402,9 @@ defmodule MvWeb.UserLive.Form do
|
||||||
defp load_user_or_redirect(nil, _actor, _socket), do: {:ok, nil}
|
defp load_user_or_redirect(nil, _actor, _socket), do: {:ok, nil}
|
||||||
|
|
||||||
defp load_user_or_redirect(id, actor, socket) do
|
defp load_user_or_redirect(id, actor, socket) do
|
||||||
user = Ash.get!(UserResource, id, domain: Accounts, load: [:member], actor: actor)
|
user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor)
|
||||||
|
|
||||||
if SystemActor.system_user?(user) do
|
if Mv.Helpers.SystemActor.system_user?(user) do
|
||||||
{:redirect,
|
{:redirect,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:error, gettext("This user cannot be edited."))
|
|> put_flash(:error, gettext("This user cannot be edited."))
|
||||||
|
|
@ -427,9 +420,9 @@ defmodule MvWeb.UserLive.Form do
|
||||||
page_title = action <> " " <> gettext("User")
|
page_title = action <> " " <> gettext("User")
|
||||||
|
|
||||||
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
|
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
|
||||||
can_manage_member_linking = can?(actor, :destroy, UserResource)
|
can_manage_member_linking = can?(actor, :destroy, Mv.Accounts.User)
|
||||||
# Only admins can assign user roles (Role update permission).
|
# Only admins can assign user roles (Role update permission).
|
||||||
can_assign_role = can?(actor, :update, RoleResource)
|
can_assign_role = can?(actor, :update, Mv.Authorization.Role)
|
||||||
roles = if can_assign_role, do: load_roles(actor), else: []
|
roles = if can_assign_role, do: load_roles(actor), else: []
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
|
|
@ -548,7 +541,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|> put_flash(:error, gettext("User not found"))
|
|> put_flash(:error, gettext("User not found"))
|
||||||
|> assign(:show_delete_modal, false)}
|
|> assign(:show_delete_modal, false)}
|
||||||
|
|
||||||
SystemActor.system_user?(user) ->
|
Mv.Helpers.SystemActor.system_user?(user) ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:error, gettext("System user cannot be deleted."))
|
|> put_flash(:error, gettext("System user cannot be deleted."))
|
||||||
|
|
@ -641,7 +634,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
member_name =
|
member_name =
|
||||||
if selected_member,
|
if selected_member,
|
||||||
do: MemberHelpers.display_name(selected_member),
|
do: MvWeb.Helpers.MemberHelpers.display_name(selected_member),
|
||||||
else: ""
|
else: ""
|
||||||
|
|
||||||
# Store the selected member ID and name in socket state and clear unlink flag
|
# Store the selected member ID and name in socket state and clear unlink flag
|
||||||
|
|
@ -711,17 +704,17 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
defp perform_member_link_action(socket, user, actor) do
|
defp perform_member_link_action(socket, user, actor) do
|
||||||
# Only admins may link/unlink (backend policy also restricts update_user; UI must not call it).
|
# Only admins may link/unlink (backend policy also restricts update_user; UI must not call it).
|
||||||
if can?(actor, :destroy, UserResource) do
|
if can?(actor, :destroy, Mv.Accounts.User) do
|
||||||
cond do
|
cond do
|
||||||
# Selected member ID takes precedence (new link)
|
# Selected member ID takes precedence (new link)
|
||||||
socket.assigns.selected_member_id ->
|
socket.assigns.selected_member_id ->
|
||||||
Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
|
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
|
||||||
actor: actor
|
actor: actor
|
||||||
)
|
)
|
||||||
|
|
||||||
# Unlink flag is set
|
# Unlink flag is set
|
||||||
socket.assigns[:unlink_member] ->
|
socket.assigns[:unlink_member] ->
|
||||||
Accounts.update_user(user, %{member: nil}, actor: actor)
|
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
|
||||||
|
|
||||||
# No changes to member relationship
|
# No changes to member relationship
|
||||||
true ->
|
true ->
|
||||||
|
|
@ -838,8 +831,8 @@ defmodule MvWeb.UserLive.Form do
|
||||||
# For new users, use password registration if password fields are shown
|
# For new users, use password registration if password fields are shown
|
||||||
action = if show_password_fields, do: :register_with_password, else: :create_user
|
action = if show_password_fields, do: :register_with_password, else: :create_user
|
||||||
|
|
||||||
AshPhoenix.Form.for_create(UserResource, action,
|
AshPhoenix.Form.for_create(Mv.Accounts.User, action,
|
||||||
domain: Accounts,
|
domain: Mv.Accounts,
|
||||||
as: "user",
|
as: "user",
|
||||||
actor: actor
|
actor: actor
|
||||||
)
|
)
|
||||||
|
|
@ -885,7 +878,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
search_query_str = if search_query && search_query != "", do: search_query, else: nil
|
search_query_str = if search_query && search_query != "", do: search_query, else: nil
|
||||||
|
|
||||||
query =
|
query =
|
||||||
MemberResource
|
Mv.Membership.Member
|
||||||
|> Ash.Query.for_read(:available_for_linking, %{
|
|> Ash.Query.for_read(:available_for_linking, %{
|
||||||
user_email: user_email_str,
|
user_email: user_email_str,
|
||||||
search_query: search_query_str
|
search_query: search_query_str
|
||||||
|
|
@ -897,7 +890,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
if is_nil(actor) do
|
if is_nil(actor) do
|
||||||
[]
|
[]
|
||||||
else
|
else
|
||||||
case Ash.read(query, domain: Membership, actor: actor) do
|
case Ash.read(query, domain: Mv.Membership, actor: actor) do
|
||||||
{:ok, members} -> apply_email_filter(members, user_email_str)
|
{:ok, members} -> apply_email_filter(members, user_email_str)
|
||||||
{:error, _} -> []
|
{:error, _} -> []
|
||||||
end
|
end
|
||||||
|
|
@ -909,7 +902,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
defp apply_email_filter(members, nil), do: members
|
defp apply_email_filter(members, nil), do: members
|
||||||
|
|
||||||
defp apply_email_filter(members, user_email_str) when is_binary(user_email_str) do
|
defp apply_email_filter(members, user_email_str) when is_binary(user_email_str) do
|
||||||
MemberResource.filter_by_email_match(members, user_email_str)
|
Mv.Membership.Member.filter_by_email_match(members, user_email_str)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec load_roles(any()) :: [Mv.Authorization.Role.t()]
|
@spec load_roles(any()) :: [Mv.Authorization.Role.t()]
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,6 @@ defmodule MvWeb.UserLive.Index do
|
||||||
|
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
|
|
||||||
alias Mv.Accounts
|
|
||||||
alias Mv.Accounts.User, as: UserResource
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -30,9 +26,9 @@ defmodule MvWeb.UserLive.Index do
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
||||||
users =
|
users =
|
||||||
UserResource
|
Mv.Accounts.User
|
||||||
|> Ash.Query.filter(email != ^SystemActor.system_user_email())
|
|> Ash.Query.filter(email != ^Mv.Helpers.SystemActor.system_user_email())
|
||||||
|> Ash.read!(domain: Accounts, load: [:member, :role], actor: actor)
|
|> Ash.read!(domain: Mv.Accounts, load: [:member, :role], actor: actor)
|
||||||
|
|
||||||
sorted = Enum.sort_by(users, & &1.email)
|
sorted = Enum.sort_by(users, & &1.email)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,6 @@ defmodule MvWeb.UserLive.Show do
|
||||||
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
import MvWeb.LiveHelpers, only: [current_actor: 1]
|
||||||
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
|
import MvWeb.ErrorHelpers, only: [format_ash_error: 1]
|
||||||
|
|
||||||
alias Mv.Accounts
|
|
||||||
alias Mv.Accounts.User, as: UserResource
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -90,7 +86,7 @@ defmodule MvWeb.UserLive.Show do
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
|
||||||
</.link>
|
</.link>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="italic text-base-content/70">{gettext("No member linked")}</span>
|
<span class="italic text-gray-500">{gettext("No member linked")}</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</:item>
|
</:item>
|
||||||
</.list>
|
</.list>
|
||||||
|
|
@ -171,9 +167,9 @@ defmodule MvWeb.UserLive.Show do
|
||||||
actor = current_actor(socket)
|
actor = current_actor(socket)
|
||||||
|
|
||||||
user =
|
user =
|
||||||
Ash.get!(UserResource, id, domain: Accounts, load: [:member, :role], actor: actor)
|
Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member, :role], actor: actor)
|
||||||
|
|
||||||
if SystemActor.system_user?(user) do
|
if Mv.Helpers.SystemActor.system_user?(user) do
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:error, gettext("This user cannot be viewed."))
|
|> put_flash(:error, gettext("This user cannot be viewed."))
|
||||||
|
|
@ -225,7 +221,7 @@ defmodule MvWeb.UserLive.Show do
|
||||||
|> put_flash(:error, gettext("User not found"))
|
|> put_flash(:error, gettext("User not found"))
|
||||||
|> assign(:show_delete_modal, false)}
|
|> assign(:show_delete_modal, false)}
|
||||||
|
|
||||||
SystemActor.system_user?(user) ->
|
Mv.Helpers.SystemActor.system_user?(user) ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> put_flash(:error, gettext("System user cannot be deleted."))
|
|> put_flash(:error, gettext("System user cannot be deleted."))
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ defmodule MvWeb.LiveHelpers do
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
import Phoenix.Component
|
import Phoenix.Component
|
||||||
alias Mv.Authorization.Actor
|
|
||||||
alias MvWeb.Plugs.CheckPagePermission
|
alias MvWeb.Plugs.CheckPagePermission
|
||||||
|
|
||||||
def on_mount(:default, _params, session, socket) do
|
def on_mount(:default, _params, session, socket) do
|
||||||
|
|
@ -69,7 +68,7 @@ defmodule MvWeb.LiveHelpers do
|
||||||
|
|
||||||
if user do
|
if user do
|
||||||
# Use centralized Actor helper to ensure role is loaded
|
# Use centralized Actor helper to ensure role is loaded
|
||||||
user_with_role = Actor.ensure_loaded(user)
|
user_with_role = Mv.Authorization.Actor.ensure_loaded(user)
|
||||||
assign(socket, :current_user, user_with_role)
|
assign(socket, :current_user, user_with_role)
|
||||||
else
|
else
|
||||||
socket
|
socket
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,6 @@ defmodule MvWeb.LiveUserAuth do
|
||||||
import Phoenix.Component
|
import Phoenix.Component
|
||||||
use MvWeb, :verified_routes
|
use MvWeb, :verified_routes
|
||||||
|
|
||||||
alias AshAuthentication.Phoenix.LiveSession
|
|
||||||
alias Phoenix.LiveView
|
|
||||||
|
|
||||||
# This is used for nested liveviews to fetch the current user.
|
# This is used for nested liveviews to fetch the current user.
|
||||||
# To use, place the following at the top of that liveview:
|
# To use, place the following at the top of that liveview:
|
||||||
# on_mount {MvWeb.LiveUserAuth, :current_user}
|
# on_mount {MvWeb.LiveUserAuth, :current_user}
|
||||||
|
|
@ -18,7 +15,7 @@ defmodule MvWeb.LiveUserAuth do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:return_to, return_to)
|
|> assign(:return_to, return_to)
|
||||||
|> LiveSession.assign_new_resources(session)
|
|> AshAuthentication.Phoenix.LiveSession.assign_new_resources(session)
|
||||||
|
|
||||||
{:cont, session, socket}
|
{:cont, session, socket}
|
||||||
end
|
end
|
||||||
|
|
@ -32,14 +29,14 @@ defmodule MvWeb.LiveUserAuth do
|
||||||
end
|
end
|
||||||
|
|
||||||
def on_mount(:live_user_required, _params, session, socket) do
|
def on_mount(:live_user_required, _params, session, socket) do
|
||||||
socket = LiveSession.assign_new_resources(socket, session)
|
socket = AshAuthentication.Phoenix.LiveSession.assign_new_resources(socket, session)
|
||||||
|
|
||||||
case socket.assigns do
|
case socket.assigns do
|
||||||
%{current_user: %{} = user} ->
|
%{current_user: %{} = user} ->
|
||||||
{:cont, assign(socket, :current_user, user)}
|
{:cont, assign(socket, :current_user, user)}
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
socket = LiveView.redirect(socket, to: ~p"/sign-in")
|
socket = Phoenix.LiveView.redirect(socket, to: ~p"/sign-in")
|
||||||
{:halt, socket}
|
{:halt, socket}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ defmodule MvWeb.Plugs.CheckPagePermission do
|
||||||
|
|
||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
import Phoenix.Controller
|
import Phoenix.Controller
|
||||||
alias Mv.Authorization.Actor
|
|
||||||
alias Mv.Authorization.PermissionSets
|
alias Mv.Authorization.PermissionSets
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|
@ -38,7 +37,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do
|
||||||
# Ensure role is loaded (load_from_session does not load it; required for permission check)
|
# Ensure role is loaded (load_from_session does not load it; required for permission check)
|
||||||
user =
|
user =
|
||||||
conn.assigns[:current_user]
|
conn.assigns[:current_user]
|
||||||
|> Actor.ensure_loaded()
|
|> Mv.Authorization.Actor.ensure_loaded()
|
||||||
|
|
||||||
conn = Plug.Conn.assign(conn, :current_user, user)
|
conn = Plug.Conn.assign(conn, :current_user, user)
|
||||||
page_path = get_page_path(conn)
|
page_path = get_page_path(conn)
|
||||||
|
|
@ -222,8 +221,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do
|
||||||
|
|
||||||
defp path_param_equals(_, _, _, _), do: false
|
defp path_param_equals(_, _, _, _), do: false
|
||||||
|
|
||||||
# For own_data: only allow show/edit when :id is the user's linked member.
|
# For own_data: only allow show/edit when :id is the user's linked member. For other permission sets: allow when not reserved.
|
||||||
# For other permission sets: allow when not reserved.
|
|
||||||
defp members_show_allowed?(pattern, request_path, user) do
|
defp members_show_allowed?(pattern, request_path, user) do
|
||||||
if permission_set_name_from_user(user) == "own_data" do
|
if permission_set_name_from_user(user) == "own_data" do
|
||||||
path_param_equals(pattern, request_path, "id", user_member_id(user))
|
path_param_equals(pattern, request_path, "id", user_member_id(user))
|
||||||
|
|
|
||||||
1
mix.exs
1
mix.exs
|
|
@ -75,7 +75,6 @@ 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"},
|
||||||
|
|
|
||||||
6
mix.lock
6
mix.lock
|
|
@ -10,15 +10,11 @@
|
||||||
"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"},
|
||||||
|
|
@ -69,10 +65,8 @@
|
||||||
"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"},
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,6 @@ msgstr ""
|
||||||
msgid "Reset password with token"
|
msgid "Reset password with token"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
|
||||||
#, elixir-autogen
|
|
||||||
msgid "Sign in"
|
msgid "Sign in"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,6 @@ msgstr "Anfrage låuft..."
|
||||||
msgid "Reset password with token"
|
msgid "Reset password with token"
|
||||||
msgstr "Neues Passwort setzen"
|
msgstr "Neues Passwort setzen"
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
|
||||||
#, elixir-autogen
|
|
||||||
msgid "Sign in"
|
msgid "Sign in"
|
||||||
msgstr "Anmelden"
|
msgstr "Anmelden"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -784,6 +784,7 @@ msgid "Personal Data"
|
||||||
msgstr "Persönliche Daten"
|
msgstr "Persönliche Daten"
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/form.ex
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
|
|
@ -827,6 +828,11 @@ msgstr "Beispiele"
|
||||||
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
||||||
msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln."
|
msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Global Settings"
|
||||||
|
msgstr "Globale Einstellungen"
|
||||||
|
|
||||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
|
|
@ -1012,6 +1018,7 @@ msgstr "Mitgliedsfeld wurde erfolgreich %{action}"
|
||||||
msgid "A cycle for this period already exists"
|
msgid "A cycle for this period already exists"
|
||||||
msgstr "Ein Zyklus für diesen Zeitraum existiert bereits"
|
msgstr "Ein Zyklus für diesen Zeitraum existiert bereits"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "About Membership Fee Types"
|
msgid "About Membership Fee Types"
|
||||||
|
|
@ -1096,6 +1103,11 @@ msgstr "Einen neuen Zyklus manuell erstellen"
|
||||||
msgid "Current Cycle"
|
msgid "Current Cycle"
|
||||||
msgstr "Aktueller Zyklus"
|
msgstr "Aktueller Zyklus"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Current Cycle Payment Status"
|
||||||
|
msgstr "Aktueller Zyklus Zahlungsstatus"
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Current amount"
|
msgid "Current amount"
|
||||||
|
|
@ -1166,6 +1178,7 @@ msgstr "Feld bearbeiten: %{field}"
|
||||||
msgid "Edit Membership Fee Type"
|
msgid "Edit Membership Fee Type"
|
||||||
msgstr "Mitgliedsbeitragsart bearbeiten"
|
msgstr "Mitgliedsbeitragsart bearbeiten"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Edit membership fee type"
|
msgid "Edit membership fee type"
|
||||||
|
|
@ -1216,6 +1229,11 @@ msgstr "Ungültiges Datumsformat"
|
||||||
msgid "Last Cycle"
|
msgid "Last Cycle"
|
||||||
msgstr "Letzter Zyklus"
|
msgstr "Letzter Zyklus"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last Cycle Payment Status"
|
||||||
|
msgstr "Letzter Zyklus Zahlungsstatus"
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Manage membership fee types for membership fees."
|
msgid "Manage membership fee types for membership fees."
|
||||||
|
|
@ -1428,6 +1446,11 @@ msgstr "Dieses Feld ist erforderlich"
|
||||||
msgid "This is a technical field and cannot be changed"
|
msgid "This is a technical field and cannot be changed"
|
||||||
msgstr "Dies ist ein technisches Feld und kann nicht verändert werden."
|
msgstr "Dies ist ein technisches Feld und kann nicht verändert werden."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member."
|
||||||
|
msgstr "Diese Mitgliedsbeitragsart wird automatisch allen neuen Mitgliedern zugewiesen. Kann individuell pro Mitglied geändert werden."
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -1450,6 +1473,16 @@ msgstr "Warnung"
|
||||||
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
|
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
|
||||||
msgstr "Warnung: Wechsel von %{old_interval} zu %{new_interval} ist nicht erlaubt. Bitte wähle eine Mitgliedsbeitragsart mit demselben Intervall."
|
msgstr "Warnung: Wechsel von %{old_interval} zu %{new_interval} ist nicht erlaubt. Bitte wähle eine Mitgliedsbeitragsart mit demselben Intervall."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "When active: Members pay from the cycle of their joining."
|
||||||
|
msgstr "Wenn aktiviert: Mitglieder zahlen ab dem Zeitraum ihres Beitritts."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "When inactive: Members pay from the next full cycle after joining."
|
||||||
|
msgstr "Wenn deaktiviert: Mitglieder zahlen ab dem nächsten vollen Beitragszyklus nach dem Beitritt."
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Yearly Interval - Joining Cycle Excluded"
|
msgid "Yearly Interval - Joining Cycle Excluded"
|
||||||
|
|
@ -2820,6 +2853,11 @@ 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
|
||||||
|
|
@ -3197,38 +3235,3 @@ msgstr "Nicht angegeben"
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "All datafield values will be permanently deleted when you delete this datafield."
|
msgid "All datafield values will be permanently deleted when you delete this datafield."
|
||||||
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
|
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Click to edit membership fee type"
|
|
||||||
msgstr "Klicken zum Bearbeiten der Mitgliedsbeitragsart"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Current payment cycle"
|
|
||||||
msgstr "Aktueller Zahlungszyklus"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Last payment cycle"
|
|
||||||
msgstr "Letzter Zahlungszyklus"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
|
|
||||||
msgstr "Legt fest, ob Bezahlstatusfilter und Mitgliedsbeitragsstatus-Spalte den letzten abgeschlossenen oder den aktuellen Zahlungszyklus verwenden."
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Default settings"
|
|
||||||
msgstr "Standardeinstellungen"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Default type: Assigned to new members; can be changed per member."
|
|
||||||
msgstr "Standardart: Wird neuen Mitgliedern zugewiesen; pro Mitglied änderbar."
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle."
|
|
||||||
msgstr "Beitrittszyklus einbeziehen: Aktiv = Zahlung ab Beitrittszyklus; inaktiv = ab dem nächsten vollen Zyklus."
|
|
||||||
|
|
|
||||||
|
|
@ -785,6 +785,7 @@ msgid "Personal Data"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/form.ex
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
|
|
@ -828,6 +829,11 @@ msgstr ""
|
||||||
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Global Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
|
|
@ -1013,6 +1019,7 @@ msgstr ""
|
||||||
msgid "A cycle for this period already exists"
|
msgid "A cycle for this period already exists"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "About Membership Fee Types"
|
msgid "About Membership Fee Types"
|
||||||
|
|
@ -1097,6 +1104,11 @@ msgstr ""
|
||||||
msgid "Current Cycle"
|
msgid "Current Cycle"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Current Cycle Payment Status"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Current amount"
|
msgid "Current amount"
|
||||||
|
|
@ -1167,6 +1179,7 @@ msgstr ""
|
||||||
msgid "Edit Membership Fee Type"
|
msgid "Edit Membership Fee Type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Edit membership fee type"
|
msgid "Edit membership fee type"
|
||||||
|
|
@ -1217,6 +1230,11 @@ msgstr ""
|
||||||
msgid "Last Cycle"
|
msgid "Last Cycle"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last Cycle Payment Status"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Manage membership fee types for membership fees."
|
msgid "Manage membership fee types for membership fees."
|
||||||
|
|
@ -1429,6 +1447,11 @@ msgstr ""
|
||||||
msgid "This is a technical field and cannot be changed"
|
msgid "This is a technical field and cannot be changed"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -1451,6 +1474,16 @@ msgstr ""
|
||||||
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
|
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "When active: Members pay from the cycle of their joining."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "When inactive: Members pay from the next full cycle after joining."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Yearly Interval - Joining Cycle Excluded"
|
msgid "Yearly Interval - Joining Cycle Excluded"
|
||||||
|
|
@ -2820,6 +2853,11 @@ 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
|
||||||
|
|
@ -3197,38 +3235,3 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All datafield values will be permanently deleted when you delete this datafield."
|
msgid "All datafield values will be permanently deleted when you delete this datafield."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Click to edit membership fee type"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Current payment cycle"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Last payment cycle"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Default settings"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Default type: Assigned to new members; can be changed per member."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle."
|
|
||||||
msgstr ""
|
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,6 @@ msgstr ""
|
||||||
msgid "Reset password with token"
|
msgid "Reset password with token"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/sign_in_live.ex
|
|
||||||
#, elixir-autogen
|
|
||||||
msgid "Sign in"
|
msgid "Sign in"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -785,6 +785,7 @@ msgid "Personal Data"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/group_live/form.ex
|
#: lib/mv_web/live/group_live/form.ex
|
||||||
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#: lib/mv_web/live/role_live/form.ex
|
#: lib/mv_web/live/role_live/form.ex
|
||||||
|
|
@ -828,6 +829,11 @@ msgstr ""
|
||||||
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
msgid "Fixed after creation. Members can only switch between types with the same interval."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Global Settings"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
#: lib/mv_web/helpers/membership_fee_helpers.ex
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
|
|
@ -1013,6 +1019,7 @@ msgstr ""
|
||||||
msgid "A cycle for this period already exists"
|
msgid "A cycle for this period already exists"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "About Membership Fee Types"
|
msgid "About Membership Fee Types"
|
||||||
|
|
@ -1097,6 +1104,11 @@ msgstr ""
|
||||||
msgid "Current Cycle"
|
msgid "Current Cycle"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Current Cycle Payment Status"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
#: lib/mv_web/live/membership_fee_type_live/form.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Current amount"
|
msgid "Current amount"
|
||||||
|
|
@ -1167,6 +1179,7 @@ msgstr ""
|
||||||
msgid "Edit Membership Fee Type"
|
msgid "Edit Membership Fee Type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "Edit membership fee type"
|
msgid "Edit membership fee type"
|
||||||
|
|
@ -1217,6 +1230,11 @@ msgstr ""
|
||||||
msgid "Last Cycle"
|
msgid "Last Cycle"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last Cycle Payment Status"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
#: lib/mv_web/live/membership_fee_type_live/index.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Manage membership fee types for membership fees."
|
msgid "Manage membership fee types for membership fees."
|
||||||
|
|
@ -1429,6 +1447,11 @@ msgstr ""
|
||||||
msgid "This is a technical field and cannot be changed"
|
msgid "This is a technical field and cannot be changed"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "This membership fee type is automatically assigned to all new members. Can be changed individually per member."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#: lib/mv_web/live/role_live/index.html.heex
|
#: lib/mv_web/live/role_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -1451,6 +1474,16 @@ msgstr ""
|
||||||
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
|
msgid "Warning: Changing from %{old_interval} to %{new_interval} is not allowed. Please select a membership fee type with the same interval."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "When active: Members pay from the cycle of their joining."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "When inactive: Members pay from the next full cycle after joining."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
#: lib/mv_web/live/membership_fee_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Yearly Interval - Joining Cycle Excluded"
|
msgid "Yearly Interval - Joining Cycle Excluded"
|
||||||
|
|
@ -2820,6 +2853,11 @@ 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
|
||||||
|
|
@ -3197,38 +3235,3 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "All datafield values will be permanently deleted when you delete this datafield."
|
msgid "All datafield values will be permanently deleted when you delete this datafield."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Click to edit membership fee type"
|
|
||||||
msgstr "Click to edit membership fee type"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Current payment cycle"
|
|
||||||
msgstr "Current payment cycle"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Last payment cycle"
|
|
||||||
msgstr "Last payment cycle"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
|
|
||||||
msgstr "Sets whether the payment status filter and the membership fee status column use the last completed or the current payment cycle."
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Default settings"
|
|
||||||
msgstr "Default settings"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Default type: Assigned to new members; can be changed per member."
|
|
||||||
msgstr "Default type: Assigned to new members; can be changed per member."
|
|
||||||
|
|
||||||
#: lib/mv_web/live/membership_fee_settings_live.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle."
|
|
||||||
msgstr "Include joining cycle: When active, members pay from their joining cycle; when inactive, from the next full cycle."
|
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,811 @@
|
||||||
#
|
#
|
||||||
# mix run priv/repo/seeds.exs
|
# mix run priv/repo/seeds.exs
|
||||||
#
|
#
|
||||||
# Bootstrap runs in all environments. Dev seeds (members, groups, sample data)
|
|
||||||
# run only in dev and test.
|
|
||||||
#
|
|
||||||
# Compiler option ignore_module_conflict is set only during seed evaluation
|
|
||||||
# so that eval_file of bootstrap/dev does not emit "redefining module" warnings;
|
|
||||||
# it is always restored in `after` to avoid hiding real conflicts elsewhere.
|
|
||||||
|
|
||||||
prev = Code.compiler_options()
|
alias Mv.Accounts
|
||||||
Code.compiler_options(ignore_module_conflict: true)
|
alias Mv.Membership
|
||||||
|
alias Mv.MembershipFees.CycleGenerator
|
||||||
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
|
||||||
try do
|
require Ash.Query
|
||||||
# Always run bootstrap (fee types, custom fields, roles, admin, system user, settings)
|
|
||||||
Code.eval_file("priv/repo/seeds_bootstrap.exs")
|
|
||||||
|
|
||||||
# In dev and test only: run dev seeds (20 members, groups, custom field values)
|
# Create example membership fee types (no admin user yet; skip authorization for bootstrap)
|
||||||
if Mix.env() in [:dev, :test] do
|
for fee_type_attrs <- [
|
||||||
Code.eval_file("priv/repo/seeds_dev.exs")
|
%{
|
||||||
|
name: "Standard (Jährlich)",
|
||||||
|
amount: Decimal.new("120.00"),
|
||||||
|
interval: :yearly,
|
||||||
|
description: "Standard jährlicher Mitgliedsbeitrag"
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Standard (Halbjährlich)",
|
||||||
|
amount: Decimal.new("65.00"),
|
||||||
|
interval: :half_yearly,
|
||||||
|
description: "Standard halbjährlicher Mitgliedsbeitrag"
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Standard (Vierteljährlich)",
|
||||||
|
amount: Decimal.new("35.00"),
|
||||||
|
interval: :quarterly,
|
||||||
|
description: "Standard vierteljährlicher Mitgliedsbeitrag"
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Standard (Monatlich)",
|
||||||
|
amount: Decimal.new("12.00"),
|
||||||
|
interval: :monthly,
|
||||||
|
description: "Standard monatlicher Mitgliedsbeitrag"
|
||||||
|
}
|
||||||
|
] do
|
||||||
|
MembershipFeeType
|
||||||
|
|> Ash.Changeset.for_create(:create, fee_type_attrs)
|
||||||
|
|> Ash.create!(
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_name,
|
||||||
|
authorize?: false,
|
||||||
|
domain: Mv.MembershipFees
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
for attrs <- [
|
||||||
|
# Basic example fields (for testing)
|
||||||
|
%{
|
||||||
|
name: "String Field",
|
||||||
|
value_type: :string,
|
||||||
|
description: "Example for a field of type string",
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Date Field",
|
||||||
|
value_type: :date,
|
||||||
|
description: "Example for a field of type date",
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Boolean Field",
|
||||||
|
value_type: :boolean,
|
||||||
|
description: "Example for a field of type boolean",
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Email Field",
|
||||||
|
value_type: :email,
|
||||||
|
description: "Example for a field of type email",
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
# Realistic custom fields
|
||||||
|
%{
|
||||||
|
name: "Membership Number",
|
||||||
|
value_type: :string,
|
||||||
|
description: "Unique membership identification number",
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Emergency Contact",
|
||||||
|
value_type: :string,
|
||||||
|
description: "Emergency contact person name and phone",
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "T-Shirt Size",
|
||||||
|
value_type: :string,
|
||||||
|
description: "T-Shirt size for events (XS, S, M, L, XL, XXL)",
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Newsletter Subscription",
|
||||||
|
value_type: :boolean,
|
||||||
|
description: "Whether member wants to receive newsletter",
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Date of Last Medical Check",
|
||||||
|
value_type: :date,
|
||||||
|
description: "Date of last medical examination",
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Secondary Email",
|
||||||
|
value_type: :email,
|
||||||
|
description: "Alternative email address",
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Membership Type",
|
||||||
|
value_type: :string,
|
||||||
|
description: "Type of membership (e.g., Regular, Student, Senior)",
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Parking Permit",
|
||||||
|
value_type: :boolean,
|
||||||
|
description: "Whether member has parking permit",
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
] do
|
||||||
|
# Bootstrap: no admin user yet; CustomField create requires admin, so skip authorization
|
||||||
|
Membership.create_custom_field!(
|
||||||
|
attrs,
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_name,
|
||||||
|
authorize?: false
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Admin email: default for dev/test so seed_admin has a target
|
||||||
|
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
||||||
|
System.put_env("ADMIN_EMAIL", admin_email)
|
||||||
|
|
||||||
|
# In dev/test, set fallback password so seed_admin creates the admin user when none is set
|
||||||
|
if Mix.env() in [:dev, :test] and is_nil(System.get_env("ADMIN_PASSWORD")) and
|
||||||
|
is_nil(System.get_env("ADMIN_PASSWORD_FILE")) do
|
||||||
|
System.put_env("ADMIN_PASSWORD", "testpassword")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create all authorization roles (idempotent - creates only if they don't exist)
|
||||||
|
# Roles are created using create_role_with_system_flag to allow setting is_system_role
|
||||||
|
role_configs = [
|
||||||
|
%{
|
||||||
|
name: "Mitglied",
|
||||||
|
description: "Default member role with access to own data only",
|
||||||
|
permission_set_name: "own_data",
|
||||||
|
is_system_role: true
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Vorstand",
|
||||||
|
description: "Board member with read access to all member data",
|
||||||
|
permission_set_name: "read_only",
|
||||||
|
is_system_role: false
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Kassenwart",
|
||||||
|
description: "Treasurer with full member and payment management",
|
||||||
|
permission_set_name: "normal_user",
|
||||||
|
is_system_role: false
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Buchhaltung",
|
||||||
|
description: "Accounting with read-only access for auditing",
|
||||||
|
permission_set_name: "read_only",
|
||||||
|
is_system_role: false
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "Admin",
|
||||||
|
description: "Administrator with unrestricted access",
|
||||||
|
permission_set_name: "admin",
|
||||||
|
is_system_role: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create or update each role
|
||||||
|
Enum.each(role_configs, fn role_data ->
|
||||||
|
# Bind role name to variable to avoid issues with ^ pinning in macros
|
||||||
|
role_name = role_data.name
|
||||||
|
|
||||||
|
case Mv.Authorization.Role
|
||||||
|
|> Ash.Query.filter(name == ^role_name)
|
||||||
|
|> Ash.read_one(authorize?: false, domain: Mv.Authorization) do
|
||||||
|
{:ok, existing_role} when not is_nil(existing_role) ->
|
||||||
|
# Role exists - update if needed (preserve is_system_role)
|
||||||
|
if existing_role.permission_set_name != role_data.permission_set_name or
|
||||||
|
existing_role.description != role_data.description do
|
||||||
|
existing_role
|
||||||
|
|> Ash.Changeset.for_update(:update_role, %{
|
||||||
|
description: role_data.description,
|
||||||
|
permission_set_name: role_data.permission_set_name
|
||||||
|
})
|
||||||
|
|> Ash.update!(authorize?: false, domain: Mv.Authorization)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
# Role doesn't exist - create it
|
||||||
|
Mv.Authorization.Role
|
||||||
|
|> Ash.Changeset.for_create(:create_role_with_system_flag, role_data)
|
||||||
|
|> Ash.create!(authorize?: false, domain: Mv.Authorization)
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
IO.puts("Warning: Failed to check for role #{role_data.name}: #{inspect(error)}")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Get admin role for assignment to admin user
|
||||||
|
admin_role =
|
||||||
|
case Mv.Authorization.Role
|
||||||
|
|> Ash.Query.filter(name == "Admin")
|
||||||
|
|> Ash.read_one(authorize?: false, domain: Mv.Authorization) do
|
||||||
|
{:ok, role} when not is_nil(role) -> role
|
||||||
|
_ -> nil
|
||||||
end
|
end
|
||||||
|
|
||||||
IO.puts("✅ All seeds completed.")
|
if is_nil(admin_role) do
|
||||||
after
|
raise "Failed to create or find admin role. Cannot proceed with member seeding."
|
||||||
Code.compiler_options(prev)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Create/update admin user via Release.seed_admin (uses ADMIN_EMAIL, ADMIN_PASSWORD / ADMIN_PASSWORD_FILE).
|
||||||
|
# Reduces duplication and exercises the same path as production entrypoint.
|
||||||
|
Mv.Release.seed_admin()
|
||||||
|
|
||||||
|
# Load admin user with role for use as actor in member operations
|
||||||
|
# This ensures all member operations have proper authorization
|
||||||
|
admin_user_with_role =
|
||||||
|
case Accounts.User
|
||||||
|
|> Ash.Query.filter(email == ^admin_email)
|
||||||
|
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
|
||||||
|
{:ok, user} when not is_nil(user) ->
|
||||||
|
user
|
||||||
|
|> Ash.load!(:role, authorize?: false)
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
raise "Admin user not found after creation/assignment"
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
raise "Failed to load admin user: #{inspect(error)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create system user for systemic operations (email sync, validations, cycle generation)
|
||||||
|
# This user is used by Mv.Helpers.SystemActor for operations that must always run
|
||||||
|
# Email is configurable via SYSTEM_ACTOR_EMAIL environment variable
|
||||||
|
system_user_email = Mv.Helpers.SystemActor.system_user_email()
|
||||||
|
|
||||||
|
case Accounts.User
|
||||||
|
|> Ash.Query.filter(email == ^system_user_email)
|
||||||
|
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
|
||||||
|
{:ok, existing_system_user} when not is_nil(existing_system_user) ->
|
||||||
|
# System user already exists - ensure it has admin role
|
||||||
|
# Use authorize?: false for bootstrap; :update_internal bypasses system-user modification block
|
||||||
|
existing_system_user
|
||||||
|
|> Ash.Changeset.for_update(:update_internal, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update!(authorize?: false)
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
# System user doesn't exist - create it with admin role
|
||||||
|
# SECURITY: System user must NOT be able to log in:
|
||||||
|
# - No password (hashed_password = nil) - prevents password login
|
||||||
|
# - No OIDC ID (oidc_id = nil) - prevents OIDC login
|
||||||
|
# - This user is ONLY for internal system operations via SystemActor
|
||||||
|
# If either hashed_password or oidc_id is set, the user could potentially log in
|
||||||
|
# Use authorize?: false for bootstrap - system user creation happens before system actor exists
|
||||||
|
Accounts.create_user!(%{email: system_user_email},
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_email,
|
||||||
|
authorize?: false
|
||||||
|
)
|
||||||
|
|> Ash.Changeset.for_update(:update_internal, %{})
|
||||||
|
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||||
|
|> Ash.update!(authorize?: false)
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
# Log error but don't fail seeds - SystemActor will fall back to admin user
|
||||||
|
IO.puts("Warning: Failed to create system user: #{inspect(error)}")
|
||||||
|
IO.puts("SystemActor will fall back to admin user (#{admin_email})")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Load all membership fee types for assignment (admin actor for authorization)
|
||||||
|
# Sort by name to ensure deterministic order
|
||||||
|
all_fee_types =
|
||||||
|
MembershipFeeType
|
||||||
|
|> Ash.Query.sort(name: :asc)
|
||||||
|
|> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees)
|
||||||
|
|> Enum.to_list()
|
||||||
|
|
||||||
|
# Create sample members for testing - use upsert to prevent duplicates
|
||||||
|
# Member 1: Hans - All cycles paid
|
||||||
|
# Member 2: Greta - All cycles unpaid
|
||||||
|
# Member 3: Friedrich - Mixed cycles (paid, unpaid, suspended)
|
||||||
|
# Member 4: Marianne - No membership fee type
|
||||||
|
member_attrs_list = [
|
||||||
|
%{
|
||||||
|
first_name: "Hans",
|
||||||
|
last_name: "Müller",
|
||||||
|
email: "hans.mueller@example.de",
|
||||||
|
join_date: ~D[2023-01-15],
|
||||||
|
city: "München",
|
||||||
|
street: "Hauptstraße",
|
||||||
|
house_number: "42",
|
||||||
|
postal_code: "80331",
|
||||||
|
membership_fee_type_id: Enum.at(all_fee_types, 0).id,
|
||||||
|
cycle_status: :all_paid
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
first_name: "Greta",
|
||||||
|
last_name: "Schmidt",
|
||||||
|
email: "greta.schmidt@example.de",
|
||||||
|
join_date: ~D[2023-02-01],
|
||||||
|
city: "Hamburg",
|
||||||
|
street: "Lindenstraße",
|
||||||
|
house_number: "17",
|
||||||
|
postal_code: "20095",
|
||||||
|
notes: "Interessiert an Fortgeschrittenen-Kursen",
|
||||||
|
membership_fee_type_id: Enum.at(all_fee_types, 1).id,
|
||||||
|
cycle_status: :all_unpaid
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
first_name: "Friedrich",
|
||||||
|
last_name: "Wagner",
|
||||||
|
email: "friedrich.wagner@example.de",
|
||||||
|
join_date: ~D[2022-11-10],
|
||||||
|
city: "Berlin",
|
||||||
|
street: "Kastanienallee",
|
||||||
|
house_number: "8",
|
||||||
|
postal_code: "10435",
|
||||||
|
membership_fee_type_id: Enum.at(all_fee_types, 2).id,
|
||||||
|
cycle_status: :mixed
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
first_name: "Marianne",
|
||||||
|
last_name: "Wagner",
|
||||||
|
email: "marianne.wagner@example.de",
|
||||||
|
join_date: ~D[2022-11-10],
|
||||||
|
city: "Berlin",
|
||||||
|
street: "Kastanienallee",
|
||||||
|
house_number: "8",
|
||||||
|
postal_code: "10435"
|
||||||
|
# No membership_fee_type_id - member without fee type
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create members and generate cycles
|
||||||
|
Enum.each(member_attrs_list, fn member_attrs ->
|
||||||
|
cycle_status = Map.get(member_attrs, :cycle_status)
|
||||||
|
member_attrs_without_status = Map.delete(member_attrs, :cycle_status)
|
||||||
|
|
||||||
|
# Use upsert to prevent duplicates based on email
|
||||||
|
# First create/update member without membership_fee_type_id to avoid overwriting existing assignments
|
||||||
|
member_attrs_without_fee_type = Map.delete(member_attrs_without_status, :membership_fee_type_id)
|
||||||
|
|
||||||
|
member =
|
||||||
|
Membership.create_member!(member_attrs_without_fee_type,
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_email,
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only set membership_fee_type_id if member doesn't have one yet (idempotent)
|
||||||
|
final_member =
|
||||||
|
if is_nil(member.membership_fee_type_id) and
|
||||||
|
Map.has_key?(member_attrs_without_status, :membership_fee_type_id) do
|
||||||
|
{:ok, updated} =
|
||||||
|
Membership.update_member(
|
||||||
|
member,
|
||||||
|
%{
|
||||||
|
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
|
||||||
|
},
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
|
|
||||||
|
updated
|
||||||
|
else
|
||||||
|
member
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate cycles if member has a fee type
|
||||||
|
if final_member.membership_fee_type_id do
|
||||||
|
# Load member with cycles to check if they already exist (actor required for auth)
|
||||||
|
member_with_cycles =
|
||||||
|
Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role)
|
||||||
|
|
||||||
|
# Only generate if no cycles exist yet (to avoid duplicates on re-run)
|
||||||
|
cycles =
|
||||||
|
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
|
||||||
|
# Generate cycles
|
||||||
|
{:ok, new_cycles, _notifications} =
|
||||||
|
CycleGenerator.generate_cycles_for_member(final_member.id,
|
||||||
|
skip_lock?: true,
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
|
|
||||||
|
new_cycles
|
||||||
|
else
|
||||||
|
# Use existing cycles
|
||||||
|
member_with_cycles.membership_fee_cycles
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set cycle statuses based on member type
|
||||||
|
if cycle_status do
|
||||||
|
cycles
|
||||||
|
|> Enum.sort_by(& &1.cycle_start, Date)
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.each(fn {cycle, index} ->
|
||||||
|
status =
|
||||||
|
case cycle_status do
|
||||||
|
:all_paid ->
|
||||||
|
:paid
|
||||||
|
|
||||||
|
:all_unpaid ->
|
||||||
|
:unpaid
|
||||||
|
|
||||||
|
:mixed ->
|
||||||
|
# Mix: first paid, second unpaid, third suspended, then repeat
|
||||||
|
case rem(index, 3) do
|
||||||
|
0 -> :paid
|
||||||
|
1 -> :unpaid
|
||||||
|
2 -> :suspended
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only update if status is different
|
||||||
|
if cycle.status != status do
|
||||||
|
cycle
|
||||||
|
|> Ash.Changeset.for_update(:update, %{status: status})
|
||||||
|
|> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Create additional users for user-member linking examples (no password by default)
|
||||||
|
# Only admin gets a password (admin_set_password when created); all other users have no password.
|
||||||
|
additional_users = [
|
||||||
|
%{email: "hans.mueller@example.de"},
|
||||||
|
%{email: "greta.schmidt@example.de"},
|
||||||
|
%{email: "maria.weber@example.de"},
|
||||||
|
%{email: "thomas.klein@example.de"}
|
||||||
|
]
|
||||||
|
|
||||||
|
created_users =
|
||||||
|
Enum.map(additional_users, fn user_attrs ->
|
||||||
|
user =
|
||||||
|
Accounts.create_user!(user_attrs,
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_email,
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reload user to ensure all fields (including member_id) are loaded
|
||||||
|
Accounts.User
|
||||||
|
|> Ash.Query.filter(id == ^user.id)
|
||||||
|
|> Ash.read_one!(domain: Mv.Accounts, actor: admin_user_with_role)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Create members with linked users to demonstrate the 1:1 relationship
|
||||||
|
# Only create if users don't already have members
|
||||||
|
linked_members = [
|
||||||
|
%{
|
||||||
|
first_name: "Maria",
|
||||||
|
last_name: "Weber",
|
||||||
|
email: "maria.weber@example.de",
|
||||||
|
join_date: ~D[2023-03-15],
|
||||||
|
city: "Frankfurt",
|
||||||
|
street: "Goetheplatz",
|
||||||
|
house_number: "5",
|
||||||
|
postal_code: "60313",
|
||||||
|
notes: "Linked to user account",
|
||||||
|
# Link to the third user (maria.weber@example.de)
|
||||||
|
user: Enum.at(created_users, 2)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
first_name: "Thomas",
|
||||||
|
last_name: "Klein",
|
||||||
|
email: "thomas.klein@example.de",
|
||||||
|
join_date: ~D[2023-04-01],
|
||||||
|
city: "Köln",
|
||||||
|
street: "Rheinstraße",
|
||||||
|
house_number: "23",
|
||||||
|
postal_code: "50667",
|
||||||
|
notes: "Linked to user account - needs payment follow-up",
|
||||||
|
# Link to the fourth user (thomas.klein@example.de)
|
||||||
|
user: Enum.at(created_users, 3)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create the linked members - use upsert to prevent duplicates
|
||||||
|
# Assign fee types to linked members using round-robin
|
||||||
|
# Continue from where we left off with the previous members
|
||||||
|
Enum.with_index(linked_members)
|
||||||
|
|> Enum.each(fn {member_attrs, index} ->
|
||||||
|
user = member_attrs.user
|
||||||
|
member_attrs_without_user = Map.delete(member_attrs, :user)
|
||||||
|
|
||||||
|
# Use upsert to prevent duplicates based on email
|
||||||
|
# First create/update member without membership_fee_type_id to avoid overwriting existing assignments
|
||||||
|
member_attrs_without_fee_type = Map.delete(member_attrs_without_user, :membership_fee_type_id)
|
||||||
|
|
||||||
|
# Check if user already has a member
|
||||||
|
member =
|
||||||
|
if user.member_id == nil do
|
||||||
|
# User is free, create member and link - use upsert to prevent duplicates
|
||||||
|
# Use authorize?: false for User lookup during relationship management (bootstrap phase)
|
||||||
|
Membership.create_member!(
|
||||||
|
Map.put(member_attrs_without_fee_type, :user, %{id: user.id}),
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_email,
|
||||||
|
actor: admin_user_with_role,
|
||||||
|
authorize?: false
|
||||||
|
)
|
||||||
|
else
|
||||||
|
# User already has a member, just create the member without linking - use upsert to prevent duplicates
|
||||||
|
Membership.create_member!(member_attrs_without_fee_type,
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_email,
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only set membership_fee_type_id if member doesn't have one yet (idempotent)
|
||||||
|
final_member =
|
||||||
|
if is_nil(member.membership_fee_type_id) do
|
||||||
|
# Assign deterministically using round-robin
|
||||||
|
# Start from where previous members ended (3 members before this)
|
||||||
|
fee_type_index = rem(3 + index, length(all_fee_types))
|
||||||
|
fee_type = Enum.at(all_fee_types, fee_type_index)
|
||||||
|
|
||||||
|
{:ok, updated} =
|
||||||
|
Membership.update_member(member, %{membership_fee_type_id: fee_type.id},
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
|
|
||||||
|
updated
|
||||||
|
else
|
||||||
|
member
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate cycles for linked members
|
||||||
|
if final_member.membership_fee_type_id do
|
||||||
|
# Load member with cycles to check if they already exist (actor required for auth)
|
||||||
|
member_with_cycles =
|
||||||
|
Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role)
|
||||||
|
|
||||||
|
# Only generate if no cycles exist yet (to avoid duplicates on re-run)
|
||||||
|
cycles =
|
||||||
|
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
|
||||||
|
# Generate cycles
|
||||||
|
{:ok, new_cycles, _notifications} =
|
||||||
|
CycleGenerator.generate_cycles_for_member(final_member.id,
|
||||||
|
skip_lock?: true,
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
|
|
||||||
|
new_cycles
|
||||||
|
else
|
||||||
|
# Use existing cycles
|
||||||
|
member_with_cycles.membership_fee_cycles
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set some cycles to paid for linked members (mixed status)
|
||||||
|
cycles
|
||||||
|
|> Enum.sort_by(& &1.cycle_start, Date)
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.each(fn {cycle, index} ->
|
||||||
|
# Every other cycle is paid, rest unpaid
|
||||||
|
status = if rem(index, 2) == 0, do: :paid, else: :unpaid
|
||||||
|
|
||||||
|
# Only update if status is different
|
||||||
|
if cycle.status != status do
|
||||||
|
cycle
|
||||||
|
|> Ash.Changeset.for_update(:update, %{status: status})
|
||||||
|
|> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Create example groups (idempotent: create only if name does not exist)
|
||||||
|
group_configs = [
|
||||||
|
%{name: "Vorstand", description: "Gremium Vorstand"},
|
||||||
|
%{name: "Trainer*innen", description: "Alle lizenzierten Trainer*innen"},
|
||||||
|
%{name: "Jugend", description: "Jugendbereich"},
|
||||||
|
%{name: "Newsletter", description: "Empfänger*innen Newsletter"}
|
||||||
|
]
|
||||||
|
|
||||||
|
existing_groups =
|
||||||
|
case Membership.list_groups(actor: admin_user_with_role) do
|
||||||
|
{:ok, list} -> list
|
||||||
|
{:error, _} -> []
|
||||||
|
end
|
||||||
|
|
||||||
|
existing_names_lower = MapSet.new(existing_groups, &String.downcase(&1.name))
|
||||||
|
|
||||||
|
seed_groups =
|
||||||
|
Enum.reduce(group_configs, %{}, fn config, acc ->
|
||||||
|
name = config.name
|
||||||
|
|
||||||
|
if MapSet.member?(existing_names_lower, String.downcase(name)) do
|
||||||
|
group = Enum.find(existing_groups, &(String.downcase(&1.name) == String.downcase(name)))
|
||||||
|
Map.put(acc, name, group)
|
||||||
|
else
|
||||||
|
group =
|
||||||
|
Membership.create_group!(%{name: name, description: config.description},
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
|
|
||||||
|
Map.put(acc, name, group)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Create sample custom field values for some members
|
||||||
|
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
|
||||||
|
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
|
||||||
|
|
||||||
|
# Helper function to find custom field by name
|
||||||
|
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
|
||||||
|
find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end
|
||||||
|
|
||||||
|
# Assign seed members to groups (idempotent: duplicate create_member_group is skipped)
|
||||||
|
member_group_assignments = [
|
||||||
|
{"hans.mueller@example.de", ["Vorstand", "Newsletter"]},
|
||||||
|
{"greta.schmidt@example.de", ["Jugend", "Newsletter"]},
|
||||||
|
{"friedrich.wagner@example.de", ["Trainer*innen"]},
|
||||||
|
{"maria.weber@example.de", ["Newsletter"]},
|
||||||
|
{"thomas.klein@example.de", ["Newsletter"]}
|
||||||
|
]
|
||||||
|
|
||||||
|
Enum.each(member_group_assignments, fn {email, group_names} ->
|
||||||
|
member = find_member.(email)
|
||||||
|
|
||||||
|
if member do
|
||||||
|
Enum.each(group_names, fn group_name ->
|
||||||
|
group = seed_groups[group_name]
|
||||||
|
|
||||||
|
if group do
|
||||||
|
case Membership.create_member_group(
|
||||||
|
%{member_id: member.id, group_id: group.id},
|
||||||
|
actor: admin_user_with_role
|
||||||
|
) do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, _} -> :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Add custom field values for Hans Müller
|
||||||
|
if hans = find_member.("hans.mueller@example.de") do
|
||||||
|
[
|
||||||
|
{find_field.("Membership Number"),
|
||||||
|
%{"_union_type" => "string", "_union_value" => "M-2023-001"}},
|
||||||
|
{find_field.("T-Shirt Size"), %{"_union_type" => "string", "_union_value" => "L"}},
|
||||||
|
{find_field.("Newsletter Subscription"),
|
||||||
|
%{"_union_type" => "boolean", "_union_value" => true}},
|
||||||
|
{find_field.("Membership Type"), %{"_union_type" => "string", "_union_value" => "Regular"}},
|
||||||
|
{find_field.("Parking Permit"), %{"_union_type" => "boolean", "_union_value" => true}},
|
||||||
|
{find_field.("Secondary Email"),
|
||||||
|
%{"_union_type" => "email", "_union_value" => "hans.m@private.de"}}
|
||||||
|
]
|
||||||
|
|> Enum.each(fn {field, value} ->
|
||||||
|
if field do
|
||||||
|
Membership.CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: hans.id,
|
||||||
|
custom_field_id: field.id,
|
||||||
|
value: value
|
||||||
|
})
|
||||||
|
|> Ash.create!(
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_custom_field_per_member,
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add custom field values for Greta Schmidt
|
||||||
|
if greta = find_member.("greta.schmidt@example.de") do
|
||||||
|
[
|
||||||
|
{find_field.("Membership Number"),
|
||||||
|
%{"_union_type" => "string", "_union_value" => "M-2023-015"}},
|
||||||
|
{find_field.("T-Shirt Size"), %{"_union_type" => "string", "_union_value" => "M"}},
|
||||||
|
{find_field.("Newsletter Subscription"),
|
||||||
|
%{"_union_type" => "boolean", "_union_value" => true}},
|
||||||
|
{find_field.("Membership Type"), %{"_union_type" => "string", "_union_value" => "Student"}},
|
||||||
|
{find_field.("Emergency Contact"),
|
||||||
|
%{"_union_type" => "string", "_union_value" => "Anna Schmidt, +49301234567"}}
|
||||||
|
]
|
||||||
|
|> Enum.each(fn {field, value} ->
|
||||||
|
if field do
|
||||||
|
Membership.CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: greta.id,
|
||||||
|
custom_field_id: field.id,
|
||||||
|
value: value
|
||||||
|
})
|
||||||
|
|> Ash.create!(
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_custom_field_per_member,
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add custom field values for Friedrich Wagner
|
||||||
|
if friedrich = find_member.("friedrich.wagner@example.de") do
|
||||||
|
[
|
||||||
|
{find_field.("Membership Number"),
|
||||||
|
%{"_union_type" => "string", "_union_value" => "M-2022-042"}},
|
||||||
|
{find_field.("T-Shirt Size"), %{"_union_type" => "string", "_union_value" => "XL"}},
|
||||||
|
{find_field.("Newsletter Subscription"),
|
||||||
|
%{"_union_type" => "boolean", "_union_value" => false}},
|
||||||
|
{find_field.("Membership Type"), %{"_union_type" => "string", "_union_value" => "Senior"}},
|
||||||
|
{find_field.("Parking Permit"), %{"_union_type" => "boolean", "_union_value" => false}},
|
||||||
|
{find_field.("Date of Last Medical Check"),
|
||||||
|
%{"_union_type" => "date", "_union_value" => ~D[2024-03-15]}}
|
||||||
|
]
|
||||||
|
|> Enum.each(fn {field, value} ->
|
||||||
|
if field do
|
||||||
|
Membership.CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: friedrich.id,
|
||||||
|
custom_field_id: field.id,
|
||||||
|
value: value
|
||||||
|
})
|
||||||
|
|> Ash.create!(
|
||||||
|
upsert?: true,
|
||||||
|
upsert_identity: :unique_custom_field_per_member,
|
||||||
|
actor: admin_user_with_role
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create or update global settings (singleton)
|
||||||
|
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
|
||||||
|
|
||||||
|
case Membership.get_settings() do
|
||||||
|
{:ok, existing_settings} ->
|
||||||
|
# Settings exist, update if club_name is different from env var
|
||||||
|
# Also ensure exit_date is set to false by default if not already configured
|
||||||
|
updates =
|
||||||
|
%{}
|
||||||
|
|> then(fn acc ->
|
||||||
|
if existing_settings.club_name != default_club_name,
|
||||||
|
do: Map.put(acc, :club_name, default_club_name),
|
||||||
|
else: acc
|
||||||
|
end)
|
||||||
|
|> then(fn acc ->
|
||||||
|
visibility_config = existing_settings.member_field_visibility || %{}
|
||||||
|
# Ensure exit_date is set to false if not already configured
|
||||||
|
if not Map.has_key?(visibility_config, "exit_date") and
|
||||||
|
not Map.has_key?(visibility_config, :exit_date) do
|
||||||
|
updated_visibility = Map.put(visibility_config, "exit_date", false)
|
||||||
|
Map.put(acc, :member_field_visibility, updated_visibility)
|
||||||
|
else
|
||||||
|
acc
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
if map_size(updates) > 0 do
|
||||||
|
{:ok, _updated} = Membership.update_settings(existing_settings, updates)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, nil} ->
|
||||||
|
# Settings don't exist yet, create with exit_date defaulting to false
|
||||||
|
{:ok, _settings} =
|
||||||
|
Membership.Setting
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
club_name: default_club_name,
|
||||||
|
member_field_visibility: %{"exit_date" => false}
|
||||||
|
})
|
||||||
|
|> Ash.create!()
|
||||||
|
end
|
||||||
|
|
||||||
|
IO.puts("✅ Seeds completed successfully!")
|
||||||
|
IO.puts("📝 Created sample data:")
|
||||||
|
IO.puts(" - Global settings: club_name = #{default_club_name}")
|
||||||
|
IO.puts(" - Membership fee types: 4 types (Yearly, Half-yearly, Quarterly, Monthly)")
|
||||||
|
IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)")
|
||||||
|
|
||||||
|
password_configured =
|
||||||
|
System.get_env("ADMIN_PASSWORD") != nil or System.get_env("ADMIN_PASSWORD_FILE") != nil
|
||||||
|
|
||||||
|
IO.puts(
|
||||||
|
" - Admin user: #{admin_email} (password: #{if password_configured, do: "set", else: "not set"})"
|
||||||
|
)
|
||||||
|
|
||||||
|
IO.puts(" - Sample members: Hans, Greta, Friedrich")
|
||||||
|
IO.puts(" - Groups: Vorstand, Trainer*innen, Jugend, Newsletter (with members assigned)")
|
||||||
|
|
||||||
|
IO.puts(
|
||||||
|
" - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de"
|
||||||
|
)
|
||||||
|
|
||||||
|
IO.puts(
|
||||||
|
" - Linked members: Maria Weber ↔ maria.weber@example.de, Thomas Klein ↔ thomas.klein@example.de"
|
||||||
|
)
|
||||||
|
|
||||||
|
IO.puts(
|
||||||
|
" - Custom field values: Sample data for Hans (6 fields), Greta (5 fields), Friedrich (6 fields)"
|
||||||
|
)
|
||||||
|
|
||||||
|
IO.puts("🔗 Visit the application to see user-member relationships and custom fields in action!")
|
||||||
|
|
|
||||||
|
|
@ -1,314 +0,0 @@
|
||||||
# Bootstrap seeds: run in all environments (dev, test, prod).
|
|
||||||
# Creates only data required for system startup: fee types, custom fields,
|
|
||||||
# roles, admin user, system user, global settings. No members, no groups.
|
|
||||||
|
|
||||||
alias Mv.Accounts
|
|
||||||
alias Mv.Membership
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
# 1. Membership fee types (authorize?: false for bootstrap)
|
|
||||||
# Names without interval to avoid duplication in UI; interval is shown separately.
|
|
||||||
fee_type_configs = [
|
|
||||||
%{
|
|
||||||
name: "Standard",
|
|
||||||
amount: Decimal.new("120.00"),
|
|
||||||
interval: :yearly,
|
|
||||||
description: "Standard jährlicher Mitgliedsbeitrag"
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Ermäßigt",
|
|
||||||
amount: Decimal.new("80.00"),
|
|
||||||
interval: :yearly,
|
|
||||||
description: "Ermäßigter jährlicher Mitgliedsbeitrag"
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Unterstützer",
|
|
||||||
amount: Decimal.new("60.00"),
|
|
||||||
interval: :half_yearly,
|
|
||||||
description: "Unterstützerbeitrag halbjährlich"
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Fördermitglied",
|
|
||||||
amount: Decimal.new("30.00"),
|
|
||||||
interval: :quarterly,
|
|
||||||
description: "Fördermitgliedschaft quartalsweise"
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Probemitgliedschaft",
|
|
||||||
amount: Decimal.new("10.00"),
|
|
||||||
interval: :monthly,
|
|
||||||
description: "Probemitgliedschaft monatlich"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
for attrs <- fee_type_configs do
|
|
||||||
MembershipFeeType
|
|
||||||
|> Ash.Changeset.for_create(:create, attrs)
|
|
||||||
|> Ash.create!(
|
|
||||||
upsert?: true,
|
|
||||||
upsert_identity: :unique_name,
|
|
||||||
authorize?: false,
|
|
||||||
domain: Mv.MembershipFees
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Resolve default fee type (Standard, 120€ yearly) for settings
|
|
||||||
# Filter by name and interval to avoid ambiguity if multiple "Standard" types exist
|
|
||||||
default_fee_type =
|
|
||||||
Mv.MembershipFees.MembershipFeeType
|
|
||||||
|> Ash.Query.filter(name == "Standard" and interval == :yearly)
|
|
||||||
|> Ash.read_one!(authorize?: false, domain: Mv.MembershipFees)
|
|
||||||
|
|
||||||
# 2. Custom fields (authorize?: false for bootstrap)
|
|
||||||
# Only Geburtsdatum is shown in overview by default; others hidden to avoid clutter.
|
|
||||||
custom_field_configs = [
|
|
||||||
%{
|
|
||||||
name: "Geburtsdatum",
|
|
||||||
value_type: :date,
|
|
||||||
description: "Geburtsdatum der/des Mitglieds",
|
|
||||||
required: false,
|
|
||||||
show_in_overview: true
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Datenschutzerklärung akzeptiert",
|
|
||||||
value_type: :boolean,
|
|
||||||
description: "Angabe, ob Datenschutzerklärung akzeptiert wurde",
|
|
||||||
required: false,
|
|
||||||
show_in_overview: false
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "SEPA-Mandat",
|
|
||||||
value_type: :boolean,
|
|
||||||
description: "SEPA-Lastschriftmandat erteilt",
|
|
||||||
required: false,
|
|
||||||
show_in_overview: false
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rechnungs-E-Mail",
|
|
||||||
value_type: :email,
|
|
||||||
description: "E-Mail-Adresse für Rechnungen",
|
|
||||||
required: false,
|
|
||||||
show_in_overview: false
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "IBAN",
|
|
||||||
value_type: :string,
|
|
||||||
description: "IBAN für Lastschrift",
|
|
||||||
required: false,
|
|
||||||
show_in_overview: false
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Stunden ehrenamtlich",
|
|
||||||
value_type: :integer,
|
|
||||||
description: "Geleistete ehrenamtliche Stunden",
|
|
||||||
required: false,
|
|
||||||
show_in_overview: false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
for attrs <- custom_field_configs do
|
|
||||||
Membership.create_custom_field!(
|
|
||||||
attrs,
|
|
||||||
upsert?: true,
|
|
||||||
upsert_identity: :unique_name,
|
|
||||||
authorize?: false
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# 3. Admin email and password fallback for dev/test
|
|
||||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
|
||||||
System.put_env("ADMIN_EMAIL", admin_email)
|
|
||||||
|
|
||||||
if Mix.env() in [:dev, :test] and is_nil(System.get_env("ADMIN_PASSWORD")) and
|
|
||||||
is_nil(System.get_env("ADMIN_PASSWORD_FILE")) do
|
|
||||||
System.put_env("ADMIN_PASSWORD", "testpassword")
|
|
||||||
end
|
|
||||||
|
|
||||||
# 4. Authorization roles (German descriptions)
|
|
||||||
role_configs = [
|
|
||||||
%{
|
|
||||||
name: "Mitglied",
|
|
||||||
description: "Standardrolle für Mitglieder mit Zugriff nur auf die eigenen Daten",
|
|
||||||
permission_set_name: "own_data",
|
|
||||||
is_system_role: true
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Vorstand",
|
|
||||||
description: "Vorstandsmitglied mit Lesezugriff auf alle Mitgliederdaten",
|
|
||||||
permission_set_name: "read_only",
|
|
||||||
is_system_role: false
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Kassenwart",
|
|
||||||
description: "Kassenwart mit voller Mitglieder- und Zahlungsverwaltung",
|
|
||||||
permission_set_name: "normal_user",
|
|
||||||
is_system_role: false
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Buchhaltung",
|
|
||||||
description: "Buchhaltung mit Lesezugriff für Prüfungen",
|
|
||||||
permission_set_name: "read_only",
|
|
||||||
is_system_role: false
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Admin",
|
|
||||||
description: "Administrator mit uneingeschränktem Zugriff",
|
|
||||||
permission_set_name: "admin",
|
|
||||||
is_system_role: false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
Enum.each(role_configs, fn role_data ->
|
|
||||||
role_name = role_data.name
|
|
||||||
|
|
||||||
case Mv.Authorization.Role
|
|
||||||
|> Ash.Query.filter(name == ^role_name)
|
|
||||||
|> Ash.read_one(authorize?: false, domain: Mv.Authorization) do
|
|
||||||
{:ok, existing_role} when not is_nil(existing_role) ->
|
|
||||||
if existing_role.permission_set_name != role_data.permission_set_name or
|
|
||||||
existing_role.description != role_data.description do
|
|
||||||
existing_role
|
|
||||||
|> Ash.Changeset.for_update(:update_role, %{
|
|
||||||
description: role_data.description,
|
|
||||||
permission_set_name: role_data.permission_set_name
|
|
||||||
})
|
|
||||||
|> Ash.update!(authorize?: false, domain: Mv.Authorization)
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok, nil} ->
|
|
||||||
Mv.Authorization.Role
|
|
||||||
|> Ash.Changeset.for_create(:create_role_with_system_flag, role_data)
|
|
||||||
|> Ash.create!(authorize?: false, domain: Mv.Authorization)
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
IO.puts("Warning: Failed to check for role #{role_data.name}: #{inspect(error)}")
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
admin_role =
|
|
||||||
case Mv.Authorization.Role
|
|
||||||
|> Ash.Query.filter(name == "Admin")
|
|
||||||
|> Ash.read_one(authorize?: false, domain: Mv.Authorization) do
|
|
||||||
{:ok, role} when not is_nil(role) -> role
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
|
|
||||||
if is_nil(admin_role) do
|
|
||||||
raise "Failed to create or find admin role. Cannot proceed with bootstrap."
|
|
||||||
end
|
|
||||||
|
|
||||||
# 5. Admin user
|
|
||||||
Mv.Release.seed_admin()
|
|
||||||
|
|
||||||
admin_user_with_role =
|
|
||||||
case Accounts.User
|
|
||||||
|> Ash.Query.filter(email == ^admin_email)
|
|
||||||
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
|
|
||||||
{:ok, user} when not is_nil(user) ->
|
|
||||||
user |> Ash.load!(:role, authorize?: false)
|
|
||||||
|
|
||||||
{:ok, nil} ->
|
|
||||||
raise "Admin user not found after creation/assignment"
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
raise "Failed to load admin user: #{inspect(error)}"
|
|
||||||
end
|
|
||||||
|
|
||||||
# 6. System user
|
|
||||||
system_user_email = Mv.Helpers.SystemActor.system_user_email()
|
|
||||||
|
|
||||||
case Accounts.User
|
|
||||||
|> Ash.Query.filter(email == ^system_user_email)
|
|
||||||
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
|
|
||||||
{:ok, existing_system_user} when not is_nil(existing_system_user) ->
|
|
||||||
existing_system_user
|
|
||||||
|> Ash.Changeset.for_update(:update_internal, %{})
|
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|
||||||
|> Ash.update!(authorize?: false)
|
|
||||||
|
|
||||||
{:ok, nil} ->
|
|
||||||
Accounts.create_user!(%{email: system_user_email},
|
|
||||||
upsert?: true,
|
|
||||||
upsert_identity: :unique_email,
|
|
||||||
authorize?: false
|
|
||||||
)
|
|
||||||
|> Ash.Changeset.for_update(:update_internal, %{})
|
|
||||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
|
||||||
|> Ash.update!(authorize?: false)
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
IO.puts("Warning: Failed to create system user: #{inspect(error)}")
|
|
||||||
IO.puts("SystemActor will fall back to admin user (#{admin_email})")
|
|
||||||
end
|
|
||||||
|
|
||||||
# 7. Global settings (with default membership fee type and default field visibility)
|
|
||||||
# By default hide exit_date, notes, country, membership_fee_start_date in overview (like exit_date).
|
|
||||||
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
|
|
||||||
|
|
||||||
default_hidden_in_overview = %{
|
|
||||||
"exit_date" => false,
|
|
||||||
"notes" => false,
|
|
||||||
"country" => false,
|
|
||||||
"membership_fee_start_date" => false
|
|
||||||
}
|
|
||||||
|
|
||||||
case Membership.get_settings() do
|
|
||||||
{:ok, existing_settings} ->
|
|
||||||
updates =
|
|
||||||
%{}
|
|
||||||
|> then(fn acc ->
|
|
||||||
if existing_settings.club_name != default_club_name,
|
|
||||||
do: Map.put(acc, :club_name, default_club_name),
|
|
||||||
else: acc
|
|
||||||
end)
|
|
||||||
|> then(fn acc ->
|
|
||||||
if existing_settings.default_membership_fee_type_id != default_fee_type.id,
|
|
||||||
do: Map.put(acc, :default_membership_fee_type_id, default_fee_type.id),
|
|
||||||
else: acc
|
|
||||||
end)
|
|
||||||
|> then(fn acc ->
|
|
||||||
visibility_config = existing_settings.member_field_visibility || %{}
|
|
||||||
# Ensure default-hidden fields are set if not already present (string or atom keys)
|
|
||||||
has_key = fn vis, k ->
|
|
||||||
try do
|
|
||||||
Map.has_key?(vis, k) or Map.has_key?(vis, String.to_existing_atom(k))
|
|
||||||
rescue
|
|
||||||
ArgumentError -> Map.has_key?(vis, k)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
merged =
|
|
||||||
Enum.reduce(default_hidden_in_overview, visibility_config, fn {key, val}, vis ->
|
|
||||||
if has_key.(vis, key), do: vis, else: Map.put(vis, key, val)
|
|
||||||
end)
|
|
||||||
if merged != visibility_config, do: Map.put(acc, :member_field_visibility, merged), else: acc
|
|
||||||
end)
|
|
||||||
|
|
||||||
if map_size(updates) > 0 do
|
|
||||||
{:ok, _} = Membership.update_settings(existing_settings, updates)
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok, nil} ->
|
|
||||||
{:ok, _} =
|
|
||||||
Membership.Setting
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
club_name: default_club_name,
|
|
||||||
member_field_visibility: default_hidden_in_overview,
|
|
||||||
default_membership_fee_type_id: default_fee_type.id
|
|
||||||
})
|
|
||||||
|> Ash.create!()
|
|
||||||
end
|
|
||||||
|
|
||||||
IO.puts("✅ Bootstrap seeds completed.")
|
|
||||||
|
|
||||||
IO.puts(
|
|
||||||
" - Fee types: 5 (Standard, Ermäßigt, Unterstützer, Fördermitglied, Probemitgliedschaft)"
|
|
||||||
)
|
|
||||||
|
|
||||||
IO.puts(
|
|
||||||
" - Custom fields: 6 (Geburtsdatum shown in overview; others hidden by default)"
|
|
||||||
)
|
|
||||||
|
|
||||||
IO.puts(" - Roles: 5 (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)")
|
|
||||||
IO.puts(" - Default fee type: Standard (120€ yearly)")
|
|
||||||
|
|
@ -1,488 +0,0 @@
|
||||||
# Dev/local seeds: run only in dev and test (Mix.env in [:dev, :test]).
|
|
||||||
# Creates 20 sample members, groups, and optional custom field values.
|
|
||||||
# Requires bootstrap seeds to have run first.
|
|
||||||
|
|
||||||
alias Mv.Accounts
|
|
||||||
alias Mv.Membership
|
|
||||||
alias Mv.MembershipFees.CycleGenerator
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
|
||||||
|
|
||||||
require Ash.Query
|
|
||||||
|
|
||||||
admin_email = System.get_env("ADMIN_EMAIL") || "admin@localhost"
|
|
||||||
|
|
||||||
admin_user_with_role =
|
|
||||||
case Accounts.User
|
|
||||||
|> Ash.Query.filter(email == ^admin_email)
|
|
||||||
|> Ash.read_one(domain: Mv.Accounts, authorize?: false) do
|
|
||||||
{:ok, user} when not is_nil(user) ->
|
|
||||||
user |> Ash.load!(:role, authorize?: false)
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
raise "Dev seeds require bootstrap: admin user not found (#{admin_email})"
|
|
||||||
end
|
|
||||||
|
|
||||||
all_fee_types =
|
|
||||||
MembershipFeeType
|
|
||||||
|> Ash.Query.sort(name: :asc)
|
|
||||||
|> Ash.read!(actor: admin_user_with_role, domain: Mv.MembershipFees)
|
|
||||||
|> Enum.to_list()
|
|
||||||
|
|
||||||
# Countries: mostly Germany, 1–2 exceptions (index 7 = Österreich, index 14 = Schweiz)
|
|
||||||
countries_list =
|
|
||||||
List.duplicate("Deutschland", 20)
|
|
||||||
|> List.replace_at(7, "Österreich")
|
|
||||||
|> List.replace_at(14, "Schweiz")
|
|
||||||
|
|
||||||
# 20 members: varied names, cities, join dates; fee types by index (last 2 without fee type)
|
|
||||||
member_configs = [
|
|
||||||
%{
|
|
||||||
first_name: "Anna",
|
|
||||||
last_name: "Schmidt",
|
|
||||||
city: "München",
|
|
||||||
street: "Hauptstraße",
|
|
||||||
house_number: "1",
|
|
||||||
postal_code: "80331",
|
|
||||||
join_date: ~D[2022-01-10]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Bruno",
|
|
||||||
last_name: "Müller",
|
|
||||||
city: "Hamburg",
|
|
||||||
street: "Lindenstraße",
|
|
||||||
house_number: "5",
|
|
||||||
postal_code: "20095",
|
|
||||||
join_date: ~D[2022-03-15]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Clara",
|
|
||||||
last_name: "Fischer",
|
|
||||||
city: "Berlin",
|
|
||||||
street: "Kastanienallee",
|
|
||||||
house_number: "12",
|
|
||||||
postal_code: "10435",
|
|
||||||
join_date: ~D[2022-05-20]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "David",
|
|
||||||
last_name: "Weber",
|
|
||||||
city: "Köln",
|
|
||||||
street: "Rheinstraße",
|
|
||||||
house_number: "8",
|
|
||||||
postal_code: "50667",
|
|
||||||
join_date: ~D[2022-07-01]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Elena",
|
|
||||||
last_name: "Wagner",
|
|
||||||
city: "Frankfurt",
|
|
||||||
street: "Goetheplatz",
|
|
||||||
house_number: "3",
|
|
||||||
postal_code: "60313",
|
|
||||||
join_date: ~D[2022-09-12]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Felix",
|
|
||||||
last_name: "Becker",
|
|
||||||
city: "Stuttgart",
|
|
||||||
street: "Königstraße",
|
|
||||||
house_number: "22",
|
|
||||||
postal_code: "70173",
|
|
||||||
join_date: ~D[2023-01-05]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Greta",
|
|
||||||
last_name: "Schulz",
|
|
||||||
city: "Düsseldorf",
|
|
||||||
street: "Schadowstraße",
|
|
||||||
house_number: "14",
|
|
||||||
postal_code: "40212",
|
|
||||||
join_date: ~D[2023-02-14]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Henrik",
|
|
||||||
last_name: "Hoffmann",
|
|
||||||
city: "Leipzig",
|
|
||||||
street: "Nikolaistraße",
|
|
||||||
house_number: "7",
|
|
||||||
postal_code: "04109",
|
|
||||||
join_date: ~D[2023-04-20]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Ines",
|
|
||||||
last_name: "Koch",
|
|
||||||
city: "Dortmund",
|
|
||||||
street: "Westenhellweg",
|
|
||||||
house_number: "45",
|
|
||||||
postal_code: "44137",
|
|
||||||
join_date: ~D[2023-06-08]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Jakob",
|
|
||||||
last_name: "Richter",
|
|
||||||
city: "Essen",
|
|
||||||
street: "Kettwiger Straße",
|
|
||||||
house_number: "2",
|
|
||||||
postal_code: "45127",
|
|
||||||
join_date: ~D[2023-08-11]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Laura",
|
|
||||||
last_name: "Klein",
|
|
||||||
city: "Dresden",
|
|
||||||
street: "Prager Straße",
|
|
||||||
house_number: "9",
|
|
||||||
postal_code: "01069",
|
|
||||||
join_date: ~D[2023-10-01]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Max",
|
|
||||||
last_name: "Wolf",
|
|
||||||
city: "Hannover",
|
|
||||||
street: "Georgstraße",
|
|
||||||
house_number: "50",
|
|
||||||
postal_code: "30159",
|
|
||||||
join_date: ~D[2023-11-15]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Nina",
|
|
||||||
last_name: "Schröder",
|
|
||||||
city: "Nürnberg",
|
|
||||||
street: "Königstraße",
|
|
||||||
house_number: "73",
|
|
||||||
postal_code: "90402",
|
|
||||||
join_date: ~D[2024-01-20]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Oliver",
|
|
||||||
last_name: "Neumann",
|
|
||||||
city: "Bremen",
|
|
||||||
street: "Obernstraße",
|
|
||||||
house_number: "31",
|
|
||||||
postal_code: "28195",
|
|
||||||
join_date: ~D[2024-03-10]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Paula",
|
|
||||||
last_name: "Schwarz",
|
|
||||||
city: "Mannheim",
|
|
||||||
street: "Planken",
|
|
||||||
house_number: "11",
|
|
||||||
postal_code: "68161",
|
|
||||||
join_date: ~D[2024-05-22]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Quirin",
|
|
||||||
last_name: "Zimmermann",
|
|
||||||
city: "Karlsruhe",
|
|
||||||
street: "Kaiserstraße",
|
|
||||||
house_number: "145",
|
|
||||||
postal_code: "76133",
|
|
||||||
join_date: ~D[2024-07-07]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Rosa",
|
|
||||||
last_name: "Braun",
|
|
||||||
city: "Wiesbaden",
|
|
||||||
street: "Wilhelmstraße",
|
|
||||||
house_number: "6",
|
|
||||||
postal_code: "65183",
|
|
||||||
join_date: ~D[2024-09-01]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Stefan",
|
|
||||||
last_name: "Krüger",
|
|
||||||
city: "Münster",
|
|
||||||
street: "Ludgeristraße",
|
|
||||||
house_number: "18",
|
|
||||||
postal_code: "48143",
|
|
||||||
join_date: ~D[2024-10-15]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Thea",
|
|
||||||
last_name: "Hartmann",
|
|
||||||
city: "Augsburg",
|
|
||||||
street: "Maximilianstraße",
|
|
||||||
house_number: "4",
|
|
||||||
postal_code: "86150",
|
|
||||||
join_date: ~D[2024-11-20]
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
first_name: "Uwe",
|
|
||||||
last_name: "Lange",
|
|
||||||
city: "Bonn",
|
|
||||||
street: "Remigiusstraße",
|
|
||||||
house_number: "27",
|
|
||||||
postal_code: "53111",
|
|
||||||
join_date: ~D[2024-12-01]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Fee type index per member: 0..4 round-robin for first 18, nil for last 2
|
|
||||||
# Cycle status: all_paid, all_unpaid, mixed (varied)
|
|
||||||
cycle_statuses = [
|
|
||||||
:all_paid,
|
|
||||||
:all_unpaid,
|
|
||||||
:mixed,
|
|
||||||
:all_paid,
|
|
||||||
:mixed,
|
|
||||||
:all_unpaid,
|
|
||||||
:all_paid,
|
|
||||||
:mixed,
|
|
||||||
:all_unpaid,
|
|
||||||
:all_paid,
|
|
||||||
:mixed,
|
|
||||||
:all_paid,
|
|
||||||
:all_unpaid,
|
|
||||||
:mixed,
|
|
||||||
:all_paid,
|
|
||||||
:mixed,
|
|
||||||
:all_unpaid,
|
|
||||||
:all_paid,
|
|
||||||
:mixed,
|
|
||||||
nil
|
|
||||||
]
|
|
||||||
|
|
||||||
Enum.with_index(member_configs)
|
|
||||||
|> Enum.each(fn {config, index} ->
|
|
||||||
email = "mitglied#{index + 1}@example.de"
|
|
||||||
fee_type_index = if index >= 18, do: nil, else: rem(index, length(all_fee_types))
|
|
||||||
fee_type_id = if fee_type_index, do: Enum.at(all_fee_types, fee_type_index).id, else: nil
|
|
||||||
cycle_status = Enum.at(cycle_statuses, index)
|
|
||||||
|
|
||||||
# Do not include membership_fee_type_id in upsert so re-runs do not overwrite
|
|
||||||
# existing assignments; set via update below only when member has none
|
|
||||||
base_attrs = %{
|
|
||||||
first_name: config.first_name,
|
|
||||||
last_name: config.last_name,
|
|
||||||
email: email,
|
|
||||||
join_date: config.join_date,
|
|
||||||
city: config.city,
|
|
||||||
street: config.street,
|
|
||||||
house_number: config.house_number,
|
|
||||||
postal_code: config.postal_code,
|
|
||||||
country: Enum.at(countries_list, index)
|
|
||||||
}
|
|
||||||
|
|
||||||
member =
|
|
||||||
Membership.create_member!(base_attrs,
|
|
||||||
upsert?: true,
|
|
||||||
upsert_identity: :unique_email,
|
|
||||||
actor: admin_user_with_role
|
|
||||||
)
|
|
||||||
|
|
||||||
final_member =
|
|
||||||
if is_nil(member.membership_fee_type_id) and fee_type_id do
|
|
||||||
{:ok, updated} =
|
|
||||||
Membership.update_member(member, %{membership_fee_type_id: fee_type_id},
|
|
||||||
actor: admin_user_with_role
|
|
||||||
)
|
|
||||||
|
|
||||||
updated
|
|
||||||
else
|
|
||||||
member
|
|
||||||
end
|
|
||||||
|
|
||||||
if not is_nil(final_member.membership_fee_type_id) and not is_nil(cycle_status) do
|
|
||||||
member_with_cycles =
|
|
||||||
Ash.load!(final_member, :membership_fee_cycles, actor: admin_user_with_role)
|
|
||||||
|
|
||||||
cycles =
|
|
||||||
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
|
|
||||||
{:ok, new_cycles, _} =
|
|
||||||
CycleGenerator.generate_cycles_for_member(final_member.id,
|
|
||||||
skip_lock?: true,
|
|
||||||
actor: admin_user_with_role
|
|
||||||
)
|
|
||||||
|
|
||||||
new_cycles
|
|
||||||
else
|
|
||||||
member_with_cycles.membership_fee_cycles
|
|
||||||
end
|
|
||||||
|
|
||||||
cycles
|
|
||||||
|> Enum.sort_by(& &1.cycle_start, Date)
|
|
||||||
|> Enum.with_index()
|
|
||||||
|> Enum.each(fn {cycle, idx} ->
|
|
||||||
status =
|
|
||||||
case cycle_status do
|
|
||||||
:all_paid ->
|
|
||||||
:paid
|
|
||||||
|
|
||||||
:all_unpaid ->
|
|
||||||
:unpaid
|
|
||||||
|
|
||||||
:mixed ->
|
|
||||||
case rem(idx, 3) do
|
|
||||||
0 -> :paid
|
|
||||||
1 -> :unpaid
|
|
||||||
2 -> :suspended
|
|
||||||
end
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
cycle.status
|
|
||||||
end
|
|
||||||
|
|
||||||
if cycle.status != status do
|
|
||||||
cycle
|
|
||||||
|> Ash.Changeset.for_update(:update, %{status: status})
|
|
||||||
|> Ash.update!(actor: admin_user_with_role, domain: Mv.MembershipFees)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
# Groups (idempotent)
|
|
||||||
group_configs = [
|
|
||||||
%{name: "Vorstand", description: "Gremium Vorstand"},
|
|
||||||
%{name: "Jugend", description: "Jugendbereich"},
|
|
||||||
%{name: "Newsletter", description: "Empfänger*innen Newsletter"}
|
|
||||||
]
|
|
||||||
|
|
||||||
existing_groups =
|
|
||||||
case Membership.list_groups(actor: admin_user_with_role) do
|
|
||||||
{:ok, list} -> list
|
|
||||||
{:error, _} -> []
|
|
||||||
end
|
|
||||||
|
|
||||||
existing_names_lower = MapSet.new(existing_groups, &String.downcase(&1.name))
|
|
||||||
|
|
||||||
seed_groups =
|
|
||||||
Enum.reduce(group_configs, %{}, fn config, acc ->
|
|
||||||
name = config.name
|
|
||||||
|
|
||||||
if MapSet.member?(existing_names_lower, String.downcase(name)) do
|
|
||||||
group = Enum.find(existing_groups, &(String.downcase(&1.name) == String.downcase(name)))
|
|
||||||
Map.put(acc, name, group)
|
|
||||||
else
|
|
||||||
{:ok, group} =
|
|
||||||
Membership.create_group(%{name: name, description: config.description},
|
|
||||||
actor: admin_user_with_role
|
|
||||||
)
|
|
||||||
|
|
||||||
Map.put(acc, name, group)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
# Test users: create users linked to members (same email as member so sync is no-op),
|
|
||||||
# each with a different role for testing authorization.
|
|
||||||
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
|
|
||||||
|
|
||||||
test_users_config = [
|
|
||||||
{"mitglied1@example.de", "Mitglied"},
|
|
||||||
{"mitglied2@example.de", "Vorstand"},
|
|
||||||
{"mitglied3@example.de", "Kassenwart"},
|
|
||||||
{"mitglied4@example.de", "Buchhaltung"}
|
|
||||||
]
|
|
||||||
|
|
||||||
roles_by_name =
|
|
||||||
Mv.Authorization.Role
|
|
||||||
|> Ash.read!(authorize?: false, domain: Mv.Authorization)
|
|
||||||
|> Map.new(&{&1.name, &1})
|
|
||||||
|
|
||||||
Enum.each(test_users_config, fn {email, role_name} ->
|
|
||||||
member = Enum.find(all_members, &(&1.email == email))
|
|
||||||
role = roles_by_name[role_name]
|
|
||||||
|
|
||||||
if not is_nil(member) and not is_nil(role) do
|
|
||||||
user =
|
|
||||||
Accounts.create_user!(
|
|
||||||
%{email: email, member: %{id: member.id}},
|
|
||||||
upsert?: true,
|
|
||||||
upsert_identity: :unique_email,
|
|
||||||
actor: admin_user_with_role
|
|
||||||
)
|
|
||||||
|
|
||||||
user
|
|
||||||
|> Ash.Changeset.for_update(:update_user, %{role_id: role.id}, domain: Mv.Accounts)
|
|
||||||
|> Ash.update!(actor: admin_user_with_role)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
# Assign some members to groups (mitglied1–5 to Vorstand/Newsletter etc.)
|
|
||||||
member_group_assignments = [
|
|
||||||
{"mitglied1@example.de", ["Vorstand", "Newsletter"]},
|
|
||||||
{"mitglied2@example.de", ["Jugend", "Newsletter"]},
|
|
||||||
{"mitglied3@example.de", ["Vorstand"]},
|
|
||||||
{"mitglied4@example.de", ["Newsletter"]},
|
|
||||||
{"mitglied5@example.de", ["Newsletter"]}
|
|
||||||
]
|
|
||||||
|
|
||||||
find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end
|
|
||||||
|
|
||||||
Enum.each(member_group_assignments, fn {email, group_names} ->
|
|
||||||
member = find_member.(email)
|
|
||||||
|
|
||||||
if member do
|
|
||||||
Enum.each(group_names, fn group_name ->
|
|
||||||
group = seed_groups[group_name]
|
|
||||||
|
|
||||||
if group do
|
|
||||||
Membership.create_member_group(
|
|
||||||
%{member_id: member.id, group_id: group.id},
|
|
||||||
actor: admin_user_with_role
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
|
|
||||||
# Custom field values for ~80% of members (16 of 20): most of the 6 fields filled per member
|
|
||||||
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
|
|
||||||
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
|
|
||||||
|
|
||||||
# 16 members with 4–6 custom field values each (Geburtsdatum, Datenschutz, SEPA, Rechnungs-E-Mail, IBAN, Stunden)
|
|
||||||
custom_value_assignments =
|
|
||||||
Enum.map(1..16, fn n ->
|
|
||||||
email = "mitglied#{n}@example.de"
|
|
||||||
# Vary birth dates and values per index
|
|
||||||
base_date = Date.add(~D[1970-01-01], 365 * (n + 10) + rem(n * 31, 365))
|
|
||||||
values = [
|
|
||||||
{"Geburtsdatum", %{"_union_type" => "date", "_union_value" => base_date}},
|
|
||||||
{"Datenschutzerklärung akzeptiert",
|
|
||||||
%{"_union_type" => "boolean", "_union_value" => n in [1, 2, 3, 5, 7, 9, 11, 13, 15]}},
|
|
||||||
{"SEPA-Mandat", %{"_union_type" => "boolean", "_union_value" => rem(n, 3) != 0}},
|
|
||||||
{"Rechnungs-E-Mail",
|
|
||||||
%{"_union_type" => "email", "_union_value" => "rechnung#{n}@example.de"}},
|
|
||||||
{"IBAN",
|
|
||||||
%{
|
|
||||||
"_union_type" => "string",
|
|
||||||
"_union_value" =>
|
|
||||||
"DE8937040044#{String.pad_leading(to_string(rem(532013000 + n, 1_000_000_000)), 10, "0")}"
|
|
||||||
}},
|
|
||||||
{"Stunden ehrenamtlich", %{"_union_type" => "integer", "_union_value" => 5 + rem(n * 3, 25)}}
|
|
||||||
]
|
|
||||||
# Drop 0–2 fields per member so not all have 6 (still ~80% overall filled)
|
|
||||||
drop_count = rem(n, 3)
|
|
||||||
{email, Enum.take(values, 6 - drop_count)}
|
|
||||||
end)
|
|
||||||
|
|
||||||
for {email, values} <- custom_value_assignments do
|
|
||||||
member = find_member.(email)
|
|
||||||
|
|
||||||
if member do
|
|
||||||
Enum.each(values, fn {field_name, value} ->
|
|
||||||
field = find_field.(field_name)
|
|
||||||
|
|
||||||
if field do
|
|
||||||
Membership.CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: member.id,
|
|
||||||
custom_field_id: field.id,
|
|
||||||
value: value
|
|
||||||
})
|
|
||||||
|> Ash.create!(
|
|
||||||
upsert?: true,
|
|
||||||
upsert_identity: :unique_custom_field_per_member,
|
|
||||||
actor: admin_user_with_role
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
IO.puts("✅ Dev seeds completed.")
|
|
||||||
IO.puts(" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz)")
|
|
||||||
IO.puts(" - Test users: 4 linked to mitglied1–4 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung")
|
|
||||||
IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)")
|
|
||||||
IO.puts(" - Custom field values: ~80% filled (16 members, 4–6 fields each)")
|
|
||||||
|
|
@ -4,9 +4,9 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
alias Mv.MembershipFees.Changes.ValidateSameInterval
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.MembershipFees.Changes.ValidateSameInterval
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ defmodule Mv.MembershipFees.ForeignKeyTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,8 +169,8 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "cannot delete when cycles exist", %{actor: actor, fee_type: fee_type} do
|
test "cannot delete when cycles exist", %{actor: actor, fee_type: fee_type} do
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
|
||||||
# Create a member with this fee type
|
# Create a member with this fee type
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ defmodule Mv.Helpers.SystemActorTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
alias Mv.Accounts
|
|
||||||
alias Mv.Authorization
|
|
||||||
alias Mv.Helpers.SystemActor
|
alias Mv.Helpers.SystemActor
|
||||||
|
alias Mv.Authorization
|
||||||
|
alias Mv.Accounts
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
|
||||||
# async: false because we need database commits to be visible across queries
|
# async: false because we need database commits to be visible across queries
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
alias Mv.Accounts
|
|
||||||
alias Mv.Membership.{CustomField, CustomFieldValue}
|
alias Mv.Membership.{CustomField, CustomFieldValue}
|
||||||
|
alias Mv.Accounts
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,378 +1 @@
|
||||||
defmodule Mv.Membership.MemberExport.BuildTest do
|
|
||||||
@moduledoc """
|
|
||||||
Tests for MemberExport.Build module.
|
|
||||||
|
|
||||||
Tests verify that the module correctly:
|
|
||||||
- Loads and filters members based on query/selected_ids
|
|
||||||
- Builds column specifications (without labels)
|
|
||||||
- Generates row data as cell strings
|
|
||||||
- Handles member fields, custom fields, and computed fields
|
|
||||||
- Applies sorting and filtering consistently
|
|
||||||
"""
|
|
||||||
use Mv.DataCase, async: true
|
|
||||||
|
|
||||||
alias Mv.Constants
|
|
||||||
alias Mv.Fixtures
|
|
||||||
alias Mv.Membership.{CustomField, MemberExport.Build}
|
|
||||||
|
|
||||||
@custom_field_prefix Constants.custom_field_prefix()
|
|
||||||
|
|
||||||
setup do
|
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
|
||||||
|
|
||||||
# Create test members
|
|
||||||
member1 =
|
|
||||||
Fixtures.member_fixture(%{
|
|
||||||
first_name: "Alice",
|
|
||||||
last_name: "Anderson",
|
|
||||||
email: "alice@example.com"
|
|
||||||
})
|
|
||||||
|
|
||||||
member2 =
|
|
||||||
Fixtures.member_fixture(%{
|
|
||||||
first_name: "Bob",
|
|
||||||
last_name: "Brown",
|
|
||||||
email: "bob@example.com"
|
|
||||||
})
|
|
||||||
|
|
||||||
%{actor: system_actor, member1: member1, member2: member2}
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "build/3 - standard member fields" do
|
|
||||||
test "returns columns and rows for standard member fields", %{
|
|
||||||
actor: actor,
|
|
||||||
member1: m1,
|
|
||||||
member2: m2
|
|
||||||
} do
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id, m2.id],
|
|
||||||
member_fields: ["first_name", "last_name", "email"],
|
|
||||||
selectable_member_fields: ["first_name", "last_name", "email"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [],
|
|
||||||
boolean_filters: %{},
|
|
||||||
cycle_status_filter: nil,
|
|
||||||
query: nil,
|
|
||||||
sort_field: nil,
|
|
||||||
sort_order: nil,
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
result = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
assert {:ok, data} = result
|
|
||||||
assert %{columns: columns, rows: rows, meta: meta} = data
|
|
||||||
|
|
||||||
# Check columns structure
|
|
||||||
assert length(columns) == 3
|
|
||||||
first_name_col = Enum.find(columns, &(&1.key == "first_name" && &1.kind == :member_field))
|
|
||||||
assert first_name_col
|
|
||||||
assert first_name_col.label == "Label"
|
|
||||||
assert Enum.find(columns, &(&1.key == "last_name" && &1.kind == :member_field))
|
|
||||||
assert Enum.find(columns, &(&1.key == "email" && &1.kind == :member_field))
|
|
||||||
|
|
||||||
# Check rows - should have 2 members
|
|
||||||
assert length(rows) == 2
|
|
||||||
|
|
||||||
# Check first row (member1)
|
|
||||||
row1 = Enum.at(rows, 0)
|
|
||||||
assert length(row1) == 3
|
|
||||||
assert "Alice" in row1
|
|
||||||
assert "Anderson" in row1
|
|
||||||
assert "alice@example.com" in row1
|
|
||||||
|
|
||||||
# Check meta
|
|
||||||
assert %{generated_at: _timestamp, member_count: 2} = meta
|
|
||||||
assert is_binary(meta.generated_at)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "filters members by selected_ids", %{actor: actor, member1: m1, member2: _m2} do
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id],
|
|
||||||
member_fields: ["first_name"],
|
|
||||||
selectable_member_fields: ["first_name"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [],
|
|
||||||
boolean_filters: %{},
|
|
||||||
cycle_status_filter: nil,
|
|
||||||
query: nil,
|
|
||||||
sort_field: nil,
|
|
||||||
sort_order: nil,
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
assert length(data.rows) == 1
|
|
||||||
row = hd(data.rows)
|
|
||||||
assert "Alice" in row
|
|
||||||
assert data.meta.member_count == 1
|
|
||||||
end
|
|
||||||
|
|
||||||
test "applies search query when selected_ids is empty", %{
|
|
||||||
actor: actor,
|
|
||||||
member1: _m1,
|
|
||||||
member2: _m2
|
|
||||||
} do
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [],
|
|
||||||
member_fields: ["first_name", "last_name"],
|
|
||||||
selectable_member_fields: ["first_name", "last_name"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [],
|
|
||||||
boolean_filters: %{},
|
|
||||||
cycle_status_filter: nil,
|
|
||||||
query: "Alice",
|
|
||||||
sort_field: nil,
|
|
||||||
sort_order: nil,
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
assert length(data.rows) == 1
|
|
||||||
row = hd(data.rows)
|
|
||||||
assert "Alice" in row
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "build/3 - custom fields" do
|
|
||||||
test "includes custom field columns and values", %{
|
|
||||||
actor: actor,
|
|
||||||
member1: m1
|
|
||||||
} do
|
|
||||||
# Create custom field
|
|
||||||
{:ok, custom_field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "Membership Number",
|
|
||||||
value_type: :string
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
# Create custom field value for member
|
|
||||||
{:ok, _cfv} =
|
|
||||||
Mv.Membership.CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: m1.id,
|
|
||||||
custom_field_id: custom_field.id,
|
|
||||||
value: "M12345"
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id],
|
|
||||||
member_fields: ["first_name"],
|
|
||||||
selectable_member_fields: ["first_name"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [custom_field.id],
|
|
||||||
boolean_filters: %{},
|
|
||||||
cycle_status_filter: nil,
|
|
||||||
query: nil,
|
|
||||||
sort_field: nil,
|
|
||||||
sort_order: nil,
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
# Should have 2 columns: first_name + custom field
|
|
||||||
assert length(data.columns) == 2
|
|
||||||
|
|
||||||
custom_col =
|
|
||||||
Enum.find(
|
|
||||||
data.columns,
|
|
||||||
&(&1.kind == :custom_field && &1.key == to_string(custom_field.id))
|
|
||||||
)
|
|
||||||
|
|
||||||
assert custom_col
|
|
||||||
assert custom_col.custom_field.id == custom_field.id
|
|
||||||
# Label comes from custom field name when provided via label_fn key
|
|
||||||
assert custom_col.label in ["Label", "Membership Number"]
|
|
||||||
|
|
||||||
# Check row has custom field value
|
|
||||||
row = hd(data.rows)
|
|
||||||
assert length(row) == 2
|
|
||||||
assert "M12345" in row
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles members without custom field values", %{
|
|
||||||
actor: actor,
|
|
||||||
member1: m1
|
|
||||||
} do
|
|
||||||
# Create custom field but no value for member
|
|
||||||
{:ok, custom_field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "Optional Field",
|
|
||||||
value_type: :string
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id],
|
|
||||||
member_fields: ["first_name"],
|
|
||||||
selectable_member_fields: ["first_name"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [custom_field.id],
|
|
||||||
boolean_filters: %{},
|
|
||||||
cycle_status_filter: nil,
|
|
||||||
query: nil,
|
|
||||||
sort_field: nil,
|
|
||||||
sort_order: nil,
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
row = hd(data.rows)
|
|
||||||
# Custom field value should be empty string
|
|
||||||
assert "" in row
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "build/3 - computed fields" do
|
|
||||||
test "includes computed field columns and values", %{
|
|
||||||
actor: actor,
|
|
||||||
member1: m1
|
|
||||||
} do
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id],
|
|
||||||
member_fields: ["first_name", "membership_fee_status"],
|
|
||||||
selectable_member_fields: ["first_name"],
|
|
||||||
computed_fields: ["membership_fee_status"],
|
|
||||||
custom_field_ids: [],
|
|
||||||
boolean_filters: %{},
|
|
||||||
cycle_status_filter: nil,
|
|
||||||
query: nil,
|
|
||||||
sort_field: nil,
|
|
||||||
sort_order: nil,
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
# Should have 2 columns: first_name + computed field
|
|
||||||
assert length(data.columns) == 2
|
|
||||||
|
|
||||||
computed_col =
|
|
||||||
Enum.find(data.columns, &(&1.kind == :computed && &1.key == :membership_fee_status))
|
|
||||||
|
|
||||||
assert computed_col
|
|
||||||
assert computed_col.label == "Label"
|
|
||||||
|
|
||||||
# Check row has computed field value (may be empty if no cycles)
|
|
||||||
row = hd(data.rows)
|
|
||||||
assert length(row) == 2
|
|
||||||
# membership_fee_status should be present (even if empty)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "build/3 - sorting" do
|
|
||||||
test "sorts by member field", %{actor: actor, member1: m1, member2: m2} do
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id, m2.id],
|
|
||||||
member_fields: ["first_name"],
|
|
||||||
selectable_member_fields: ["first_name"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [],
|
|
||||||
boolean_filters: %{},
|
|
||||||
cycle_status_filter: nil,
|
|
||||||
query: nil,
|
|
||||||
sort_field: "first_name",
|
|
||||||
sort_order: "asc",
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
# Should be sorted: Alice, Bob
|
|
||||||
[row1, row2] = data.rows
|
|
||||||
assert "Alice" in row1
|
|
||||||
assert "Bob" in row2
|
|
||||||
end
|
|
||||||
|
|
||||||
test "sorts by custom field", %{actor: actor, member1: m1, member2: m2} do
|
|
||||||
# Create custom field
|
|
||||||
{:ok, custom_field} =
|
|
||||||
CustomField
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
name: "Sort Field",
|
|
||||||
value_type: :string
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
# Add values: m1="Zebra", m2="Alpha"
|
|
||||||
{:ok, _cfv1} =
|
|
||||||
Mv.Membership.CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: m1.id,
|
|
||||||
custom_field_id: custom_field.id,
|
|
||||||
value: "Zebra"
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
|
||||||
Mv.Membership.CustomFieldValue
|
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|
||||||
member_id: m2.id,
|
|
||||||
custom_field_id: custom_field.id,
|
|
||||||
value: "Alpha"
|
|
||||||
})
|
|
||||||
|> Ash.create(actor: actor)
|
|
||||||
|
|
||||||
sort_field = "#{@custom_field_prefix}#{custom_field.id}"
|
|
||||||
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id, m2.id],
|
|
||||||
member_fields: ["first_name"],
|
|
||||||
selectable_member_fields: ["first_name"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [custom_field.id],
|
|
||||||
boolean_filters: %{},
|
|
||||||
cycle_status_filter: nil,
|
|
||||||
query: nil,
|
|
||||||
sort_field: sort_field,
|
|
||||||
sort_order: "asc",
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
# Should be sorted by custom field: Alpha (Bob), Zebra (Alice)
|
|
||||||
[row1, row2] = data.rows
|
|
||||||
# Alpha
|
|
||||||
assert "Bob" in row1
|
|
||||||
# Zebra
|
|
||||||
assert "Alice" in row2
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "build/3 - error handling" do
|
|
||||||
test "returns error when actor lacks permission", %{member1: m1} do
|
|
||||||
# User with own_data can only read linked member; m1 is not linked to this user
|
|
||||||
user = Fixtures.user_with_role_fixture("own_data")
|
|
||||||
|
|
||||||
parsed = %{
|
|
||||||
selected_ids: [m1.id],
|
|
||||||
member_fields: ["first_name"],
|
|
||||||
selectable_member_fields: ["first_name"],
|
|
||||||
computed_fields: [],
|
|
||||||
custom_field_ids: [],
|
|
||||||
boolean_filters: %{},
|
|
||||||
cycle_status_filter: nil,
|
|
||||||
query: nil,
|
|
||||||
sort_field: nil,
|
|
||||||
sort_order: nil,
|
|
||||||
show_current_cycle: false
|
|
||||||
}
|
|
||||||
|
|
||||||
result = Build.build(user, parsed, fn _key -> "Label" end)
|
|
||||||
|
|
||||||
# own_data user cannot read m1 (not linked); build returns ok with empty rows
|
|
||||||
assert {:ok, data} = result
|
|
||||||
assert data.meta.member_count == 0
|
|
||||||
assert data.rows == []
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ defmodule Mv.Membership.MemberPoliciesTest do
|
||||||
# in the same test (especially for unlinked members)
|
# in the same test (especially for unlinked members)
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
alias Mv.Accounts
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
alias Mv.Accounts
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,267 +1 @@
|
||||||
defmodule Mv.Membership.MembersPDFTest do
|
|
||||||
@moduledoc """
|
|
||||||
Tests for MembersPDF module.
|
|
||||||
|
|
||||||
Tests verify that the module correctly:
|
|
||||||
- Loads the Typst template
|
|
||||||
- Converts export data to template format
|
|
||||||
- Generates valid PDF binary (starts with "%PDF")
|
|
||||||
- Handles errors gracefully
|
|
||||||
"""
|
|
||||||
# async: false so tests that manipulate the template file (e.g. "returns error when template file is missing")
|
|
||||||
# do not run in parallel and cannot leave the template removed on failure
|
|
||||||
use ExUnit.Case, async: false
|
|
||||||
|
|
||||||
alias Mv.Config
|
|
||||||
alias Mv.Membership.MembersPDF
|
|
||||||
|
|
||||||
describe "render/1" do
|
|
||||||
test "rejects export when row count exceeds limit" do
|
|
||||||
max_rows = Config.pdf_export_row_limit()
|
|
||||||
rows_over_limit = max_rows + 1
|
|
||||||
|
|
||||||
export_data = %{
|
|
||||||
columns: [
|
|
||||||
%{key: "first_name", kind: :member_field, label: "Vorname"}
|
|
||||||
],
|
|
||||||
rows: Enum.map(1..rows_over_limit, fn i -> ["Member #{i}"] end),
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: rows_over_limit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:error, {:row_limit_exceeded, ^rows_over_limit, ^max_rows}} = result
|
|
||||||
end
|
|
||||||
|
|
||||||
test "allows export when row count equals limit" do
|
|
||||||
max_rows = Config.pdf_export_row_limit()
|
|
||||||
|
|
||||||
export_data = %{
|
|
||||||
columns: [
|
|
||||||
%{key: "first_name", kind: :member_field, label: "Vorname"}
|
|
||||||
],
|
|
||||||
rows: Enum.map(1..max_rows, fn i -> ["Member #{i}"] end),
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: max_rows
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:ok, pdf_binary} = result
|
|
||||||
assert String.starts_with?(pdf_binary, "%PDF")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "allows export when row count is below limit" do
|
|
||||||
max_rows = Config.pdf_export_row_limit()
|
|
||||||
rows_below_limit = max(1, max_rows - 10)
|
|
||||||
|
|
||||||
export_data = %{
|
|
||||||
columns: [
|
|
||||||
%{key: "first_name", kind: :member_field, label: "Vorname"}
|
|
||||||
],
|
|
||||||
rows: Enum.map(1..rows_below_limit, fn i -> ["Member #{i}"] end),
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: rows_below_limit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:ok, pdf_binary} = result
|
|
||||||
assert String.starts_with?(pdf_binary, "%PDF")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "generates valid PDF from minimal dataset" do
|
|
||||||
export_data = %{
|
|
||||||
columns: [
|
|
||||||
%{key: "first_name", kind: :member_field, label: "Vorname"},
|
|
||||||
%{key: "last_name", kind: :member_field, label: "Nachname"}
|
|
||||||
],
|
|
||||||
rows: [
|
|
||||||
["Max", "Mustermann"],
|
|
||||||
["Anna", "Schmidt"]
|
|
||||||
],
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:ok, pdf_binary} = result
|
|
||||||
assert is_binary(pdf_binary)
|
|
||||||
assert String.starts_with?(pdf_binary, "%PDF")
|
|
||||||
assert byte_size(pdf_binary) > 1000
|
|
||||||
end
|
|
||||||
|
|
||||||
test "generates valid PDF with custom fields and computed fields" do
|
|
||||||
export_data = %{
|
|
||||||
columns: [
|
|
||||||
%{key: "first_name", kind: :member_field, label: "Vorname"},
|
|
||||||
%{key: "last_name", kind: :member_field, label: "Nachname"},
|
|
||||||
%{key: "email", kind: :member_field, label: "E-Mail"},
|
|
||||||
%{key: :membership_fee_status, kind: :computed, label: "Beitragsstatus"},
|
|
||||||
%{
|
|
||||||
key: "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
kind: :custom_field,
|
|
||||||
label: "Mitgliedsnummer",
|
|
||||||
custom_field: %{
|
|
||||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
name: "Mitgliedsnummer",
|
|
||||||
value_type: :string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
rows: [
|
|
||||||
["Max", "Mustermann", "max@example.com", "paid", "M-2024-001"],
|
|
||||||
["Anna", "Schmidt", "anna@example.com", "unpaid", "M-2024-002"]
|
|
||||||
],
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:ok, pdf_binary} = result
|
|
||||||
assert is_binary(pdf_binary)
|
|
||||||
assert String.starts_with?(pdf_binary, "%PDF")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "maintains deterministic column and row order" do
|
|
||||||
export_data = %{
|
|
||||||
columns: [
|
|
||||||
%{key: "first_name", kind: :member_field, label: "Vorname"},
|
|
||||||
%{key: "last_name", kind: :member_field, label: "Nachname"},
|
|
||||||
%{key: "email", kind: :member_field, label: "E-Mail"}
|
|
||||||
],
|
|
||||||
rows: [
|
|
||||||
["Max", "Mustermann", "max@example.com"],
|
|
||||||
["Anna", "Schmidt", "anna@example.com"],
|
|
||||||
["Peter", "Müller", "peter@example.com"]
|
|
||||||
],
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Render twice and verify identical output
|
|
||||||
{:ok, pdf1} = MembersPDF.render(export_data)
|
|
||||||
{:ok, pdf2} = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert pdf1 == pdf2
|
|
||||||
assert String.starts_with?(pdf1, "%PDF")
|
|
||||||
assert String.starts_with?(pdf2, "%PDF")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns error when template file is missing" do
|
|
||||||
template_path =
|
|
||||||
Path.join(Application.app_dir(:mv, "priv"), "pdf_templates/members_export.typ")
|
|
||||||
|
|
||||||
original_content = File.read!(template_path)
|
|
||||||
File.rm(template_path)
|
|
||||||
|
|
||||||
try do
|
|
||||||
export_data = %{
|
|
||||||
columns: [%{key: "first_name", kind: :member_field, label: "Vorname"}],
|
|
||||||
rows: [["Max"]],
|
|
||||||
meta: %{generated_at: "2024-01-15T14:30:00Z", member_count: 1}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:error, {:template_not_found, _reason}} = result
|
|
||||||
after
|
|
||||||
File.write!(template_path, original_content)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles empty rows gracefully" do
|
|
||||||
export_data = %{
|
|
||||||
columns: [
|
|
||||||
%{key: "first_name", kind: :member_field, label: "Vorname"},
|
|
||||||
%{key: "last_name", kind: :member_field, label: "Nachname"}
|
|
||||||
],
|
|
||||||
rows: [],
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:ok, pdf_binary} = result
|
|
||||||
assert is_binary(pdf_binary)
|
|
||||||
assert String.starts_with?(pdf_binary, "%PDF")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles many columns correctly" do
|
|
||||||
# Test with 10 columns to ensure dynamic column width calculation works
|
|
||||||
columns =
|
|
||||||
Enum.map(1..10, fn i ->
|
|
||||||
%{key: "field_#{i}", kind: :member_field, label: "Feld #{i}"}
|
|
||||||
end)
|
|
||||||
|
|
||||||
export_data = %{
|
|
||||||
columns: columns,
|
|
||||||
rows: [Enum.map(1..10, &"Wert #{&1}")],
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:ok, pdf_binary} = result
|
|
||||||
assert is_binary(pdf_binary)
|
|
||||||
assert String.starts_with?(pdf_binary, "%PDF")
|
|
||||||
end
|
|
||||||
|
|
||||||
test "creates and cleans up temp directory" do
|
|
||||||
export_data = %{
|
|
||||||
columns: [%{key: "first_name", kind: :member_field, label: "Vorname"}],
|
|
||||||
rows: [["Max"]],
|
|
||||||
meta: %{
|
|
||||||
generated_at: "2024-01-15T14:30:00Z",
|
|
||||||
member_count: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get temp base directory
|
|
||||||
temp_base = System.tmp_dir!()
|
|
||||||
|
|
||||||
# Count temp directories before
|
|
||||||
before_count =
|
|
||||||
temp_base
|
|
||||||
|> File.ls!()
|
|
||||||
|> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end)
|
|
||||||
|
|
||||||
result = MembersPDF.render(export_data)
|
|
||||||
|
|
||||||
assert {:ok, _pdf_binary} = result
|
|
||||||
|
|
||||||
# Wait a bit for cleanup (async cleanup might take a moment)
|
|
||||||
Process.sleep(100)
|
|
||||||
|
|
||||||
# Count temp directories after
|
|
||||||
after_count =
|
|
||||||
temp_base
|
|
||||||
|> File.ls!()
|
|
||||||
|> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end)
|
|
||||||
|
|
||||||
# Should have same or fewer temp dirs (cleanup should have run)
|
|
||||||
assert after_count <= before_count + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
alias Mv.Membership
|
|
||||||
alias Mv.MembershipFees
|
alias Mv.MembershipFees
|
||||||
|
alias Mv.Membership
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ defmodule Mv.OidcRoleSyncTest do
|
||||||
# Minimal JWT: header.payload.signature with "groups" in payload (Rauthy puts groups in access_token)
|
# Minimal JWT: header.payload.signature with "groups" in payload (Rauthy puts groups in access_token)
|
||||||
payload = Jason.encode!(%{"groups" => ["mila-admin"], "sub" => "oidc-123"})
|
payload = Jason.encode!(%{"groups" => ["mila-admin"], "sub" => "oidc-123"})
|
||||||
payload_b64 = Base.url_encode64(payload, padding: false)
|
payload_b64 = Base.url_encode64(payload, padding: false)
|
||||||
header_b64 = Base.url_encode64(~s({"alg":"HS256","typ":"JWT"}), padding: false)
|
header_b64 = Base.url_encode64("{\"alg\":\"HS256\",\"typ\":\"JWT\"}", padding: false)
|
||||||
sig_b64 = Base.url_encode64("sig", padding: false)
|
sig_b64 = Base.url_encode64("sig", padding: false)
|
||||||
access_token = "#{header_b64}.#{payload_b64}.#{sig_b64}"
|
access_token = "#{header_b64}.#{payload_b64}.#{sig_b64}"
|
||||||
oauth_tokens = %{"access_token" => access_token}
|
oauth_tokens = %{"access_token" => access_token}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,10 @@ defmodule Mv.StatisticsTest do
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
alias Mv.Membership.Member
|
||||||
|
alias Mv.Statistics
|
||||||
alias Mv.MembershipFees
|
alias Mv.MembershipFees
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias Mv.Statistics
|
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
actor = Mv.Helpers.SystemActor.get_system_actor()
|
actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ defmodule Mv.Vereinfacht.ClientTest do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Tests for Mv.Vereinfacht.Client.
|
Tests for Mv.Vereinfacht.Client.
|
||||||
|
|
||||||
"Not configured" path: no HTTP. When configured we use Bypass to stub the API
|
Only tests the "not configured" path; no real HTTP calls. Config reads from
|
||||||
and assert on request (query params) and response parsing.
|
ENV first, then from Settings (DB), so we use DataCase so get_settings() is available.
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
|
@ -30,84 +30,6 @@ 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",
|
||||||
|
|
@ -120,12 +42,6 @@ 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")
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ defmodule MvWeb.AuthorizationTest do
|
||||||
"""
|
"""
|
||||||
use ExUnit.Case, async: true
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
alias Mv.Accounts.User
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias MvWeb.Authorization
|
alias MvWeb.Authorization
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias Mv.Accounts.User
|
||||||
|
|
||||||
describe "can?/3 with resource atom" do
|
describe "can?/3 with resource atom" do
|
||||||
test "returns true when user has permission for resource+action" do
|
test "returns true when user has permission for resource+action" do
|
||||||
|
|
|
||||||
|
|
@ -199,8 +199,7 @@ defmodule MvWeb.AuthControllerTest do
|
||||||
|
|
||||||
assert to =~ "/auth/user/password/sign_in_with_token"
|
assert to =~ "/auth/user/password/sign_in_with_token"
|
||||||
|
|
||||||
# After login, user is redirected to /auth/user/password/sign_in_with_token.
|
# After login, user is redirected to /auth/user/password/sign_in_with_token. Session handling for protected routes should be tested in integration or E2E tests.
|
||||||
# Session handling for protected routes should be tested in integration or E2E tests.
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Edge cases
|
# Edge cases
|
||||||
|
|
|
||||||
|
|
@ -119,78 +119,6 @@ defmodule MvWeb.MemberExportControllerTest do
|
||||||
assert body =~ "Carol"
|
assert body =~ "Carol"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "selected_ids override filters: only selected members exported when filters also set", %{
|
|
||||||
conn: conn,
|
|
||||||
member1: m1,
|
|
||||||
member2: m2,
|
|
||||||
member3: _m3
|
|
||||||
} do
|
|
||||||
# When selected_ids is set, cycle_status_filter and boolean_filters must not reduce the set:
|
|
||||||
# only the selected members are exported.
|
|
||||||
payload = %{
|
|
||||||
"selected_ids" => [m1.id, m2.id],
|
|
||||||
"member_fields" => ["first_name", "last_name", "email"],
|
|
||||||
"custom_field_ids" => [],
|
|
||||||
"query" => nil,
|
|
||||||
"sort_field" => nil,
|
|
||||||
"sort_order" => nil,
|
|
||||||
"cycle_status_filter" => "paid",
|
|
||||||
"boolean_filters" => %{}
|
|
||||||
}
|
|
||||||
|
|
||||||
conn = get(conn, "/members")
|
|
||||||
csrf_token = csrf_token_from_conn(conn)
|
|
||||||
|
|
||||||
conn =
|
|
||||||
post(conn, "/members/export.csv", %{
|
|
||||||
"payload" => Jason.encode!(payload),
|
|
||||||
"_csrf_token" => csrf_token
|
|
||||||
})
|
|
||||||
|
|
||||||
assert conn.status == 200
|
|
||||||
body = response(conn, 200)
|
|
||||||
lines = export_lines(body)
|
|
||||||
|
|
||||||
assert length(lines) == 3
|
|
||||||
assert body =~ "Alice"
|
|
||||||
assert body =~ "Bob"
|
|
||||||
refute body =~ "Carol"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "cycle_status_filter applied when export all returns CSV", %{
|
|
||||||
conn: conn,
|
|
||||||
member1: _m1,
|
|
||||||
member2: _m2,
|
|
||||||
member3: _m3
|
|
||||||
} do
|
|
||||||
payload = %{
|
|
||||||
"selected_ids" => [],
|
|
||||||
"member_fields" => ["first_name", "email"],
|
|
||||||
"custom_field_ids" => [],
|
|
||||||
"query" => nil,
|
|
||||||
"sort_field" => nil,
|
|
||||||
"sort_order" => nil,
|
|
||||||
"cycle_status_filter" => "paid",
|
|
||||||
"show_current_cycle" => true
|
|
||||||
}
|
|
||||||
|
|
||||||
conn = get(conn, "/members")
|
|
||||||
csrf_token = csrf_token_from_conn(conn)
|
|
||||||
|
|
||||||
conn =
|
|
||||||
post(conn, "/members/export.csv", %{
|
|
||||||
"payload" => Jason.encode!(payload),
|
|
||||||
"_csrf_token" => csrf_token
|
|
||||||
})
|
|
||||||
|
|
||||||
assert conn.status == 200
|
|
||||||
assert get_resp_header(conn, "content-type") |> List.first() =~ "text/csv"
|
|
||||||
body = response(conn, 200)
|
|
||||||
lines = export_lines(body)
|
|
||||||
assert lines != []
|
|
||||||
assert hd(lines) =~ "First Name"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "filters out unknown member fields from export", %{conn: conn, member1: m1} do
|
test "filters out unknown member fields from export", %{conn: conn, member1: m1} do
|
||||||
payload = %{
|
payload = %{
|
||||||
"selected_ids" => [m1.id],
|
"selected_ids" => [m1.id],
|
||||||
|
|
@ -249,8 +177,8 @@ defmodule MvWeb.MemberExportControllerTest do
|
||||||
assert body =~ "Alice"
|
assert body =~ "Alice"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Regression: when membership_fee_start_date is not in member_fields, Fee Type must still be exported.
|
# Regression: when membership_fee_start_date is not in member_fields, Fee Type must still be exported (append fallback)
|
||||||
test "export includes Fee Type when first_name and membership_fee_type only (no start_date)",
|
test "export includes Fee Type when only first_name and membership_fee_type are requested (no start_date)",
|
||||||
%{
|
%{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
member1: m1
|
member1: m1
|
||||||
|
|
@ -636,113 +564,6 @@ defmodule MvWeb.MemberExportControllerTest do
|
||||||
assert phone_idx < membership_idx
|
assert phone_idx < membership_idx
|
||||||
assert membership_idx < active_idx
|
assert membership_idx < active_idx
|
||||||
end
|
end
|
||||||
|
|
||||||
test "exports only filtered members when selected_ids empty and boolean_filters set (Export all)",
|
|
||||||
%{
|
|
||||||
conn: conn,
|
|
||||||
boolean_field: boolean_field,
|
|
||||||
member_with_boolean: member_with_boolean,
|
|
||||||
member_with_string: member_with_string,
|
|
||||||
member_with_integer: member_with_integer,
|
|
||||||
member_without_value: member_without_value
|
|
||||||
} do
|
|
||||||
# Simulate "filter + Export (all)": no selection, but boolean filter "Active Member = true"
|
|
||||||
payload = %{
|
|
||||||
"selected_ids" => [],
|
|
||||||
"member_fields" => ["first_name", "last_name"],
|
|
||||||
"custom_field_ids" => [boolean_field.id],
|
|
||||||
"query" => nil,
|
|
||||||
"sort_field" => nil,
|
|
||||||
"sort_order" => nil,
|
|
||||||
"boolean_filters" => %{to_string(boolean_field.id) => true}
|
|
||||||
}
|
|
||||||
|
|
||||||
conn = get(conn, "/members")
|
|
||||||
csrf_token = csrf_token_from_conn(conn)
|
|
||||||
|
|
||||||
conn =
|
|
||||||
post(conn, "/members/export.csv", %{
|
|
||||||
"payload" => Jason.encode!(payload),
|
|
||||||
"_csrf_token" => csrf_token
|
|
||||||
})
|
|
||||||
|
|
||||||
assert conn.status == 200
|
|
||||||
body = response(conn, 200)
|
|
||||||
lines = export_lines(body)
|
|
||||||
|
|
||||||
# Header + data rows: only members matching the boolean filter (Active Member = true)
|
|
||||||
assert length(lines) >= 2
|
|
||||||
assert body =~ "Boolean"
|
|
||||||
assert body =~ member_with_boolean.last_name
|
|
||||||
# Other test members (no value or different value for that custom field) must not appear
|
|
||||||
refute body =~ member_with_string.last_name
|
|
||||||
refute body =~ member_with_integer.last_name
|
|
||||||
refute body =~ member_without_value.last_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "boolean_filters accept string true/false from query encoding", %{
|
|
||||||
conn: conn,
|
|
||||||
boolean_field: boolean_field,
|
|
||||||
member_with_boolean: member_with_boolean
|
|
||||||
} do
|
|
||||||
payload = %{
|
|
||||||
"selected_ids" => [],
|
|
||||||
"member_fields" => ["first_name", "last_name"],
|
|
||||||
"custom_field_ids" => [boolean_field.id],
|
|
||||||
"query" => nil,
|
|
||||||
"sort_field" => nil,
|
|
||||||
"sort_order" => nil,
|
|
||||||
"boolean_filters" => %{to_string(boolean_field.id) => "true"}
|
|
||||||
}
|
|
||||||
|
|
||||||
conn = get(conn, "/members")
|
|
||||||
csrf_token = csrf_token_from_conn(conn)
|
|
||||||
|
|
||||||
conn =
|
|
||||||
post(conn, "/members/export.csv", %{
|
|
||||||
"payload" => Jason.encode!(payload),
|
|
||||||
"_csrf_token" => csrf_token
|
|
||||||
})
|
|
||||||
|
|
||||||
assert conn.status == 200
|
|
||||||
body = response(conn, 200)
|
|
||||||
assert body =~ member_with_boolean.last_name
|
|
||||||
end
|
|
||||||
|
|
||||||
test "combination cycle_status_filter and boolean_filters applied when export all", %{
|
|
||||||
conn: conn,
|
|
||||||
boolean_field: boolean_field,
|
|
||||||
member_with_boolean: _member_with_boolean
|
|
||||||
} do
|
|
||||||
# Both filters are applied (AND). Export returns 200 and valid CSV.
|
|
||||||
payload = %{
|
|
||||||
"selected_ids" => [],
|
|
||||||
"member_fields" => ["first_name", "last_name"],
|
|
||||||
"custom_field_ids" => [boolean_field.id],
|
|
||||||
"query" => nil,
|
|
||||||
"sort_field" => nil,
|
|
||||||
"sort_order" => nil,
|
|
||||||
"cycle_status_filter" => "paid",
|
|
||||||
"show_current_cycle" => true,
|
|
||||||
"boolean_filters" => %{to_string(boolean_field.id) => true}
|
|
||||||
}
|
|
||||||
|
|
||||||
conn = get(conn, "/members")
|
|
||||||
csrf_token = csrf_token_from_conn(conn)
|
|
||||||
|
|
||||||
conn =
|
|
||||||
post(conn, "/members/export.csv", %{
|
|
||||||
"payload" => Jason.encode!(payload),
|
|
||||||
"_csrf_token" => csrf_token
|
|
||||||
})
|
|
||||||
|
|
||||||
assert conn.status == 200
|
|
||||||
assert get_resp_header(conn, "content-type") |> List.first() =~ "text/csv"
|
|
||||||
body = response(conn, 200)
|
|
||||||
lines = export_lines(body)
|
|
||||||
assert lines != []
|
|
||||||
assert hd(lines) =~ "First Name"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "POST /members/export.pdf" do
|
describe "POST /members/export.pdf" do
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
alias Mv.MembershipFees.CalendarCycles
|
|
||||||
alias MvWeb.Helpers.MembershipFeeHelpers
|
alias MvWeb.Helpers.MembershipFeeHelpers
|
||||||
|
alias Mv.MembershipFees.CalendarCycles
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ defmodule MvWeb.GroupLive.IndexTest do
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Mv.Fixtures
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
describe "mount and display" do
|
describe "mount and display" do
|
||||||
test "page renders successfully for admin user", %{conn: conn} do
|
test "page renders successfully for admin user", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ defmodule MvWeb.GroupLive.IntegrationTest do
|
||||||
import Ash.Expr
|
import Ash.Expr
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Mv.Fixtures
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
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)", %{
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Mv.Fixtures
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
describe "ARIA labels and roles" do
|
describe "ARIA labels and roles" do
|
||||||
test "search input has proper ARIA attributes", %{conn: conn} do
|
test "search input has proper ARIA attributes", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|
||||||
import MvWeb.GroupLiveHelpers
|
import MvWeb.GroupLiveHelpers
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Mv.Fixtures
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
describe "successful add member" do
|
describe "successful add member" do
|
||||||
test "member is added to group after selection and clicking Add", %{conn: conn} do
|
test "member is added to group after selection and clicking Add", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Mv.Fixtures
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
describe "Add Member button visibility" do
|
describe "Add Member button visibility" do
|
||||||
@tag role: :read_only
|
@tag role: :read_only
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Mv.Fixtures
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
describe "server-side authorization" do
|
describe "server-side authorization" do
|
||||||
test "add member event handler checks :update permission", %{conn: conn} do
|
test "add member event handler checks :update permission", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Mv.Fixtures
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
describe "data consistency" do
|
describe "data consistency" do
|
||||||
test "member appears in group after add (verified in database)", %{conn: conn} do
|
test "member appears in group after add (verified in database)", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Mv.Fixtures
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
# Helper to setup authenticated connection for admin
|
# Helper to setup authenticated connection for admin
|
||||||
defp setup_admin_conn(conn) do
|
defp setup_admin_conn(conn) do
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Mv.Fixtures
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
describe "successful remove member" do
|
describe "successful remove member" do
|
||||||
test "member is removed from group after clicking Remove", %{conn: conn} do
|
test "member is removed from group after clicking Remove", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ defmodule MvWeb.GroupLive.ShowTest do
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
alias Mv.Fixtures
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
describe "mount and display" do
|
describe "mount and display" do
|
||||||
test "page renders successfully", %{conn: conn} do
|
test "page renders successfully", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -93,14 +93,14 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|
||||||
assert to == "/membership_fee_settings/new_fee_type"
|
assert to == "/membership_fee_settings/new_fee_type"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "row click navigates to edit form", %{conn: conn, current_user: admin_user} do
|
test "edit button per row navigates to edit form", %{conn: conn, current_user: admin_user} do
|
||||||
fee_type = create_fee_type(%{interval: :yearly}, admin_user)
|
fee_type = create_fee_type(%{interval: :yearly}, admin_user)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/membership_fee_settings")
|
{:ok, view, _html} = live(conn, "/membership_fee_settings")
|
||||||
|
|
||||||
{:error, {:live_redirect, %{to: to}}} =
|
{:error, {:live_redirect, %{to: to}}} =
|
||||||
view
|
view
|
||||||
|> element("#membership_fee_types tr#mft-#{fee_type.id} td:first-of-type")
|
|> element("a[href='/membership_fee_settings/#{fee_type.id}/edit_fee_type']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
assert to == "/membership_fee_settings/#{fee_type.id}/edit_fee_type"
|
assert to == "/membership_fee_settings/#{fee_type.id}/edit_fee_type"
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
||||||
"""
|
"""
|
||||||
use Mv.DataCase, async: false
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
|
alias Mv.Membership.Member
|
||||||
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
alias Mv.Helpers.SystemActor
|
|
||||||
alias Mv.Membership
|
|
||||||
alias Mv.Membership.CustomField
|
|
||||||
alias Mv.Membership.CustomFieldValue
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
alias MvWeb.MemberLive.Index, as: MemberIndex
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
|
||||||
# Helper to create a membership fee type (shared across all tests)
|
# Helper to create a membership fee type (shared across all tests)
|
||||||
defp create_fee_type(attrs, actor) do
|
defp create_fee_type(attrs, actor) do
|
||||||
|
|
@ -303,10 +298,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
@tag :ui
|
@tag :ui
|
||||||
test "member index does not render Edit or Delete actions", %{conn: conn} do
|
test "member index does not render Edit or Delete actions", %{conn: conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, _member} =
|
{:ok, _member} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{first_name: "Test", last_name: "User", email: "test@example.com"},
|
%{first_name: "Test", last_name: "User", email: "test@example.com"},
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
@ -320,10 +315,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
@tag :ui
|
@tag :ui
|
||||||
test "row click navigates to member show", %{conn: conn} do
|
test "row click navigates to member show", %{conn: conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{first_name: "Row", last_name: "Click", email: "rowclick@example.com"},
|
%{first_name: "Row", last_name: "Click", email: "rowclick@example.com"},
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
@ -343,10 +338,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
@describetag :ui
|
@describetag :ui
|
||||||
|
|
||||||
test "clickable rows have hover and focus-within ring classes", %{conn: conn} do
|
test "clickable rows have hover and focus-within ring classes", %{conn: conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, _member} =
|
{:ok, _member} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{first_name: "Hover", last_name: "Test", email: "hover@example.com"},
|
%{first_name: "Hover", last_name: "Test", email: "hover@example.com"},
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
@ -361,10 +356,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "selected outline only from checkbox selection, not from highlight param", %{conn: conn} do
|
test "selected outline only from checkbox selection, not from highlight param", %{conn: conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{first_name: "Highlight", last_name: "Only", email: "highlight@example.com"},
|
%{first_name: "Highlight", last_name: "Only", email: "highlight@example.com"},
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
@ -379,11 +374,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
describe "copy_emails feature" do
|
describe "copy_emails feature" do
|
||||||
setup do
|
setup do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
# Create test members
|
# Create test members
|
||||||
{:ok, member1} =
|
{:ok, member1} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "Max",
|
first_name: "Max",
|
||||||
last_name: "Mustermann",
|
last_name: "Mustermann",
|
||||||
|
|
@ -393,7 +388,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, member2} =
|
{:ok, member2} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "Erika",
|
first_name: "Erika",
|
||||||
last_name: "Musterfrau",
|
last_name: "Musterfrau",
|
||||||
|
|
@ -403,7 +398,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, member3} =
|
{:ok, member3} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "Hans",
|
first_name: "Hans",
|
||||||
last_name: "Müller-Lüdenscheidt",
|
last_name: "Müller-Lüdenscheidt",
|
||||||
|
|
@ -490,7 +485,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
render_click(view, "select_member", %{"id" => member1.id})
|
render_click(view, "select_member", %{"id" => member1.id})
|
||||||
|
|
||||||
# Delete the member from the database
|
# Delete the member from the database
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
Ash.destroy!(member1, actor: system_actor)
|
Ash.destroy!(member1, actor: system_actor)
|
||||||
|
|
||||||
# Trigger copy_emails event directly - selection still contains the deleted ID
|
# Trigger copy_emails event directly - selection still contains the deleted ID
|
||||||
|
|
@ -531,10 +526,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
# Create a member with known data
|
# Create a member with known data
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, test_member} =
|
{:ok, test_member} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Format",
|
last_name: "Format",
|
||||||
|
|
@ -603,10 +598,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
describe "export dropdown" do
|
describe "export dropdown" do
|
||||||
setup do
|
setup do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
{:ok, m1} =
|
{:ok, m1} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{first_name: "Export", last_name: "One", email: "export1@example.com"},
|
%{first_name: "Export", last_name: "One", email: "export1@example.com"},
|
||||||
actor: system_actor
|
actor: system_actor
|
||||||
)
|
)
|
||||||
|
|
@ -760,12 +755,12 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
attrs = Map.merge(default_attrs, attrs)
|
attrs = Map.merge(default_attrs, attrs)
|
||||||
{:ok, member} = Membership.create_member(attrs, actor: actor)
|
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
|
||||||
member
|
member
|
||||||
end
|
end
|
||||||
|
|
||||||
test "filter shows only members with paid status in last cycle", %{conn: conn} do
|
test "filter shows only members with paid status in last cycle", %{conn: conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
|
|
@ -812,7 +807,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
|
test "filter shows only members with unpaid status in last cycle", %{conn: conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
|
|
@ -859,7 +854,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "filter shows only members with paid status in current cycle", %{conn: conn} do
|
test "filter shows only members with paid status in current cycle", %{conn: conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
|
|
@ -906,7 +901,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
|
test "filter shows only members with unpaid status in current cycle", %{conn: conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
|
|
@ -975,9 +970,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "boolean custom field filters" do
|
describe "boolean custom field filters" do
|
||||||
|
alias Mv.Membership.CustomField
|
||||||
|
|
||||||
# Helper to create a boolean custom field (uses system actor for authorization)
|
# Helper to create a boolean custom field (uses system actor for authorization)
|
||||||
defp create_boolean_custom_field(attrs \\ %{}) do
|
defp create_boolean_custom_field(attrs \\ %{}) do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "test_boolean_#{System.unique_integer([:positive])}",
|
name: "test_boolean_#{System.unique_integer([:positive])}",
|
||||||
|
|
@ -993,7 +990,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
# Helper to create a non-boolean custom field (uses system actor for authorization)
|
# Helper to create a non-boolean custom field (uses system actor for authorization)
|
||||||
defp create_string_custom_field(attrs \\ %{}) do
|
defp create_string_custom_field(attrs \\ %{}) do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
default_attrs = %{
|
default_attrs = %{
|
||||||
name: "test_string_#{System.unique_integer([:positive])}",
|
name: "test_string_#{System.unique_integer([:positive])}",
|
||||||
|
|
@ -1247,7 +1244,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
test "handle_params removes filter when custom field is deleted", %{conn: conn} do
|
test "handle_params removes filter when custom field is deleted", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
# Set up filter via URL
|
# Set up filter via URL
|
||||||
|
|
@ -1362,10 +1359,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
}
|
}
|
||||||
|> Map.merge(member_attrs)
|
|> Map.merge(member_attrs)
|
||||||
|
|
||||||
{:ok, member} = Membership.create_member(attrs, actor: actor)
|
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
|
||||||
|
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member.id,
|
member_id: member.id,
|
||||||
custom_field_id: custom_field.id,
|
custom_field_id: custom_field.id,
|
||||||
|
|
@ -1380,33 +1377,33 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
# Tests for get_boolean_custom_field_value/2
|
# Tests for get_boolean_custom_field_value/2
|
||||||
test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do
|
test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
member = create_member_with_boolean_value(%{}, boolean_field, true, system_actor)
|
member = create_member_with_boolean_value(%{}, boolean_field, true, system_actor)
|
||||||
|
|
||||||
# Test the function (will fail until implemented)
|
# Test the function (will fail until implemented)
|
||||||
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
|
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||||||
|
|
||||||
assert result == true
|
assert result == true
|
||||||
end
|
end
|
||||||
|
|
||||||
test "get_boolean_custom_field_value extracts false from Ash.Union format", %{conn: _conn} do
|
test "get_boolean_custom_field_value extracts false from Ash.Union format", %{conn: _conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
member = create_member_with_boolean_value(%{}, boolean_field, false, system_actor)
|
member = create_member_with_boolean_value(%{}, boolean_field, false, system_actor)
|
||||||
|
|
||||||
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
|
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||||||
|
|
||||||
assert result == false
|
assert result == false
|
||||||
end
|
end
|
||||||
|
|
||||||
test "get_boolean_custom_field_value extracts true from map format with _union_type and _union_value keys",
|
test "get_boolean_custom_field_value extracts true from map format with _union_type and _union_value keys",
|
||||||
%{conn: _conn} do
|
%{conn: _conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -1417,7 +1414,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
# Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
|
# Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member.id,
|
member_id: member.id,
|
||||||
custom_field_id: boolean_field.id,
|
custom_field_id: boolean_field.id,
|
||||||
|
|
@ -1428,7 +1425,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
# Reload member with custom field values
|
# Reload member with custom field values
|
||||||
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
||||||
|
|
||||||
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
|
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||||||
|
|
||||||
assert result == true
|
assert result == true
|
||||||
end
|
end
|
||||||
|
|
@ -1436,11 +1433,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{
|
test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{
|
||||||
conn: _conn
|
conn: _conn
|
||||||
} do
|
} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -1452,7 +1449,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
# Member has no custom field value for this field
|
# Member has no custom field value for this field
|
||||||
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
||||||
|
|
||||||
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
|
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||||||
|
|
||||||
assert result == nil
|
assert result == nil
|
||||||
end
|
end
|
||||||
|
|
@ -1460,11 +1457,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{
|
test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{
|
||||||
conn: _conn
|
conn: _conn
|
||||||
} do
|
} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -1475,7 +1472,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
# Create CustomFieldValue with nil value (edge case)
|
# Create CustomFieldValue with nil value (edge case)
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member.id,
|
member_id: member.id,
|
||||||
custom_field_id: boolean_field.id,
|
custom_field_id: boolean_field.id,
|
||||||
|
|
@ -1485,7 +1482,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
||||||
|
|
||||||
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
|
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||||||
|
|
||||||
assert result == nil
|
assert result == nil
|
||||||
end
|
end
|
||||||
|
|
@ -1493,12 +1490,12 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{
|
test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{
|
||||||
conn: _conn
|
conn: _conn
|
||||||
} do
|
} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
string_field = create_string_custom_field()
|
string_field = create_string_custom_field()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
{:ok, member} =
|
{:ok, member} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "Test",
|
first_name: "Test",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -1509,7 +1506,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
# Create string custom field value (not boolean)
|
# Create string custom field value (not boolean)
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member.id,
|
member_id: member.id,
|
||||||
custom_field_id: string_field.id,
|
custom_field_id: string_field.id,
|
||||||
|
|
@ -1520,7 +1517,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
member = member |> Ash.load!(:custom_field_values, actor: system_actor)
|
||||||
|
|
||||||
# Try to get boolean value from string field - should return nil
|
# Try to get boolean value from string field - should return nil
|
||||||
result = MemberIndex.get_boolean_custom_field_value(member, boolean_field)
|
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||||||
|
|
||||||
assert result == nil
|
assert result == nil
|
||||||
end
|
end
|
||||||
|
|
@ -1528,7 +1525,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
# Tests for apply_boolean_custom_field_filters/2
|
# Tests for apply_boolean_custom_field_filters/2
|
||||||
test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values",
|
test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values",
|
||||||
%{conn: _conn} do
|
%{conn: _conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
member_with_true =
|
member_with_true =
|
||||||
|
|
@ -1548,7 +1545,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, member_without_value} =
|
{:ok, member_without_value} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "NoValue",
|
first_name: "NoValue",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -1562,10 +1559,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
members = [member_with_true, member_with_false, member_without_value]
|
members = [member_with_true, member_with_false, member_without_value]
|
||||||
filters = %{to_string(boolean_field.id) => true}
|
filters = %{to_string(boolean_field.id) => true}
|
||||||
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
|
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
MemberIndex.apply_boolean_custom_field_filters(
|
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||||
members,
|
members,
|
||||||
filters,
|
filters,
|
||||||
all_custom_fields
|
all_custom_fields
|
||||||
|
|
@ -1579,7 +1576,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
test "apply_boolean_custom_field_filters filters members with false value and excludes true/without values",
|
test "apply_boolean_custom_field_filters filters members with false value and excludes true/without values",
|
||||||
%{conn: _conn} do
|
%{conn: _conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
member_with_true =
|
member_with_true =
|
||||||
|
|
@ -1599,7 +1596,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, member_without_value} =
|
{:ok, member_without_value} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "NoValue",
|
first_name: "NoValue",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -1613,10 +1610,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
members = [member_with_true, member_with_false, member_without_value]
|
members = [member_with_true, member_with_false, member_without_value]
|
||||||
filters = %{to_string(boolean_field.id) => false}
|
filters = %{to_string(boolean_field.id) => false}
|
||||||
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
|
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
MemberIndex.apply_boolean_custom_field_filters(
|
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||||
members,
|
members,
|
||||||
filters,
|
filters,
|
||||||
all_custom_fields
|
all_custom_fields
|
||||||
|
|
@ -1631,7 +1628,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
test "apply_boolean_custom_field_filters returns all members when filter map is empty", %{
|
test "apply_boolean_custom_field_filters returns all members when filter map is empty", %{
|
||||||
conn: _conn
|
conn: _conn
|
||||||
} do
|
} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
member1 =
|
member1 =
|
||||||
|
|
@ -1652,10 +1649,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
members = [member1, member2]
|
members = [member1, member2]
|
||||||
filters = %{}
|
filters = %{}
|
||||||
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
|
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
MemberIndex.apply_boolean_custom_field_filters(
|
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||||
members,
|
members,
|
||||||
filters,
|
filters,
|
||||||
all_custom_fields
|
all_custom_fields
|
||||||
|
|
@ -1671,13 +1668,13 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
test "apply_boolean_custom_field_filters applies multiple filters with AND logic", %{
|
test "apply_boolean_custom_field_filters applies multiple filters with AND logic", %{
|
||||||
conn: _conn
|
conn: _conn
|
||||||
} do
|
} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
|
boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
|
||||||
boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
|
boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
|
||||||
|
|
||||||
# Member with both fields = true
|
# Member with both fields = true
|
||||||
{:ok, member_both_true} =
|
{:ok, member_both_true} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "BothTrue",
|
first_name: "BothTrue",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -1687,7 +1684,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, _cfv1} =
|
{:ok, _cfv1} =
|
||||||
CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member_both_true.id,
|
member_id: member_both_true.id,
|
||||||
custom_field_id: boolean_field1.id,
|
custom_field_id: boolean_field1.id,
|
||||||
|
|
@ -1696,7 +1693,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|> Ash.create(actor: system_actor)
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
{:ok, _cfv2} =
|
||||||
CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member_both_true.id,
|
member_id: member_both_true.id,
|
||||||
custom_field_id: boolean_field2.id,
|
custom_field_id: boolean_field2.id,
|
||||||
|
|
@ -1708,7 +1705,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
# Member with field1 = true, field2 = false
|
# Member with field1 = true, field2 = false
|
||||||
{:ok, member_mixed} =
|
{:ok, member_mixed} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "Mixed",
|
first_name: "Mixed",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -1718,7 +1715,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, _cfv3} =
|
{:ok, _cfv3} =
|
||||||
CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member_mixed.id,
|
member_id: member_mixed.id,
|
||||||
custom_field_id: boolean_field1.id,
|
custom_field_id: boolean_field1.id,
|
||||||
|
|
@ -1727,7 +1724,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|> Ash.create(actor: system_actor)
|
|> Ash.create(actor: system_actor)
|
||||||
|
|
||||||
{:ok, _cfv4} =
|
{:ok, _cfv4} =
|
||||||
CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member_mixed.id,
|
member_id: member_mixed.id,
|
||||||
custom_field_id: boolean_field2.id,
|
custom_field_id: boolean_field2.id,
|
||||||
|
|
@ -1744,10 +1741,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
to_string(boolean_field2.id) => true
|
to_string(boolean_field2.id) => true
|
||||||
}
|
}
|
||||||
|
|
||||||
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
|
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
MemberIndex.apply_boolean_custom_field_filters(
|
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||||
members,
|
members,
|
||||||
filters,
|
filters,
|
||||||
all_custom_fields
|
all_custom_fields
|
||||||
|
|
@ -1761,7 +1758,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
test "apply_boolean_custom_field_filters ignores filter with non-existent custom field ID", %{
|
test "apply_boolean_custom_field_filters ignores filter with non-existent custom field ID", %{
|
||||||
conn: _conn
|
conn: _conn
|
||||||
} do
|
} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
fake_id = Ecto.UUID.generate()
|
fake_id = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
|
@ -1775,10 +1772,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
members = [member]
|
members = [member]
|
||||||
filters = %{fake_id => true}
|
filters = %{fake_id => true}
|
||||||
all_custom_fields = CustomField |> Ash.read!(actor: system_actor)
|
all_custom_fields = Mv.Membership.CustomField |> Ash.read!(actor: system_actor)
|
||||||
|
|
||||||
result =
|
result =
|
||||||
MemberIndex.apply_boolean_custom_field_filters(
|
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||||
members,
|
members,
|
||||||
filters,
|
filters,
|
||||||
all_custom_fields
|
all_custom_fields
|
||||||
|
|
@ -1791,7 +1788,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
# Integration tests for boolean custom field filters in load_members
|
# Integration tests for boolean custom field filters in load_members
|
||||||
test "boolean filter integration filters members by boolean custom field value via URL parameter",
|
test "boolean filter integration filters members by boolean custom field value via URL parameter",
|
||||||
%{conn: conn} do
|
%{conn: conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
|
|
@ -1812,7 +1809,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, _member_without_value} =
|
{:ok, _member_without_value} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "NoValue",
|
first_name: "NoValue",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -1839,7 +1836,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "boolean filter integration works together with cycle_status_filter", %{conn: conn} do
|
test "boolean filter integration works together with cycle_status_filter", %{conn: conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
fee_type = create_fee_type(%{interval: :yearly}, system_actor)
|
||||||
|
|
@ -1848,7 +1845,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
# Member with true boolean value and paid status
|
# Member with true boolean value and paid status
|
||||||
{:ok, member_paid_true} =
|
{:ok, member_paid_true} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "PaidTrue",
|
first_name: "PaidTrue",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -1859,7 +1856,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member_paid_true.id,
|
member_id: member_paid_true.id,
|
||||||
custom_field_id: boolean_field.id,
|
custom_field_id: boolean_field.id,
|
||||||
|
|
@ -1876,7 +1873,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
# Member with true boolean value but unpaid status
|
# Member with true boolean value but unpaid status
|
||||||
{:ok, member_unpaid_true} =
|
{:ok, member_unpaid_true} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "UnpaidTrue",
|
first_name: "UnpaidTrue",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -1887,7 +1884,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
{:ok, _cfv2} =
|
||||||
CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member_unpaid_true.id,
|
member_id: member_unpaid_true.id,
|
||||||
custom_field_id: boolean_field.id,
|
custom_field_id: boolean_field.id,
|
||||||
|
|
@ -1912,7 +1909,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "boolean filter integration works together with search query", %{conn: conn} do
|
test "boolean filter integration works together with search query", %{conn: conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
|
|
@ -1942,7 +1939,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "boolean filter works even when custom field is not visible in overview", %{conn: conn} do
|
test "boolean filter works even when custom field is not visible in overview", %{conn: conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
|
|
||||||
# Create boolean field with show_in_overview: false
|
# Create boolean field with show_in_overview: false
|
||||||
|
|
@ -1965,7 +1962,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, _member_without_value} =
|
{:ok, _member_without_value} =
|
||||||
Membership.create_member(
|
Mv.Membership.create_member(
|
||||||
%{
|
%{
|
||||||
first_name: "NoValue",
|
first_name: "NoValue",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
|
|
@ -2019,7 +2016,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
@tag :slow
|
@tag :slow
|
||||||
test "boolean filter performance with 150 members", %{conn: conn} do
|
test "boolean filter performance with 150 members", %{conn: conn} do
|
||||||
system_actor = SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Mv.MembershipFees.MembershipFeeCycle
|
|
||||||
alias Mv.MembershipFees.MembershipFeeType
|
alias Mv.MembershipFees.MembershipFeeType
|
||||||
|
alias Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: true
|
use MvWeb.ConnCase, async: true
|
||||||
|
|
||||||
alias Mv.Fixtures
|
|
||||||
alias MvWeb.Plugs.CheckPagePermission
|
alias MvWeb.Plugs.CheckPagePermission
|
||||||
|
alias Mv.Fixtures
|
||||||
|
|
||||||
defp conn_with_user(path, user) do
|
defp conn_with_user(path, user) do
|
||||||
build_conn(:get, path)
|
build_conn(:get, path)
|
||||||
|
|
@ -46,21 +46,21 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "dynamic routes" do
|
describe "dynamic routes" do
|
||||||
test ~s(user with "/members/:id" permission can access "/members/123") do
|
test "user with \"/members/:id\" permission can access \"/members/123\"" do
|
||||||
user = Fixtures.user_with_role_fixture("read_only")
|
user = Fixtures.user_with_role_fixture("read_only")
|
||||||
conn = conn_with_user("/members/123", user) |> CheckPagePermission.call([])
|
conn = conn_with_user("/members/123", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
refute conn.halted
|
refute conn.halted
|
||||||
end
|
end
|
||||||
|
|
||||||
test ~s(user with "/members/:id/edit" permission can access "/members/456/edit") do
|
test "user with \"/members/:id/edit\" permission can access \"/members/456/edit\"" do
|
||||||
user = Fixtures.user_with_role_fixture("normal_user")
|
user = Fixtures.user_with_role_fixture("normal_user")
|
||||||
conn = conn_with_user("/members/456/edit", user) |> CheckPagePermission.call([])
|
conn = conn_with_user("/members/456/edit", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
refute conn.halted
|
refute conn.halted
|
||||||
end
|
end
|
||||||
|
|
||||||
test ~s(user with only "/members/:id" cannot access "/members/123/edit") do
|
test "user with only \"/members/:id\" cannot access \"/members/123/edit\"" do
|
||||||
user = Fixtures.user_with_role_fixture("read_only")
|
user = Fixtures.user_with_role_fixture("read_only")
|
||||||
conn = conn_with_user("/members/123/edit", user) |> CheckPagePermission.call([])
|
conn = conn_with_user("/members/123/edit", user) |> CheckPagePermission.call([])
|
||||||
|
|
||||||
|
|
@ -456,8 +456,7 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
end
|
end
|
||||||
|
|
||||||
# Full-router test: session may not preserve member_id; plug logic covered by unit test
|
# Full-router test: session may not preserve member_id; plug logic covered by unit test "own_data user with linked member can access /members/:id/edit (plug direct call)"
|
||||||
# "own_data user with linked member can access /members/:id/edit (plug direct call)".
|
|
||||||
@tag role: :member
|
@tag role: :member
|
||||||
@tag :skip
|
@tag :skip
|
||||||
test "GET /members/:id/edit (linked member edit) returns 200 when user has linked member", %{
|
test "GET /members/:id/edit (linked member edit) returns 200 when user has linked member", %{
|
||||||
|
|
@ -513,8 +512,7 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
end
|
end
|
||||||
|
|
||||||
# Skipped: MemberLive.Show requires membership fee cycle data; plug allows access
|
# Skipped: MemberLive.Show requires membership fee cycle data; plug allows access (page loads then LiveView may error).
|
||||||
# (page loads then LiveView may error).
|
|
||||||
@tag role: :member
|
@tag role: :member
|
||||||
@tag :skip
|
@tag :skip
|
||||||
test "GET /members/:id for linked member returns 200", %{conn: conn, current_user: user} do
|
test "GET /members/:id for linked member returns 200", %{conn: conn, current_user: user} do
|
||||||
|
|
|
||||||
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