diff --git a/.drone.yml b/.drone.yml index 8c7f325..483a08a 100644 --- a/.drone.yml +++ b/.drone.yml @@ -4,7 +4,7 @@ name: check services: - name: postgres - image: docker.io/library/postgres:17.7 + image: docker.io/library/postgres:17.6 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -57,7 +57,7 @@ steps: - mix gettext.extract --check-up-to-date - name: wait_for_postgres - image: docker.io/library/postgres:17.7 + image: docker.io/library/postgres:17.6 commands: # Wait for postgres to become available - | @@ -166,7 +166,7 @@ environment: steps: - name: renovate - image: renovate/renovate:42.44 + image: renovate/renovate:41.173 environment: RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_TOKEN: diff --git a/Dockerfile b/Dockerfile index 7a01d21..88468a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -90,4 +90,4 @@ USER nobody # above and adding an entrypoint. See https://github.com/krallin/tini for details # ENTRYPOINT ["/tini", "--"] -ENTRYPOINT ["/app/bin/docker-entrypoint.sh"] +CMD ["/app/bin/server"] diff --git a/README.md b/README.md index 6255f8d..090f4e9 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ For testing the production Docker build locally: docker compose -f docker-compose.prod.yml up ``` -5. **Database migrations run automatically** on app start. For manual migration: +5. **Run database migrations:** ```bash docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate" ``` diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5b35e10..b4b7a1f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -33,7 +33,7 @@ services: restart: unless-stopped db-prod: - image: postgres:17.7-alpine + image: postgres:16-alpine container_name: mv-prod-db environment: POSTGRES_USER: postgres diff --git a/docker-compose.yml b/docker-compose.yml index feff34c..9b8645e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ networks: services: db: - image: postgres:17.7-alpine + image: postgres:17.6-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -29,7 +29,7 @@ services: rauthy: container_name: rauthy-dev - image: ghcr.io/sebadob/rauthy:0.33.1 + image: ghcr.io/sebadob/rauthy:0.33.0 environment: - LOCAL_TEST=true - SMTP_URL=mailcrab diff --git a/docs/contributions-architecture.md b/docs/contributions-architecture.md new file mode 100644 index 0000000..3718a3b --- /dev/null +++ b/docs/contributions-architecture.md @@ -0,0 +1,653 @@ +# Membership Contributions - Technical Architecture + +**Project:** Mila - Membership Management System +**Feature:** Membership Contribution Management +**Version:** 1.0 +**Last Updated:** 2025-11-27 +**Status:** Architecture Design - Ready for Implementation + +--- + +## Purpose + +This document defines the technical architecture for the Membership Contributions system. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details. + +**Related Documents:** +- [contributions-overview.md](./contributions-overview.md) - Business logic and requirements +- [database-schema-readme.md](./database-schema-readme.md) - Database documentation +- [database_schema.dbml](./database_schema.dbml) - Database schema definition + +--- + +## Table of Contents + +1. [Architecture Principles](#architecture-principles) +2. [Domain Structure](#domain-structure) +3. [Data Architecture](#data-architecture) +4. [Business Logic Architecture](#business-logic-architecture) +5. [Integration Points](#integration-points) +6. [Acceptance Criteria](#acceptance-criteria) +7. [Testing Strategy](#testing-strategy) +8. [Security Considerations](#security-considerations) +9. [Performance Considerations](#performance-considerations) + +--- + +## Architecture Principles + +### Core Design Decisions + +1. **Single Responsibility:** + - Each module has one clear responsibility + - Period generation separated from status management + - Calendar logic isolated in dedicated module + +2. **No Redundancy:** + - No `period_end` field (calculated from `period_start` + `interval`) + - No `interval_type` field (read from `contribution_type.interval`) + - Eliminates data inconsistencies + +3. **Immutability Where Important:** + - `contribution_type.interval` cannot be changed after creation + - Prevents complex migration scenarios + - Enforced via Ash change validation + +4. **Historical Accuracy:** + - `amount` stored per period for audit trail + - Enables tracking of contribution changes over time + - Old periods retain original amounts + +5. **Calendar-Based Periods:** + - All periods aligned to calendar boundaries + - Simplifies date calculations + - Predictable period generation + +--- + +## Domain Structure + +### Ash Domain: `Mv.Contributions` + +**Purpose:** Encapsulates all contribution-related resources and logic + +**Resources:** +- `ContributionType` - Contribution type definitions (admin-managed) +- `ContributionPeriod` - Individual contribution periods per member + +**Extensions:** +- Member resource extended with contribution fields + +### Module Organization + +``` +lib/ +├── contributions/ +│ ├── contributions.ex # Ash domain definition +│ ├── contribution_type.ex # ContributionType resource +│ ├── contribution_period.ex # ContributionPeriod resource +│ └── changes/ +│ ├── prevent_interval_change.ex # Validates interval immutability +│ ├── set_contribution_start_date.ex # Auto-sets start date +│ └── validate_same_interval.ex # Validates interval match on type change +├── mv/ +│ └── contributions/ +│ ├── period_generator.ex # Period generation algorithm +│ └── calendar_periods.ex # Calendar period calculations +└── membership/ + └── member.ex # Extended with contribution relationships +``` + +### Separation of Concerns + +**Domain Layer (Ash Resources):** +- Data validation +- Relationship management +- Policy enforcement +- Action definitions + +**Business Logic Layer (`Mv.Contributions`):** +- Period generation algorithm +- Calendar calculations +- Date boundary handling +- Status transitions + +**UI Layer (LiveView):** +- User interaction +- Display logic +- Authorization checks +- Form handling + +--- + +## Data Architecture + +### Database Schema Extensions + +**See:** [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema documentation. + +### New Tables + +1. **`contribution_types`** + - Purpose: Define contribution types with fixed intervals + - Key Constraint: `interval` field immutable after creation + - Relationships: has_many members, has_many contribution_periods + +2. **`contribution_periods`** + - Purpose: Individual contribution periods for members + - Key Design: NO `period_end` or `interval_type` fields (calculated) + - Relationships: belongs_to member, belongs_to contribution_type + - Composite uniqueness: One period per member per period_start + +### Member Table Extensions + +**Fields Added:** +- `contribution_type_id` (FK, NOT NULL with default from settings) +- `contribution_start_date` (Date, nullable) + +**Existing Fields Used:** +- `joined_at` - For calculating contribution start +- `left_at` - For limiting period generation +- These fields must remain member fields and should not be replaced by custom fields in the future + +### Settings Integration + +**Global Settings:** +- `contributions.include_joining_period` (Boolean) +- `contributions.default_contribution_type_id` (UUID) + +**Storage:** Existing settings mechanism (TBD: dedicated table or configuration resource) + +### Foreign Key Behaviors + +| Relationship | On Delete | Rationale | +|--------------|-----------|-----------| +| `contribution_periods.member_id → members.id` | CASCADE | Remove periods when member deleted | +| `contribution_periods.contribution_type_id → contribution_types.id` | RESTRICT | Prevent type deletion if periods exist | +| `members.contribution_type_id → contribution_types.id` | RESTRICT | Prevent type deletion if assigned to members | + +--- + +## Business Logic Architecture + +### Period Generation System + +**Component:** `Mv.Contributions.PeriodGenerator` + +**Responsibilities:** +- Calculate which periods should exist for a member +- Generate missing periods +- Respect contribution_start_date and left_at boundaries +- Skip existing periods (idempotent) + +**Triggers:** +1. Member contribution type assigned (via Ash change) +2. Member created with contribution type (via Ash change) +3. Scheduled job runs (daily/weekly cron) +4. Admin manual regeneration (UI action) + +**Algorithm Steps:** +1. Retrieve member with contribution_type and dates +2. Determine first period start (based on contribution_start_date) +3. Calculate all period starts from first to today (or left_at) +4. Query existing periods for member +5. Generate missing periods with current contribution_type.amount +6. Insert new periods (batch operation) + +**Edge Case Handling:** +- If contribution_start_date is NULL: Calculate from joined_at + global setting +- If left_at is set: Stop generation at left_at +- If contribution_type changes: Handled separately by regeneration logic + +### Calendar Period Calculations + +**Component:** `Mv.Contributions.CalendarPeriods` + +**Responsibilities:** +- Calculate period boundaries based on interval type +- Determine current period +- Determine last completed period +- Calculate period_end from period_start + interval + +**Functions (high-level):** +- `calculate_period_start/3` - Given date and interval, find period start +- `calculate_period_end/2` - Given period_start and interval, calculate end +- `next_period_start/2` - Given period_start and interval, find next +- `is_current_period?/2` - Check if period contains today +- `is_last_completed_period?/2` - Check if period just ended + +**Interval Logic:** +- **Monthly:** Start = 1st of month, End = last day of month +- **Quarterly:** Start = 1st of quarter (Jan/Apr/Jul/Oct), End = last day of quarter +- **Half-yearly:** Start = 1st of half (Jan/Jul), End = last day of half +- **Yearly:** Start = Jan 1st, End = Dec 31st + +### Status Management + +**Component:** Ash actions on `ContributionPeriod` + +**Status Transitions:** +- Simple state machine: unpaid ↔ paid ↔ suspended +- No complex validation (all transitions allowed) +- Permissions checked via Ash policies + +**Actions Required:** +- `mark_as_paid` - Set status to :paid +- `mark_as_suspended` - Set status to :suspended +- `mark_as_unpaid` - Set status to :unpaid (error correction) + +**Bulk Operations:** +- `bulk_mark_as_paid` - Mark multiple periods as paid (efficiency) + - low priority, can be a future issue + +### Contribution Type Change Handling + +**Component:** Ash change on `Member.contribution_type_id` + +**Validation:** +- Check if new type has same interval as old type +- If different: Reject change (MVP constraint) +- If same: Allow change + +**Side Effects on Allowed Change:** +1. Keep all existing periods unchanged +2. Find future unpaid periods +3. Delete future unpaid periods +4. Regenerate periods with new contribution_type_id and amount + +**Implementation Pattern:** +- Use Ash change module to validate +- Use after_action hook to trigger regeneration +- Use transaction to ensure atomicity + +--- + +## Integration Points + +### Member Resource Integration + +**Extension Points:** +1. Add fields via migration +2. Add relationships (belongs_to, has_many) +3. Add calculations (current_period_status, overdue_count) +4. Add changes (auto-set contribution_start_date, validate interval) + +**Backward Compatibility:** +- New fields nullable or with defaults +- Existing members get default contribution type from settings +- No breaking changes to existing member functionality + +### Settings System Integration + +**Requirements:** +- Store two global settings +- Provide UI for admin to modify +- Default values if not set +- Validation (e.g., default_contribution_type_id must exist) + +**Access Pattern:** +- Read settings during period generation +- Read settings during member creation +- Write settings only via admin UI + +### Permission System Integration + +**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) + +**Required Permissions:** +- `ContributionType.create/update/destroy` - Admin only +- `ContributionType.read` - Admin, Treasurer, Board +- `ContributionPeriod.update` (status changes) - Admin, Treasurer +- `ContributionPeriod.read` - Admin, Treasurer, Board, Own member + +**Policy Patterns:** +- Use existing HasPermission check +- Leverage existing roles (Admin, Kassenwart) +- Member can read own periods (linked via member_id) + +### LiveView Integration + +**New LiveViews Required:** +1. ContributionType index/form (admin) +2. ContributionPeriod table component (member detail view) +3. Settings form section (admin) +4. Member list column (contribution status) + +**Existing LiveViews to Extend:** +- Member detail view: Add contributions section +- Member list view: Add status column +- Settings page: Add contributions section + +**Authorization Helpers:** +- Use existing `can?/3` helper for UI conditionals +- Check permissions before showing actions + +--- + +## Acceptance Criteria + +### ContributionType Resource + +**AC-CT-1:** Admin can create contribution type with name, amount, interval, description +**AC-CT-2:** Interval field is immutable after creation (validation error on change attempt) +**AC-CT-3:** Admin can update name, amount, description (but not interval) +**AC-CT-4:** Cannot delete contribution type if assigned to members +**AC-CT-5:** Cannot delete contribution type if periods exist referencing it +**AC-CT-6:** Interval must be one of: monthly, quarterly, half_yearly, yearly + +### ContributionPeriod Resource + +**AC-CP-1:** Period has period_start, status, amount, notes, member_id, contribution_type_id +**AC-CP-2:** Period_end is calculated, not stored +**AC-CP-3:** Status defaults to :unpaid +**AC-CP-4:** One period per member per period_start (uniqueness constraint) +**AC-CP-5:** Amount is set at generation time from contribution_type.amount +**AC-CP-6:** Periods cascade delete when member deleted +**AC-CP-7:** Admin/Treasurer can change status +**AC-CP-8:** Member can read own periods + +### Member Extensions + +**AC-M-1:** Member has contribution_type_id field (NOT NULL with default) +**AC-M-2:** Member has contribution_start_date field (nullable) +**AC-M-3:** New members get default contribution type from global setting +**AC-M-4:** contribution_start_date auto-set based on joined_at and global setting +**AC-M-5:** Admin can manually override contribution_start_date +**AC-M-6:** Cannot change to contribution type with different interval (MVP) + +### Period Generation + +**AC-PG-1:** Periods generated when member gets contribution type +**AC-PG-2:** Periods generated when member created (via change hook) +**AC-PG-3:** Scheduled job generates missing periods daily +**AC-PG-4:** Generation respects contribution_start_date +**AC-PG-5:** Generation stops at left_at if member exited +**AC-PG-6:** Generation is idempotent (skips existing periods) +**AC-PG-7:** Periods align to calendar boundaries (1st of month/quarter/half/year) +**AC-PG-8:** Amount comes from contribution_type at generation time + +### Calendar Logic + +**AC-CL-1:** Monthly periods: 1st to last day of month +**AC-CL-2:** Quarterly periods: 1st of Jan/Apr/Jul/Oct to last day of quarter +**AC-CL-3:** Half-yearly periods: 1st of Jan/Jul to last day of half +**AC-CL-4:** Yearly periods: Jan 1 to Dec 31 +**AC-CL-5:** Period_end calculated correctly for all interval types +**AC-CL-6:** Current period determined correctly based on today's date +**AC-CL-7:** Last completed period determined correctly + +### Contribution Type Change + +**AC-TC-1:** Can change to type with same interval +**AC-TC-2:** Cannot change to type with different interval (error message) +**AC-TC-3:** On allowed change: future unpaid periods regenerated +**AC-TC-4:** On allowed change: paid/suspended periods unchanged +**AC-TC-5:** On allowed change: amount updated to new type's amount +**AC-TC-6:** Change is atomic (transaction) + +### Settings + +**AC-S-1:** Global setting: include_joining_period (boolean, default true) +**AC-S-2:** Global setting: default_contribution_type_id (UUID, required) +**AC-S-3:** Admin can modify settings via UI +**AC-S-4:** Settings validated (e.g., default type must exist) +**AC-S-5:** Settings applied to new members immediately + +### UI - Member List + +**AC-UI-ML-1:** New column shows contribution status +**AC-UI-ML-2:** Default: Shows last completed period status +**AC-UI-ML-3:** Optional: Toggle to show current period status +**AC-UI-ML-4:** Color coding: green (paid), red (unpaid), gray (suspended) +**AC-UI-ML-5:** Filter: Unpaid in last period +**AC-UI-ML-6:** Filter: Unpaid in current period + +### UI - Member Detail + +**AC-UI-MD-1:** Contributions section shows all periods +**AC-UI-MD-2:** Table columns: Period, Interval, Amount, Status, Actions +**AC-UI-MD-3:** Checkbox per period for bulk marking (low prio) +**AC-UI-MD-4:** "Mark selected as paid" button +**AC-UI-MD-5:** Dropdown to change contribution type (same interval only) +**AC-UI-MD-6:** Warning if different interval selected +**AC-UI-MD-7:** Only show actions if user has permission + +### UI - Contribution Types Admin + +**AC-UI-CTA-1:** List all contribution types +**AC-UI-CTA-2:** Show: Name, Amount, Interval, Member count +**AC-UI-CTA-3:** Create new contribution type form +**AC-UI-CTA-4:** Edit form: Name, Amount, Description editable +**AC-UI-CTA-5:** Edit form: Interval grayed out (not editable) +**AC-UI-CTA-6:** Warning on amount change (explain impact) +**AC-UI-CTA-7:** Cannot delete if members assigned +**AC-UI-CTA-8:** Only admin can access + +### UI - Settings Admin + +**AC-UI-SA-1:** Contributions section in settings +**AC-UI-SA-2:** Dropdown to select default contribution type +**AC-UI-SA-3:** Checkbox: Include joining period +**AC-UI-SA-4:** Explanatory text with examples +**AC-UI-SA-5:** Save button with validation + +--- + +## Testing Strategy + +### Unit Testing + +**Period Generator Tests:** +- Correct period_start calculation for all interval types +- Correct period count from start to end date +- Respects contribution_start_date boundary +- Respects left_at boundary +- Skips existing periods (idempotent) +- Handles edge dates (year boundaries, leap years) + +**Calendar Periods Tests:** +- Period boundaries correct for all intervals +- Period_end calculation correct +- Current period detection +- Last completed period detection +- Next period calculation + +**Validation Tests:** +- Interval immutability enforced +- Same interval validation on type change +- Status transitions allowed +- Uniqueness constraints enforced + +### Integration Testing + +**Period Generation Flow:** +- Member creation triggers generation +- Type assignment triggers generation +- Type change regenerates future periods +- Scheduled job generates missing periods +- Left member stops generation + +**Status Management Flow:** +- Mark single period as paid +- Bulk mark multiple periods (low prio) +- Status transitions work +- Permissions enforced + +**Contribution Type Management:** +- Create type +- Update amount (regeneration triggered) +- Cannot update interval +- Cannot delete if in use + +### LiveView Testing + +**Member List:** +- Status column displays correctly +- Toggle between last/current works +- Filters work correctly +- Color coding applied + +**Member Detail:** +- Periods table displays all periods +- Checkboxes work +- Bulk marking works (low prio) +- Type change validation works +- Actions only shown with permission + +**Admin UI:** +- Type CRUD works +- Settings save correctly +- Validations display errors +- Only authorized users can access + +### Edge Case Testing + +**Interval Change Attempt:** +- Error message displayed +- No data modified +- User can cancel/choose different type + +**Exit with Unpaid:** +- Warning shown +- Option to suspend offered +- Exit completes correctly + +**Amount Change:** +- Warning displayed +- Only future unpaid regenerated +- Historical periods unchanged + +**Date Boundaries:** +- Today = period start handled +- Today = period end handled +- Leap year handled + +### Performance Testing + +**Period Generation:** +- Generate 10 years of monthly periods: < 100ms +- Generate for 1000 members: < 5 seconds +- Idempotent check efficient (no full scan) + +**Member List Query:** +- With status column: < 200ms for 1000 members +- Filters applied efficiently +- No N+1 queries + +--- + +## Security Considerations + +### Authorization + +**Permissions Required:** +- ContributionType management: Admin only +- ContributionPeriod status changes: Admin + Treasurer +- View all periods: Admin + Treasurer + Board +- View own periods: All authenticated users + +**Policy Enforcement:** +- All actions protected by Ash policies +- UI shows/hides based on permissions +- Backend validates permissions (never trust UI alone) + +### Data Integrity + +**Validation Layers:** +1. Database constraints (NOT NULL, UNIQUE, CHECK) +2. Ash validations (business rules) +3. UI validations (user experience) + +**Immutability Protection:** +- Interval change prevented at multiple layers +- Period amounts immutable (audit trail) +- Settings changes logged (future) + +### Audit Trail + +**Tracked Information:** +- Period status changes (who, when) - future enhancement +- Type amount changes (implicit via period amounts) +- Member type assignments (via timestamps) + +--- + +## Performance Considerations + +### Database Indexes + +**Required Indexes:** +- `contribution_periods(member_id)` - For member period lookups +- `contribution_periods(contribution_type_id)` - For type queries +- `contribution_periods(status)` - For unpaid filters +- `contribution_periods(period_start)` - For date range queries +- `contribution_periods(member_id, period_start)` - Composite unique index +- `members(contribution_type_id)` - For type membership count + +### Query Optimization + +**Preloading:** +- Load contribution_type with periods (avoid N+1) +- Load periods when displaying member detail +- Use Ash's load for efficient preloading + +**Calculated Fields:** +- period_end calculated on-demand (not stored) +- current_period_status calculated when needed +- Use Ash calculations for lazy evaluation + +**Pagination:** +- Period list paginated if > 50 periods +- Member list already paginated + +### Caching Strategy + +**No caching needed in MVP:** +- Contribution types rarely change +- Period queries are fast +- Settings read infrequently + +**Future caching if needed:** +- Cache settings in application memory +- Cache contribution types list +- Invalidate on change + +### Scheduled Job Performance + +**Period Generation Job:** +- Run daily or weekly (not hourly) +- Batch members (process 100 at a time) +- Skip members with no changes +- Log failures for retry + +--- + +## Future Enhancements + +### Phase 2: Interval Change Support + +**Architecture Changes:** +- Add logic to handle period overlaps +- Calculate prorata amounts if needed +- More complex validation +- Migration path for existing periods + +### Phase 3: Payment Details + +**Architecture Changes:** +- Add PaymentTransaction resource +- Link transactions to periods +- Support multiple payments per period +- Reconciliation logic + +### Phase 4: vereinfacht.digital Integration + +**Architecture Changes:** +- External API client module +- Webhook handling for transactions +- Automatic matching logic +- Manual review interface + +--- + +**End of Architecture Document** + diff --git a/docs/membership-fee-overview.md b/docs/contributions-overview.md similarity index 59% rename from docs/membership-fee-overview.md rename to docs/contributions-overview.md index 229b73b..e0c4bc8 100644 --- a/docs/membership-fee-overview.md +++ b/docs/contributions-overview.md @@ -1,7 +1,7 @@ -# Membership Fees - Overview +# Membership Contributions - Overview **Project:** Mila - Membership Management System -**Feature:** Membership Fee Management +**Feature:** Membership Contribution Management **Version:** 1.0 **Last Updated:** 2025-11-27 **Status:** Concept - Ready for Review @@ -10,9 +10,9 @@ ## Purpose -This document provides a comprehensive overview of the Membership Fees system. It covers business logic, data model, UI/UX design, and technical architecture in a concise, bullet-point format. +This document provides a comprehensive overview of the Membership Contributions system. It covers business logic, data model, UI/UX design, and technical architecture in a concise, bullet-point format. -**For detailed implementation:** See [membership-fee-implementation-plan.md](./membership-fee-implementation-plan.md) (created after concept iterations) +**For detailed implementation:** See [contributions-implementation-plan.md](./contributions-implementation-plan.md) (created after concept iterations) --- @@ -36,7 +36,7 @@ This document provides a comprehensive overview of the Membership Fees system. I - Minimal complexity - Clear data model without redundancies - Intuitive operation -- Calendar cycle-based (Month/Quarter/Half-Year/Year) +- Calendar period-based (Month/Quarter/Half-Year/Year) --- @@ -46,9 +46,9 @@ This document provides a comprehensive overview of the Membership Fees system. I **Core Entities:** -- Beitragsart ↔ Membership Fee Type -- Beitragszyklus ↔ Membership Fee Cycle -- Mitgliedsbeitrag ↔ Membership Fee +- Beitragsart ↔ Contribution Type / Membership Fee Type +- Beitragsintervall ↔ Contribution Period +- Mitgliedsbeitrag ↔ Membership Fee / Contribution **Status:** @@ -56,7 +56,7 @@ This document provides a comprehensive overview of the Membership Fees system. I - unbezahlt ↔ unpaid - ausgesetzt ↔ suspended / waived -**Intervals (Frequenz / Payment Frequency):** +**Intervals:** - monatlich ↔ monthly - quartalsweise ↔ quarterly @@ -65,8 +65,8 @@ This document provides a comprehensive overview of the Membership Fees system. I **UI Elements:** -- "Letzter Zyklus" ↔ "Last Cycle" (e.g., 2023 when in 2024) -- "Aktueller Zyklus" ↔ "Current Cycle" (e.g., 2024) +- "Letztes Intervall" ↔ "Last Period" (e.g., 2023 when in 2024) +- "Aktuelles Intervall" ↔ "Current Period" (e.g., 2024) - "Als bezahlt markieren" ↔ "Mark as paid" - "Aussetzen" ↔ "Suspend" / "Waive" @@ -74,41 +74,43 @@ This document provides a comprehensive overview of the Membership Fees system. I ## Data Model -### Membership Fee Type (MembershipFeeType) +### Contribution Type (ContributionType) ``` - id (UUID) - name (String) - e.g., "Regular", "Reduced", "Student" -- amount (Decimal) - Membership fee amount in Euro +- amount (Decimal) - Contribution amount in Euro - interval (Enum) - :monthly, :quarterly, :half_yearly, :yearly - description (Text, optional) +- timestamps ``` **Important:** - `interval` is **IMMUTABLE** after creation! - Admin can only change `name`, `amount`, `description` -- On change: Future unpaid cycles regenerated with new amount +- On change: Future unpaid periods regenerated with new amount -### Membership Fee Cycle (MembershipFeeCycle) +### Contribution Period (ContributionPeriod) ``` - id (UUID) - member_id (FK → members.id) -- membership_fee_type_id (FK → membership_fee_types.id) -- cycle_start (Date) - Calendar cycle start (01.01., 01.04., 01.07., 01.10., etc.) +- contribution_type_id (FK → contribution_types.id) +- period_start (Date) - Calendar period start (01.01., 01.04., 01.07., 01.10., etc.) - status (Enum) - :unpaid (default), :paid, :suspended -- amount (Decimal) - Membership fee amount at generation time (history when type changes) +- amount (Decimal) - Amount at generation time (history when type changes) - notes (Text, optional) - Admin notes +- timestamps ``` **Important:** -- **NO** `cycle_end` - calculated from `cycle_start` + `interval` -- **NO** `interval_type` - read from `membership_fee_type.interval` +- **NO** `period_end` - calculated from `period_start` + `interval` +- **NO** `interval_type` - read from `contribution_type.interval` - Avoids redundancy and inconsistencies! -**Calendar Cycle Logic:** +**Calendar Period Logic:** - Monthly: 01.01. - 31.01., 01.02. - 28./29.02., etc. - Quarterly: 01.01. - 31.03., 01.04. - 30.06., 01.07. - 30.09., 01.10. - 31.12. @@ -118,75 +120,70 @@ This document provides a comprehensive overview of the Membership Fees system. I ### Member (Extensions) ``` -- membership_fee_type_id (FK → membership_fee_types.id, NOT NULL, default from settings) -- membership_fee_start_date (Date, nullable) - When to start generating membership fees +- contribution_type_id (FK → contribution_types.id, NOT NULL, default from settings) +- contribution_start_date (Date, nullable) - When to start generating contributions - left_at (Date, nullable) - Exit date (existing) ``` -**Logic for membership_fee_start_date:** +**Logic for contribution_start_date:** -- Auto-set based on global setting `include_joining_cycle` -- If `include_joining_cycle = true`: First day of joining month/quarter/year -- If `include_joining_cycle = false`: First day of NEXT cycle after joining +- Auto-set based on global setting `include_joining_period` +- If `include_joining_period = true`: First day of joining month/quarter/year +- If `include_joining_period = false`: First day of NEXT period after joining - Can be manually overridden by admin -**NO** `include_joining_cycle` field on Member - unnecessary due to `membership_fee_start_date`! +**NO** `include_joining_period` field on Member - unnecessary due to `contribution_start_date`! ### Global Settings ``` -key: "membership_fees.include_joining_cycle" +key: "contributions.include_joining_period" value: Boolean (Default: true) -key: "membership_fees.default_membership_fee_type_id" -value: UUID (Required) - Default membership fee type for new members +key: "contributions.default_contribution_type_id" +value: UUID (Required) - Default contribution type for new members ``` -**Meaning include_joining_cycle:** +**Meaning include_joining_period:** -- `true`: Joining cycle is included (member pays from joining cycle) -- `false`: Only from next full cycle after joining +- `true`: Joining period is included (member pays from joining period) +- `false`: Only from next full period after joining -**Meaning of default membership fee type setting:** +**Meaning default_contribution_type_id:** -- Every new member automatically gets this membership fee type +- Every new member automatically gets this contribution type - Must be configured in admin settings -- Prevents: Members without membership fee type +- Prevents: Members without contribution type --- ## Business Logic -### Cycle Generation +### Period Generation **Triggers:** -- Member gets membership fee type assigned (also during member creation) -- New cycle begins (Cron job daily/weekly) +- Member gets contribution type assigned (also during member creation) +- New period begins (Cron job daily/weekly) - Admin requests manual regeneration **Algorithm:** -Lock the whole cycle table for the duration of the algorithm - -1. Get `member.membership_fee_start_date` and member's membership fee type -2. Generate cycles until today (or `left_at` if present): - - If no cycle exists: - - Generate all cycles from `membership_fee_start_date` - - else: - - Generate all cycles from last existing cycle - - use the interval to generate the cycles -3. Set `amount` to current membership fee type's amount +1. Get `member.contribution_start_date` and `member.contribution_type` +2. Calculate first period based on `contribution_start_date` +3. Generate all periods from start to today (or `left_at` if present) +4. Skip existing periods +5. Set `amount` to current `contribution_type.amount` **Example (Yearly):** ``` Joining date: 15.03.2023 -include_joining_cycle: true -→ membership_fee_start_date: 01.01.2023 +include_joining_period: true +→ contribution_start_date: 01.01.2023 -Generated cycles: -- 01.01.2023 - 31.12.2023 (joining cycle) +Generated periods: +- 01.01.2023 - 31.12.2023 (joining period) - 01.01.2024 - 31.12.2024 - 01.01.2025 - 31.12.2025 (current year) ``` @@ -195,10 +192,10 @@ Generated cycles: ``` Joining date: 15.03.2023 -include_joining_cycle: false -→ membership_fee_start_date: 01.04.2023 +include_joining_period: false +→ contribution_start_date: 01.04.2023 -Generated cycles: +Generated periods: - 01.04.2023 - 30.06.2023 (first full quarter) - 01.07.2023 - 30.09.2023 - 01.10.2023 - 31.12.2023 @@ -221,44 +218,44 @@ suspended → unpaid - Admin + Treasurer (Kassenwart) can change status - Uses existing permission system -### Membership Fee Type Change +### Contribution Type Change -**MVP - Same Cycle Only:** +**MVP - Same Interval Only:** -- Member can only choose membership fee type with **same cycle** +- Member can only choose contribution type with **same interval** - Example: From "Regular (yearly)" to "Reduced (yearly)" ✓ - Example: From "Regular (yearly)" to "Reduced (monthly)" ✗ **Logic on Change:** -1. Check: New membership fee type has same interval -2. If yes: Set `member.membership_fee_type_id` -3. Future **unpaid** cycles: Delete and regenerate with new amount -4. Paid/suspended cycles: Remain unchanged (historical amount) +1. Check: New contribution type has same interval +2. If yes: Set `member.contribution_type_id` +3. Future **unpaid** periods: Delete and regenerate with new amount +4. Paid/suspended periods: Remain unchanged (historical amount) **Future - Different Intervals:** - Enable interval switching (e.g., yearly → monthly) -- More complex logic for cycle overlaps +- More complex logic for period overlaps - Needs additional validation ### Member Exit **Logic:** -- Cycles only generated until `member.left_at` -- Existing cycles remain visible -- Unpaid exit cycle can be marked as "suspended" +- Periods only generated until `member.left_at` +- Existing periods remain visible +- Unpaid exit period can be marked as "suspended" **Example:** ``` Exit: 15.08.2024 -Yearly cycle: 01.01.2024 - 31.12.2024 +Yearly period: 01.01.2024 - 31.12.2024 -→ Cycle 2024 is shown (Status: unpaid) +→ Period 2024 is shown (Status: unpaid) → Admin can set to "suspended" -→ No cycles for 2025+ generated +→ No periods for 2025+ generated ``` --- @@ -267,46 +264,46 @@ Yearly cycle: 01.01.2024 - 31.12.2024 ### Member List View -**New Column: "Membership Fee Status"** +**New Column: "Contribution Status"** -**Default Display (Last Cycle):** +**Default Display (Last Period):** -- Shows status of **last completed** cycle -- Example in 2024: Shows membership fee for 2023 +- Shows status of **last completed** period +- Example in 2024: Shows contribution for 2023 - Color coding: - Green: paid ✓ - Red: unpaid ✗ - Gray: suspended ⊘ -**Optional: Show Current Cycle** +**Optional: Show Current Period** -- Toggle: "Show current cycle" (2024) +- Toggle: "Show current period" (2024) - Admin decides what to display **Filters:** -- "Unpaid membership fees in last cycle" -- "Unpaid membership fees in current cycle" +- "Unpaid contributions in last period" +- "Unpaid contributions in current period" ### Member Detail View -**Section: "Membership Fees"** +**Section: "Contributions"** -**Membership Fee Type Assignment:** +**Contribution Type Assignment:** ``` ┌─────────────────────────────────────┐ -│ Membership Fee Type: [Dropdown] │ -│ ⚠ Only types with same interval │ -│ can be selected │ +│ Contribution Type: [Dropdown] │ +│ ⚠ Only types with same interval │ +│ can be selected │ └─────────────────────────────────────┘ ``` -**Cycle Table:** +**Period Table:** ``` ┌───────────────┬──────────┬────────┬──────────┬─────────┐ -│ Cycle │ Interval │ Amount │ Status │ Action │ +│ Period │ Interval │ Amount │ Status │ Action │ ├───────────────┼──────────┼────────┼──────────┼─────────┤ │ 01.01.2023- │ Yearly │ 50 € │ ☑ Paid │ │ │ 31.12.2023 │ │ │ │ │ @@ -325,9 +322,9 @@ Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended - Checkbox in each row for fast marking - Button: "Mark selected as paid/unpaid/suspended" -- Bulk action for multiple cycles +- Bulk action for multiple periods -### Admin: Membership Fee Types Management +### Admin: Contribution Types Management **List:** @@ -355,37 +352,37 @@ Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended Impact: - 45 members affected -- Future unpaid cycles will be generated with 65 € -- Already paid cycles remain with old amount +- Future unpaid periods will be generated with 65 € +- Already paid periods remain with old amount [Cancel] [Confirm] ``` ### Admin: Settings -**Membership Fee Configuration:** +**Contribution Configuration:** ``` -Default Membership Fee Type: [Dropdown: Membership Fee Types] +Default Contribution Type: [Dropdown: Contribution Types] Selected: "Regular (60 €, Yearly)" -This membership fee type is automatically assigned to all new members. +This contribution type is automatically assigned to all new members. Can be changed individually per member. --- -☐ Include joining cycle +☐ Include joining period When active: -Members pay from the cycle of their joining. +Members pay from the period of their joining. Example (Yearly): Joining: 15.03.2023 → Pays from 2023 When inactive: -Members pay from the next full cycle. +Members pay from the next full period. Example (Yearly): Joining: 15.03.2023 @@ -396,7 +393,7 @@ Joining: 15.03.2023 ## Edge Cases -### 1. Membership Fee Type Change with Different Interval +### 1. Contribution Type Change with Different Interval **MVP:** Blocked (only same interval allowed) @@ -405,11 +402,11 @@ Joining: 15.03.2023 ``` Error: Interval change not possible -Current membership fee type: "Regular (Yearly)" -Selected membership fee type: "Student (Monthly)" +Current contribution type: "Regular (Yearly)" +Selected contribution type: "Student (Monthly)" Changing the interval is currently not possible. -Please select a membership fee type with interval "Yearly". +Please select a contribution type with interval "Yearly". [OK] ``` @@ -418,32 +415,32 @@ Please select a membership fee type with interval "Yearly". - Allow interval switching - Calculate overlaps -- Generate new cycles without duplicates +- Generate new periods without duplicates -### 2. Exit with Unpaid Membership Fees +### 2. Exit with Unpaid Contributions **Scenario:** ``` Member exits: 15.08.2024 -Yearly cycle 2024: unpaid +Yearly period 2024: unpaid ``` **UI Notice on Exit: (Low Prio)** ``` -⚠ Unpaid membership fees present +⚠ Unpaid contributions present -This member has 1 unpaid cycle(s): +This member has 1 unpaid period(s): - 2024: 60 € (unpaid) Do you want to continue? -[ ] Mark membership fee as "suspended" +[ ] Mark contribution as "suspended" [Cancel] [Confirm Exit] ``` -### 3. Multiple Unpaid Cycles +### 3. Multiple Unpaid Periods **Scenario:** Member hasn't paid for 2 years @@ -470,9 +467,9 @@ Do you want to continue? **Result:** -- Cycle 2023: Saved with 50 € (history) -- Cycle 2024: Generated with 60 € (current) -- Both cycles show correct historical amount +- Period 2023: Saved with 50 € (history) +- Period 2024: Generated with 60 € (current) +- Both periods show correct historical amount ### 5. Date Boundaries @@ -480,7 +477,7 @@ Do you want to continue? **Solution:** -- Current cycle (2025) is generated +- Current period (2025) is generated - Status: unpaid (open) - Shown in overview @@ -492,17 +489,17 @@ Do you want to continue? **Included:** -- ✓ Membership fee types (CRUD) -- ✓ Automatic cycle generation +- ✓ Contribution types (CRUD) +- ✓ Automatic period generation - ✓ Status management (paid/unpaid/suspended) -- ✓ Member overview with membership fee status -- ✓ Cycle view per member +- ✓ Member overview with contribution status +- ✓ Period view per member - ✓ Quick checkbox marking - ✓ Bulk actions - ✓ Amount history - ✓ Same-interval type change -- ✓ Default membership fee type -- ✓ Joining cycle configuration +- ✓ Default contribution type +- ✓ Joining period configuration **NOT Included:** @@ -518,7 +515,7 @@ Do you want to continue? **Phase 2:** - Payment details (date, amount, method) -- Interval change for future unpaid cycles +- Interval change for future unpaid periods - Manual vereinfacht.digital links per member - Extended filter options diff --git a/docs/custom-fields-search-performance.md b/docs/custom-fields-search-performance.md deleted file mode 100644 index 3987c85..0000000 --- a/docs/custom-fields-search-performance.md +++ /dev/null @@ -1,243 +0,0 @@ -# Performance Analysis: Custom Fields in Search Vector - -## Current Implementation - -The search vector includes custom field values via database triggers that: -1. Aggregate all custom field values for a member -2. Extract values from JSONB format -3. Add them to the search_vector with weight 'C' - -## Performance Considerations - -### 1. Trigger Performance on Member Updates - -**Current Implementation:** -- `members_search_vector_trigger()` executes a subquery on every INSERT/UPDATE: - ```sql - SELECT string_agg(...) FROM custom_field_values WHERE member_id = NEW.id - ``` - -**Performance Impact:** -- ✅ **Good:** Index on `member_id` exists (`custom_field_values_member_id_idx`) -- ✅ **Good:** Subquery only runs for the affected member -- ⚠️ **Potential Issue:** With many custom fields per member (e.g., 50+), aggregation could be slower -- ⚠️ **Potential Issue:** JSONB extraction (`value->>'_union_value'`) is relatively fast but adds overhead - -**Expected Performance:** -- **Small scale (< 10 custom fields per member):** Negligible impact (< 5ms per operation) -- **Medium scale (10-30 custom fields):** Minor impact (5-20ms per operation) -- **Large scale (30+ custom fields):** Noticeable impact (20-50ms+ per operation) - -### 2. Trigger Performance on Custom Field Value Changes - -**Current Implementation:** -- `update_member_search_vector_from_custom_field_value()` executes on every INSERT/UPDATE/DELETE on `custom_field_values` -- **Optimized:** Only fetches required member fields (not full record) to reduce overhead -- **Optimized:** Skips re-aggregation on UPDATE if value hasn't actually changed -- Aggregates all custom field values, then updates member search_vector - -**Performance Impact:** -- ✅ **Good:** Index on `member_id` ensures fast lookup -- ✅ **Optimized:** Only required fields are fetched (first_name, last_name, email, etc.) instead of full record -- ✅ **Optimized:** UPDATE operations that don't change the value skip expensive re-aggregation (early return) -- ⚠️ **Note:** Re-aggregation is still necessary when values change (required for search_vector consistency) -- ⚠️ **Critical:** Bulk operations (e.g., importing 1000 members with custom fields) will trigger this for each row - -**Expected Performance:** -- **Single operation (value changed):** 3-10ms per custom field value change (improved from 5-15ms) -- **Single operation (value unchanged):** <1ms (early return, no aggregation) -- **Bulk operations:** Could be slow (consider disabling trigger temporarily) - -### 3. Search Vector Size - -**Current Constraints:** -- String values: max 10,000 characters per custom field -- No limit on number of custom fields per member -- tsvector has no explicit size limit, but very large vectors can cause issues - -**Potential Issues:** -- **Theoretical maximum:** If a member has 100 custom fields with 10,000 char strings each, the aggregated text could be ~1MB -- **Practical concern:** Very large search vectors (> 100KB) can slow down: - - Index updates (GIN index maintenance) - - Search queries (tsvector operations) - - Trigger execution time - -**Recommendation:** -- Monitor search_vector size in production -- Consider limiting total custom field content per member if needed -- PostgreSQL can handle large tsvectors, but performance degrades gradually - -### 4. Initial Migration Performance - -**Current Implementation:** -- Updates ALL members in a single transaction: - ```sql - UPDATE members m SET search_vector = ... (subquery for each member) - ``` - -**Performance Impact:** -- ⚠️ **Potential Issue:** With 10,000+ members, this could take minutes -- ⚠️ **Potential Issue:** Single transaction locks the members table -- ⚠️ **Potential Issue:** If migration fails, entire rollback required - -**Recommendation:** -- For large datasets (> 10,000 members), consider: - - Batch updates (e.g., 1000 members at a time) - - Run during maintenance window - - Monitor progress - -### 5. Search Query Performance - -**Current Implementation:** -- Full-text search uses GIN index on `search_vector` (fast) -- Additional LIKE queries on `custom_field_values` for substring matching: - ```sql - EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = id AND ... LIKE ...) - ``` - -**Performance Impact:** -- ✅ **Good:** GIN index on `search_vector` is very fast -- ⚠️ **Potential Issue:** LIKE queries on JSONB are not indexed (sequential scan) -- ⚠️ **Potential Issue:** EXISTS subquery runs for every search, even if search_vector match is found -- ⚠️ **Potential Issue:** With many custom fields, the LIKE queries could be slow - -**Expected Performance:** -- **With GIN index match:** Very fast (< 10ms for typical queries) -- **Without GIN index match (fallback to LIKE):** Slower (10-100ms depending on data size) -- **Worst case:** Sequential scan of all custom_field_values for all members - -## Recommendations - -### Short-term (Current Implementation) - -1. **Monitor Performance:** - - Add logging for trigger execution time - - Monitor search_vector size distribution - - Track search query performance - -2. **Index Verification:** - - Ensure `custom_field_values_member_id_idx` exists and is used - - Verify GIN index on `search_vector` is maintained - -3. **Bulk Operations:** - - For bulk imports, consider temporarily disabling the custom_field_values trigger - - Re-enable and update search_vectors in batch after import - -### Medium-term Optimizations - -1. **✅ Optimize Trigger Function (FULLY IMPLEMENTED):** - - ✅ Only fetch required member fields instead of full record (reduces overhead) - - ✅ Skip re-aggregation on UPDATE if value hasn't actually changed (early return optimization) - -2. **Limit Search Vector Size:** - - Truncate very long custom field values (e.g., first 1000 chars) - - Add warning if aggregated text exceeds threshold - -3. **Optimize LIKE Queries:** - - Consider adding a generated column for searchable text - - Or use a materialized view for custom field search - -### Long-term Considerations - -1. **Alternative Approaches:** - - Separate search index table for custom fields - - Use Elasticsearch or similar for advanced search - - Materialized view for search optimization - -2. **Scaling Strategy:** - - If performance becomes an issue with 100+ custom fields per member: - - Consider limiting which custom fields are searchable - - Use a separate search service - - Implement search result caching - -## Performance Benchmarks (Estimated) - -Based on typical PostgreSQL performance: - -| Scenario | Members | Custom Fields/Member | Expected Impact | -|----------|---------|---------------------|-----------------| -| Small | < 1,000 | < 10 | Negligible (< 5ms per operation) | -| Medium | 1,000-10,000 | 10-30 | Minor (5-20ms per operation) | -| Large | 10,000-100,000 | 30-50 | Noticeable (20-50ms per operation) | -| Very Large | > 100,000 | 50+ | Significant (50-200ms+ per operation) | - -## Monitoring Queries - -```sql --- Check search_vector size distribution -SELECT - pg_size_pretty(octet_length(search_vector::text)) as size, - COUNT(*) as member_count -FROM members -WHERE search_vector IS NOT NULL -GROUP BY octet_length(search_vector::text) -ORDER BY octet_length(search_vector::text) DESC -LIMIT 20; - --- Check average custom fields per member -SELECT - AVG(custom_field_count) as avg_custom_fields, - MAX(custom_field_count) as max_custom_fields -FROM ( - SELECT member_id, COUNT(*) as custom_field_count - FROM custom_field_values - GROUP BY member_id -) subq; - --- Check trigger execution time (requires pg_stat_statements) -SELECT - mean_exec_time, - calls, - query -FROM pg_stat_statements -WHERE query LIKE '%members_search_vector_trigger%' -ORDER BY mean_exec_time DESC; -``` - -## Code Quality Improvements (Post-Review) - -### Refactored Search Implementation - -The search query has been refactored for better maintainability and clarity: - -**Before:** Single large OR-chain with mixed search types (hard to maintain) - -**After:** Modular functions grouped by search type: -- `build_fts_filter/1` - Full-text search (highest priority, fastest) -- `build_substring_filter/2` - Substring matching on structured fields -- `build_custom_field_filter/1` - Custom field value search (JSONB LIKE) -- `build_fuzzy_filter/2` - Trigram/fuzzy matching for names and streets - -**Benefits:** -- ✅ Clear separation of concerns -- ✅ Easier to maintain and test -- ✅ Better documentation of search priority -- ✅ Easier to optimize individual search types - -**Search Priority Order:** -1. **FTS (Full-Text Search)** - Fastest, uses GIN index on search_vector -2. **Substring** - For structured fields (postal_code, phone_number, etc.) -3. **Custom Fields** - JSONB LIKE queries (fallback for substring matching) -4. **Fuzzy Matching** - Trigram similarity for names and streets - -## Conclusion - -The current implementation is **well-optimized for typical use cases** (< 30 custom fields per member, < 10,000 members). For larger scales, monitoring and potential optimizations may be needed. - -**Key Strengths:** -- Indexed lookups (member_id index) -- Efficient GIN index for search -- Trigger-based automatic updates -- Modular, maintainable search code structure - -**Key Weaknesses:** -- LIKE queries on JSONB (not indexed) -- Re-aggregation on every custom field change (necessary for consistency) -- Potential size issues with many/large custom fields -- Substring searches (contains/ILIKE) not index-optimized - -**Recent Optimizations:** -- ✅ Trigger function optimized to fetch only required fields (reduces overhead by ~30-50%) -- ✅ Early return on UPDATE when value hasn't changed (skips expensive re-aggregation, <1ms vs 3-10ms) -- ✅ Improved performance for custom field value updates (3-10ms vs 5-15ms when value changes) - diff --git a/docs/database-schema-readme.md b/docs/database-schema-readme.md index 6457db5..1644f2a 100644 --- a/docs/database-schema-readme.md +++ b/docs/database-schema-readme.md @@ -168,16 +168,9 @@ Member (1) → (N) Properties ### Weighted Fields - **Weight A (highest):** first_name, last_name - **Weight B:** email, notes -- **Weight C:** phone_number, city, street, house_number, postal_code, custom_field_values +- **Weight C:** phone_number, city, street, house_number, postal_code - **Weight D (lowest):** join_date, exit_date -### Custom Field Values in Search -Custom field values are automatically included in the search vector: -- All custom field values (string, integer, boolean, date, email) are aggregated and added to the search vector -- Values are converted to text format for indexing -- Custom field values receive weight 'C' (same as phone_number, city, etc.) -- The search vector is automatically updated when custom field values are created, updated, or deleted via database triggers - ### Usage Example ```sql SELECT * FROM members diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index c4ecfc9..2f86f5e 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -187,16 +187,16 @@ **Current State:** - ✅ Basic "paid" boolean field on members -- ✅ **UI Mock-ups for Membership Fee Types & Settings** (2025-12-02) +- ✅ **UI Mock-ups for Contribution Types & Settings** (2025-12-02) - ⚠️ No payment tracking **Open Issues:** - [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority) -- [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Membership Fee Mockup Pages (Preview) +- [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Contribution Mockup Pages (Preview) **Mock-Up Pages (Non-Functional Preview):** -- `/membership_fee_types` - Membership Fee Types Management -- `/membership_fee_settings` - Global Membership Fee Settings +- `/contribution_types` - Contribution Types Management +- `/contribution_settings` - Global Contribution Settings **Missing Features:** - ❌ Membership fee configuration diff --git a/docs/membership-fee-architecture.md b/docs/membership-fee-architecture.md deleted file mode 100644 index c601b79..0000000 --- a/docs/membership-fee-architecture.md +++ /dev/null @@ -1,712 +0,0 @@ -# Membership Fees - Technical Architecture - -**Project:** Mila - Membership Management System -**Feature:** Membership Fee Management -**Version:** 1.0 -**Last Updated:** 2025-11-27 -**Status:** Architecture Design - Ready for Implementation - ---- - -## Purpose - -This document defines the technical architecture for the Membership Fees system. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details. - -**Related Documents:** - -- [membership-fee-overview.md](./membership-fee-overview.md) - Business logic and requirements -- [database-schema-readme.md](./database-schema-readme.md) - Database documentation -- [database_schema.dbml](./database_schema.dbml) - Database schema definition - ---- - -## Table of Contents - -1. [Architecture Principles](#architecture-principles) -2. [Domain Structure](#domain-structure) -3. [Data Architecture](#data-architecture) -4. [Business Logic Architecture](#business-logic-architecture) -5. [Integration Points](#integration-points) -6. [Acceptance Criteria](#acceptance-criteria) -7. [Testing Strategy](#testing-strategy) -8. [Security Considerations](#security-considerations) -9. [Performance Considerations](#performance-considerations) - ---- - -## Architecture Principles - -### Core Design Decisions - -1. **Single Responsibility:** - - Each module has one clear responsibility - - Cycle generation separated from status management - - Calendar logic isolated in dedicated module - -2. **No Redundancy:** - - No `cycle_end` field (calculated from `cycle_start` + `interval`) - - No `interval_type` field (read from `membership_fee_type.interval`) - - Eliminates data inconsistencies - -3. **Immutability Where Important:** - - `membership_fee_type.interval` cannot be changed after creation - - Prevents complex migration scenarios - - Enforced via Ash change validation - -4. **Historical Accuracy:** - - `amount` stored per cycle for audit trail - - Enables tracking of membership fee changes over time - - Old cycles retain original amounts - -5. **Calendar-Based Cycles:** - - All cycles aligned to calendar boundaries - - Simplifies date calculations - - Predictable cycle generation - ---- - -## Domain Structure - -### Ash Domain: `Mv.MembershipFees` - -**Purpose:** Encapsulates all membership fee-related resources and logic - -**Resources:** - -- `MembershipFeeType` - Membership fee type definitions (admin-managed) -- `MembershipFeeCycle` - Individual membership fee cycles per member - -**Extensions:** - -- Member resource extended with membership fee fields - -### Module Organization - -``` -lib/ -├── membership_fees/ -│ ├── membership_fees.ex # Ash domain definition -│ ├── membership_fee_type.ex # MembershipFeeType resource -│ ├── membership_fee_cycle.ex # MembershipFeeCycle resource -│ └── changes/ -│ ├── prevent_interval_change.ex # Validates interval immutability -│ ├── set_membership_fee_start_date.ex # Auto-sets start date -│ └── validate_same_interval.ex # Validates interval match on type change -├── mv/ -│ └── membership_fees/ -│ ├── cycle_generator.ex # Cycle generation algorithm -│ └── calendar_cycles.ex # Calendar cycle calculations -└── membership/ - └── member.ex # Extended with membership fee relationships -``` - -### Separation of Concerns - -**Domain Layer (Ash Resources):** - -- Data validation -- Relationship management -- Policy enforcement -- Action definitions - -**Business Logic Layer (`Mv.MembershipFees`):** - -- Cycle generation algorithm -- Calendar calculations -- Date boundary handling -- Status transitions - -**UI Layer (LiveView):** - -- User interaction -- Display logic -- Authorization checks -- Form handling - ---- - -## Data Architecture - -### Database Schema Extensions - -**See:** [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema documentation. - -### New Tables - -1. **`membership_fee_types`** - - Purpose: Define membership fee types with fixed intervals - - Key Constraint: `interval` field immutable after creation - - Relationships: has_many members, has_many membership_fee_cycles - -2. **`membership_fee_cycles`** - - Purpose: Individual membership fee cycles for members - - Key Design: NO `cycle_end` or `interval_type` fields (calculated) - - Relationships: belongs_to member, belongs_to membership_fee_type - - Composite uniqueness: One cycle per member per cycle_start - -### Member Table Extensions - -**Fields Added:** - -- `membership_fee_type_id` (FK, NOT NULL with default from settings) -- `membership_fee_start_date` (Date, nullable) - -**Existing Fields Used:** - -- `joined_at` - For calculating membership fee start -- `left_at` - For limiting cycle generation -- These fields must remain member fields and should not be replaced by custom fields in the future - -### Settings Integration - -**Global Settings:** - -- `membership_fees.include_joining_cycle` (Boolean) -- `membership_fees.default_membership_fee_type_id` (UUID) - -**Storage:** Existing settings mechanism (TBD: dedicated table or configuration resource) - -### Foreign Key Behaviors - -| Relationship | On Delete | Rationale | -|--------------|-----------|-----------| -| `membership_fee_cycles.member_id → members.id` | CASCADE | Remove membership fee cycles when member deleted | -| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if cycles exist | -| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if assigned to members | - ---- - -## Business Logic Architecture - -### Cycle Generation System - -**Component:** `Mv.MembershipFees.CycleGenerator` - -**Responsibilities:** - -- Calculate which cycles should exist for a member -- Generate missing cycles -- Respect membership_fee_start_date and left_at boundaries -- Skip existing cycles (idempotent) - -**Triggers:** - -1. Member membership fee type assigned (via Ash change) -2. Member created with membership fee type (via Ash change) -3. Scheduled job runs (daily/weekly cron) -4. Admin manual regeneration (UI action) - -**Algorithm Steps:** - -1. Retrieve member with membership fee type and dates -2. Determine first cycle start (based on membership_fee_start_date) -3. Calculate all cycle starts from first to today (or left_at) -4. Query existing cycles for member -5. Generate missing cycles with current membership fee type's amount -6. Insert new cycles (batch operation) - -**Edge Case Handling:** - -- If membership_fee_start_date is NULL: Calculate from joined_at + global setting -- If left_at is set: Stop generation at left_at -- If membership fee type changes: Handled separately by regeneration logic - -### Calendar Cycle Calculations - -**Component:** `Mv.MembershipFees.CalendarCycles` - -**Responsibilities:** - -- Calculate cycle boundaries based on interval type -- Determine current cycle -- Determine last completed cycle -- Calculate cycle_end from cycle_start + interval - -**Functions (high-level):** - -- `calculate_cycle_start/3` - Given date and interval, find cycle start -- `calculate_cycle_end/2` - Given cycle_start and interval, calculate end -- `next_cycle_start/2` - Given cycle_start and interval, find next -- `is_current_cycle?/2` - Check if cycle contains today -- `is_last_completed_cycle?/2` - Check if cycle just ended - -**Interval Logic:** - -- **Monthly:** Start = 1st of month, End = last day of month -- **Quarterly:** Start = 1st of quarter (Jan/Apr/Jul/Oct), End = last day of quarter -- **Half-yearly:** Start = 1st of half (Jan/Jul), End = last day of half -- **Yearly:** Start = Jan 1st, End = Dec 31st - -### Status Management - -**Component:** Ash actions on `MembershipFeeCycle` - -**Status Transitions:** - -- Simple state machine: unpaid ↔ paid ↔ suspended -- No complex validation (all transitions allowed) -- Permissions checked via Ash policies - -**Actions Required:** - -- `mark_as_paid` - Set status to :paid -- `mark_as_suspended` - Set status to :suspended -- `mark_as_unpaid` - Set status to :unpaid (error correction) - -**Bulk Operations:** - -- `bulk_mark_as_paid` - Mark multiple cycles as paid (efficiency) - - low priority, can be a future issue - -### Membership Fee Type Change Handling - -**Component:** Ash change on `Member.membership_fee_type_id` - -**Validation:** - -- Check if new type has same interval as old type -- If different: Reject change (MVP constraint) -- If same: Allow change - -**Side Effects on Allowed Change:** - -1. Keep all existing cycles unchanged -2. Find future unpaid cycles -3. Delete future unpaid cycles -4. Regenerate cycles with new membership_fee_type_id and amount - -**Implementation Pattern:** - -- Use Ash change module to validate -- Use after_action hook to trigger regeneration -- Use transaction to ensure atomicity - ---- - -## Integration Points - -### Member Resource Integration - -**Extension Points:** - -1. Add fields via migration -2. Add relationships (belongs_to, has_many) -3. Add calculations (current_cycle_status, overdue_count) -4. Add changes (auto-set membership_fee_start_date, validate interval) - -**Backward Compatibility:** - -- New fields nullable or with defaults -- Existing members get default membership fee type from settings -- No breaking changes to existing member functionality - -### Settings System Integration - -**Requirements:** - -- Store two global settings -- Provide UI for admin to modify -- Default values if not set -- Validation (e.g., default membership fee type must exist) - -**Access Pattern:** - -- Read settings during cycle generation -- Read settings during member creation -- Write settings only via admin UI - -### Permission System Integration - -**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md) - -**Required Permissions:** - -- `MembershipFeeType.create/update/destroy` - Admin only -- `MembershipFeeType.read` - Admin, Treasurer, Board -- `MembershipFeeCycle.update` (status changes) - Admin, Treasurer -- `MembershipFeeCycle.read` - Admin, Treasurer, Board, Own member - -**Policy Patterns:** - -- Use existing HasPermission check -- Leverage existing roles (Admin, Kassenwart) -- Member can read own cycles (linked via member_id) - -### LiveView Integration - -**New LiveViews Required:** - -1. MembershipFeeType index/form (admin) -2. MembershipFeeCycle table component (member detail view) -3. Settings form section (admin) -4. Member list column (membership fee status) - -**Existing LiveViews to Extend:** - -- Member detail view: Add membership fees section -- Member list view: Add status column -- Settings page: Add membership fees section - -**Authorization Helpers:** - -- Use existing `can?/3` helper for UI conditionals -- Check permissions before showing actions - ---- - -## Acceptance Criteria - -### MembershipFeeType Resource - -**AC-MFT-1:** Admin can create membership fee type with name, amount, interval, description -**AC-MFT-2:** Interval field is immutable after creation (validation error on change attempt) -**AC-MFT-3:** Admin can update name, amount, description (but not interval) -**AC-MFT-4:** Cannot delete membership fee type if assigned to members -**AC-MFT-5:** Cannot delete membership fee type if cycles exist referencing it -**AC-MFT-6:** Interval must be one of: monthly, quarterly, half_yearly, yearly - -### MembershipFeeCycle Resource - -**AC-MFC-1:** Cycle has cycle_start, status, amount, notes, member_id, membership_fee_type_id -**AC-MFC-2:** cycle_end is calculated, not stored -**AC-MFC-3:** Status defaults to :unpaid -**AC-MFC-4:** One cycle per member per cycle_start (uniqueness constraint) -**AC-MFC-5:** Amount is set at generation time from membership_fee_type.amount -**AC-MFC-6:** Cycles cascade delete when member deleted -**AC-MFC-7:** Admin/Treasurer can change status -**AC-MFC-8:** Member can read own cycles - -### Member Extensions - -**AC-M-1:** Member has membership_fee_type_id field (NOT NULL with default) -**AC-M-2:** Member has membership_fee_start_date field (nullable) -**AC-M-3:** New members get default membership fee type from global setting -**AC-M-4:** membership_fee_start_date auto-set based on joined_at and global setting -**AC-M-5:** Admin can manually override membership_fee_start_date -**AC-M-6:** Cannot change to membership fee type with different interval (MVP) - -### Cycle Generation - -**AC-CG-1:** Cycles generated when member gets membership fee type -**AC-CG-2:** Cycles generated when member created (via change hook) -**AC-CG-3:** Scheduled job generates missing cycles daily -**AC-CG-4:** Generation respects membership_fee_start_date -**AC-CG-5:** Generation stops at left_at if member exited -**AC-CG-6:** Generation is idempotent (skips existing cycles) -**AC-CG-7:** Cycles align to calendar boundaries (1st of month/quarter/half/year) -**AC-CG-8:** Amount comes from membership_fee_type at generation time - -### Calendar Logic - -**AC-CL-1:** Monthly cycles: 1st to last day of month -**AC-CL-2:** Quarterly cycles: 1st of Jan/Apr/Jul/Oct to last day of quarter -**AC-CL-3:** Half-yearly cycles: 1st of Jan/Jul to last day of half -**AC-CL-4:** Yearly cycles: Jan 1 to Dec 31 -**AC-CL-5:** cycle_end calculated correctly for all interval types -**AC-CL-6:** Current cycle determined correctly based on today's date -**AC-CL-7:** Last completed cycle determined correctly - -### Membership Fee Type Change - -**AC-TC-1:** Can change to type with same interval -**AC-TC-2:** Cannot change to type with different interval (error message) -**AC-TC-3:** On allowed change: future unpaid cycles regenerated -**AC-TC-4:** On allowed change: paid/suspended cycles unchanged -**AC-TC-5:** On allowed change: amount updated to new type's amount -**AC-TC-6:** Change is atomic (transaction) - -### Settings - -**AC-S-1:** Global setting: include_joining_cycle (boolean, default true) -**AC-S-2:** Global setting: default_membership_fee_type_id (UUID, required) -**AC-S-3:** Admin can modify settings via UI -**AC-S-4:** Settings validated (e.g., default membership fee type must exist) -**AC-S-5:** Settings applied to new members immediately - -### UI - Member List - -**AC-UI-ML-1:** New column shows membership fee status -**AC-UI-ML-2:** Default: Shows last completed cycle status -**AC-UI-ML-3:** Optional: Toggle to show current cycle status -**AC-UI-ML-4:** Color coding: green (paid), red (unpaid), gray (suspended) -**AC-UI-ML-5:** Filter: Unpaid in last cycle -**AC-UI-ML-6:** Filter: Unpaid in current cycle - -### UI - Member Detail - -**AC-UI-MD-1:** Membership fees section shows all cycles -**AC-UI-MD-2:** Table columns: Cycle, Interval, Amount, Status, Actions -**AC-UI-MD-3:** Checkbox per cycle for bulk marking (low prio) -**AC-UI-MD-4:** "Mark selected as paid" button -**AC-UI-MD-5:** Dropdown to change membership fee type (same interval only) -**AC-UI-MD-6:** Warning if different interval selected -**AC-UI-MD-7:** Only show actions if user has permission - -### UI - Membership Fee Types Admin - -**AC-UI-CTA-1:** List all membership fee types -**AC-UI-CTA-2:** Show: Name, Amount, Interval, Member count -**AC-UI-CTA-3:** Create new membership fee type form -**AC-UI-CTA-4:** Edit form: Name, Amount, Description editable -**AC-UI-CTA-5:** Edit form: Interval grayed out (not editable) -**AC-UI-CTA-6:** Warning on amount change (explain impact) -**AC-UI-CTA-7:** Cannot delete if members assigned -**AC-UI-CTA-8:** Only admin can access - -### UI - Settings Admin - -**AC-UI-SA-1:** Membership fees section in settings -**AC-UI-SA-2:** Dropdown to select default membership fee type -**AC-UI-SA-3:** Checkbox: Include joining cycle -**AC-UI-SA-4:** Explanatory text with examples -**AC-UI-SA-5:** Save button with validation - ---- - -## Testing Strategy - -### Unit Testing - -**Cycle Generator Tests:** - -- Correct cycle_start calculation for all interval types -- Correct cycle count from start to end date -- Respects membership_fee_start_date boundary -- Respects left_at boundary -- Skips existing cycles (idempotent) -- Handles edge dates (year boundaries, leap years) - -**Calendar Cycles Tests:** - -- Cycle boundaries correct for all intervals -- cycle_end calculation correct -- Current cycle detection -- Last completed cycle detection -- Next cycle calculation - -**Validation Tests:** - -- Interval immutability enforced -- Same interval validation on type change -- Status transitions allowed -- Uniqueness constraints enforced - -### Integration Testing - -**Cycle Generation Flow:** - -- Member creation triggers generation -- Type assignment triggers generation -- Type change regenerates future cycles -- Scheduled job generates missing cycles -- Left member stops generation - -**Status Management Flow:** - -- Mark single cycle as paid -- Bulk mark multiple cycles (low prio) -- Status transitions work -- Permissions enforced - -**Membership Fee Type Management:** - -- Create type -- Update amount (regeneration triggered) -- Cannot update interval -- Cannot delete if in use - -### LiveView Testing - -**Member List:** - -- Status column displays correctly -- Toggle between last/current works -- Filters work correctly -- Color coding applied - -**Member Detail:** - -- Cycles table displays all cycles -- Checkboxes work -- Bulk marking works (low prio) -- Membership fee type change validation works -- Actions only shown with permission - -**Admin UI:** - -- Type CRUD works -- Settings save correctly -- Validations display errors -- Only authorized users can access - -### Edge Case Testing - -**Interval Change Attempt:** - -- Error message displayed -- No data modified -- User can cancel/choose different type - -**Exit with Unpaid:** - -- Warning shown -- Option to suspend offered -- Exit completes correctly - -**Amount Change:** - -- Warning displayed -- Only future unpaid regenerated -- Historical cycles unchanged - -**Date Boundaries:** - -- Today = cycle start handled -- Today = cycle end handled -- Leap year handled - -### Performance Testing - -**Cycle Generation:** - -- Generate 10 years of monthly cycles: < 100ms -- Generate for 1000 members: < 5 seconds -- Idempotent check efficient (no full scan) - -**Member List Query:** - -- With status column: < 200ms for 1000 members -- Filters applied efficiently -- No N+1 queries - ---- - -## Security Considerations - -### Authorization - -**Permissions Required:** - -- Membership fee type management: Admin only -- Membership fee cycle status changes: Admin + Treasurer -- View all cycles: Admin + Treasurer + Board -- View own cycles: All authenticated users - -**Policy Enforcement:** - -- All actions protected by Ash policies -- UI shows/hides based on permissions -- Backend validates permissions (never trust UI alone) - -### Data Integrity - -**Validation Layers:** - -1. Database constraints (NOT NULL, UNIQUE, CHECK) -2. Ash validations (business rules) -3. UI validations (user experience) - -**Immutability Protection:** - -- Interval change prevented at multiple layers -- Cycle amounts immutable (audit trail) -- Settings changes logged (future) - -### Audit Trail - -**Tracked Information:** - -- Cycle status changes (who, when) - future enhancement -- Membership fee type amount changes (implicit via cycle amounts) - ---- - -## Performance Considerations - -### Database Indexes - -**Required Indexes:** - -- `membership_fee_cycles(member_id)` - For member cycle lookups -- `membership_fee_cycles(membership_fee_type_id)` - For type queries -- `membership_fee_cycles(status)` - For unpaid filters -- `membership_fee_cycles(cycle_start)` - For date range queries -- `membership_fee_cycles(member_id, cycle_start)` - Composite unique index -- `members(membership_fee_type_id)` - For type membership count - -### Query Optimization - -**Preloading:** - -- Load membership_fee_type with cycles (avoid N+1) -- Load cycles when displaying member detail -- Use Ash's load for efficient preloading - -**Calculated Fields:** - -- cycle_end calculated on-demand (not stored) -- current_cycle_status calculated when needed -- Use Ash calculations for lazy evaluation - -**Pagination:** - -- Cycle list paginated if > 50 cycles -- Member list already paginated - -### Caching Strategy - -**No caching needed in MVP:** - -- Membership fee types rarely change -- Cycle queries are fast -- Settings read infrequently - -**Future caching if needed:** - -- Cache settings in application memory -- Cache membership fee types list -- Invalidate on change - -### Scheduled Job Performance - -**Cycle Generation Job:** - -- Run daily or weekly (not hourly) -- Batch members (process 100 at a time) -- Skip members with no changes -- Log failures for retry - ---- - -## Future Enhancements - -### Phase 2: Interval Change Support - -**Architecture Changes:** - -- Add logic to handle cycle overlaps -- Calculate prorata amounts if needed -- More complex validation -- Migration path for existing cycles - -### Phase 3: Payment Details - -**Architecture Changes:** - -- Add PaymentTransaction resource -- Link transactions to cycles -- Support multiple payments per cycle -- Reconciliation logic - -### Phase 4: vereinfacht.digital Integration - -**Architecture Changes:** - -- External API client module -- Webhook handling for transactions -- Automatic matching logic -- Manual review interface - ---- - -**End of Architecture Document** diff --git a/lib/membership/member.ex b/lib/membership/member.ex index d29a759..b788dc9 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -29,9 +29,7 @@ defmodule Mv.Membership.Member do ## Full-Text Search Members have a `search_vector` attribute (tsvector) that is automatically - updated via database trigger. Search includes name, email, notes, contact fields, - and all custom field values. Custom field values are automatically included in - the search vector with weight 'C' (same as phone_number, city, etc.). + updated via database trigger. Search includes name, email, notes, and contact fields. """ use Ash.Resource, domain: Mv.Membership, @@ -42,21 +40,6 @@ defmodule Mv.Membership.Member do # Module constants @member_search_limit 10 - - # Similarity threshold for fuzzy name/address matching. - # Lower value = more results but less accurate (0.1-0.9) - # - # Fuzzy matching uses two complementary strategies: - # 1. % operator: Fast GIN-index-based matching using server-wide threshold (default 0.3) - # - Catches exact trigram matches quickly via index - # 2. similarity/word_similarity functions: Precise matching with this configurable threshold - # - Catches partial matches that % operator might miss - # - # Value 0.2 chosen based on testing with typical German names: - # - "Müller" vs "Mueller": similarity ~0.65 ✓ - # - "Schmidt" vs "Schmitt": similarity ~0.75 ✓ - # - "Wagner" vs "Wegner": similarity ~0.55 ✓ - # - Random unrelated names: similarity ~0.15 ✗ @default_similarity_threshold 0.2 # Use constants from Mv.Constants for member fields @@ -156,21 +139,30 @@ defmodule Mv.Membership.Member do if is_binary(q) and String.trim(q) != "" do q2 = String.trim(q) - # Sanitize for LIKE patterns (escape % and _), limit length to 100 chars - q2_sanitized = sanitize_search_query(q2) - pat = "%" <> q2_sanitized <> "%" - - # Build search filters grouped by search type for maintainability - # Priority: FTS > Substring > Custom Fields > Fuzzy Matching - # Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized) - fts_match = build_fts_filter(q2) - substring_match = build_substring_filter(q2_sanitized, pat) - custom_field_match = build_custom_field_filter(pat) - fuzzy_match = build_fuzzy_filter(q2, threshold) + pat = "%" <> q2 <> "%" + # FTS as main filter and fuzzy search just for first name, last name and strees query |> Ash.Query.filter( - expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match) + expr( + # Substring on numeric-like fields (best effort, supports middle substrings) + fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^q2) or + fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q2) or + contains(postal_code, ^q2) or + contains(house_number, ^q2) or + contains(phone_number, ^q2) or + contains(email, ^q2) or + contains(city, ^q2) or ilike(city, ^pat) or + fragment("? % first_name", ^q2) or + fragment("? % last_name", ^q2) or + fragment("? % street", ^q2) or + fragment("word_similarity(?, first_name) > ?", ^q2, ^threshold) or + fragment("word_similarity(?, last_name) > ?", ^q2, ^threshold) or + fragment("word_similarity(?, street) > ?", ^q2, ^threshold) or + fragment("similarity(first_name, ?) > ?", ^q2, ^threshold) or + fragment("similarity(last_name, ?) > ?", ^q2, ^threshold) or + fragment("similarity(street, ?) > ?", ^q2, ^threshold) + ) ) else query @@ -484,6 +476,7 @@ defmodule Mv.Membership.Member do - `query` - Ash.Query.t() to apply search to - `opts` - Keyword list or map with search options: - `:query` or `"query"` - Search string + - `:fields` or `"fields"` - Optional field restrictions ## Returns - Modified Ash.Query.t() with search filters applied @@ -504,103 +497,16 @@ defmodule Mv.Membership.Member do if String.trim(q) == "" do query else - Ash.Query.for_read(query, :search, %{query: q}) + args = + case opts[:fields] || opts["fields"] do + nil -> %{query: q} + fields -> %{query: q, fields: fields} + end + + Ash.Query.for_read(query, :search, args) end end - # ============================================================================ - # Search Input Sanitization - # ============================================================================ - - # Sanitizes search input to prevent LIKE pattern injection. - # Escapes SQL LIKE wildcards (% and _) and limits query length. - # - # ## Examples - # - # iex> sanitize_search_query("test%injection") - # "test\\%injection" - # - # iex> sanitize_search_query("very_long_search") - # "very\\_long\\_search" - # - defp sanitize_search_query(query) when is_binary(query) do - query - |> String.slice(0, 100) - |> String.replace("\\", "\\\\") - |> String.replace("%", "\\%") - |> String.replace("_", "\\_") - end - - defp sanitize_search_query(_), do: "" - - # ============================================================================ - # Search Filter Builders - # ============================================================================ - # These functions build search filters grouped by search type for maintainability. - # Priority order: FTS > Substring > Custom Fields > Fuzzy Matching - - # Builds full-text search filter using tsvector (highest priority, fastest) - # Uses GIN index on search_vector for optimal performance - defp build_fts_filter(query) do - expr( - fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^query) or - fragment("search_vector @@ plainto_tsquery('simple', ?)", ^query) - ) - end - - # Builds substring search filter for structured fields - # Note: contains/2 uses ILIKE '%value%' which is not index-optimized - # Performance: Good for small datasets, may be slow on large tables - defp build_substring_filter(query, _pattern) do - expr( - contains(postal_code, ^query) or - contains(house_number, ^query) or - contains(phone_number, ^query) or - contains(email, ^query) or - contains(city, ^query) - ) - end - - # Builds search filter for custom field values using ILIKE on JSONB - # Note: ILIKE on JSONB is not index-optimized, may be slow with many custom fields - # This is a fallback for substring matching in custom fields (e.g., phone numbers) - # Uses ->> operator which always returns TEXT directly (no need for -> + ::text fallback) - # Important: `id` must be passed as parameter to correctly reference the outer members table - defp build_custom_field_filter(pattern) do - expr( - fragment( - "EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = ? AND (value->>'_union_value' ILIKE ? OR value->>'value' ILIKE ?))", - id, - ^pattern, - ^pattern - ) - ) - end - - # Builds fuzzy/trigram matching filter for name and street fields. - # Uses pg_trgm extension with GIN indexes for performance. - # - # Two-tier matching strategy: - # - % operator: Uses server-wide pg_trgm.similarity_threshold (typically 0.3) - # for fast index-based initial filtering - # - similarity/word_similarity: Uses @default_similarity_threshold (0.2) - # for more lenient matching to catch edge cases - # - # Note: Requires trigram GIN indexes on first_name, last_name, street. - defp build_fuzzy_filter(query, threshold) do - expr( - fragment("? % first_name", ^query) or - fragment("? % last_name", ^query) or - fragment("? % street", ^query) or - fragment("word_similarity(?, first_name) > ?", ^query, ^threshold) or - fragment("word_similarity(?, last_name) > ?", ^query, ^threshold) or - fragment("word_similarity(?, street) > ?", ^query, ^threshold) or - fragment("similarity(first_name, ?) > ?", ^query, ^threshold) or - fragment("similarity(last_name, ?) > ?", ^query, ^threshold) or - fragment("similarity(street, ?) > ?", ^query, ^threshold) - ) - end - # Private helper to apply filters for :available_for_linking action # user_email: may be nil/empty when creating new user, or populated when editing # search_query: optional search term for fuzzy matching @@ -609,9 +515,9 @@ defmodule Mv.Membership.Member do # - Empty user_email ("") → email == "" is always false → only fuzzy search matches # - This allows a single filter expression instead of duplicating fuzzy search logic # - # Note: Custom field search is intentionally excluded from linking to optimize - # autocomplete performance. Custom fields are still searchable via the main - # member search which uses the indexed search_vector. + # Cyclomatic complexity is unavoidable here: PostgreSQL fuzzy search requires + # multiple OR conditions for good search quality (FTS + trigram similarity + substring) + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defp apply_linking_filters(query, user_email, search_query) do has_search = search_query && String.trim(search_query) != "" # Use empty string instead of nil to simplify filter logic @@ -620,23 +526,35 @@ defmodule Mv.Membership.Member do if has_search do # Search query provided: return email-match OR fuzzy-search candidates trimmed_search = String.trim(search_query) - # Sanitize for LIKE patterns (contains uses ILIKE internally) - sanitized_search = sanitize_search_query(trimmed_search) - - # Build search filters - excluding custom_field_filter for performance - fts_match = build_fts_filter(trimmed_search) - fuzzy_match = build_fuzzy_filter(trimmed_search, @default_similarity_threshold) - email_substring_match = expr(contains(email, ^sanitized_search)) query |> Ash.Query.filter( expr( - # Email exact match has highest priority (for filter_by_email_match) - # If email is "", this is always false and search filters take over + # Email match candidate (for filter_by_email_match priority) + # If email is "", this is always false and fuzzy search takes over + # Fuzzy search candidates email == ^trimmed_email or - ^fts_match or - ^fuzzy_match or - ^email_substring_match + fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed_search) or + fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed_search) or + fragment("? % first_name", ^trimmed_search) or + fragment("? % last_name", ^trimmed_search) or + fragment("word_similarity(?, first_name) > 0.2", ^trimmed_search) or + fragment( + "word_similarity(?, last_name) > ?", + ^trimmed_search, + ^@default_similarity_threshold + ) or + fragment( + "similarity(first_name, ?) > ?", + ^trimmed_search, + ^@default_similarity_threshold + ) or + fragment( + "similarity(last_name, ?) > ?", + ^trimmed_search, + ^@default_similarity_threshold + ) or + contains(email, ^trimmed_search) ) ) else diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index a23381d..d19b1eb 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -153,7 +153,7 @@ defmodule MvWeb.CoreComponents do aria-haspopup="menu" aria-expanded={@open} aria-controls={@id} - class="btn" + class="btn btn-ghost" phx-click="toggle_dropdown" phx-target={@phx_target} data-testid="dropdown-button" @@ -236,30 +236,6 @@ defmodule MvWeb.CoreComponents do """ end - @doc """ - Renders a section in with a border similar to cards. - - - ## Examples - - <.form_section title={gettext("Personal Data")}> -

input

- - """ - attr :title, :string, required: true - slot :inner_block, required: true - - def form_section(assigns) do - ~H""" -
-

{@title}

-
- {render_slot(@inner_block)} -
-
- """ - end - @doc """ Renders an input with label and error messages. @@ -458,7 +434,7 @@ defmodule MvWeb.CoreComponents do ~H"""
-

+

{render_slot(@inner_block)}

@@ -498,7 +474,6 @@ defmodule MvWeb.CoreComponents do slot :col, required: true do attr :label, :string - attr :class, :string attr :col_click, :any, doc: "optional column-specific click handler that overrides row_click" end @@ -515,7 +490,7 @@ defmodule MvWeb.CoreComponents do - +
{col[:label]}{col[:label]} <.live_component module={MvWeb.Components.SortHeaderComponent} @@ -539,34 +514,7 @@ defmodule MvWeb.CoreComponents do (col[:col_click] && col[:col_click].(@row_item.(row))) || (@row_click && @row_click.(row)) } - class={ - col_class = Map.get(col, :class) - has_click = col[:col_click] || @row_click - classes = ["max-w-xs"] - - classes = - if col_class == nil || (col_class && !String.contains?(col_class, "text-center")) do - ["truncate" | classes] - else - classes - end - - classes = - if has_click do - ["hover:cursor-pointer" | classes] - else - classes - end - - classes = - if col_class do - [col_class | classes] - else - classes - end - - Enum.join(classes, " ") - } + class={["max-w-xs truncate", (col[:col_click] || @row_click) && "hover:cursor-pointer"]} > {render_slot(col, @row_item.(row))} diff --git a/lib/mv_web/live/components/field_visibility_dropdown_component.ex b/lib/mv_web/live/components/field_visibility_dropdown_component.ex index 5fc0abf..642273c 100644 --- a/lib/mv_web/live/components/field_visibility_dropdown_component.ex +++ b/lib/mv_web/live/components/field_visibility_dropdown_component.ex @@ -152,25 +152,9 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do defp field_to_string(field) when is_atom(field), do: Atom.to_string(field) defp field_to_string(field) when is_binary(field), do: field - defp format_field_label(field) when is_atom(field) do - MvWeb.Translations.MemberFields.label(field) - end - - defp format_field_label(field) when is_binary(field) do - case safe_to_existing_atom(field) do - {:ok, atom} -> MvWeb.Translations.MemberFields.label(atom) - :error -> fallback_label(field) - end - end - - defp safe_to_existing_atom(string) do - {:ok, String.to_existing_atom(string)} - rescue - ArgumentError -> :error - end - - defp fallback_label(field) do + defp format_field_label(field) do field + |> field_to_string() |> String.replace("_", " ") |> String.split() |> Enum.map_join(" ", &String.capitalize/1) diff --git a/lib/mv_web/live/components/payment_filter_component.ex b/lib/mv_web/live/components/payment_filter_component.ex index 47556dd..c9dc731 100644 --- a/lib/mv_web/live/components/payment_filter_component.ex +++ b/lib/mv_web/live/components/payment_filter_component.ex @@ -44,7 +44,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do - +
+ +
+ {@custom_field_to_delete.slug} +
+
+ +
- - + + + + """ end diff --git a/lib/mv_web/live/global_settings_live.ex b/lib/mv_web/live/global_settings_live.ex index 0b3ec1c..bb919cb 100644 --- a/lib/mv_web/live/global_settings_live.ex +++ b/lib/mv_web/live/global_settings_live.ex @@ -46,22 +46,22 @@ defmodule MvWeb.GlobalSettingsLive do <%!-- Club Settings Section --%> - <.form_section title={gettext("Club Settings")}> - <.form for={@form} id="settings-form" phx-change="validate" phx-submit="save"> -
- <.input - field={@form[:club_name]} - type="text" - label={gettext("Association Name")} - required - /> -
+ <.header> + {gettext("Club Settings")} + + <.form for={@form} id="settings-form" phx-change="validate" phx-submit="save"> + <.input + field={@form[:club_name]} + type="text" + label={gettext("Association Name")} + required + /> + + <.button phx-disable-with={gettext("Saving...")} variant="primary"> + {gettext("Save Settings")} + + - <.button phx-disable-with={gettext("Saving...")} variant="primary"> - {gettext("Save Settings")} - - - <%!-- Custom Fields Section --%> <.live_component module={MvWeb.CustomFieldLive.IndexComponent} diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 87148ad..5380d0f 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -348,6 +348,25 @@ defmodule MvWeb.MemberLive.Form do defp return_path("show", nil), do: ~p"/members" defp return_path("show", member), do: ~p"/members/#{member.id}" + # ----------------------------------------------------------------- + # Helper Components + # ----------------------------------------------------------------- + + # Renders a form section box with border and title. + attr :title, :string, required: true + slot :inner_block, required: true + + defp form_section(assigns) do + ~H""" +
+

