feat: add groups resource #371
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
8e9fbe76cf
commit
6db64bf996
12 changed files with 742 additions and 18 deletions
|
|
@ -1069,10 +1069,9 @@ We test our business logic and domain-specific behavior, not core framework feat
|
||||||
- Create unique index on slug (for slug uniqueness and lookups)
|
- Create unique index on slug (for slug uniqueness and lookups)
|
||||||
- Create index on lowercased name for search
|
- Create index on lowercased name for search
|
||||||
|
|
||||||
**Note:** Slug generation follows the same pattern as CustomFields:
|
**Note:** Slug generation uses the shared `Mv.Membership.Changes.GenerateSlug` change,
|
||||||
- Uses `Mv.Membership.CustomField.Changes.GenerateSlug` (reusable change)
|
which is used by both CustomFields and Groups for consistent slug generation.
|
||||||
- Or create `Mv.Membership.Group.Changes.GenerateSlug` if needed
|
Slug is generated on create, immutable on update.
|
||||||
- Slug is generated on create, immutable on update
|
|
||||||
|
|
||||||
**Migration 2: Create member_groups join table**
|
**Migration 2: Create member_groups join table**
|
||||||
- Create table with UUID v7 primary key
|
- Create table with UUID v7 primary key
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
defmodule Mv.Membership.CustomField.Changes.GenerateSlug do
|
defmodule Mv.Membership.Changes.GenerateSlug do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Ash Change that automatically generates a URL-friendly slug from the `name` attribute.
|
Ash Change that automatically generates a URL-friendly slug from the `name` attribute.
|
||||||
|
|
||||||
|
|
@ -14,12 +14,26 @@ defmodule Mv.Membership.CustomField.Changes.GenerateSlug do
|
||||||
- Trims leading/trailing hyphens
|
- Trims leading/trailing hyphens
|
||||||
- Truncates to max 100 characters
|
- Truncates to max 100 characters
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Works for any resource with `name` and `slug` attributes.
|
||||||
|
Used by CustomField and Group resources.
|
||||||
|
|
||||||
|
create :create do
|
||||||
|
accept [:name, :description]
|
||||||
|
change Mv.Membership.Changes.GenerateSlug
|
||||||
|
validate string_length(:slug, min: 1)
|
||||||
|
end
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
# Create with automatic slug generation
|
# Create with automatic slug generation
|
||||||
CustomField.create!(%{name: "Mobile Phone"})
|
CustomField.create!(%{name: "Mobile Phone"})
|
||||||
# => %CustomField{name: "Mobile Phone", slug: "mobile-phone"}
|
# => %CustomField{name: "Mobile Phone", slug: "mobile-phone"}
|
||||||
|
|
||||||
|
Group.create!(%{name: "Test Group"})
|
||||||
|
# => %Group{name: "Test Group", slug: "test-group"}
|
||||||
|
|
||||||
# German umlauts are converted
|
# German umlauts are converted
|
||||||
CustomField.create!(%{name: "Café Müller"})
|
CustomField.create!(%{name: "Café Müller"})
|
||||||
# => %CustomField{name: "Café Müller", slug: "cafe-muller"}
|
# => %CustomField{name: "Café Müller", slug: "cafe-muller"}
|
||||||
|
|
@ -32,7 +46,7 @@ defmodule Mv.Membership.CustomField.Changes.GenerateSlug do
|
||||||
## Implementation Note
|
## Implementation Note
|
||||||
|
|
||||||
This change only runs on `:create` actions. The slug is immutable by design,
|
This change only runs on `:create` actions. The slug is immutable by design,
|
||||||
as changing slugs would break external references (e.g., CSV imports/exports).
|
as changing slugs would break external references (e.g., CSV imports/exports, URL routes).
|
||||||
"""
|
"""
|
||||||
use Ash.Resource.Change
|
use Ash.Resource.Change
|
||||||
|
|
||||||
|
|
@ -47,11 +61,14 @@ defmodule Mv.Membership.CustomField.Changes.GenerateSlug do
|
||||||
## Parameters
|
## Parameters
|
||||||
|
|
||||||
- `changeset` - The Ash changeset
|
- `changeset` - The Ash changeset
|
||||||
|
- `_opts` - Options passed to the change (unused)
|
||||||
|
- `_context` - Ash context map (unused)
|
||||||
|
|
||||||
## Returns
|
## Returns
|
||||||
|
|
||||||
The changeset with the `:slug` attribute set to the generated slug.
|
The changeset with the `:slug` attribute set to the generated slug.
|
||||||
"""
|
"""
|
||||||
|
@impl true
|
||||||
def change(changeset, _opts, _context) do
|
def change(changeset, _opts, _context) do
|
||||||
# Only generate slug on create, not on update (immutability)
|
# Only generate slug on create, not on update (immutability)
|
||||||
if changeset.action_type == :create do
|
if changeset.action_type == :create do
|
||||||
|
|
@ -62,6 +79,9 @@ defmodule Mv.Membership.CustomField.Changes.GenerateSlug do
|
||||||
name when is_binary(name) ->
|
name when is_binary(name) ->
|
||||||
slug = generate_slug(name)
|
slug = generate_slug(name)
|
||||||
Ash.Changeset.force_change_attribute(changeset, :slug, slug)
|
Ash.Changeset.force_change_attribute(changeset, :slug, slug)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
changeset
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
# On update, don't touch the slug (immutable)
|
# On update, don't touch the slug (immutable)
|
||||||
|
|
@ -80,6 +100,14 @@ defmodule Mv.Membership.CustomField.Changes.GenerateSlug do
|
||||||
- Leading/trailing hyphens removed
|
- Leading/trailing hyphens removed
|
||||||
- Maximum length of 100 characters
|
- Maximum length of 100 characters
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
- `name` - The string to convert to a slug
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
A URL-friendly slug string, or empty string if input is invalid.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
iex> generate_slug("Mobile Phone")
|
iex> generate_slug("Mobile Phone")
|
||||||
|
|
@ -104,6 +132,7 @@ defmodule Mv.Membership.CustomField.Changes.GenerateSlug do
|
||||||
"strasse"
|
"strasse"
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
@spec generate_slug(String.t()) :: String.t()
|
||||||
def generate_slug(name) when is_binary(name) do
|
def generate_slug(name) when is_binary(name) do
|
||||||
slug = Slug.slugify(name)
|
slug = Slug.slugify(name)
|
||||||
|
|
||||||
|
|
@ -63,7 +63,7 @@ defmodule Mv.Membership.CustomField do
|
||||||
|
|
||||||
create :create do
|
create :create do
|
||||||
accept [:name, :value_type, :description, :required, :show_in_overview]
|
accept [:name, :value_type, :description, :required, :show_in_overview]
|
||||||
change Mv.Membership.CustomField.Changes.GenerateSlug
|
change Mv.Membership.Changes.GenerateSlug
|
||||||
validate string_length(:slug, min: 1)
|
validate string_length(:slug, min: 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
171
lib/membership/group.ex
Normal file
171
lib/membership/group.ex
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
defmodule Mv.Membership.Group do
|
||||||
|
@moduledoc """
|
||||||
|
Ash resource representing a group that members can belong to.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Groups allow organizing members into categories (e.g., "Board Members", "Active Members").
|
||||||
|
Each member can belong to multiple groups, and each group can contain multiple members.
|
||||||
|
|
||||||
|
## Attributes
|
||||||
|
- `name` - Unique group name (required, max 100 chars, case-insensitive uniqueness)
|
||||||
|
- `slug` - URL-friendly identifier (required, max 100 chars, auto-generated from name, immutable)
|
||||||
|
- `description` - Optional description (max 500 chars)
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
- `has_many :member_groups` - Relationship to MemberGroup join table
|
||||||
|
- `many_to_many :members` - Relationship to Members through MemberGroup
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- Name must be unique (case-insensitive, using LOWER(name) in database)
|
||||||
|
- Slug must be unique (case-sensitive, exact match)
|
||||||
|
- Name cannot be null
|
||||||
|
- Slug cannot be null
|
||||||
|
|
||||||
|
## Calculations
|
||||||
|
- `member_count` - Returns the number of members in this group
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
# Create a new group
|
||||||
|
Group.create!(%{name: "Board Members", description: "Members of the board"})
|
||||||
|
# => %Group{name: "Board Members", slug: "board-members", ...}
|
||||||
|
|
||||||
|
# Update group (slug remains unchanged)
|
||||||
|
group = Group.get_by_slug!("board-members")
|
||||||
|
Group.update!(group, %{description: "Updated description"})
|
||||||
|
# => %Group{slug: "board-members", ...} # slug unchanged!
|
||||||
|
"""
|
||||||
|
use Ash.Resource,
|
||||||
|
domain: Mv.Membership,
|
||||||
|
data_layer: AshPostgres.DataLayer
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
import Ash.Expr
|
||||||
|
alias Mv.Helpers
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
postgres do
|
||||||
|
table "groups"
|
||||||
|
repo Mv.Repo
|
||||||
|
end
|
||||||
|
|
||||||
|
actions do
|
||||||
|
defaults [:read, :destroy]
|
||||||
|
|
||||||
|
create :create do
|
||||||
|
accept [:name, :description]
|
||||||
|
change Mv.Membership.Changes.GenerateSlug
|
||||||
|
validate string_length(:slug, min: 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
update :update do
|
||||||
|
accept [:name, :description]
|
||||||
|
require_atomic? false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
validations do
|
||||||
|
validate present(:name)
|
||||||
|
|
||||||
|
# Case-insensitive name uniqueness validation
|
||||||
|
validate fn changeset, _context ->
|
||||||
|
name = Ash.Changeset.get_attribute(changeset, :name)
|
||||||
|
current_id = Ash.Changeset.get_attribute(changeset, :id)
|
||||||
|
|
||||||
|
if name do
|
||||||
|
check_name_uniqueness(name, current_id)
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
attributes do
|
||||||
|
uuid_v7_primary_key :id
|
||||||
|
|
||||||
|
attribute :name, :string do
|
||||||
|
allow_nil? false
|
||||||
|
public? true
|
||||||
|
|
||||||
|
constraints max_length: 100,
|
||||||
|
trim?: true
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :slug, :string do
|
||||||
|
allow_nil? false
|
||||||
|
public? true
|
||||||
|
writable? false
|
||||||
|
|
||||||
|
constraints max_length: 100,
|
||||||
|
trim?: true
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :description, :string do
|
||||||
|
allow_nil? true
|
||||||
|
public? true
|
||||||
|
|
||||||
|
constraints max_length: 500,
|
||||||
|
trim?: true
|
||||||
|
end
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
|
||||||
|
relationships do
|
||||||
|
has_many :member_groups, Mv.Membership.MemberGroup
|
||||||
|
many_to_many :members, Mv.Membership.Member, through: Mv.Membership.MemberGroup
|
||||||
|
end
|
||||||
|
|
||||||
|
calculations do
|
||||||
|
calculate :member_count, :integer do
|
||||||
|
description "Number of members in this group"
|
||||||
|
|
||||||
|
calculation fn [group], _context ->
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
|
query =
|
||||||
|
Mv.Membership.MemberGroup
|
||||||
|
|> Ash.Query.filter(group_id == ^group.id)
|
||||||
|
|
||||||
|
case Ash.read(query, opts) do
|
||||||
|
{:ok, member_groups} -> [length(member_groups)]
|
||||||
|
{:error, _} -> [0]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
identities do
|
||||||
|
identity :unique_slug, [:slug]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Private helper function for case-insensitive name uniqueness check
|
||||||
|
defp check_name_uniqueness(name, exclude_id) do
|
||||||
|
query =
|
||||||
|
Mv.Membership.Group
|
||||||
|
|> Ash.Query.filter(fragment("LOWER(?) = LOWER(?)", name, ^name))
|
||||||
|
|> maybe_exclude_id(exclude_id)
|
||||||
|
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
|
case Ash.read(query, opts) do
|
||||||
|
{:ok, []} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:ok, _} ->
|
||||||
|
{:error, field: :name, message: "has already been taken", value: name}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning(
|
||||||
|
"Name uniqueness validation query failed for group name '#{name}': #{inspect(reason)}. Allowing operation to proceed (fail-open)."
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_exclude_id(query, nil), do: query
|
||||||
|
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
||||||
|
end
|
||||||
|
|
@ -582,6 +582,12 @@ defmodule Mv.Membership.Member do
|
||||||
|
|
||||||
# has_many: All fee cycles for this member
|
# has_many: All fee cycles for this member
|
||||||
has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle
|
has_many :membership_fee_cycles, Mv.MembershipFees.MembershipFeeCycle
|
||||||
|
|
||||||
|
# Groups relationships
|
||||||
|
# has_many: All member-group associations for this member
|
||||||
|
has_many :member_groups, Mv.Membership.MemberGroup
|
||||||
|
# many_to_many: All groups this member belongs to (through MemberGroup)
|
||||||
|
many_to_many :groups, Mv.Membership.Group, through: Mv.Membership.MemberGroup
|
||||||
end
|
end
|
||||||
|
|
||||||
calculations do
|
calculations do
|
||||||
|
|
|
||||||
128
lib/membership/member_group.ex
Normal file
128
lib/membership/member_group.ex
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
defmodule Mv.Membership.MemberGroup do
|
||||||
|
@moduledoc """
|
||||||
|
Ash resource representing the join table for the many-to-many relationship
|
||||||
|
between Members and Groups.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
MemberGroup is a join table that links members to groups. It enables the
|
||||||
|
many-to-many relationship where:
|
||||||
|
- A member can belong to multiple groups
|
||||||
|
- A group can contain multiple members
|
||||||
|
|
||||||
|
## Attributes
|
||||||
|
- `member_id` - Foreign key to Member (required)
|
||||||
|
- `group_id` - Foreign key to Group (required)
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
- `belongs_to :member` - Relationship to Member
|
||||||
|
- `belongs_to :group` - Relationship to Group
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- Unique constraint on `(member_id, group_id)` - prevents duplicate memberships
|
||||||
|
- CASCADE delete: Removing member removes all group associations
|
||||||
|
- CASCADE delete: Removing group removes all member associations
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
# Add member to group
|
||||||
|
MemberGroup.create!(%{member_id: member.id, group_id: group.id})
|
||||||
|
|
||||||
|
# Remove member from group
|
||||||
|
member_group = MemberGroup.get_by_member_and_group!(member.id, group.id)
|
||||||
|
MemberGroup.destroy!(member_group)
|
||||||
|
"""
|
||||||
|
use Ash.Resource,
|
||||||
|
domain: Mv.Membership,
|
||||||
|
data_layer: AshPostgres.DataLayer
|
||||||
|
|
||||||
|
require Ash.Query
|
||||||
|
import Ash.Expr
|
||||||
|
|
||||||
|
postgres do
|
||||||
|
table "member_groups"
|
||||||
|
repo Mv.Repo
|
||||||
|
end
|
||||||
|
|
||||||
|
actions do
|
||||||
|
defaults [:read, :destroy]
|
||||||
|
|
||||||
|
create :create do
|
||||||
|
accept [:member_id, :group_id]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
validations do
|
||||||
|
validate present(:member_id)
|
||||||
|
validate present(:group_id)
|
||||||
|
|
||||||
|
# Prevent duplicate associations
|
||||||
|
validate fn changeset, _context ->
|
||||||
|
member_id = Ash.Changeset.get_attribute(changeset, :member_id)
|
||||||
|
group_id = Ash.Changeset.get_attribute(changeset, :group_id)
|
||||||
|
current_id = Ash.Changeset.get_attribute(changeset, :id)
|
||||||
|
|
||||||
|
if member_id && group_id do
|
||||||
|
check_duplicate_association(member_id, group_id, current_id)
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
attributes do
|
||||||
|
uuid_v7_primary_key :id
|
||||||
|
|
||||||
|
attribute :member_id, :uuid do
|
||||||
|
allow_nil? false
|
||||||
|
end
|
||||||
|
|
||||||
|
attribute :group_id, :uuid do
|
||||||
|
allow_nil? false
|
||||||
|
end
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
|
||||||
|
relationships do
|
||||||
|
belongs_to :member, Mv.Membership.Member do
|
||||||
|
allow_nil? false
|
||||||
|
end
|
||||||
|
|
||||||
|
belongs_to :group, Mv.Membership.Group do
|
||||||
|
allow_nil? false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
identities do
|
||||||
|
identity :unique_member_group, [:member_id, :group_id]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Private helper function to check for duplicate associations
|
||||||
|
defp check_duplicate_association(member_id, group_id, exclude_id) do
|
||||||
|
alias Mv.Helpers
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
query =
|
||||||
|
Mv.Membership.MemberGroup
|
||||||
|
|> Ash.Query.filter(member_id == ^member_id and group_id == ^group_id)
|
||||||
|
|> maybe_exclude_id(exclude_id)
|
||||||
|
|
||||||
|
system_actor = SystemActor.get_system_actor()
|
||||||
|
opts = Helpers.ash_actor_opts(system_actor)
|
||||||
|
|
||||||
|
case Ash.read(query, opts) do
|
||||||
|
{:ok, []} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:ok, _} ->
|
||||||
|
{:error, field: :member_id, message: "Member is already in this group", value: member_id}
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
# Fail-open: if query fails, allow operation to proceed
|
||||||
|
# Database constraint will catch duplicates anyway
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_exclude_id(query, nil), do: query
|
||||||
|
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
|
||||||
|
end
|
||||||
|
|
@ -7,6 +7,8 @@ defmodule Mv.Membership do
|
||||||
- `CustomFieldValue` - Dynamic custom field values attached to members
|
- `CustomFieldValue` - Dynamic custom field values attached to members
|
||||||
- `CustomField` - Schema definitions for custom fields
|
- `CustomField` - Schema definitions for custom fields
|
||||||
- `Setting` - Global application settings (singleton)
|
- `Setting` - Global application settings (singleton)
|
||||||
|
- `Group` - Groups that members can belong to
|
||||||
|
- `MemberGroup` - Join table for many-to-many relationship between Members and Groups
|
||||||
|
|
||||||
## Public API
|
## Public API
|
||||||
The domain exposes these main actions:
|
The domain exposes these main actions:
|
||||||
|
|
@ -14,6 +16,8 @@ defmodule Mv.Membership do
|
||||||
- Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc.
|
- Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc.
|
||||||
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, `list_required_custom_fields/0`, etc.
|
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, `list_required_custom_fields/0`, etc.
|
||||||
- Settings management: `get_settings/0`, `update_settings/2`, `update_member_field_visibility/2`, `update_single_member_field_visibility/3`
|
- Settings management: `get_settings/0`, `update_settings/2`, `update_member_field_visibility/2`, `update_single_member_field_visibility/3`
|
||||||
|
- Group management: `create_group/1`, `list_groups/0`, `update_group/2`, `destroy_group/1`
|
||||||
|
- Member-group associations: `create_member_group/1`, `list_member_groups/0`, `destroy_member_group/1`
|
||||||
|
|
||||||
## Admin Interface
|
## Admin Interface
|
||||||
The domain is configured with AshAdmin for management UI.
|
The domain is configured with AshAdmin for management UI.
|
||||||
|
|
@ -61,6 +65,19 @@ defmodule Mv.Membership do
|
||||||
define :update_single_member_field_visibility,
|
define :update_single_member_field_visibility,
|
||||||
action: :update_single_member_field_visibility
|
action: :update_single_member_field_visibility
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resource Mv.Membership.Group do
|
||||||
|
define :create_group, action: :create
|
||||||
|
define :list_groups, action: :read
|
||||||
|
define :update_group, action: :update
|
||||||
|
define :destroy_group, action: :destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
resource Mv.Membership.MemberGroup do
|
||||||
|
define :create_member_group, action: :create
|
||||||
|
define :list_member_groups, action: :read
|
||||||
|
define :destroy_member_group, action: :destroy
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Singleton pattern: Get the single settings record
|
# Singleton pattern: Get the single settings record
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
defmodule Mv.Repo.Migrations.AddGroupsAndMemberGroups 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(:member_groups, primary_key: false) do
|
||||||
|
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
|
||||||
|
|
||||||
|
add :member_id,
|
||||||
|
references(:members,
|
||||||
|
column: :id,
|
||||||
|
name: "member_groups_member_id_fkey",
|
||||||
|
type: :uuid,
|
||||||
|
prefix: "public",
|
||||||
|
on_delete: :delete_all
|
||||||
|
),
|
||||||
|
null: false
|
||||||
|
|
||||||
|
add :group_id, :uuid, null: false
|
||||||
|
|
||||||
|
add :inserted_at, :utc_datetime_usec,
|
||||||
|
null: false,
|
||||||
|
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||||
|
|
||||||
|
add :updated_at, :utc_datetime_usec,
|
||||||
|
null: false,
|
||||||
|
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||||
|
end
|
||||||
|
|
||||||
|
create table(:groups, primary_key: false) do
|
||||||
|
add :id, :uuid, null: false, default: fragment("uuid_generate_v7()"), primary_key: true
|
||||||
|
end
|
||||||
|
|
||||||
|
alter table(:member_groups) do
|
||||||
|
modify :group_id,
|
||||||
|
references(:groups,
|
||||||
|
column: :id,
|
||||||
|
name: "member_groups_group_id_fkey",
|
||||||
|
type: :uuid,
|
||||||
|
prefix: "public",
|
||||||
|
on_delete: :delete_all
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unique constraint on (member_id, group_id) to prevent duplicate associations
|
||||||
|
create unique_index(:member_groups, [:member_id, :group_id],
|
||||||
|
name: "member_groups_unique_member_group_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes for efficient queries
|
||||||
|
create index(:member_groups, [:member_id], name: "member_groups_member_id_index")
|
||||||
|
create index(:member_groups, [:group_id], name: "member_groups_group_id_index")
|
||||||
|
|
||||||
|
alter table(:groups) do
|
||||||
|
add :name, :text, null: false
|
||||||
|
add :slug, :text, null: false
|
||||||
|
add :description, :text
|
||||||
|
|
||||||
|
add :inserted_at, :utc_datetime_usec,
|
||||||
|
null: false,
|
||||||
|
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||||
|
|
||||||
|
add :updated_at, :utc_datetime_usec,
|
||||||
|
null: false,
|
||||||
|
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unique index on slug (case-sensitive)
|
||||||
|
create unique_index(:groups, [:slug], name: "groups_unique_slug_index")
|
||||||
|
|
||||||
|
# Unique index on LOWER(name) for case-insensitive uniqueness
|
||||||
|
# Using execute because Ecto doesn't support fragment in index column list
|
||||||
|
execute(
|
||||||
|
"CREATE UNIQUE INDEX groups_unique_name_lower_index ON groups (LOWER(name))",
|
||||||
|
"DROP INDEX IF EXISTS groups_unique_name_lower_index"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
execute("DROP INDEX IF EXISTS groups_unique_name_lower_index", "")
|
||||||
|
|
||||||
|
drop_if_exists unique_index(:groups, [:slug], name: "groups_unique_slug_index")
|
||||||
|
|
||||||
|
alter table(:groups) do
|
||||||
|
remove :updated_at
|
||||||
|
remove :inserted_at
|
||||||
|
remove :description
|
||||||
|
remove :slug
|
||||||
|
remove :name
|
||||||
|
end
|
||||||
|
|
||||||
|
drop_if_exists index(:member_groups, [:group_id], name: "member_groups_group_id_index")
|
||||||
|
drop_if_exists index(:member_groups, [:member_id], name: "member_groups_member_id_index")
|
||||||
|
|
||||||
|
drop_if_exists unique_index(:member_groups, [:member_id, :group_id],
|
||||||
|
name: "member_groups_unique_member_group_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
drop constraint(:member_groups, "member_groups_group_id_fkey")
|
||||||
|
|
||||||
|
alter table(:member_groups) do
|
||||||
|
modify :group_id, :uuid
|
||||||
|
end
|
||||||
|
|
||||||
|
drop table(:groups)
|
||||||
|
|
||||||
|
drop constraint(:member_groups, "member_groups_member_id_fkey")
|
||||||
|
|
||||||
|
drop table(:member_groups)
|
||||||
|
end
|
||||||
|
end
|
||||||
106
priv/resource_snapshots/repo/groups/20260127141620.json
Normal file
106
priv/resource_snapshots/repo/groups/20260127141620.json
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
{
|
||||||
|
"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": "slug",
|
||||||
|
"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": "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": "EB2489A9C4F649CBBDBD5E0685F703F10AF04448FB01A424801EEE36BAFF1A4A",
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"all_tenants?": false,
|
||||||
|
"base_filter": null,
|
||||||
|
"index_name": "groups_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": "groups"
|
||||||
|
}
|
||||||
136
priv/resource_snapshots/repo/member_groups/20260127141620.json
Normal file
136
priv/resource_snapshots/repo/member_groups/20260127141620.json
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"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": "member_groups_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": "member_groups_group_id_fkey",
|
||||||
|
"on_delete": null,
|
||||||
|
"on_update": null,
|
||||||
|
"primary_key?": true,
|
||||||
|
"schema": "public",
|
||||||
|
"table": "groups"
|
||||||
|
},
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "group_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": "6A81B894ADE7993917E2F97AB0C7233894AA7E59126DF2C17A7F04AEBDA6C159",
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"all_tenants?": false,
|
||||||
|
"base_filter": null,
|
||||||
|
"index_name": "member_groups_unique_member_group_index",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "member_id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "group_id"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "unique_member_group",
|
||||||
|
"nils_distinct?": true,
|
||||||
|
"where": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mv.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "member_groups"
|
||||||
|
}
|
||||||
|
|
@ -50,7 +50,7 @@ defmodule Mv.Membership.GroupTest do
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||||
Membership.create_group(attrs, actor: actor)
|
Membership.create_group(attrs, actor: actor)
|
||||||
|
|
||||||
assert error_message(errors, :name) =~ "must be at most 100"
|
assert error_message(errors, :name) =~ "100" or error_message(errors, :name) =~ "length"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "return error when name is not unique (case-insensitive) - application level validation",
|
test "return error when name is not unique (case-insensitive) - application level validation",
|
||||||
|
|
@ -77,7 +77,8 @@ defmodule Mv.Membership.GroupTest do
|
||||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||||
Membership.create_group(attrs, actor: actor)
|
Membership.create_group(attrs, actor: actor)
|
||||||
|
|
||||||
assert error_message(errors, :description) =~ "must be at most 500"
|
assert error_message(errors, :description) =~ "500" or
|
||||||
|
error_message(errors, :description) =~ "length"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -123,9 +124,13 @@ defmodule Mv.Membership.GroupTest do
|
||||||
Membership.create_group(%{name: "!!!"}, actor: actor)
|
Membership.create_group(%{name: "!!!"}, actor: actor)
|
||||||
|
|
||||||
assert Enum.any?(errors, fn err ->
|
assert Enum.any?(errors, fn err ->
|
||||||
(err.field == :slug or err.field == :name) and
|
field = Map.get(err, :field)
|
||||||
(String.contains?(err.message, "cannot be empty") or
|
message = Map.get(err, :message, Exception.message(err))
|
||||||
String.contains?(err.message, "is required"))
|
|
||||||
|
(field == :slug or field == :name) and
|
||||||
|
(String.contains?(message, "cannot be empty") or
|
||||||
|
String.contains?(message, "is required") or
|
||||||
|
String.contains?(message, "must be present"))
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -277,8 +282,15 @@ defmodule Mv.Membership.GroupTest do
|
||||||
# Returns the error message for a given field, or empty string if not found
|
# Returns the error message for a given field, or empty string if not found
|
||||||
defp error_message(errors, field) do
|
defp error_message(errors, field) do
|
||||||
case Enum.find(errors, fn err -> Map.get(err, :field) == field end) do
|
case Enum.find(errors, fn err -> Map.get(err, :field) == field end) do
|
||||||
nil -> ""
|
nil ->
|
||||||
err -> Map.get(err, :message, "")
|
""
|
||||||
|
|
||||||
|
err ->
|
||||||
|
# Handle different error types (Ash.Error.Changes.Required doesn't have :message)
|
||||||
|
case Map.get(err, :message) do
|
||||||
|
nil -> Exception.message(err)
|
||||||
|
message -> message
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,14 @@ defmodule Mv.Membership.MemberGroupTest do
|
||||||
)
|
)
|
||||||
|
|
||||||
assert Enum.any?(errors, fn err ->
|
assert Enum.any?(errors, fn err ->
|
||||||
((err.field == :member_id or err.field == :group_id) and
|
field = Map.get(err, :field)
|
||||||
String.contains?(err.message, "already been taken")) or
|
message = Map.get(err, :message, "")
|
||||||
String.contains?(err.message, "already exists") or
|
|
||||||
String.contains?(err.message, "duplicate")
|
(field == :member_id or field == :group_id) and
|
||||||
|
(String.contains?(message, "already been taken") or
|
||||||
|
String.contains?(message, "already exists") or
|
||||||
|
String.contains?(message, "duplicate") or
|
||||||
|
String.contains?(message, "already in this group"))
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue