Compare commits

..

1 commit

Author SHA1 Message Date
Renovate Bot
cf62b47ff1 chore(deps): update dependency just to v1.45.0
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-12-10 21:26:59 +00:00
33 changed files with 1133 additions and 2932 deletions

View file

@ -4,7 +4,7 @@ name: check
services: services:
- name: postgres - name: postgres
image: docker.io/library/postgres:17.7 image: docker.io/library/postgres:17.6
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
@ -57,7 +57,7 @@ steps:
- mix gettext.extract --check-up-to-date - mix gettext.extract --check-up-to-date
- name: wait_for_postgres - name: wait_for_postgres
image: docker.io/library/postgres:17.7 image: docker.io/library/postgres:17.6
commands: commands:
# Wait for postgres to become available # Wait for postgres to become available
- | - |
@ -166,7 +166,7 @@ environment:
steps: steps:
- name: renovate - name: renovate
image: renovate/renovate:42.44 image: renovate/renovate:41.173
environment: environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN: RENOVATE_TOKEN:

View file

@ -90,4 +90,4 @@ USER nobody
# above and adding an entrypoint. See https://github.com/krallin/tini for details # above and adding an entrypoint. See https://github.com/krallin/tini for details
# ENTRYPOINT ["/tini", "--"] # ENTRYPOINT ["/tini", "--"]
ENTRYPOINT ["/app/bin/docker-entrypoint.sh"] CMD ["/app/bin/server"]

View file

@ -255,7 +255,7 @@ For testing the production Docker build locally:
docker compose -f docker-compose.prod.yml up docker compose -f docker-compose.prod.yml up
``` ```
5. **Database migrations run automatically** on app start. For manual migration: 5. **Run database migrations:**
```bash ```bash
docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate" docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate"
``` ```

View file

@ -33,7 +33,7 @@ services:
restart: unless-stopped restart: unless-stopped
db-prod: db-prod:
image: postgres:17.7-alpine image: postgres:16-alpine
container_name: mv-prod-db container_name: mv-prod-db
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres

View file

@ -4,7 +4,7 @@ networks:
services: services:
db: db:
image: postgres:17.7-alpine image: postgres:17.6-alpine
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres

View file

@ -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**

View file

