Merge remote-tracking branch 'origin/main' into sidebar
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-01-06 10:52:24 +01:00
commit ff625c91c5
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
113 changed files with 19602 additions and 2699 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,259 @@
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

@ -0,0 +1,142 @@
defmodule Mv.Repo.Migrations.AddMembershipFeesTables do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
create table(:membership_fee_types, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
add :name, :text, null: false
# Precision: 10 digits total, 2 decimal places (max 99,999,999.99)
add :amount, :numeric, null: false, precision: 10, scale: 2
add :interval, :text, null: false
add :description, :text
end
create unique_index(:membership_fee_types, [:name],
name: "membership_fee_types_unique_name_index"
)
# CHECK constraint for interval values (enforced at DB level)
create constraint(:membership_fee_types, :membership_fee_types_interval_check,
check: "interval IN ('monthly', 'quarterly', 'half_yearly', 'yearly')"
)
# CHECK constraint for non-negative amount
create constraint(:membership_fee_types, :membership_fee_types_amount_check,
check: "amount >= 0"
)
create table(:membership_fee_cycles, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
add :cycle_start, :date, null: false
# Precision: 10 digits total, 2 decimal places (max 99,999,999.99)
add :amount, :numeric, null: false, precision: 10, scale: 2
add :status, :text, null: false, default: "unpaid"
add :notes, :text
# CASCADE: Delete cycles when member is deleted
add :member_id,
references(:members,
column: :id,
name: "membership_fee_cycles_member_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :delete_all
),
null: false
# RESTRICT: Cannot delete fee type if cycles reference it
add :membership_fee_type_id,
references(:membership_fee_types,
column: :id,
name: "membership_fee_cycles_membership_fee_type_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :restrict
),
null: false
end
# CHECK constraint for status values (enforced at DB level)
create constraint(:membership_fee_cycles, :membership_fee_cycles_status_check,
check: "status IN ('unpaid', 'paid', 'suspended')"
)
# CHECK constraint for non-negative amount
create constraint(:membership_fee_cycles, :membership_fee_cycles_amount_check,
check: "amount >= 0"
)
# Indexes as specified in architecture document
create index(:membership_fee_cycles, [:member_id])
create index(:membership_fee_cycles, [:membership_fee_type_id])
create index(:membership_fee_cycles, [:status])
create index(:membership_fee_cycles, [:cycle_start])
# Composite unique index: one cycle per member per cycle_start
create unique_index(:membership_fee_cycles, [:member_id, :cycle_start],
name: "membership_fee_cycles_unique_cycle_per_member_index"
)
# Extend members table with membership fee fields
alter table(:members) do
add :membership_fee_start_date, :date
# RESTRICT: Cannot delete fee type if members are assigned to it
add :membership_fee_type_id,
references(:membership_fee_types,
column: :id,
name: "members_membership_fee_type_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :restrict
)
end
# Index for efficient lookup of members by fee type
create index(:members, [:membership_fee_type_id])
end
def down do
# First: Remove members extension (depends on membership_fee_types)
drop_if_exists index(:members, [:membership_fee_type_id])
drop constraint(:members, "members_membership_fee_type_id_fkey")
alter table(:members) do
remove :membership_fee_type_id
remove :membership_fee_start_date
end
# Second: Drop cycles table (depends on membership_fee_types)
drop_if_exists unique_index(:membership_fee_cycles, [:member_id, :cycle_start],
name: "membership_fee_cycles_unique_cycle_per_member_index"
)
drop_if_exists index(:membership_fee_cycles, [:cycle_start])
drop_if_exists index(:membership_fee_cycles, [:status])
drop_if_exists index(:membership_fee_cycles, [:membership_fee_type_id])
drop_if_exists index(:membership_fee_cycles, [:member_id])
drop constraint(:membership_fee_cycles, "membership_fee_cycles_member_id_fkey")
drop constraint(:membership_fee_cycles, "membership_fee_cycles_membership_fee_type_id_fkey")
drop_if_exists constraint(:membership_fee_cycles, :membership_fee_cycles_status_check)
drop_if_exists constraint(:membership_fee_cycles, :membership_fee_cycles_amount_check)
drop table(:membership_fee_cycles)
# Third: Drop fee types table
drop_if_exists unique_index(:membership_fee_types, [:name],
name: "membership_fee_types_unique_name_index"
)
drop_if_exists constraint(:membership_fee_types, :membership_fee_types_interval_check)
drop_if_exists constraint(:membership_fee_types, :membership_fee_types_amount_check)
drop table(:membership_fee_types)
end
end

View file

@ -0,0 +1,21 @@
defmodule Mv.Repo.Migrations.RemoveImmutableFromCustomFields do
@moduledoc """
Removes the immutable column from custom_fields table.
The immutable field is no longer needed in the custom field definition.
"""
use Ecto.Migration
def up do
alter table(:custom_fields) do
remove :immutable
end
end
def down do
alter table(:custom_fields) do
add :immutable, :boolean, null: false, default: false
end
end
end

View file

@ -0,0 +1,25 @@
defmodule Mv.Repo.Migrations.AddMembershipFeeSettings do
@moduledoc """
Adds membership fee settings to the settings table.
Note: The members table columns (membership_fee_start_date, membership_fee_type_id)
were already added in migration 20251211151449_add_membership_fees_tables.
"""
use Ecto.Migration
def up do
# Add membership fee settings to the settings table
alter table(:settings) do
add_if_not_exists :include_joining_cycle, :boolean, null: false, default: true
add_if_not_exists :default_membership_fee_type_id, :uuid
end
end
def down do
alter table(:settings) do
remove_if_exists :default_membership_fee_type_id, :uuid
remove_if_exists :include_joining_cycle, :boolean
end
end
end

View file

@ -0,0 +1,21 @@
defmodule Mv.Repo.Migrations.RemovePaidFromMembers do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:members) do
remove :paid
end
end
def down do
alter table(:members) do
add :paid, :boolean
end
end
end

View file

@ -5,6 +5,40 @@
alias Mv.Membership
alias Mv.Accounts
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.CycleGenerator
# Create example membership fee types
for fee_type_attrs <- [
%{
name: "Standard (Jährlich)",
amount: Decimal.new("120.00"),
interval: :yearly,
description: "Standard jährlicher Mitgliedsbeitrag"
},
%{
name: "Standard (Halbjährlich)",
amount: Decimal.new("65.00"),
interval: :half_yearly,
description: "Standard halbjährlicher Mitgliedsbeitrag"
},
%{
name: "Standard (Vierteljährlich)",
amount: Decimal.new("35.00"),
interval: :quarterly,
description: "Standard vierteljährlicher Mitgliedsbeitrag"
},
%{
name: "Standard (Monatlich)",
amount: Decimal.new("12.00"),
interval: :monthly,
description: "Standard monatlicher Mitgliedsbeitrag"
}
] do
MembershipFeeType
|> Ash.Changeset.for_create(:create, fee_type_attrs)
|> Ash.create!(upsert?: true, upsert_identity: :unique_name)
end
for attrs <- [
# Basic example fields (for testing)
@ -12,28 +46,24 @@ for attrs <- [
name: "String Field",
value_type: :string,
description: "Example for a field of type string",
immutable: true,
required: false
},
%{
name: "Date Field",
value_type: :date,
description: "Example for a field of type date",
immutable: true,
required: false
},
%{
name: "Boolean Field",
value_type: :boolean,
description: "Example for a field of type boolean",
immutable: true,
required: false
},
%{
name: "Email Field",
value_type: :email,
description: "Example for a field of type email",
immutable: true,
required: false
},
# Realistic custom fields
@ -41,56 +71,48 @@ for attrs <- [
name: "Membership Number",
value_type: :string,
description: "Unique membership identification number",
immutable: false,
required: false
},
%{
name: "Emergency Contact",
value_type: :string,
description: "Emergency contact person name and phone",
immutable: false,
required: false
},
%{
name: "T-Shirt Size",
value_type: :string,
description: "T-Shirt size for events (XS, S, M, L, XL, XXL)",
immutable: false,
required: false
},
%{
name: "Newsletter Subscription",
value_type: :boolean,
description: "Whether member wants to receive newsletter",
immutable: false,
required: false
},
%{
name: "Date of Last Medical Check",
value_type: :date,
description: "Date of last medical examination",
immutable: false,
required: false
},
%{
name: "Secondary Email",
value_type: :email,
description: "Alternative email address",
immutable: false,
required: false
},
%{
name: "Membership Type",
value_type: :string,
description: "Type of membership (e.g., Regular, Student, Senior)",
immutable: false,
required: false
},
%{
name: "Parking Permit",
value_type: :boolean,
description: "Whether member has parking permit",
immutable: false,
required: false
}
] do
@ -106,59 +128,153 @@ Accounts.create_user!(%{email: "admin@mv.local"}, upsert?: true, upsert_identity
|> Ash.Changeset.for_update(:admin_set_password, %{password: "testpassword"})
|> Ash.update!()
# Load all membership fee types for assignment
# Sort by name to ensure deterministic order
all_fee_types =
MembershipFeeType
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
|> Enum.to_list()
# Create sample members for testing - use upsert to prevent duplicates
for member_attrs <- [
%{
first_name: "Hans",
last_name: "Müller",
email: "hans.mueller@example.de",
join_date: ~D[2023-01-15],
paid: true,
phone_number: "+49301234567",
city: "München",
street: "Hauptstraße",
house_number: "42",
postal_code: "80331"
},
%{
first_name: "Greta",
last_name: "Schmidt",
email: "greta.schmidt@example.de",
join_date: ~D[2023-02-01],
paid: false,
phone_number: "+49309876543",
city: "Hamburg",
street: "Lindenstraße",
house_number: "17",
postal_code: "20095",
notes: "Interessiert an Fortgeschrittenen-Kursen"
},
%{
first_name: "Friedrich",
last_name: "Wagner",
email: "friedrich.wagner@example.de",
join_date: ~D[2022-11-10],
paid: true,
phone_number: "+49301122334",
city: "Berlin",
street: "Kastanienallee",
house_number: "8"
},
%{
first_name: "Marianne",
last_name: "Wagner",
email: "marianne.wagner@example.de",
join_date: ~D[2022-11-10],
paid: true,
phone_number: "+49301122334",
city: "Berlin",
street: "Kastanienallee",
house_number: "8"
}
] do
# Member 1: Hans - All cycles paid
# Member 2: Greta - All cycles unpaid
# Member 3: Friedrich - Mixed cycles (paid, unpaid, suspended)
# Member 4: Marianne - No membership fee type
member_attrs_list = [
%{
first_name: "Hans",
last_name: "Müller",
email: "hans.mueller@example.de",
join_date: ~D[2023-01-15],
phone_number: "+49301234567",
city: "München",
street: "Hauptstraße",
house_number: "42",
postal_code: "80331",
membership_fee_type_id: Enum.at(all_fee_types, 0).id,
cycle_status: :all_paid
},
%{
first_name: "Greta",
last_name: "Schmidt",
email: "greta.schmidt@example.de",
join_date: ~D[2023-02-01],
phone_number: "+49309876543",
city: "Hamburg",
street: "Lindenstraße",
house_number: "17",
postal_code: "20095",
notes: "Interessiert an Fortgeschrittenen-Kursen",
membership_fee_type_id: Enum.at(all_fee_types, 1).id,
cycle_status: :all_unpaid
},
%{
first_name: "Friedrich",
last_name: "Wagner",
email: "friedrich.wagner@example.de",
join_date: ~D[2022-11-10],
phone_number: "+49301122334",
city: "Berlin",
street: "Kastanienallee",
house_number: "8",
membership_fee_type_id: Enum.at(all_fee_types, 2).id,
cycle_status: :mixed
},
%{
first_name: "Marianne",
last_name: "Wagner",
email: "marianne.wagner@example.de",
join_date: ~D[2022-11-10],
phone_number: "+49301122334",
city: "Berlin",
street: "Kastanienallee",
house_number: "8"
# No membership_fee_type_id - member without fee type
}
]
# Create members and generate cycles
Enum.each(member_attrs_list, fn member_attrs ->
cycle_status = Map.get(member_attrs, :cycle_status)
member_attrs_without_status = Map.delete(member_attrs, :cycle_status)
# Use upsert to prevent duplicates based on email
Membership.create_member!(member_attrs, upsert?: true, upsert_identity: :unique_email)
end
# First create/update member without membership_fee_type_id to avoid overwriting existing assignments
member_attrs_without_fee_type = Map.delete(member_attrs_without_status, :membership_fee_type_id)
member =
Membership.create_member!(member_attrs_without_fee_type,
upsert?: true,
upsert_identity: :unique_email
)
# Only set membership_fee_type_id if member doesn't have one yet (idempotent)
final_member =
if is_nil(member.membership_fee_type_id) and
Map.has_key?(member_attrs_without_status, :membership_fee_type_id) do
member
|> Ash.Changeset.for_update(:update_member, %{
membership_fee_type_id: member_attrs_without_status.membership_fee_type_id
})
|> Ash.update!()
else
member
end
# Generate cycles if member has a fee type
if final_member.membership_fee_type_id do
# Load member with cycles to check if they already exist
member_with_cycles =
final_member
|> Ash.load!(:membership_fee_cycles)
# Only generate if no cycles exist yet (to avoid duplicates on re-run)
cycles =
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
# Generate cycles
{:ok, new_cycles, _notifications} =
CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true)
new_cycles
else
# Use existing cycles
member_with_cycles.membership_fee_cycles
end
# Set cycle statuses based on member type
if cycle_status do
cycles
|> Enum.sort_by(& &1.cycle_start, Date)
|> Enum.with_index()
|> Enum.each(fn {cycle, index} ->
status =
case cycle_status do
:all_paid ->
:paid
:all_unpaid ->
:unpaid
:mixed ->
# Mix: first paid, second unpaid, third suspended, then repeat
case rem(index, 3) do
0 -> :paid
1 -> :unpaid
2 -> :suspended
end
end
# Only update if status is different
if cycle.status != status do
cycle
|> Ash.Changeset.for_update(:update, %{status: status})
|> Ash.update!()
end
end)
end
end
end)
# Create additional users for user-member linking examples
additional_users = [
@ -183,7 +299,6 @@ linked_members = [
last_name: "Weber",
email: "maria.weber@example.de",
join_date: ~D[2023-03-15],
paid: true,
phone_number: "+49301357924",
city: "Frankfurt",
street: "Goetheplatz",
@ -198,7 +313,6 @@ linked_members = [
last_name: "Klein",
email: "thomas.klein@example.de",
join_date: ~D[2023-04-01],
paid: false,
phone_number: "+49302468135",
city: "Köln",
street: "Rheinstraße",
@ -211,24 +325,84 @@ linked_members = [
]
# Create the linked members - use upsert to prevent duplicates
Enum.each(linked_members, fn member_attrs ->
# Assign fee types to linked members using round-robin
# Continue from where we left off with the previous members
Enum.with_index(linked_members)
|> Enum.each(fn {member_attrs, index} ->
user = member_attrs.user
member_attrs_without_user = Map.delete(member_attrs, :user)
# Use upsert to prevent duplicates based on email
# First create/update member without membership_fee_type_id to avoid overwriting existing assignments
member_attrs_without_fee_type = Map.delete(member_attrs_without_user, :membership_fee_type_id)
# Check if user already has a member
if user.member_id == nil do
# User is free, create member and link - use upsert to prevent duplicates
Membership.create_member!(
Map.put(member_attrs_without_user, :user, %{id: user.id}),
upsert?: true,
upsert_identity: :unique_email
)
else
# User already has a member, just create the member without linking - use upsert to prevent duplicates
Membership.create_member!(member_attrs_without_user,
upsert?: true,
upsert_identity: :unique_email
)
member =
if user.member_id == nil do
# User is free, create member and link - use upsert to prevent duplicates
Membership.create_member!(
Map.put(member_attrs_without_fee_type, :user, %{id: user.id}),
upsert?: true,
upsert_identity: :unique_email
)
else
# User already has a member, just create the member without linking - use upsert to prevent duplicates
Membership.create_member!(member_attrs_without_fee_type,
upsert?: true,
upsert_identity: :unique_email
)
end
# Only set membership_fee_type_id if member doesn't have one yet (idempotent)
final_member =
if is_nil(member.membership_fee_type_id) do
# Assign deterministically using round-robin
# Start from where previous members ended (3 members before this)
fee_type_index = rem(3 + index, length(all_fee_types))
fee_type = Enum.at(all_fee_types, fee_type_index)
member
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: fee_type.id})
|> Ash.update!()
else
member
end
# Generate cycles for linked members
if final_member.membership_fee_type_id do
# Load member with cycles to check if they already exist
member_with_cycles =
final_member
|> Ash.load!(:membership_fee_cycles)
# Only generate if no cycles exist yet (to avoid duplicates on re-run)
cycles =
if Enum.empty?(member_with_cycles.membership_fee_cycles) do
# Generate cycles
{:ok, new_cycles, _notifications} =
CycleGenerator.generate_cycles_for_member(final_member.id, skip_lock?: true)
new_cycles
else
# Use existing cycles
member_with_cycles.membership_fee_cycles
end
# Set some cycles to paid for linked members (mixed status)
cycles
|> Enum.sort_by(& &1.cycle_start, Date)
|> Enum.with_index()
|> Enum.each(fn {cycle, index} ->
# Every other cycle is paid, rest unpaid
status = if rem(index, 2) == 0, do: :paid, else: :unpaid
# Only update if status is different
if cycle.status != status do
cycle
|> Ash.Changeset.for_update(:update, %{status: status})
|> Ash.update!()
end
end)
end
end)
@ -332,6 +506,7 @@ end
IO.puts("✅ Seeds completed successfully!")
IO.puts("📝 Created sample data:")
IO.puts(" - Global settings: club_name = #{default_club_name}")
IO.puts(" - Membership fee types: 4 types (Yearly, Half-yearly, Quarterly, Monthly)")
IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)")
IO.puts(" - Admin user: admin@mv.local (password: testpassword)")
IO.puts(" - Sample members: Hans, Greta, Friedrich")

View file

@ -0,0 +1,132 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"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": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "slug",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "value_type",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "required",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "true",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "show_in_overview",
"type": "boolean"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "6FEA699A67D34CFBA261DA8316AB711F6853C4F953D42C5D7940B22D17699B2E",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_fields_unique_name_index",
"keys": [
{
"type": "atom",
"value": "name"
}
],
"name": "unique_name",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_fields_unique_slug_index",
"keys": [
{
"type": "atom",
"value": "slug"
}
],
"name": "unique_slug",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "custom_fields"
}

View file

@ -0,0 +1,202 @@
{
"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

@ -0,0 +1,245 @@
{
"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"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "membership_fee_start_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "members_membership_fee_type_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "membership_fee_types"
},
"scale": null,
"size": null,
"source": "membership_fee_type_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "6ECD721659E1CC7CB4219293153BCED585111A49765B9DB0D1CAE0B37C54949E",
"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

@ -0,0 +1,233 @@
{
"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": "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"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "membership_fee_start_date",
"type": "date"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "members_membership_fee_type_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "membership_fee_types"
},
"scale": null,
"size": null,
"source": "membership_fee_type_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "E18E4B404581EFF050F85E895FAE986B79DB62C9E1611164C92B46B954C371C1",
"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

@ -0,0 +1,160 @@
{
"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": "cycle_start",
"type": "date"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "amount",
"type": "decimal"
},
{
"allow_nil?": false,
"default": "\"unpaid\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "status",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "notes",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "membership_fee_cycles_member_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "members"
},
"scale": null,
"size": null,
"source": "member_id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "membership_fee_cycles_membership_fee_type_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "membership_fee_types"
},
"scale": null,
"size": null,
"source": "membership_fee_type_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "43EA9EA365C09D423249AC4B6757A9AC07788C6C1E4BC7C50F8EF2CE01DE5684",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "membership_fee_cycles_unique_cycle_per_member_index",
"keys": [
{
"type": "atom",
"value": "member_id"
},
{
"type": "atom",
"value": "cycle_start"
}
],
"name": "unique_cycle_per_member",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "membership_fee_cycles"
}