{@title}

+
+ {render_slot(@inner_block)} +
+
+ """ + end + # ----------------------------------------------------------------- # Helper Functions for Custom Fields # ----------------------------------------------------------------- diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 8857298..f9d8aa1 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -668,7 +668,7 @@ defmodule MvWeb.MemberLive.Index do query end - defp load_custom_field_values(query, custom_field_ids) do + defp load_custom_field_values(query, custom_field_ids) when length(custom_field_ids) > 0 do # Filter custom field values at the database level using Ash relationship query # This ensures only visible custom field values are loaded custom_field_values_query = diff --git a/lib/mv_web/translations/field_types.ex b/lib/mv_web/translations/field_types.ex deleted file mode 100644 index 969f20b..0000000 --- a/lib/mv_web/translations/field_types.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule MvWeb.Translations.FieldTypes do - @moduledoc """ - Helper module to dynamically translate field types. - - ## Features - - Can be used in templates to dynamically translate technical field type words to human friendly text - - ## Example - assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1) - In template: - <%= @field_type_label.(custom_field.value_type) %> - """ - use Gettext, backend: MvWeb.Gettext - - @spec label(atom()) :: String.t() - def label(:string), do: gettext("Text") - def label(:integer), do: gettext("Number") - def label(:boolean), do: gettext("Yes/No-Selection") - def label(:date), do: gettext("Date") - def label(:email), do: gettext("E-Mail") -end diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex deleted file mode 100644 index 3750bcb..0000000 --- a/lib/mv_web/translations/member_fields.ex +++ /dev/null @@ -1,41 +0,0 @@ -defmodule MvWeb.Translations.MemberFields do - @moduledoc """ - Helper module to dynamically translate member field names. - - ## Features - - Translates technical field names (atoms) to human-friendly localized text - - Used primarily in the field visibility dropdown component - - ## Example - - iex> MvWeb.Translations.MemberFields.label(:first_name) - "Vorname" # when locale is "de" - - iex> MvWeb.Translations.MemberFields.label(:first_name) - "First Name" # when locale is "en" - """ - use Gettext, backend: MvWeb.Gettext - - @spec label(atom()) :: String.t() - def label(:first_name), do: gettext("First Name") - def label(:last_name), do: gettext("Last Name") - def label(:email), do: gettext("Email") - def label(:paid), do: gettext("Paid") - def label(:phone_number), do: gettext("Phone") - def label(:join_date), do: gettext("Join Date") - def label(:exit_date), do: gettext("Exit Date") - def label(:notes), do: gettext("Notes") - def label(:city), do: gettext("City") - def label(:street), do: gettext("Street") - def label(:house_number), do: gettext("House Number") - def label(:postal_code), do: gettext("Postal Code") - - # Fallback for unknown fields - def label(field) do - field - |> to_string() - |> String.replace("_", " ") - |> String.split() - |> Enum.map_join(" ", &String.capitalize/1) - end -end diff --git a/mix.exs b/mix.exs index 6aa5f9f..7a13ab0 100644 --- a/mix.exs +++ b/mix.exs @@ -38,7 +38,7 @@ defmodule Mv.MixProject do [ {:tidewave, "~> 0.5", only: [:dev]}, {:sourceror, "~> 1.8", only: [:dev, :test]}, - {:live_debugger, "~> 0.5", only: [:dev]}, + {:live_debugger, "~> 0.4", only: [:dev]}, {:ash_admin, "~> 0.13"}, {:ash_postgres, "~> 2.0"}, {:ash_phoenix, "~> 2.0"}, @@ -46,7 +46,7 @@ defmodule Mv.MixProject do {:bcrypt_elixir, "~> 3.0"}, {:ash_authentication, "~> 4.9"}, {:ash_authentication_phoenix, "~> 2.10"}, - {:igniter, "~> 0.7", only: [:dev, :test]}, + {:igniter, "~> 0.6", only: [:dev, :test]}, {:phoenix, "~> 1.8.0-rc.4", override: true}, {:phoenix_ecto, "~> 4.5"}, {:ecto_sql, "~> 3.10"}, @@ -69,7 +69,7 @@ defmodule Mv.MixProject do {:req, "~> 0.5"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, - {:gettext, "~> 1.0"}, + {:gettext, "~> 0.26"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, {:bandit, "~> 1.5"}, diff --git a/mix.lock b/mix.lock index a1c7505..77dcc09 100644 --- a/mix.lock +++ b/mix.lock @@ -1,32 +1,32 @@ %{ - "ash": {:hex, :ash, "3.11.1", "9794620bffeb83d1803d92a64e7803f70b57372eb4addba5c12a24343cd04e1a", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e0074302bb88d667635fcbfdacbf8a641c53973a3902d0e744f567a49ec808fc"}, - "ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"}, - "ash_authentication": {:hex, :ash_authentication, "4.13.3", "4d7a2e96b5a8fe68797ba0124cf40e6897c82b9fb69182fc5fdaac529b72d436", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "03d95b68766b28cda241e68217f6d1d839be350f7e8f20923162b163fb521b91"}, - "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.12.2", "a4646498a7e21fbdbe372f0d8afab08b5d7125b629f91bfcf8f4d1961bc9d57b", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1dd6fa3a8f7d2563a53cf22aeda31770c855e927421af4d8bfaf480332acf721"}, - "ash_phoenix": {:hex, :ash_phoenix, "2.3.18", "fad1b8af1405758888086de568f08650c2911ee97074cfe2e325b14854bc43dd", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7ec28f9216221e83b90d9c3605e9d1cdd228984e09a1a86c9b9d393cebf25222"}, - "ash_postgres": {:hex, :ash_postgres, "2.6.26", "f995bac8762ae039d4fb94cf2b628430aa69b0b30bf4366b96b3543dbd679ae7", [:mix], [{:ash, "~> 3.9", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.12 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7050b3169d5a31d73f7e69a6564d1102cb2bc185e67ea428e78fda3da46a69fc"}, - "ash_sql": {:hex, :ash_sql, "0.3.15", "8b8daae1870ab37b4fb2f980e323194caf23cdb4218fef126c49cc11a01fa243", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "97432507b6f406eb2461e5d0fbf2e5104a8c61a2570322d11de2f124d822d8ff"}, + "ash": {:hex, :ash, "3.7.1", "abb55dee19e0959e529e52fe0622468825ae05400f535484919713e492d9a9e7", [:mix], [{:crux, "~> 0.1.0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4474ce9befe9862d1ed73cadf8a755e836c45a14a7b3b952d02e1a12f2b2e529"}, + "ash_admin": {:hex, :ash_admin, "0.13.19", "43227905381ea0b835039fb3f3d255a3664925619937869e605402bc2f95c5e5", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "41e6262c437164df6f052e43cc93be225a7e148b49a813fc451e70172338ee38"}, + "ash_authentication": {:hex, :ash_authentication, "4.11.0", "4165ede37e179cb0a24b7bfc38d620fa93c05fb6272fbd353cafe27652b1e68b", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "~> 0.2.13", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "8201d0169944c1df3db9b560494e50e1c3bc99c3b1a8a2ef1e61b0f77bc820df"}, + "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.12.0", "75d7d77e3b626f3d8ea6ee44291d885950172ab399d997b2934f93d2e0a55a61", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "a423e22b40fdf3b1a7f2178e44ca68f48fdb5ba0d87e8d42a43de1a3b63ca704"}, + "ash_phoenix": {:hex, :ash_phoenix, "2.3.17", "a074ae6d9d7135d99c4edc91ddebe4c035ca380b044592bf9c3d58471669cf52", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "94e4a6cc6ced31cddba930c45c1c3477aa59b956e7fc3cdc63095cf0e506bdf5"}, + "ash_postgres": {:hex, :ash_postgres, "2.6.23", "5976a7e5e204b7bc627b1d17026bec9da4d880f2e09cd94bf4e8cee41fef32ce", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.7 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "61de4aedfe30f1ae14d8185cfc37a5b1940b45b60f2dfbdf9eb056f97dca41c5"}, + "ash_sql": {:hex, :ash_sql, "0.3.7", "80affa5446075d71deb157c67290685a84b392d723be766bfb684f58fe0143de", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "ce4d974b8e784171c5a2a62593b3672b42dfd4888fa2239f01a6b32bad769038"}, "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, "bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [: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", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"}, "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"}, - "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, + "castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, - "credo": {:hex, :credo, "1.7.14", "c7e75216cea8d978ba8c60ed9dede4cc79a1c99a266c34b3600dd2c33b96bc92", [: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", "12a97d6bb98c277e4fb1dff45aaf5c137287416009d214fb46e68147bd9e0203"}, - "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"}, + "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [: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", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, + "crux": {:hex, :crux, "0.1.1", "94f2f97d2a6079ae3c57f356412bc3b307f9579a80e43f526447b1d508dd4a72", [: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", "e59d498f038193cbe31e448f9199f5b4c53a4c67cece9922bb839595189dd2b6"}, "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, - "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, + "ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"}, "ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"}, - "ecto_sql": {:hex, :ecto_sql, "3.13.3", "81f7067dd1951081888529002dbc71f54e5e891b69c60195040ea44697e1104a", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5751caea36c8f5dd0d1de6f37eceffea19d10bd53f20e5bbe31c45f2efc8944a"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, "ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"}, - "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, + "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, @@ -35,14 +35,14 @@ "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"}, + "igniter": {:hex, :igniter, "0.6.30", "83a466369ebb8fe009e0823c7bf04314dc545122c2d48f896172fc79df33e99d", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "76a14d5b7f850bb03b5243088c3649d54a2e52e34a2aa1104dee23cf50a8bae0"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, - "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, + "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, - "live_debugger": {:hex, :live_debugger, "0.5.0", "95e0f7727d61010f7e9053923fb2a9416904a7533c2dfb36120e7684cba4c0af", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "73ebe95118d22aa402675f677abd731cb16b136d1b6ae5f4010441fb50753b14"}, + "live_debugger": {:hex, :live_debugger, "0.4.2", "775c3a570ef3c44d27d261b3c1aae23ef35cac949a57f67b3e7b1aa1fb2707bc", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "5b24e37985f0424056a322a18dab4a5fb0f4e8ee4e55975985364e0b45d683b9"}, "luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, @@ -50,41 +50,41 @@ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, - "phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, + "phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.18", "b5410017b3d4edf261d9c98ebc334e0637d7189457c730720cfc13e206443d43", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f189b759595feff0420e9a1d544396397f9cf9e2d5a8cb98ba5b6cab01927da0"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.14", "cae84abc4cd00dde4bb200b8516db556704c585c267aff9cd4955ff83cceb86c", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b827980e2bc00fddd8674e3b567519a4e855b5de04bf8607140414f1101e2627"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "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"}, - "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.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [: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", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [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", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"}, "reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [: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", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"}, - "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [: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", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"}, + "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [: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", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, "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"}, "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, - "spark": {:hex, :spark, "2.3.14", "a08420d08e6e0e49d740aed3e160f1cb894ba8f6b3f5e6c63253e9df1995265c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "af50c4ea5dd67eba822247f1c98e1d4e598cb7f6c28ccf5d002f0e0718096f4f"}, + "spark": {:hex, :spark, "2.3.5", "f30d30ecc3b4ab9b932d9aada66af7677fc1f297a2c349b0bcec3eafb9f996e8", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "0e9d339704d5d148f77f2b2fef3bcfc873a9e9bb4224fcf289c545d65827202f"}, "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, "splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"}, "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, - "swoosh": {:hex, :swoosh, "1.19.9", "4eb2c471b8cf06adbdcaa1d57a0ad53c0ed9348ce8586a06cc491f9f0dbcb553", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "516898263a64925c31723c56bc7999a26e97b04e869707f681f4c9bca7ee1688"}, + "swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, - "thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"}, - "tidewave": {:hex, :tidewave, "0.5.2", "f549acffe9daeed8b6b547c232c60de987770da7f827f9b3300140dfc465b102", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "34ab3ffee7e402f05cd1eae68d0e77ed0e0d1925677971ef83634247553e8afd"}, + "thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"}, + "tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, "ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"}, diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 25f685d..bb781f7 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -29,7 +29,6 @@ msgstr "Verbindung wird wiederhergestellt" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "City" msgstr "Stadt" @@ -49,7 +48,7 @@ msgstr "Löschen" #: lib/mv_web/live/user_live/index.html.heex #, elixir-autogen, elixir-format msgid "Edit" -msgstr "Bearbeiten" +msgstr "Bearbeite" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex @@ -64,14 +63,12 @@ msgstr "Mitglied bearbeiten" #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Email" msgstr "E-Mail" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "First Name" msgstr "Vorname" @@ -79,14 +76,12 @@ msgstr "Vorname" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Join Date" msgstr "Beitrittsdatum" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Last Name" msgstr "Nachname" @@ -120,13 +115,11 @@ msgstr "schließen" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Exit Date" msgstr "Austrittsdatum" #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "House Number" msgstr "Hausnummer" @@ -134,7 +127,6 @@ msgstr "Hausnummer" #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Notes" msgstr "Notizen" @@ -144,7 +136,6 @@ msgstr "Notizen" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Paid" msgstr "Bezahlt" @@ -156,7 +147,6 @@ msgstr "Telefonnummer" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Postal Code" msgstr "Postleitzahl" @@ -177,7 +167,6 @@ msgstr "Speichern..." #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Street" msgstr "Straße" @@ -225,7 +214,7 @@ msgstr "Falsche E-Mail oder Passwort" #: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format msgid "Member %{action} successfully" -msgstr "Mitglied wurde erfolgreich %{action}" +msgstr "Mitglied %{action} erfolgreich" #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format @@ -429,9 +418,9 @@ msgid "Admin Note" msgstr "Administrator*innen-Hinweis" #: lib/mv_web/live/user_live/form.ex -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgid "As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system." -msgstr "Als Administrator*in können Sie direkt ein neues Passwort für diese*n Benutzer*in setzen." +msgstr "Als Administrator*in können Sie direkt ein neues Passwort für diese*n Benutzer*in setzen, wobei das gleiche sichere Ash Authentication System verwendet wird." #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format @@ -667,10 +656,9 @@ msgid "To confirm deletion, please enter this text:" msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:" #: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Show in overview" -msgstr "In Übersicht anzeigen" +msgstr "In der Mitglieder-Übersicht anzeigen" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format @@ -881,7 +869,6 @@ msgstr "Persönliche Daten" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Phone" msgstr "Telefon" @@ -917,96 +904,96 @@ msgstr "Mitglied erstellen" #, elixir-autogen, elixir-format msgid "%{count} period selected" msgid_plural "%{count} periods selected" -msgstr[0] "%{count} Zyklus ausgewählt" -msgstr[1] "%{count} Zyklen ausgewählt" +msgstr[0] "" +msgstr[1] "" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "About Contribution Types" -msgstr "Über Beitragsarten" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Amount" -msgstr "Betrag" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Back to Settings" -msgstr "Zurück zu den Einstellungen" +msgstr "" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Can be changed at any time. Amount changes affect future periods only." -msgstr "Kann jederzeit geändert werden. Änderungen des Betrags betreffen nur zukünftige Zyklen." +msgstr "" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Cannot delete - members assigned" -msgstr "Löschen nicht möglich – es sind Mitglieder zugewiesen" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Change Contribution Type" -msgstr "Beitragsart ändern" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "Configure global settings for membership contributions." -msgstr "Globale Einstellungen für Mitgliedsbeiträge konfigurieren." +msgstr "" #: lib/mv_web/components/layouts/navbar.ex #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Contribution Settings" -msgstr "Beitragseinstellungen" +msgstr "Beitrag" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Contribution Start" -msgstr "Beitragsbeginn" +msgstr "Beitrag" #: lib/mv_web/components/layouts/navbar.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Contribution Types" -msgstr "Beitragsarten" +msgstr "Beitrag" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Contribution start" -msgstr "Beitragsbeginn" +msgstr "Beitrag" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Contribution type" -msgstr "Beitragsart" +msgstr "Beitrag" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Contribution types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation." -msgstr "Beitragsarten definieren verschiedene Beitragsmodelle. Jede Art hat einen festen Zyklus (monatlich, vierteljährlich, halbjährlich, jährlich), der nach Erstellung nicht mehr geändert werden kann." +msgstr "" #: lib/mv_web/components/layouts/navbar.ex #, elixir-autogen, elixir-format, fuzzy msgid "Contributions" -msgstr "Beiträge" +msgstr "Beitrag" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Contributions for %{name}" -msgstr "Beiträge für %{name}" +msgstr "Beitrag" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Current" -msgstr "Aktuell" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "Default Contribution Type" -msgstr "Standard-Beitragsart" +msgstr "" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy @@ -1016,28 +1003,28 @@ msgstr "Löschen" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "Example: Member Contribution View" -msgstr "Beispiel: Ansicht Mitgliedsbeiträge" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "Examples" -msgstr "Beispiele" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Family" -msgstr "Familie" +msgstr "" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format 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 "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "Generated periods" -msgstr "Generierte Zyklen" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format, fuzzy @@ -1049,29 +1036,29 @@ msgstr "Vereinsdaten" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Half-yearly" -msgstr "Halbjährlich" +msgstr "" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Half-yearly contribution for supporting members" -msgstr "Halbjährlicher Beitrag für Fördermitglieder" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Honorary" -msgstr "Ehrenamtlich" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "Include joining period" -msgstr "Beitrittsdatum einbeziehen" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Interval" -msgstr "Zyklus" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format, fuzzy @@ -1081,240 +1068,240 @@ msgstr "Beitrittsdatum" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Joining year - reduced to 0" -msgstr "Beitrittsjahr – auf 0 reduziert" +msgstr "" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Manage contribution types for membership fees." -msgstr "Beitragsarten für Mitgliedsbeiträge verwalten." +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Mark as Paid" -msgstr "Als bezahlt markieren" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Mark as Suspended" -msgstr "Als pausiert markieren" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Mark as Unpaid" -msgstr "Als unbezahlt markieren" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Member Contributions" -msgstr "Mitgliedsbeiträge" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays for the year they joined" -msgstr "Mitglied zahlt für das Beitrittsjahr" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays from the joining month" -msgstr "Mitglied zahlt ab Beitrittsmonat" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays from the next full quarter" -msgstr "Mitglied zahlt ab dem nächsten vollständigen Quartal" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "Member pays from the next full year" -msgstr "Mitglied zahlt ab dem nächsten vollständigen Jahr" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format, fuzzy msgid "Member since" -msgstr "Mitglied seit" +msgstr "Mitglieder" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps." -msgstr "Mitglieder können nur zwischen Beitragsarten mit demselben Zahlungszyklus wechseln (z. B. jährlich zu jährlich). Dadurch werden komplexe Überlappungen vermieden." +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "Monthly" -msgstr "Monatlich" +msgstr "monatlich" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "Monthly Interval - Joining Period Included" -msgstr "Monatliches Intervall – Beitrittszeitraum einbezogen" +msgstr "" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Monthly fee for students and trainees" -msgstr "Monatlicher Beitrag für Studierende und Auszubildende" +msgstr "" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Name & Amount" -msgstr "Name & Betrag" +msgstr "" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format, fuzzy msgid "New Contribution Type" -msgstr "Neue Beitragsart" +msgstr "Beitrag" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "No fee for honorary members" -msgstr "Kein Beitrag für ehrenamtliche Mitglieder" +msgstr "" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Only possible if no members are assigned to this type." -msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind." +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Open Contributions" -msgstr "Offene Beiträge" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Paid via bank transfer" -msgstr "Bezahlt durch Überweisung" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Preview Mockup" -msgstr "Vorschau" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Quarterly" -msgstr "Vierteljährlich" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "Quarterly Interval - Joining Period Excluded" -msgstr "Vierteljährliches Intervall – Beitrittszeitraum nicht einbezogen" +msgstr "" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Quarterly fee for family memberships" -msgstr "Vierteljährlicher Beitrag für Familienmitgliedschaften" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Reduced" -msgstr "Reduziert" +msgstr "" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Reduced fee for unemployed, pensioners, or low income" -msgstr "Ermäßigter Beitrag für Arbeitslose, Rentner*innen oder Geringverdienende" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Regular" -msgstr "Regulär" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Reopen" -msgstr "Wieder öffnen" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods." -msgstr "Beispielhafte Anzeige der Beitragsperioden für ein einzelnes Mitglied. In diesem Beispiel wird Maria Weber mit mehreren Zyklen angezeigt." +msgstr "" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Standard membership fee for regular members" -msgstr "Regulärer Mitgliedsbeitrag für Vollmitglieder" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Status" -msgstr "Status" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Student" -msgstr "Student" +msgstr "" #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "Supporting Member" -msgstr "Fördermitglied" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Suspend" -msgstr "Pausieren" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Suspended" -msgstr "Pausiert" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member." -msgstr "Dieser Beitragstyp wird automatisch neuen Mitgliedern zugewiesen. Kann individuell angepasst werden." +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_type_live/index.ex #, elixir-autogen, elixir-format msgid "This page is not functional and only displays the planned features." -msgstr "Diese Seite ist nicht funktionsfähig und zeigt nur geplante Funktionen." +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Time Period" -msgstr "Zeitraum" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Total Contributions" -msgstr "Gesamtbeiträge" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Unpaid" -msgstr "Unbezahlt" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "View Example Member" -msgstr "Beispielmitglied anzeigen" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "When active: Members pay from the period of their joining." -msgstr "Wenn aktiviert: Mitglieder zahlen ab dem Zeitraum ihres Beitritts." +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "When inactive: Members pay from the next full period after joining." -msgstr "Wenn deaktiviert: Mitglieder zahlen ab dem nächsten vollen Beitragszyklus nach dem Beitritt." +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #, elixir-autogen, elixir-format msgid "Why are not all contribution types shown?" -msgstr "Warum werden nicht alle Beitragsarten angezeigt?" +msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_settings_live.ex @@ -1326,12 +1313,12 @@ msgstr "jährlich" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "Yearly Interval - Joining Period Excluded" -msgstr "Jährliches Intervall – Beitrittszeitraum nicht einbezogen" +msgstr "" #: lib/mv_web/live/contribution_settings_live.ex #, elixir-autogen, elixir-format msgid "Yearly Interval - Joining Period Included" -msgstr "Jährliches Intervall – Beitrittszeitraum einbezogen" +msgstr "" #: lib/mv_web/live/components/field_visibility_dropdown_component.ex #, elixir-autogen, elixir-format @@ -1376,7 +1363,7 @@ msgstr "Zurück zur Felderliste" #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Custom field deleted successfully" -msgstr "Benutzerdefiniertes Feld erfolgreich gelöscht" +msgstr "Benutzerdefiniertes Feld erfolgreich %{action}" #: lib/mv_web/live/custom_field_live/form_component.ex #, elixir-autogen, elixir-format, fuzzy @@ -1398,6 +1385,11 @@ msgstr "Benutzerdefiniertes Feld speichern" msgid "New Custom field" msgstr "Benutzerdefiniertes Feld speichern" +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Show in Overview" +msgstr "In der Mitglieder-Übersicht anzeigen" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Slug does not match. Deletion cancelled." @@ -1413,31 +1405,6 @@ msgstr "Diese Felder können zusätzlich zu den normalen Daten ausgefüllt werde msgid "Value Type" msgstr "Wertetyp" -#: lib/mv_web/translations/field_types.ex -#, elixir-autogen, elixir-format -msgid "Date" -msgstr "Datum" - -#: lib/mv_web/translations/field_types.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "E-Mail" -msgstr "E-Mail" - -#: lib/mv_web/translations/field_types.ex -#, elixir-autogen, elixir-format -msgid "Number" -msgstr "Zahl" - -#: lib/mv_web/translations/field_types.ex -#, elixir-autogen, elixir-format -msgid "Text" -msgstr "Textfeld" - -#: lib/mv_web/translations/field_types.ex -#, elixir-autogen, elixir-format -msgid "Yes/No-Selection" -msgstr "Ja/Nein-Auswahl" - #~ #: lib/mv_web/live/custom_field_live/show.ex #~ #, elixir-autogen, elixir-format #~ msgid "Auto-generated identifier (immutable)" @@ -1483,11 +1450,6 @@ msgstr "Ja/Nein-Auswahl" #~ msgid "OIDC ID" #~ msgstr "OIDC ID" -#~ #: lib/mv_web/live/custom_field_live/index_component.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Show in Overview" -#~ msgstr "In der Mitglieder-Übersicht anzeigen" - #~ #: lib/mv_web/live/member_live/show.ex #~ #, elixir-autogen, elixir-format #~ msgid "This is a member record from your database." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a7ab36b..7581d62 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -30,7 +30,6 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "City" msgstr "" @@ -65,14 +64,12 @@ msgstr "" #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Email" msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "First Name" msgstr "" @@ -80,14 +77,12 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Join Date" msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Last Name" msgstr "" @@ -121,13 +116,11 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "House Number" msgstr "" @@ -135,7 +128,6 @@ msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Notes" msgstr "" @@ -145,7 +137,6 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Paid" msgstr "" @@ -157,7 +148,6 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Postal Code" msgstr "" @@ -178,7 +168,6 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Street" msgstr "" @@ -668,7 +657,6 @@ msgid "To confirm deletion, please enter this text:" msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Show in overview" msgstr "" @@ -882,7 +870,6 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Phone" msgstr "" @@ -1399,6 +1386,11 @@ msgstr "" msgid "New Custom field" msgstr "" +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format +msgid "Show in Overview" +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Slug does not match. Deletion cancelled." @@ -1413,28 +1405,3 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Value Type" msgstr "" - -#: lib/mv_web/translations/field_types.ex -#, elixir-autogen, elixir-format -msgid "Date" -msgstr "" - -#: lib/mv_web/translations/field_types.ex -#, elixir-autogen, elixir-format -msgid "E-Mail" -msgstr "" - -#: lib/mv_web/translations/field_types.ex -#, elixir-autogen, elixir-format -msgid "Number" -msgstr "" - -#: lib/mv_web/translations/field_types.ex -#, elixir-autogen, elixir-format -msgid "Text" -msgstr "" - -#: lib/mv_web/translations/field_types.ex -#, elixir-autogen, elixir-format -msgid "Yes/No-Selection" -msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index e2a1876..dc86840 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -30,7 +30,6 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "City" msgstr "" @@ -65,14 +64,12 @@ msgstr "" #: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Email" msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "First Name" msgstr "" @@ -80,14 +77,12 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Join Date" msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Last Name" msgstr "" @@ -121,13 +116,11 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Exit Date" msgstr "" #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "House Number" msgstr "" @@ -135,7 +128,6 @@ msgstr "" #: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Notes" msgstr "" @@ -145,7 +137,6 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Paid" msgstr "" @@ -157,7 +148,6 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Postal Code" msgstr "" @@ -178,7 +168,6 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/index.html.heex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format msgid "Street" msgstr "" @@ -209,14 +198,14 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format msgid "create" -msgstr "created" +msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_value_live/form.ex #: lib/mv_web/live/member_live/form.ex #, elixir-autogen, elixir-format msgid "update" -msgstr "updated" +msgstr "" #: lib/mv_web/controllers/auth_controller.ex #, elixir-autogen, elixir-format @@ -668,7 +657,6 @@ msgid "To confirm deletion, please enter this text:" msgstr "" #: lib/mv_web/live/custom_field_live/form_component.ex -#: lib/mv_web/live/custom_field_live/index_component.ex #, elixir-autogen, elixir-format msgid "Show in overview" msgstr "" @@ -882,7 +870,6 @@ msgstr "" #: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/show.ex -#: lib/mv_web/translations/member_fields.ex #, elixir-autogen, elixir-format, fuzzy msgid "Phone" msgstr "" @@ -1399,6 +1386,11 @@ msgstr "" msgid "New Custom field" msgstr "" +#: lib/mv_web/live/custom_field_live/index_component.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Show in Overview" +msgstr "" + #: lib/mv_web/live/global_settings_live.ex #, elixir-autogen, elixir-format msgid "Slug does not match. Deletion cancelled." @@ -1414,31 +1406,6 @@ msgstr "" msgid "Value Type" msgstr "" -#: lib/mv_web/translations/field_types.ex -#, elixir-autogen, elixir-format -msgid "Date" -msgstr "" - -#: lib/mv_web/translations/field_types.ex -#, elixir-autogen, elixir-format, fuzzy -msgid "E-Mail" -msgstr "" - -#: lib/mv_web/translations/field_types.ex -#, elixir-autogen, elixir-format -msgid "Number" -msgstr "" - -#: lib/mv_web/translations/field_types.ex -#, elixir-autogen, elixir-format -msgid "Text" -msgstr "" - -#: lib/mv_web/translations/field_types.ex -#, elixir-autogen, elixir-format -msgid "Yes/No-Selection" -msgstr "" - #~ #: lib/mv_web/live/custom_field_live/show.ex #~ #, elixir-autogen, elixir-format #~ msgid "Auto-generated identifier (immutable)" @@ -1482,11 +1449,6 @@ msgstr "" #~ msgid "OIDC ID" #~ msgstr "" -#~ #: lib/mv_web/live/custom_field_live/index_component.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Show in Overview" -#~ msgstr "" - #~ #: lib/mv_web/live/member_live/show.ex #~ #, elixir-autogen, elixir-format #~ msgid "This is a member record from your database." diff --git a/priv/repo/migrations/20251204123714_add_custom_field_values_to_search_vector.exs b/priv/repo/migrations/20251204123714_add_custom_field_values_to_search_vector.exs deleted file mode 100644 index 45a12e1..0000000 --- a/priv/repo/migrations/20251204123714_add_custom_field_values_to_search_vector.exs +++ /dev/null @@ -1,259 +0,0 @@ -defmodule Mv.Repo.Migrations.AddCustomFieldValuesToSearchVector do - @moduledoc """ - Extends the search_vector in members table to include custom_field_values. - - This migration: - 1. Updates the members_search_vector_trigger() function to include custom field values - 2. Creates a trigger function to update member search_vector when custom_field_values change - 3. Creates a trigger on custom_field_values table - 4. Updates existing search_vector values for all members - """ - - use Ecto.Migration - - def up do - # Update the main trigger function to include custom_field_values - execute(""" - CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$ - DECLARE - custom_values_text text; - BEGIN - -- Aggregate all custom field values for this member - -- Support both formats: _union_type/_union_value (Ash format) and type/value (legacy) - -- ->> operator always returns TEXT directly (no need for -> + ::text fallback) - SELECT string_agg( - CASE - WHEN value ? '_union_value' THEN value->>'_union_value' - WHEN value ? 'value' THEN value->>'value' - ELSE '' - END, - ' ' - ) - INTO custom_values_text - FROM custom_field_values - WHERE member_id = NEW.id AND value IS NOT NULL; - - -- Build search_vector with member fields and custom field values - NEW.search_vector := - setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') || - setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') || - setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') || - setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') || - setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') || - setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') || - setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') || - setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') || - setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') || - setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') || - setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C') || - setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C'); - RETURN NEW; - END - $$ LANGUAGE plpgsql; - """) - - # Create trigger function to update member search_vector when custom_field_values change - # Optimized: - # 1. Only fetch required fields instead of full member record to reduce overhead - # 2. Skip re-aggregation on UPDATE if value hasn't actually changed - execute(""" - CREATE FUNCTION update_member_search_vector_from_custom_field_value() RETURNS trigger AS $$ - DECLARE - member_id_val uuid; - member_first_name text; - member_last_name text; - member_email text; - member_phone_number text; - member_join_date date; - member_exit_date date; - member_notes text; - member_city text; - member_street text; - member_house_number text; - member_postal_code text; - custom_values_text text; - old_value_text text; - new_value_text text; - BEGIN - -- Get member ID from trigger context - member_id_val := COALESCE(NEW.member_id, OLD.member_id); - - -- Optimization: For UPDATE operations, check if value actually changed - -- If value hasn't changed, we can skip the expensive re-aggregation - IF TG_OP = 'UPDATE' THEN - -- Extract OLD value for comparison (handle both JSONB formats) - -- ->> operator always returns TEXT directly - old_value_text := COALESCE( - NULLIF(OLD.value->>'_union_value', ''), - NULLIF(OLD.value->>'value', ''), - '' - ); - - -- Extract NEW value for comparison (handle both JSONB formats) - new_value_text := COALESCE( - NULLIF(NEW.value->>'_union_value', ''), - NULLIF(NEW.value->>'value', ''), - '' - ); - - -- Check if value, member_id, or custom_field_id actually changed - -- If nothing changed, skip expensive re-aggregation - IF (old_value_text IS NOT DISTINCT FROM new_value_text) AND - (OLD.member_id IS NOT DISTINCT FROM NEW.member_id) AND - (OLD.custom_field_id IS NOT DISTINCT FROM NEW.custom_field_id) THEN - RETURN COALESCE(NEW, OLD); - END IF; - END IF; - - -- Fetch only required fields instead of full record (performance optimization) - SELECT - first_name, - last_name, - email, - phone_number, - join_date, - exit_date, - notes, - city, - street, - house_number, - postal_code - INTO - member_first_name, - member_last_name, - member_email, - member_phone_number, - member_join_date, - member_exit_date, - member_notes, - member_city, - member_street, - member_house_number, - member_postal_code - FROM members - WHERE id = member_id_val; - - -- Aggregate all custom field values for this member - -- Support both formats: _union_type/_union_value (Ash format) and type/value (legacy) - -- ->> operator always returns TEXT directly - SELECT string_agg( - CASE - WHEN value ? '_union_value' THEN value->>'_union_value' - WHEN value ? 'value' THEN value->>'value' - ELSE '' - END, - ' ' - ) - INTO custom_values_text - FROM custom_field_values - WHERE member_id = member_id_val AND value IS NOT NULL; - - -- Update the search_vector for the affected member - UPDATE members - SET search_vector = - setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') || - setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') || - setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') || - setweight(to_tsvector('simple', coalesce(member_phone_number, '')), 'C') || - setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') || - setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') || - setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') || - setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') || - setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') || - setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') || - setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') || - setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C') - WHERE id = member_id_val; - - RETURN COALESCE(NEW, OLD); - END - $$ LANGUAGE plpgsql; - """) - - # Create trigger on custom_field_values table - execute(""" - CREATE TRIGGER update_member_search_vector_on_custom_field_value_change - AFTER INSERT OR UPDATE OR DELETE ON custom_field_values - FOR EACH ROW - EXECUTE FUNCTION update_member_search_vector_from_custom_field_value() - """) - - # Update existing search_vector values for all members - execute(""" - UPDATE members m - SET search_vector = - setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') || - setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') || - setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') || - setweight(to_tsvector('simple', coalesce(m.phone_number, '')), 'C') || - setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') || - setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') || - setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') || - setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') || - setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') || - setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') || - setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') || - setweight(to_tsvector('simple', coalesce( - (SELECT string_agg( - CASE - WHEN value ? '_union_value' THEN value->>'_union_value' - WHEN value ? 'value' THEN value->>'value' - ELSE '' - END, - ' ' - ) - FROM custom_field_values - WHERE member_id = m.id AND value IS NOT NULL), - '' - )), 'C') - """) - end - - def down do - # Drop trigger on custom_field_values - execute( - "DROP TRIGGER IF EXISTS update_member_search_vector_on_custom_field_value_change ON custom_field_values" - ) - - # Drop trigger function - execute("DROP FUNCTION IF EXISTS update_member_search_vector_from_custom_field_value()") - - # Restore original trigger function without custom_field_values - execute(""" - CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$ - BEGIN - NEW.search_vector := - setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') || - setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') || - setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') || - setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') || - setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') || - setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') || - setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') || - setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') || - setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') || - setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') || - setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C'); - RETURN NEW; - END - $$ LANGUAGE plpgsql; - """) - - # Update existing search_vector values to remove custom_field_values - execute(""" - UPDATE members m - SET search_vector = - setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') || - setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') || - setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') || - setweight(to_tsvector('simple', coalesce(m.phone_number, '')), 'C') || - setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') || - setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') || - setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') || - setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') || - setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') || - setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') || - setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') - """) - end -end diff --git a/priv/resource_snapshots/repo/members/20251204123714.json b/priv/resource_snapshots/repo/members/20251204123714.json deleted file mode 100644 index 8f3bf6c..0000000 --- a/priv/resource_snapshots/repo/members/20251204123714.json +++ /dev/null @@ -1,202 +0,0 @@ -{ - "attributes": [ - { - "allow_nil?": false, - "default": "fragment(\"uuid_generate_v7()\")", - "generated?": false, - "precision": null, - "primary_key?": true, - "references": null, - "scale": null, - "size": null, - "source": "id", - "type": "uuid" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "first_name", - "type": "text" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "last_name", - "type": "text" - }, - { - "allow_nil?": false, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "email", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "paid", - "type": "boolean" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "phone_number", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "join_date", - "type": "date" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "exit_date", - "type": "date" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "notes", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "city", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "street", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "house_number", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "postal_code", - "type": "text" - }, - { - "allow_nil?": true, - "default": "nil", - "generated?": false, - "precision": null, - "primary_key?": false, - "references": null, - "scale": null, - "size": null, - "source": "search_vector", - "type": "tsvector" - } - ], - "base_filter": null, - "check_constraints": [], - "custom_indexes": [], - "custom_statements": [], - "has_create_action": true, - "hash": "247CACFA5C8FD24BDD553252E9BBF489E8FE54F60704383B6BE66C616D203A65", - "identities": [ - { - "all_tenants?": false, - "base_filter": null, - "index_name": "members_unique_email_index", - "keys": [ - { - "type": "atom", - "value": "email" - } - ], - "name": "unique_email", - "nils_distinct?": true, - "where": null - } - ], - "multitenancy": { - "attribute": null, - "global": null, - "strategy": null - }, - "repo": "Elixir.Mv.Repo", - "schema": null, - "table": "members" -} \ No newline at end of file diff --git a/rel/overlays/bin/docker-entrypoint.sh b/rel/overlays/bin/docker-entrypoint.sh deleted file mode 100755 index d6b0dd7..0000000 --- a/rel/overlays/bin/docker-entrypoint.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh -set -e - -echo "==> Running database migrations..." -/app/bin/migrate - -echo "==> Starting application..." -exec /app/bin/server - diff --git a/test/membership/fuzzy_search_test.exs b/test/membership/fuzzy_search_test.exs index 19286df..6ec582b 100644 --- a/test/membership/fuzzy_search_test.exs +++ b/test/membership/fuzzy_search_test.exs @@ -69,7 +69,7 @@ defmodule Mv.Membership.FuzzySearchTest do ids = Enum.map(result, & &1.id) assert thomas.id in ids refute jane.id in ids - assert not Enum.empty?(ids) + assert length(ids) >= 1 end test "empty query returns all members" do diff --git a/test/membership/member_search_with_custom_fields_test.exs b/test/membership/member_search_with_custom_fields_test.exs deleted file mode 100644 index 6711df8..0000000 --- a/test/membership/member_search_with_custom_fields_test.exs +++ /dev/null @@ -1,702 +0,0 @@ -defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do - @moduledoc """ - Tests for full-text search including custom_field_values. - - Tests verify that custom field values are included in the search_vector - and can be found through the fuzzy_search functionality. - """ - use Mv.DataCase, async: false - - alias Mv.Membership.{CustomField, CustomFieldValue, Member} - - setup do - # Create test members - {:ok, member1} = - Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Alice", - last_name: "Anderson", - email: "alice@example.com" - }) - |> Ash.create() - - {:ok, member2} = - Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Bob", - last_name: "Brown", - email: "bob@example.com" - }) - |> Ash.create() - - {:ok, member3} = - Member - |> Ash.Changeset.for_create(:create_member, %{ - first_name: "Charlie", - last_name: "Clark", - email: "charlie@example.com" - }) - |> Ash.create() - - # Create custom fields for different types - {:ok, string_field} = - CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "membership_number", - value_type: :string - }) - |> Ash.create() - - {:ok, integer_field} = - CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "member_id_number", - value_type: :integer - }) - |> Ash.create() - - {:ok, email_field} = - CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "secondary_email", - value_type: :email - }) - |> Ash.create() - - {:ok, date_field} = - CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "birthday", - value_type: :date - }) - |> Ash.create() - - {:ok, boolean_field} = - CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "newsletter", - value_type: :boolean - }) - |> Ash.create() - - %{ - member1: member1, - member2: member2, - member3: member3, - string_field: string_field, - integer_field: integer_field, - email_field: email_field, - date_field: date_field, - boolean_field: boolean_field - } - end - - describe "search with custom field values" do - test "finds member by string custom field value", %{ - member1: member1, - string_field: string_field - } do - # Create custom field value - {:ok, _cfv} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: string_field.id, - value: %{"_union_type" => "string", "_union_value" => "MEMBER12345"} - }) - |> Ash.create() - - # Force search_vector update by reloading member - {:ok, _updated_member} = - member1 - |> Ash.Changeset.for_update(:update_member, %{}) - |> Ash.update() - - # Search for the custom field value - results = - Member - |> Member.fuzzy_search(%{query: "MEMBER12345"}) - |> Ash.read!() - - assert length(results) == 1 - assert List.first(results).id == member1.id - end - - test "finds member by integer custom field value", %{ - member1: member1, - integer_field: integer_field - } do - # Create custom field value - {:ok, _cfv} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: integer_field.id, - value: %{"_union_type" => "integer", "_union_value" => 42_424} - }) - |> Ash.create() - - # Force search_vector update - {:ok, _updated_member} = - member1 - |> Ash.Changeset.for_update(:update_member, %{}) - |> Ash.update() - - # Search for the custom field value - results = - Member - |> Member.fuzzy_search(%{query: "42424"}) - |> Ash.read!() - - assert length(results) == 1 - assert List.first(results).id == member1.id - end - - test "finds member by email custom field value", %{ - member1: member1, - email_field: email_field - } do - # Create custom field value - {:ok, _cfv} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: email_field.id, - value: %{"_union_type" => "email", "_union_value" => "alice.secondary@example.com"} - }) - |> Ash.create() - - # Force search_vector update - {:ok, _updated_member} = - member1 - |> Ash.Changeset.for_update(:update_member, %{}) - |> Ash.update() - - # Search for partial custom field value (should work via FTS or custom field filter) - results = - Member - |> Member.fuzzy_search(%{query: "alice.secondary"}) - |> Ash.read!() - - assert length(results) == 1 - assert List.first(results).id == member1.id - - # Search for full email address (should work via custom field filter LIKE) - results_full = - Member - |> Member.fuzzy_search(%{query: "alice.secondary@example.com"}) - |> Ash.read!() - - assert length(results_full) == 1 - assert List.first(results_full).id == member1.id - - # Search for domain part (should work via FTS or custom field filter) - # Note: May return multiple results if other members have same domain - results_domain = - Member - |> Member.fuzzy_search(%{query: "example.com"}) - |> Ash.read!() - - # Verify that member1 is in the results (may have other members too) - ids = Enum.map(results_domain, & &1.id) - assert member1.id in ids - end - - test "finds member by date custom field value", %{ - member1: member1, - date_field: date_field - } do - # Create custom field value - {:ok, _cfv} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: date_field.id, - value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]} - }) - |> Ash.create() - - # Force search_vector update - {:ok, _updated_member} = - member1 - |> Ash.Changeset.for_update(:update_member, %{}) - |> Ash.update() - - # Search for the custom field value (date is stored as text in search_vector) - results = - Member - |> Member.fuzzy_search(%{query: "1990-05-15"}) - |> Ash.read!() - - assert length(results) == 1 - assert List.first(results).id == member1.id - end - - test "finds member by boolean custom field value", %{ - member1: member1, - boolean_field: boolean_field - } do - # Create custom field value - {:ok, _cfv} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: boolean_field.id, - value: %{"_union_type" => "boolean", "_union_value" => true} - }) - |> Ash.create() - - # Force search_vector update - {:ok, _updated_member} = - member1 - |> Ash.Changeset.for_update(:update_member, %{}) - |> Ash.update() - - # Search for the custom field value (boolean is stored as "true" or "false" text) - results = - Member - |> Member.fuzzy_search(%{query: "true"}) - |> Ash.read!() - - # Note: "true" might match other things, so we check that member1 is in results - assert Enum.any?(results, fn m -> m.id == member1.id end) - end - - test "custom field value update triggers search_vector update", %{ - member1: member1, - string_field: string_field - } do - # Create initial custom field value - {:ok, cfv} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: string_field.id, - value: %{"_union_type" => "string", "_union_value" => "OLDVALUE"} - }) - |> Ash.create() - - # Force search_vector update - {:ok, _updated_member} = - member1 - |> Ash.Changeset.for_update(:update_member, %{}) - |> Ash.update() - - # Update custom field value - {:ok, _updated_cfv} = - cfv - |> Ash.Changeset.for_update(:update, %{ - value: %{"_union_type" => "string", "_union_value" => "NEWVALUE123"} - }) - |> Ash.update() - - # Search for the new value - results = - Member - |> Member.fuzzy_search(%{query: "NEWVALUE123"}) - |> Ash.read!() - - assert length(results) == 1 - assert List.first(results).id == member1.id - - # Old value should not be found - old_results = - Member - |> Member.fuzzy_search(%{query: "OLDVALUE"}) - |> Ash.read!() - - refute Enum.any?(old_results, fn m -> m.id == member1.id end) - end - - test "custom field value delete triggers search_vector update", %{ - member1: member1, - string_field: string_field - } do - # Create custom field value - {:ok, cfv} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: string_field.id, - value: %{"_union_type" => "string", "_union_value" => "TOBEDELETED"} - }) - |> Ash.create() - - # Force search_vector update - {:ok, _updated_member} = - member1 - |> Ash.Changeset.for_update(:update_member, %{}) - |> Ash.update() - - # Verify it's searchable - results = - Member - |> Member.fuzzy_search(%{query: "TOBEDELETED"}) - |> Ash.read!() - - assert length(results) == 1 - assert List.first(results).id == member1.id - - # Delete custom field value - assert :ok = Ash.destroy(cfv) - - # Value should no longer be found - deleted_results = - Member - |> Member.fuzzy_search(%{query: "TOBEDELETED"}) - |> Ash.read!() - - refute Enum.any?(deleted_results, fn m -> m.id == member1.id end) - end - - test "custom field value create triggers search_vector update", %{ - member1: member1, - string_field: string_field - } do - # Create custom field value (trigger should update search_vector automatically) - {:ok, _cfv} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: string_field.id, - value: %{"_union_type" => "string", "_union_value" => "AUTOUPDATE"} - }) - |> Ash.create() - - # Search should find it immediately (trigger should have updated search_vector) - results = - Member - |> Member.fuzzy_search(%{query: "AUTOUPDATE"}) - |> Ash.read!() - - assert length(results) == 1 - assert List.first(results).id == member1.id - end - - test "member update includes custom field values in search_vector", %{ - member1: member1, - string_field: string_field - } do - # Create custom field value - {:ok, _cfv} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: string_field.id, - value: %{"_union_type" => "string", "_union_value" => "MEMBERUPDATE"} - }) - |> Ash.create() - - # Update member (should trigger search_vector update including custom fields) - {:ok, _updated_member} = - member1 - |> Ash.Changeset.for_update(:update_member, %{notes: "Updated notes"}) - |> Ash.update() - - # Search should find the custom field value - results = - Member - |> Member.fuzzy_search(%{query: "MEMBERUPDATE"}) - |> Ash.read!() - - assert length(results) == 1 - assert List.first(results).id == member1.id - end - - test "multiple custom field values are all searchable", %{ - member1: member1, - string_field: string_field, - integer_field: integer_field, - email_field: email_field - } do - # Create multiple custom field values - {:ok, _cfv1} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: string_field.id, - value: %{"_union_type" => "string", "_union_value" => "MULTI1"} - }) - |> Ash.create() - - {:ok, _cfv2} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: integer_field.id, - value: %{"_union_type" => "integer", "_union_value" => 99_999} - }) - |> Ash.create() - - {:ok, _cfv3} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: email_field.id, - value: %{"_union_type" => "email", "_union_value" => "multi@test.com"} - }) - |> Ash.create() - - # Force search_vector update - {:ok, _updated_member} = - member1 - |> Ash.Changeset.for_update(:update_member, %{}) - |> Ash.update() - - # All values should be searchable - results1 = - Member - |> Member.fuzzy_search(%{query: "MULTI1"}) - |> Ash.read!() - - assert Enum.any?(results1, fn m -> m.id == member1.id end) - - results2 = - Member - |> Member.fuzzy_search(%{query: "99999"}) - |> Ash.read!() - - assert Enum.any?(results2, fn m -> m.id == member1.id end) - - results3 = - Member - |> Member.fuzzy_search(%{query: "multi@test.com"}) - |> Ash.read!() - - assert Enum.any?(results3, fn m -> m.id == member1.id end) - end - - test "finds member by custom field value with numbers in text field (e.g. phone number)", %{ - member1: member1, - string_field: string_field - } do - # Create custom field value with numbers and text (like phone number or ID) - {:ok, _cfv} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: string_field.id, - value: %{"_union_type" => "string", "_union_value" => "M-123-456"} - }) - |> Ash.create() - - # Force search_vector update - {:ok, _updated_member} = - member1 - |> Ash.Changeset.for_update(:update_member, %{}) - |> Ash.update() - - # Search for full value (should work via search_vector) - results_full = - Member - |> Member.fuzzy_search(%{query: "M-123-456"}) - |> Ash.read!() - - assert Enum.any?(results_full, fn m -> m.id == member1.id end), - "Full value search should find member via search_vector" - - # Note: Partial substring search may require additional implementation - # For now, we test that the full value is searchable, which is the primary use case - # Substring matching for custom fields may need to be implemented separately - end - - test "finds member by phone number in Emergency Contact custom field", %{ - member1: member1 - } do - # Create Emergency Contact custom field - {:ok, emergency_contact_field} = - CustomField - |> Ash.Changeset.for_create(:create, %{ - name: "Emergency Contact", - value_type: :string - }) - |> Ash.create() - - # Create custom field value with phone number - phone_number = "+49 123 456789" - - {:ok, _cfv} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: emergency_contact_field.id, - value: %{"_union_type" => "string", "_union_value" => phone_number} - }) - |> Ash.create() - - # Force search_vector update - {:ok, _updated_member} = - member1 - |> Ash.Changeset.for_update(:update_member, %{}) - |> Ash.update() - - # Search for full phone number (should work via search_vector) - results_full = - Member - |> Member.fuzzy_search(%{query: phone_number}) - |> Ash.read!() - - assert Enum.any?(results_full, fn m -> m.id == member1.id end), - "Full phone number search should find member via search_vector" - - # Note: Partial substring search may require additional implementation - # For now, we test that the full phone number is searchable, which is the primary use case - # Substring matching for custom fields may need to be implemented separately - end - end - - describe "custom field substring search (ILIKE)" do - test "finds member by prefix of custom field value", %{ - member1: member1, - string_field: string_field - } do - # Create custom field value with a distinct word - {:ok, _cfv} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: string_field.id, - value: %{"_union_type" => "string", "_union_value" => "Premium"} - }) - |> Ash.create() - - # Test prefix searches - should all find the member - for prefix <- ["Premium", "Premiu", "Premi", "Prem", "Pre"] do - results = - Member - |> Member.fuzzy_search(%{query: prefix}) - |> Ash.read!() - - assert Enum.any?(results, fn m -> m.id == member1.id end), - "Prefix '#{prefix}' should find member with custom field 'Premium'" - end - end - - test "custom field search is case-insensitive", %{ - member1: member1, - string_field: string_field - } do - # Create custom field value - {:ok, _cfv} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: string_field.id, - value: %{"_union_type" => "string", "_union_value" => "GoldMember"} - }) - |> Ash.create() - - # Test case variations - should all find the member - for variant <- [ - "GoldMember", - "goldmember", - "GOLDMEMBER", - "GoLdMeMbEr", - "gold", - "GOLD", - "Gold" - ] do - results = - Member - |> Member.fuzzy_search(%{query: variant}) - |> Ash.read!() - - assert Enum.any?(results, fn m -> m.id == member1.id end), - "Case variant '#{variant}' should find member with custom field 'GoldMember'" - end - end - - test "finds member by suffix/middle of custom field value", %{ - member1: member1, - string_field: string_field - } do - # Create custom field value - {:ok, _cfv} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: string_field.id, - value: %{"_union_type" => "string", "_union_value" => "ActiveMember"} - }) - |> Ash.create() - - # Test suffix and middle substring searches - for substring <- ["Member", "ember", "tiveMem", "ctive"] do - results = - Member - |> Member.fuzzy_search(%{query: substring}) - |> Ash.read!() - - assert Enum.any?(results, fn m -> m.id == member1.id end), - "Substring '#{substring}' should find member with custom field 'ActiveMember'" - end - end - - test "finds correct member among multiple with different custom field values", %{ - member1: member1, - member2: member2, - member3: member3, - string_field: string_field - } do - # Create different custom field values for each member - {:ok, _cfv1} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member1.id, - custom_field_id: string_field.id, - value: %{"_union_type" => "string", "_union_value" => "Beginner"} - }) - |> Ash.create() - - {:ok, _cfv2} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member2.id, - custom_field_id: string_field.id, - value: %{"_union_type" => "string", "_union_value" => "Advanced"} - }) - |> Ash.create() - - {:ok, _cfv3} = - CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member3.id, - custom_field_id: string_field.id, - value: %{"_union_type" => "string", "_union_value" => "Expert"} - }) - |> Ash.create() - - # Search for "Begin" - should only find member1 - results_begin = - Member - |> Member.fuzzy_search(%{query: "Begin"}) - |> Ash.read!() - - assert length(results_begin) == 1 - assert List.first(results_begin).id == member1.id - - # Search for "Advan" - should only find member2 - results_advan = - Member - |> Member.fuzzy_search(%{query: "Advan"}) - |> Ash.read!() - - assert length(results_advan) == 1 - assert List.first(results_advan).id == member2.id - - # Search for "Exper" - should only find member3 - results_exper = - Member - |> Member.fuzzy_search(%{query: "Exper"}) - |> Ash.read!() - - assert length(results_exper) == 1 - assert List.first(results_exper).id == member3.id - end - - # Note: Legacy format (type/value) is supported via the SQL ILIKE query on value->>'value' - # This is tested implicitly by the migration trigger which handles both formats. - # The Ash union type only accepts the new format (_union_type/_union_value) for creation, - # but the search works on existing legacy data. - end -end diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 30b61c7..9e3323f 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -51,7 +51,7 @@ defmodule MvWeb.MemberLive.IndexTest do |> render_submit() |> follow_redirect(conn, "/members") - assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt") + assert has_element?(index_view, "#flash-group", "Mitglied erstellt erfolgreich") end test "shows translated flash message after creating a member in English", %{conn: conn} do @@ -71,7 +71,7 @@ defmodule MvWeb.MemberLive.IndexTest do |> render_submit() |> follow_redirect(conn, "/members") - assert has_element?(index_view, "#flash-group", "Member created successfully") + assert has_element?(index_view, "#flash-group", "Member create successfully") end describe "sorting integration" do diff --git a/test/seeds_test.exs b/test/seeds_test.exs index b4d887c..6d29760 100644 --- a/test/seeds_test.exs +++ b/test/seeds_test.exs @@ -11,9 +11,9 @@ defmodule Mv.SeedsTest do {:ok, members} = Ash.read(Mv.Membership.Member) {:ok, custom_fields} = Ash.read(Mv.Membership.CustomField) - assert not Enum.empty?(users), "Seeds should create at least one user" - assert not Enum.empty?(members), "Seeds should create at least one member" - assert not Enum.empty?(custom_fields), "Seeds should create at least one custom field" + assert length(users) > 0, "Seeds should create at least one user" + assert length(members) > 0, "Seeds should create at least one member" + assert length(custom_fields) > 0, "Seeds should create at least one custom field" end test "can be run multiple times (idempotent)" do