@ -1,7 +1,7 @@
# Membership Fees - Overview # Membership Contributions - Overview
**Project:** Mila - Membership Management System **Project:** Mila - Membership Management System
**Feature:** Membership Fee Management **Feature:** Membership Contribution Management
**Version:** 1.0 **Version:** 1.0
**Last Updated:** 2025-11-27 **Last Updated:** 2025-11-27
**Status:** Concept - Ready for Review **Status:** Concept - Ready for Review
@ -10,9 +10,9 @@
## Purpose ## 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 - Minimal complexity
- Clear data model without redundancies - Clear data model without redundancies
- Intuitive operation - 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:** **Core Entities:**
- Beitragsart ↔ Membership Fee Type - Beitragsart ↔ Contribution Type / Membership Fee Type
- Beitragszyklus ↔ Membership Fee Cycle - Beitragsintervall ↔ Contribution Period
- Mitgliedsbeitrag ↔ Membership Fee - Mitgliedsbeitrag ↔ Membership Fee / Contribution
**Status:** **Status:**
@ -56,7 +56,7 @@ This document provides a comprehensive overview of the Membership Fees system. I
- unbezahlt ↔ unpaid - unbezahlt ↔ unpaid
- ausgesetzt ↔ suspended / waived - ausgesetzt ↔ suspended / waived
**Intervals (Frequenz / Payment Frequency):** **Intervals:**
- monatlich ↔ monthly - monatlich ↔ monthly
- quartalsweise ↔ quarterly - quartalsweise ↔ quarterly
@ -65,8 +65,8 @@ This document provides a comprehensive overview of the Membership Fees system. I
**UI Elements:** **UI Elements:**
- "Letzter Zyklus" ↔ "Last Cycle" (e.g., 2023 when in 2024) - "Letztes Intervall" ↔ "Last Period" (e.g., 2023 when in 2024)
- "Aktueller Zyklus" ↔ "Current Cycle" (e.g., 2024) - "Aktuelles Intervall" ↔ "Current Period" (e.g., 2024)
- "Als bezahlt markieren" ↔ "Mark as paid" - "Als bezahlt markieren" ↔ "Mark as paid"
- "Aussetzen" ↔ "Suspend" / "Waive" - "Aussetzen" ↔ "Suspend" / "Waive"
@ -74,41 +74,43 @@ This document provides a comprehensive overview of the Membership Fees system. I
## Data Model ## Data Model
### Membership Fee Type (MembershipFeeType) ### Contribution Type (ContributionType)
``` ```
- id (UUID) - id (UUID)
- name (String) - e.g., "Regular", "Reduced", "Student" - 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 - interval (Enum) - :monthly, :quarterly, :half_yearly, :yearly
- description (Text, optional) - description (Text, optional)
- timestamps
``` ```
**Important:** **Important:**
- `interval` is **IMMUTABLE** after creation! - `interval` is **IMMUTABLE** after creation!
- Admin can only change `name`, `amount`, `description` - 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) - id (UUID)
- member_id (FK → members.id) - member_id (FK → members.id)
- membership_fee_type_id (FK → membership_fee_types.id) - contribution_type_id (FK → contribution_types.id)
- cycle_start (Date) - Calendar cycle start (01.01., 01.04., 01.07., 01.10., etc.) - period_start (Date) - Calendar period start (01.01., 01.04., 01.07., 01.10., etc.)
- status (Enum) - :unpaid (default), :paid, :suspended - 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 - notes (Text, optional) - Admin notes
- timestamps
``` ```
**Important:** **Important:**
- **NO** `cycle_end` - calculated from `cycle_start` + `interval` - **NO** `period_end` - calculated from `period_start` + `interval`
- **NO** `interval_type` - read from `membership_fee_type.interval` - **NO** `interval_type` - read from `contribution_type.interval`
- Avoids redundancy and inconsistencies! - Avoids redundancy and inconsistencies!
**Calendar Cycle Logic:** **Calendar Period Logic:**
- Monthly: 01.01. - 31.01., 01.02. - 28./29.02., etc. - 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. - 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) ### Member (Extensions)
``` ```
- membership_fee_type_id (FK → membership_fee_types.id, NOT NULL, default from settings) - contribution_type_id (FK → contribution_types.id, NOT NULL, default from settings)
- membership_fee_start_date (Date, nullable) - When to start generating membership fees - contribution_start_date (Date, nullable) - When to start generating contributions
- left_at (Date, nullable) - Exit date (existing) - 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` - Auto-set based on global setting `include_joining_period`
- If `include_joining_cycle = true`: First day of joining month/quarter/year - If `include_joining_period = true`: First day of joining month/quarter/year
- If `include_joining_cycle = false`: First day of NEXT cycle after joining - If `include_joining_period = false`: First day of NEXT period after joining
- Can be manually overridden by admin - 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 ### Global Settings
``` ```
key: "membership_fees.include_joining_cycle" key: "contributions.include_joining_period"
value: Boolean (Default: true) value: Boolean (Default: true)
key: "membership_fees.default_membership_fee_type_id" key: "contributions.default_contribution_type_id"
value: UUID (Required) - Default membership fee type for new members 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) - `true`: Joining period is included (member pays from joining period)
- `false`: Only from next full cycle after joining - `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 - Must be configured in admin settings
- Prevents: Members without membership fee type - Prevents: Members without contribution type
--- ---
## Business Logic ## Business Logic
### Cycle Generation ### Period Generation
**Triggers:** **Triggers:**
- Member gets membership fee type assigned (also during member creation) - Member gets contribution type assigned (also during member creation)
- New cycle begins (Cron job daily/weekly) - New period begins (Cron job daily/weekly)
- Admin requests manual regeneration - Admin requests manual regeneration
**Algorithm:** **Algorithm:**
Lock the whole cycle table for the duration of the algorithm 1. Get `member.contribution_start_date` and `member.contribution_type`
2. Calculate first period based on `contribution_start_date`
1. Get `member.membership_fee_start_date` and member's membership fee type 3. Generate all periods from start to today (or `left_at` if present)
2. Generate cycles until today (or `left_at` if present): 4. Skip existing periods
- If no cycle exists: 5. Set `amount` to current `contribution_type.amount`
- 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
**Example (Yearly):** **Example (Yearly):**
``` ```
Joining date: 15.03.2023 Joining date: 15.03.2023
include_joining_cycle: true include_joining_period: true
membership_fee_start_date: 01.01.2023 contribution_start_date: 01.01.2023
Generated cycles: Generated periods:
- 01.01.2023 - 31.12.2023 (joining cycle) - 01.01.2023 - 31.12.2023 (joining period)
- 01.01.2024 - 31.12.2024 - 01.01.2024 - 31.12.2024
- 01.01.2025 - 31.12.2025 (current year) - 01.01.2025 - 31.12.2025 (current year)
``` ```
@ -195,10 +192,10 @@ Generated cycles:
``` ```
Joining date: 15.03.2023 Joining date: 15.03.2023
include_joining_cycle: false include_joining_period: false
membership_fee_start_date: 01.04.2023 contribution_start_date: 01.04.2023
Generated cycles: Generated periods:
- 01.04.2023 - 30.06.2023 (first full quarter) - 01.04.2023 - 30.06.2023 (first full quarter)
- 01.07.2023 - 30.09.2023 - 01.07.2023 - 30.09.2023
- 01.10.2023 - 31.12.2023 - 01.10.2023 - 31.12.2023
@ -221,44 +218,44 @@ suspended → unpaid
- Admin + Treasurer (Kassenwart) can change status - Admin + Treasurer (Kassenwart) can change status
- Uses existing permission system - 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 (yearly)" ✓
- Example: From "Regular (yearly)" to "Reduced (monthly)" ✗ - Example: From "Regular (yearly)" to "Reduced (monthly)" ✗
**Logic on Change:** **Logic on Change:**
1. Check: New membership fee type has same interval 1. Check: New contribution type has same interval
2. If yes: Set `member.membership_fee_type_id` 2. If yes: Set `member.contribution_type_id`
3. Future **unpaid** cycles: Delete and regenerate with new amount 3. Future **unpaid** periods: Delete and regenerate with new amount
4. Paid/suspended cycles: Remain unchanged (historical amount) 4. Paid/suspended periods: Remain unchanged (historical amount)
**Future - Different Intervals:** **Future - Different Intervals:**
- Enable interval switching (e.g., yearly → monthly) - Enable interval switching (e.g., yearly → monthly)
- More complex logic for cycle overlaps - More complex logic for period overlaps
- Needs additional validation - Needs additional validation
### Member Exit ### Member Exit
**Logic:** **Logic:**
- Cycles only generated until `member.left_at` - Periods only generated until `member.left_at`
- Existing cycles remain visible - Existing periods remain visible
- Unpaid exit cycle can be marked as "suspended" - Unpaid exit period can be marked as "suspended"
**Example:** **Example:**
``` ```
Exit: 15.08.2024 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" → 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 ### 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 - Shows status of **last completed** period
- Example in 2024: Shows membership fee for 2023 - Example in 2024: Shows contribution for 2023
- Color coding: - Color coding:
- Green: paid ✓ - Green: paid ✓
- Red: unpaid ✗ - Red: unpaid ✗
- Gray: suspended ⊘ - 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 - Admin decides what to display
**Filters:** **Filters:**
- "Unpaid membership fees in last cycle" - "Unpaid contributions in last period"
- "Unpaid membership fees in current cycle" - "Unpaid contributions in current period"
### Member Detail View ### Member Detail View
**Section: "Membership Fees"** **Section: "Contributions"**
**Membership Fee Type Assignment:** **Contribution Type Assignment:**
``` ```
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
Membership Fee Type: [Dropdown] Contribution Type: [Dropdown]
│ ⚠ Only types with same interval │ │ ⚠ Only types with same interval │
│ can be selected │ │ can be selected │
└─────────────────────────────────────┘ └─────────────────────────────────────┘
``` ```
**Cycle Table:** **Period Table:**
``` ```
┌───────────────┬──────────┬────────┬──────────┬─────────┐ ┌───────────────┬──────────┬────────┬──────────┬─────────┐
Cycle │ Interval │ Amount │ Status │ Action │ Period │ Interval │ Amount │ Status │ Action │
├───────────────┼──────────┼────────┼──────────┼─────────┤ ├───────────────┼──────────┼────────┼──────────┼─────────┤
│ 01.01.2023- │ Yearly │ 50 € │ ☑ Paid │ │ │ 01.01.2023- │ Yearly │ 50 € │ ☑ Paid │ │
│ 31.12.2023 │ │ │ │ │ │ 31.12.2023 │ │ │ │ │
@ -325,9 +322,9 @@ Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended
- Checkbox in each row for fast marking - Checkbox in each row for fast marking
- Button: "Mark selected as paid/unpaid/suspended" - 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:** **List:**
@ -355,37 +352,37 @@ Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended
Impact: Impact:
- 45 members affected - 45 members affected
- Future unpaid cycles will be generated with 65 € - Future unpaid periods will be generated with 65 €
- Already paid cycles remain with old amount - Already paid periods remain with old amount
[Cancel] [Confirm] [Cancel] [Confirm]
``` ```
### Admin: Settings ### 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)" 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. Can be changed individually per member.
--- ---
☐ Include joining cycle ☐ Include joining period
When active: When active:
Members pay from the cycle of their joining. Members pay from the period of their joining.
Example (Yearly): Example (Yearly):
Joining: 15.03.2023 Joining: 15.03.2023
→ Pays from 2023 → Pays from 2023
When inactive: When inactive:
Members pay from the next full cycle. Members pay from the next full period.
Example (Yearly): Example (Yearly):
Joining: 15.03.2023 Joining: 15.03.2023
@ -396,7 +393,7 @@ Joining: 15.03.2023
## Edge Cases ## Edge Cases
### 1. Membership Fee Type Change with Different Interval ### 1. Contribution Type Change with Different Interval
**MVP:** Blocked (only same interval allowed) **MVP:** Blocked (only same interval allowed)
@ -405,11 +402,11 @@ Joining: 15.03.2023
``` ```
Error: Interval change not possible Error: Interval change not possible
Current membership fee type: "Regular (Yearly)" Current contribution type: "Regular (Yearly)"
Selected membership fee type: "Student (Monthly)" Selected contribution type: "Student (Monthly)"
Changing the interval is currently not possible. 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] [OK]
``` ```
@ -418,32 +415,32 @@ Please select a membership fee type with interval "Yearly".
- Allow interval switching - Allow interval switching
- Calculate overlaps - Calculate overlaps
- Generate new cycles without duplicates - Generate new periods without duplicates
### 2. Exit with Unpaid Membership Fees ### 2. Exit with Unpaid Contributions
**Scenario:** **Scenario:**
``` ```
Member exits: 15.08.2024 Member exits: 15.08.2024
Yearly cycle 2024: unpaid Yearly period 2024: unpaid
``` ```
**UI Notice on Exit: (Low Prio)** **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) - 2024: 60 € (unpaid)
Do you want to continue? Do you want to continue?
[ ] Mark membership fee as "suspended" [ ] Mark contribution as "suspended"
[Cancel] [Confirm Exit] [Cancel] [Confirm Exit]
``` ```
### 3. Multiple Unpaid Cycles ### 3. Multiple Unpaid Periods
**Scenario:** Member hasn't paid for 2 years **Scenario:** Member hasn't paid for 2 years
@ -470,9 +467,9 @@ Do you want to continue?
**Result:** **Result:**
- Cycle 2023: Saved with 50 € (history) - Period 2023: Saved with 50 € (history)
- Cycle 2024: Generated with 60 € (current) - Period 2024: Generated with 60 € (current)
- Both cycles show correct historical amount - Both periods show correct historical amount
### 5. Date Boundaries ### 5. Date Boundaries
@ -480,7 +477,7 @@ Do you want to continue?
**Solution:** **Solution:**
- Current cycle (2025) is generated - Current period (2025) is generated
- Status: unpaid (open) - Status: unpaid (open)
- Shown in overview - Shown in overview
@ -492,17 +489,17 @@ Do you want to continue?
**Included:** **Included:**
- ✓ Membership fee types (CRUD) - ✓ Contribution types (CRUD)
- ✓ Automatic cycle generation - ✓ Automatic period generation
- ✓ Status management (paid/unpaid/suspended) - ✓ Status management (paid/unpaid/suspended)
- ✓ Member overview with membership fee status - ✓ Member overview with contribution status
- ✓ Cycle view per member - ✓ Period view per member
- ✓ Quick checkbox marking - ✓ Quick checkbox marking
- ✓ Bulk actions - ✓ Bulk actions
- ✓ Amount history - ✓ Amount history
- ✓ Same-interval type change - ✓ Same-interval type change
- ✓ Default membership fee type - ✓ Default contribution type
- ✓ Joining cycle configuration - ✓ Joining period configuration
**NOT Included:** **NOT Included:**
@ -518,7 +515,7 @@ Do you want to continue?
**Phase 2:** **Phase 2:**
- Payment details (date, amount, method) - Payment details (date, amount, method)
- Interval change for future unpaid cycles - Interval change for future unpaid periods
- Manual vereinfacht.digital links per member - Manual vereinfacht.digital links per member
- Extended filter options - Extended filter options

View file

@ -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)

View file

@ -168,16 +168,9 @@ Member (1) → (N) Properties
### Weighted Fields ### Weighted Fields
- **Weight A (highest):** first_name, last_name - **Weight A (highest):** first_name, last_name
- **Weight B:** email, notes - **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 - **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 ### Usage Example
```sql ```sql
SELECT * FROM members SELECT * FROM members

View file

@ -187,16 +187,16 @@
**Current State:** **Current State:**
- ✅ Basic "paid" boolean field on members - ✅ 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 - ⚠️ No payment tracking
**Open Issues:** **Open Issues:**
- [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority) - [#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):** **Mock-Up Pages (Non-Functional Preview):**
- `/membership_fee_types` - Membership Fee Types Management - `/contribution_types` - Contribution Types Management
- `/membership_fee_settings` - Global Membership Fee Settings - `/contribution_settings` - Global Contribution Settings
**Missing Features:** **Missing Features:**
- ❌ Membership fee configuration - ❌ Membership fee configuration

View file

@ -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**

View file

@ -29,9 +29,7 @@ defmodule Mv.Membership.Member do
## Full-Text Search ## Full-Text Search
Members have a `search_vector` attribute (tsvector) that is automatically Members have a `search_vector` attribute (tsvector) that is automatically
updated via database trigger. Search includes name, email, notes, contact fields, updated via database trigger. Search includes name, email, notes, and 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.).
""" """
use Ash.Resource, use Ash.Resource,
domain: Mv.Membership, domain: Mv.Membership,
@ -42,21 +40,6 @@ defmodule Mv.Membership.Member do
# Module constants # Module constants
@member_search_limit 10 @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 @default_similarity_threshold 0.2
# Use constants from Mv.Constants for member fields # 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 if is_binary(q) and String.trim(q) != "" do
q2 = String.trim(q) q2 = String.trim(q)
# Sanitize for LIKE patterns (escape % and _), limit length to 100 chars pat = "%" <> q2 <> "%"
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)
# FTS as main filter and fuzzy search just for first name, last name and strees
query query
|> Ash.Query.filter( |> 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 else
query query
@ -484,6 +476,7 @@ defmodule Mv.Membership.Member do
- `query` - Ash.Query.t() to apply search to - `query` - Ash.Query.t() to apply search to
- `opts` - Keyword list or map with search options: - `opts` - Keyword list or map with search options:
- `:query` or `"query"` - Search string - `:query` or `"query"` - Search string
- `:fields` or `"fields"` - Optional field restrictions
## Returns ## Returns
- Modified Ash.Query.t() with search filters applied - Modified Ash.Query.t() with search filters applied
@ -504,101 +497,14 @@ defmodule Mv.Membership.Member do
if String.trim(q) == "" do if String.trim(q) == "" do
query query
else else
Ash.Query.for_read(query, :search, %{query: q}) args =
end case opts[:fields] || opts["fields"] do
nil -> %{query: q}
fields -> %{query: q, fields: fields}
end end
# ============================================================================ Ash.Query.for_read(query, :search, args)
# 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 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 end
# Private helper to apply filters for :available_for_linking action # Private helper to apply filters for :available_for_linking action
@ -609,9 +515,9 @@ defmodule Mv.Membership.Member do
# - Empty user_email ("") → email == "" is always false → only fuzzy search matches # - Empty user_email ("") → email == "" is always false → only fuzzy search matches
# - This allows a single filter expression instead of duplicating fuzzy search logic # - This allows a single filter expression instead of duplicating fuzzy search logic
# #
# Note: Custom field search is intentionally excluded from linking to optimize # Cyclomatic complexity is unavoidable here: PostgreSQL fuzzy search requires
# autocomplete performance. Custom fields are still searchable via the main # multiple OR conditions for good search quality (FTS + trigram similarity + substring)
# member search which uses the indexed search_vector. # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp apply_linking_filters(query, user_email, search_query) do defp apply_linking_filters(query, user_email, search_query) do
has_search = search_query && String.trim(search_query) != "" has_search = search_query && String.trim(search_query) != ""
# Use empty string instead of nil to simplify filter logic # Use empty string instead of nil to simplify filter logic
@ -620,23 +526,35 @@ defmodule Mv.Membership.Member do
if has_search do if has_search do
# Search query provided: return email-match OR fuzzy-search candidates # Search query provided: return email-match OR fuzzy-search candidates
trimmed_search = String.trim(search_query) 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 query
|> Ash.Query.filter( |> Ash.Query.filter(
expr( expr(
# Email exact match has highest priority (for filter_by_email_match) # Email match candidate (for filter_by_email_match priority)
# If email is "", this is always false and search filters take over # If email is "", this is always false and fuzzy search takes over
# Fuzzy search candidates
email == ^trimmed_email or email == ^trimmed_email or
^fts_match or fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed_search) or
^fuzzy_match or fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed_search) or
^email_substring_match 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 else

View file

@ -153,7 +153,7 @@ defmodule MvWeb.CoreComponents do
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={@open} aria-expanded={@open}
aria-controls={@id} aria-controls={@id}
class="btn" class="btn btn-ghost"
phx-click="toggle_dropdown" phx-click="toggle_dropdown"
phx-target={@phx_target} phx-target={@phx_target}
data-testid="dropdown-button" data-testid="dropdown-button"
@ -236,30 +236,6 @@ defmodule MvWeb.CoreComponents do
""" """
end end
@doc """
Renders a section in with a border similar to cards.
## Examples
<.form_section title={gettext("Personal Data")}>
<p>input</p>
</form_section>
"""
attr :title, :string, required: true
slot :inner_block, required: true
def form_section(assigns) do
~H"""
<section class="mb-6">
<h2 class="text-lg font-semibold mb-3">{@title}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
{render_slot(@inner_block)}
</div>
</section>
"""
end
@doc """ @doc """
Renders an input with label and error messages. Renders an input with label and error messages.
@ -458,7 +434,7 @@ defmodule MvWeb.CoreComponents do
~H""" ~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4", @class]}> <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4", @class]}>
<div> <div>
<h1 class="text-xl font-semibold leading-8"> <h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)} {render_slot(@inner_block)}
</h1> </h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70"> <p :if={@subtitle != []} class="text-sm text-base-content/70">
@ -498,7 +474,6 @@ defmodule MvWeb.CoreComponents do
slot :col, required: true do slot :col, required: true do
attr :label, :string attr :label, :string
attr :class, :string
attr :col_click, :any, doc: "optional column-specific click handler that overrides row_click" attr :col_click, :any, doc: "optional column-specific click handler that overrides row_click"
end end
@ -515,7 +490,7 @@ defmodule MvWeb.CoreComponents do
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<tr> <tr>
<th :for={col <- @col} class={Map.get(col, :class)}>{col[:label]}</th> <th :for={col <- @col}>{col[:label]}</th>
<th :for={dyn_col <- @dynamic_cols}> <th :for={dyn_col <- @dynamic_cols}>
<.live_component <.live_component
module={MvWeb.Components.SortHeaderComponent} module={MvWeb.Components.SortHeaderComponent}
@ -539,34 +514,7 @@ defmodule MvWeb.CoreComponents do
(col[:col_click] && col[:col_click].(@row_item.(row))) || (col[:col_click] && col[:col_click].(@row_item.(row))) ||
(@row_click && @row_click.(row)) (@row_click && @row_click.(row))
} }
class={ class={["max-w-xs truncate", (col[:col_click] || @row_click) && "hover:cursor-pointer"]}
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, " ")
}
> >
{render_slot(col, @row_item.(row))} {render_slot(col, @row_item.(row))}
</td> </td>

View file

@ -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_atom(field), do: Atom.to_string(field)
defp field_to_string(field) when is_binary(field), do: field defp field_to_string(field) when is_binary(field), do: field
defp format_field_label(field) when is_atom(field) do defp format_field_label(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
field field
|> field_to_string()
|> String.replace("_", " ") |> String.replace("_", " ")
|> String.split() |> String.split()
|> Enum.map_join(" ", &String.capitalize/1) |> Enum.map_join(" ", &String.capitalize/1)

View file

@ -44,7 +44,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
<button <button
type="button" type="button"
class={[ class={[
"btn gap-2", "btn btn-ghost gap-2",
@paid_filter && "btn-active" @paid_filter && "btn-active"
]} ]}
phx-click="toggle_dropdown" phx-click="toggle_dropdown"

View file

@ -14,26 +14,20 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
~H""" ~H"""
<div id={@id}> <div id={@id}>
<.form_section title={gettext("Custom Fields")}> <.header>
<div class="flex"> {gettext("Custom Fields")}
<p class="text-sm text-base-content/70"> <:subtitle>
{gettext("These will appear in addition to other data when adding new members.")} {gettext("These will appear in addition to other data when adding new members.")}
</p> </:subtitle>
<div class="ml-auto"> <:actions>
<.button <.button variant="primary" phx-click="new_custom_field" phx-target={@myself}>
class="ml-auto"
variant="primary"
phx-click="new_custom_field"
phx-target={@myself}
>
<.icon name="hero-plus" /> {gettext("New Custom field")} <.icon name="hero-plus" /> {gettext("New Custom field")}
</.button> </.button>
</div> </:actions>
</div> </.header>
<%!-- Show form when creating or editing --%> <%!-- Show form when creating or editing --%>
<div :if={@show_form} class="mb-8"> <div :if={@show_form} class="mb-8">
<.live_component <.live_component
@ -61,18 +55,14 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col> <:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
<:col :let={{_id, custom_field}} label={gettext("Value Type")}> <:col :let={{_id, custom_field}} label={gettext("Value Type")}>
{@field_type_label.(custom_field.value_type)} {custom_field.value_type}
</:col> </:col>
<:col :let={{_id, custom_field}} label={gettext("Description")}> <:col :let={{_id, custom_field}} label={gettext("Description")}>
{custom_field.description} {custom_field.description}
</:col> </:col>
<:col <:col :let={{_id, custom_field}} label={gettext("Show in Overview")}>
:let={{_id, custom_field}}
label={gettext("Show in overview")}
class="max-w-[9.375rem] text-center"
>
<span :if={custom_field.show_in_overview} class="badge badge-success"> <span :if={custom_field.show_in_overview} class="badge badge-success">
{gettext("Yes")} {gettext("Yes")}
</span> </span>
@ -90,9 +80,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
</:action> </:action>
<:action :let={{_id, custom_field}}> <:action :let={{_id, custom_field}}>
<.link phx-click={ <.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}>
JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)
}>
{gettext("Delete")} {gettext("Delete")}
</.link> </.link>
</:action> </:action>
@ -162,7 +150,6 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
</div> </div>
</div> </div>
</dialog> </dialog>
</.form_section>
</div> </div>
""" """
end end

View file

@ -46,22 +46,22 @@ defmodule MvWeb.GlobalSettingsLive do
</.header> </.header>
<%!-- Club Settings Section --%> <%!-- Club Settings Section --%>
<.form_section title={gettext("Club Settings")}> <.header>
{gettext("Club Settings")}
</.header>
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save"> <.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
<div class="w-100">
<.input <.input
field={@form[:club_name]} field={@form[:club_name]}
type="text" type="text"
label={gettext("Association Name")} label={gettext("Association Name")}
required required
/> />
</div>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Settings")} {gettext("Save Settings")}
</.button> </.button>
</.form> </.form>
</.form_section>
<%!-- Custom Fields Section --%> <%!-- Custom Fields Section --%>
<.live_component <.live_component
module={MvWeb.CustomFieldLive.IndexComponent} module={MvWeb.CustomFieldLive.IndexComponent}

View file

@ -348,6 +348,25 @@ defmodule MvWeb.MemberLive.Form do
defp return_path("show", nil), do: ~p"/members" defp return_path("show", nil), do: ~p"/members"
defp return_path("show", member), do: ~p"/members/#{member.id}" 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"""
<section class="mb-6">
<h2 class="text-lg font-semibold mb-3">{@title}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
{render_slot(@inner_block)}
</div>
</section>
"""
end
# ----------------------------------------------------------------- # -----------------------------------------------------------------
# Helper Functions for Custom Fields # Helper Functions for Custom Fields
# ----------------------------------------------------------------- # -----------------------------------------------------------------

View file

@ -668,7 +668,7 @@ defmodule MvWeb.MemberLive.Index do
query query
end 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 # Filter custom field values at the database level using Ash relationship query
# This ensures only visible custom field values are loaded # This ensures only visible custom field values are loaded
custom_field_values_query = custom_field_values_query =

View file

@ -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

View file

@ -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

View file

@ -38,7 +38,7 @@ defmodule Mv.MixProject do
[ [
{:tidewave, "~> 0.5", only: [:dev]}, {:tidewave, "~> 0.5", only: [:dev]},
{:sourceror, "~> 1.8", only: [:dev, :test]}, {:sourceror, "~> 1.8", only: [:dev, :test]},
{:live_debugger, "~> 0.5", only: [:dev]}, {:live_debugger, "~> 0.4", only: [:dev]},
{:ash_admin, "~> 0.13"}, {:ash_admin, "~> 0.13"},
{:ash_postgres, "~> 2.0"}, {:ash_postgres, "~> 2.0"},
{:ash_phoenix, "~> 2.0"}, {:ash_phoenix, "~> 2.0"},
@ -46,7 +46,7 @@ defmodule Mv.MixProject do
{:bcrypt_elixir, "~> 3.0"}, {:bcrypt_elixir, "~> 3.0"},
{:ash_authentication, "~> 4.9"}, {:ash_authentication, "~> 4.9"},
{:ash_authentication_phoenix, "~> 2.10"}, {: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, "~> 1.8.0-rc.4", override: true},
{:phoenix_ecto, "~> 4.5"}, {:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.10"}, {:ecto_sql, "~> 3.10"},
@ -69,7 +69,7 @@ defmodule Mv.MixProject do
{:req, "~> 0.5"}, {:req, "~> 0.5"},
{:telemetry_metrics, "~> 1.0"}, {:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"}, {:telemetry_poller, "~> 1.0"},
{:gettext, "~> 1.0"}, {:gettext, "~> 0.26"},
{:jason, "~> 1.2"}, {:jason, "~> 1.2"},
{:dns_cluster, "~> 0.2.0"}, {:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"}, {:bandit, "~> 1.5"},

View file

@ -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": {: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.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_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.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": {: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.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_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.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_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.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_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.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_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"}, "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"}, "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"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"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"}, "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"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"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"}, "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.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"}, "crux": {:hex, :crux, "0.1.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"}, "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"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "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_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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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]}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "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"}, "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_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "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"}, "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": {: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.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_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": {: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_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_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_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.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_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.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "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_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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
"splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"}, "splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "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"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "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_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"}, "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"}, "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"}, "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.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"}, "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"}, "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": {: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"}, "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"}, "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"}, "ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"},

View file

@ -29,7 +29,6 @@ msgstr "Verbindung wird wiederhergestellt"
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "City" msgid "City"
msgstr "Stadt" msgstr "Stadt"
@ -49,7 +48,7 @@ msgstr "Löschen"
#: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Edit" msgid "Edit"
msgstr "Bearbeiten" msgstr "Bearbeite"
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.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/form.ex
#: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex #: lib/mv_web/live/user_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Email" msgid "Email"
msgstr "E-Mail" msgstr "E-Mail"
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "First Name" msgid "First Name"
msgstr "Vorname" msgstr "Vorname"
@ -79,14 +76,12 @@ msgstr "Vorname"
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Join Date" msgid "Join Date"
msgstr "Beitrittsdatum" msgstr "Beitrittsdatum"
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Last Name" msgid "Last Name"
msgstr "Nachname" msgstr "Nachname"
@ -120,13 +115,11 @@ msgstr "schließen"
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Exit Date" msgid "Exit Date"
msgstr "Austrittsdatum" msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "House Number" msgid "House Number"
msgstr "Hausnummer" msgstr "Hausnummer"
@ -134,7 +127,6 @@ msgstr "Hausnummer"
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Notes" msgid "Notes"
msgstr "Notizen" msgstr "Notizen"
@ -144,7 +136,6 @@ msgstr "Notizen"
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "Bezahlt" msgstr "Bezahlt"
@ -156,7 +147,6 @@ msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Postal Code" msgid "Postal Code"
msgstr "Postleitzahl" msgstr "Postleitzahl"
@ -177,7 +167,6 @@ msgstr "Speichern..."
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Street" msgid "Street"
msgstr "Straße" msgstr "Straße"
@ -225,7 +214,7 @@ msgstr "Falsche E-Mail oder Passwort"
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member %{action} successfully" msgid "Member %{action} successfully"
msgstr "Mitglied wurde erfolgreich %{action}" msgstr "Mitglied %{action} erfolgreich"
#: lib/mv_web/controllers/auth_controller.ex #: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -429,9 +418,9 @@ msgid "Admin Note"
msgstr "Administrator*innen-Hinweis" msgstr "Administrator*innen-Hinweis"
#: lib/mv_web/live/user_live/form.ex #: 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." 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 #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, 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:" 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/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show in overview" msgid "Show in overview"
msgstr "In Übersicht anzeigen" msgstr "In der Mitglieder-Übersicht anzeigen"
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, 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/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Phone" msgid "Phone"
msgstr "Telefon" msgstr "Telefon"
@ -917,96 +904,96 @@ msgstr "Mitglied erstellen"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "%{count} period selected" msgid "%{count} period selected"
msgid_plural "%{count} periods selected" msgid_plural "%{count} periods selected"
msgstr[0] "%{count} Zyklus ausgewählt" msgstr[0] ""
msgstr[1] "%{count} Zyklen ausgewählt" msgstr[1] ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "About Contribution Types" msgid "About Contribution Types"
msgstr "Über Beitragsarten" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Amount" msgid "Amount"
msgstr "Betrag" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Back to Settings" msgid "Back to Settings"
msgstr "Zurück zu den Einstellungen" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Can be changed at any time. Amount changes affect future periods only." 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 #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Cannot delete - members assigned" 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 #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Change Contribution Type" msgid "Change Contribution Type"
msgstr "Beitragsart ändern" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Configure global settings for membership contributions." 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/components/layouts/navbar.ex
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Contribution Settings" msgid "Contribution Settings"
msgstr "Beitragseinstellungen" msgstr "Beitrag"
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Contribution Start" msgid "Contribution Start"
msgstr "Beitragsbeginn" msgstr "Beitrag"
#: lib/mv_web/components/layouts/navbar.ex #: lib/mv_web/components/layouts/navbar.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Contribution Types" msgid "Contribution Types"
msgstr "Beitragsarten" msgstr "Beitrag"
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Contribution start" msgid "Contribution start"
msgstr "Beitragsbeginn" msgstr "Beitrag"
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Contribution type" msgid "Contribution type"
msgstr "Beitragsart" msgstr "Beitrag"
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, 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." 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 #: lib/mv_web/components/layouts/navbar.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Contributions" msgid "Contributions"
msgstr "Beiträge" msgstr "Beitrag"
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Contributions for %{name}" msgid "Contributions for %{name}"
msgstr "Beiträge für %{name}" msgstr "Beitrag"
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Current" msgid "Current"
msgstr "Aktuell" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Default Contribution Type" msgid "Default Contribution Type"
msgstr "Standard-Beitragsart" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
@ -1016,28 +1003,28 @@ msgstr "Löschen"
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Example: Member Contribution View" msgid "Example: Member Contribution View"
msgstr "Beispiel: Ansicht Mitgliedsbeiträge" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Examples" msgid "Examples"
msgstr "Beispiele" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Family" msgid "Family"
msgstr "Familie" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Fixed after creation. Members can only switch between types with the same interval." msgid "Fixed after creation. Members can only switch between types with the same interval."
msgstr "Festgelegt nach der Erstellung. Mitglieder können nur zwischen Beitragsarten mit gleichem Intervall wechseln." msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Generated periods" msgid "Generated periods"
msgstr "Generierte Zyklen" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
@ -1049,29 +1036,29 @@ msgstr "Vereinsdaten"
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Half-yearly" msgid "Half-yearly"
msgstr "Halbjährlich" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Half-yearly contribution for supporting members" 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_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Honorary" msgid "Honorary"
msgstr "Ehrenamtlich" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Include joining period" msgid "Include joining period"
msgstr "Beitrittsdatum einbeziehen" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Interval" msgid "Interval"
msgstr "Zyklus" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
@ -1081,240 +1068,240 @@ msgstr "Beitrittsdatum"
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Joining year - reduced to 0" msgid "Joining year - reduced to 0"
msgstr "Beitrittsjahr auf 0 reduziert" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Manage contribution types for membership fees." msgid "Manage contribution types for membership fees."
msgstr "Beitragsarten für Mitgliedsbeiträge verwalten." msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Mark as Paid" msgid "Mark as Paid"
msgstr "Als bezahlt markieren" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Mark as Suspended" msgid "Mark as Suspended"
msgstr "Als pausiert markieren" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Mark as Unpaid" msgid "Mark as Unpaid"
msgstr "Als unbezahlt markieren" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member Contributions" msgid "Member Contributions"
msgstr "Mitgliedsbeiträge" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member pays for the year they joined" msgid "Member pays for the year they joined"
msgstr "Mitglied zahlt für das Beitrittsjahr" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member pays from the joining month" msgid "Member pays from the joining month"
msgstr "Mitglied zahlt ab Beitrittsmonat" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member pays from the next full quarter" 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 #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Member pays from the next full year" 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 #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Member since" msgid "Member since"
msgstr "Mitglied seit" msgstr "Mitglieder"
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, 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." 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_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Monthly" msgid "Monthly"
msgstr "Monatlich" msgstr "monatlich"
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Monthly Interval - Joining Period Included" msgid "Monthly Interval - Joining Period Included"
msgstr "Monatliches Intervall Beitrittszeitraum einbezogen" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Monthly fee for students and trainees" 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 #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Name & Amount" msgid "Name & Amount"
msgstr "Name & Betrag" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "New Contribution Type" msgid "New Contribution Type"
msgstr "Neue Beitragsart" msgstr "Beitrag"
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No fee for honorary members" msgid "No fee for honorary members"
msgstr "Kein Beitrag für ehrenamtliche Mitglieder" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Only possible if no members are assigned to this type." 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 #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Open Contributions" msgid "Open Contributions"
msgstr "Offene Beiträge" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid via bank transfer" msgid "Paid via bank transfer"
msgstr "Bezahlt durch Überweisung" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Preview Mockup" msgid "Preview Mockup"
msgstr "Vorschau" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Quarterly" msgid "Quarterly"
msgstr "Vierteljährlich" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Quarterly Interval - Joining Period Excluded" msgid "Quarterly Interval - Joining Period Excluded"
msgstr "Vierteljährliches Intervall Beitrittszeitraum nicht einbezogen" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Quarterly fee for family memberships" 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_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reduced" msgid "Reduced"
msgstr "Reduziert" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reduced fee for unemployed, pensioners, or low income" 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_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Regular" msgid "Regular"
msgstr "Regulär" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Reopen" msgid "Reopen"
msgstr "Wieder öffnen" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, 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." 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 #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Standard membership fee for regular members" msgid "Standard membership fee for regular members"
msgstr "Regulärer Mitgliedsbeitrag für Vollmitglieder" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Status" msgid "Status"
msgstr "Status" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Student" msgid "Student"
msgstr "Student" msgstr ""
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Supporting Member" msgid "Supporting Member"
msgstr "Fördermitglied" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Suspend" msgid "Suspend"
msgstr "Pausieren" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Suspended" msgid "Suspended"
msgstr "Pausiert" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "This contribution type is automatically assigned to all new members. Can be changed individually per member." 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_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#: lib/mv_web/live/contribution_type_live/index.ex #: lib/mv_web/live/contribution_type_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "This page is not functional and only displays the planned features." 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 #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Time Period" msgid "Time Period"
msgstr "Zeitraum" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Total Contributions" msgid "Total Contributions"
msgstr "Gesamtbeiträge" msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unpaid" msgid "Unpaid"
msgstr "Unbezahlt" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "View Example Member" msgid "View Example Member"
msgstr "Beispielmitglied anzeigen" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "When active: Members pay from the period of their joining." 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 #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "When inactive: Members pay from the next full period after joining." 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 #: lib/mv_web/live/contribution_period_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Why are not all contribution types shown?" 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_period_live/show.ex
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
@ -1326,12 +1313,12 @@ msgstr "jährlich"
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Yearly Interval - Joining Period Excluded" msgid "Yearly Interval - Joining Period Excluded"
msgstr "Jährliches Intervall Beitrittszeitraum nicht einbezogen" msgstr ""
#: lib/mv_web/live/contribution_settings_live.ex #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Yearly Interval - Joining Period Included" msgid "Yearly Interval - Joining Period Included"
msgstr "Jährliches Intervall Beitrittszeitraum einbezogen" msgstr ""
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex #: lib/mv_web/live/components/field_visibility_dropdown_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -1376,7 +1363,7 @@ msgstr "Zurück zur Felderliste"
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Custom field deleted successfully" 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 #: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
@ -1398,6 +1385,11 @@ msgstr "Benutzerdefiniertes Feld speichern"
msgid "New Custom field" msgid "New Custom field"
msgstr "Benutzerdefiniertes Feld speichern" 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 #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Slug does not match. Deletion cancelled." 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" msgid "Value Type"
msgstr "Wertetyp" 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 #~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Auto-generated identifier (immutable)" #~ msgid "Auto-generated identifier (immutable)"
@ -1483,11 +1450,6 @@ msgstr "Ja/Nein-Auswahl"
#~ msgid "OIDC ID" #~ msgid "OIDC ID"
#~ msgstr "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 #~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "This is a member record from your database." #~ msgid "This is a member record from your database."

View file

@ -30,7 +30,6 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "City" msgid "City"
msgstr "" msgstr ""
@ -65,14 +64,12 @@ msgstr ""
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex #: lib/mv_web/live/user_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Email" msgid "Email"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "First Name" msgid "First Name"
msgstr "" msgstr ""
@ -80,14 +77,12 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Join Date" msgid "Join Date"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Last Name" msgid "Last Name"
msgstr "" msgstr ""
@ -121,13 +116,11 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Exit Date" msgid "Exit Date"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "House Number" msgid "House Number"
msgstr "" msgstr ""
@ -135,7 +128,6 @@ msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Notes" msgid "Notes"
msgstr "" msgstr ""
@ -145,7 +137,6 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "" msgstr ""
@ -157,7 +148,6 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Postal Code" msgid "Postal Code"
msgstr "" msgstr ""
@ -178,7 +168,6 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Street" msgid "Street"
msgstr "" msgstr ""
@ -668,7 +657,6 @@ msgid "To confirm deletion, please enter this text:"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show in overview" msgid "Show in overview"
msgstr "" msgstr ""
@ -882,7 +870,6 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Phone" msgid "Phone"
msgstr "" msgstr ""
@ -1399,6 +1386,11 @@ msgstr ""
msgid "New Custom field" msgid "New Custom field"
msgstr "" 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 #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Slug does not match. Deletion cancelled." msgid "Slug does not match. Deletion cancelled."
@ -1413,28 +1405,3 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Value Type" msgid "Value Type"
msgstr "" 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 ""

View file

@ -30,7 +30,6 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "City" msgid "City"
msgstr "" msgstr ""
@ -65,14 +64,12 @@ msgstr ""
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#: lib/mv_web/live/user_live/index.html.heex #: lib/mv_web/live/user_live/index.html.heex
#: lib/mv_web/live/user_live/show.ex #: lib/mv_web/live/user_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Email" msgid "Email"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "First Name" msgid "First Name"
msgstr "" msgstr ""
@ -80,14 +77,12 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Join Date" msgid "Join Date"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Last Name" msgid "Last Name"
msgstr "" msgstr ""
@ -121,13 +116,11 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Exit Date" msgid "Exit Date"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "House Number" msgid "House Number"
msgstr "" msgstr ""
@ -135,7 +128,6 @@ msgstr ""
#: lib/mv_web/live/contribution_period_live/show.ex #: lib/mv_web/live/contribution_period_live/show.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Notes" msgid "Notes"
msgstr "" msgstr ""
@ -145,7 +137,6 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Paid" msgid "Paid"
msgstr "" msgstr ""
@ -157,7 +148,6 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Postal Code" msgid "Postal Code"
msgstr "" msgstr ""
@ -178,7 +168,6 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Street" msgid "Street"
msgstr "" msgstr ""
@ -209,14 +198,14 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "create" msgid "create"
msgstr "created" msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_value_live/form.ex #: lib/mv_web/live/custom_field_value_live/form.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "update" msgid "update"
msgstr "updated" msgstr ""
#: lib/mv_web/controllers/auth_controller.ex #: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -668,7 +657,6 @@ msgid "To confirm deletion, please enter this text:"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show in overview" msgid "Show in overview"
msgstr "" msgstr ""
@ -882,7 +870,6 @@ msgstr ""
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Phone" msgid "Phone"
msgstr "" msgstr ""
@ -1399,6 +1386,11 @@ msgstr ""
msgid "New Custom field" msgid "New Custom field"
msgstr "" 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 #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Slug does not match. Deletion cancelled." msgid "Slug does not match. Deletion cancelled."
@ -1414,31 +1406,6 @@ msgstr ""
msgid "Value Type" msgid "Value Type"
msgstr "" 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 #~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Auto-generated identifier (immutable)" #~ msgid "Auto-generated identifier (immutable)"
@ -1482,11 +1449,6 @@ msgstr ""
#~ msgid "OIDC ID" #~ msgid "OIDC ID"
#~ msgstr "" #~ 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 #~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "This is a member record from your database." #~ msgid "This is a member record from your database."

View file

@ -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

View file

@ -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"
}

View file

@ -1,9 +0,0 @@
#!/bin/sh
set -e
echo "==> Running database migrations..."
/app/bin/migrate
echo "==> Starting application..."
exec /app/bin/server

View file

@ -69,7 +69,7 @@ defmodule Mv.Membership.FuzzySearchTest do
ids = Enum.map(result, & &1.id) ids = Enum.map(result, & &1.id)
assert thomas.id in ids assert thomas.id in ids
refute jane.id in ids refute jane.id in ids
assert not Enum.empty?(ids) assert length(ids) >= 1
end end
test "empty query returns all members" do test "empty query returns all members" do

View file

@ -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

View file

@ -51,7 +51,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|> render_submit() |> render_submit()
|> follow_redirect(conn, "/members") |> 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 end
test "shows translated flash message after creating a member in English", %{conn: conn} do 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() |> render_submit()
|> follow_redirect(conn, "/members") |> 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 end
describe "sorting integration" do describe "sorting integration" do

View file

@ -11,9 +11,9 @@ defmodule Mv.SeedsTest do
{:ok, members} = Ash.read(Mv.Membership.Member) {:ok, members} = Ash.read(Mv.Membership.Member)
{:ok, custom_fields} = Ash.read(Mv.Membership.CustomField) {:ok, custom_fields} = Ash.read(Mv.Membership.CustomField)
assert not Enum.empty?(users), "Seeds should create at least one user" assert length(users) > 0, "Seeds should create at least one user"
assert not Enum.empty?(members), "Seeds should create at least one member" assert length(members) > 0, "Seeds should create at least one member"
assert not Enum.empty?(custom_fields), "Seeds should create at least one custom field" assert length(custom_fields) > 0, "Seeds should create at least one custom field"
end end
test "can be run multiple times (idempotent)" do test "can be run multiple times (idempotent)" do