View file

@ -0,0 +1,160 @@
{
"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": "cycle_start",
"type": "date"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": 2,
"size": null,
"source": "amount",
"type": "decimal"
},
{
"allow_nil?": false,
"default": "\"unpaid\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "status",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "notes",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "membership_fee_cycles_member_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "members"
},
"scale": null,
"size": null,
"source": "member_id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "membership_fee_cycles_membership_fee_type_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "membership_fee_types"
},
"scale": null,
"size": null,
"source": "membership_fee_type_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "802FB11B08D041501AC395454D84719992B71C0BEAE83B0833F3086486ABD679",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "membership_fee_cycles_unique_cycle_per_member_index",
"keys": [
{
"type": "atom",
"value": "member_id"
},
{
"type": "atom",
"value": "cycle_start"
}
],
"name": "unique_cycle_per_member",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "membership_fee_cycles"
}

View file

@ -0,0 +1,94 @@
{
"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": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "amount",
"type": "decimal"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "interval",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "E93A7A1EE90E5CEAC98CEA57C99C6330465716248642D5E2949EF578DE514E99",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "membership_fee_types_unique_name_index",
"keys": [
{
"type": "atom",
"value": "name"
}
],
"name": "unique_name",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "membership_fee_types"
}

View file

@ -0,0 +1,94 @@
{
"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": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": 2,
"size": null,
"source": "amount",
"type": "decimal"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "interval",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "C58959BF589FEB75A9F05C2C717C04B641ED14E09FF2503C8B0637392AE5A335",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "membership_fee_types_unique_name_index",
"keys": [
{
"type": "atom",
"value": "name"
}
],
"name": "unique_name",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "membership_fee_types"
}

View file

@ -0,0 +1,103 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"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": "club_name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "member_field_visibility",
"type": "map"
},
{
"allow_nil?": false,
"default": "true",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "include_joining_cycle",
"type": "boolean"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "default_membership_fee_type_id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "CD12EA080677C99D81C2A4A98F0DE419F7BDE1FA8C22206423C9D80305B064D2",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "settings"
}