defmodule MvWeb.GroupLive.Index do @moduledoc """ LiveView for displaying and managing the groups list. ## Features - List all groups with name, description, and member count - Create new groups - Navigate to group details and edit forms - Delete groups (with confirmation) ## Security - Admin users can create, edit, and delete groups - Read-only users can view groups but not manage them - Non-admin users are redirected """ use MvWeb, :live_view import MvWeb.LiveHelpers, only: [current_actor: 1, ash_actor_opts: 1] import MvWeb.Authorization @impl true def mount(_params, _session, socket) do actor = current_actor(socket) # Check if user can access the groups page (page permission check) if can_access_page?(actor, "/groups") do groups = load_groups(actor) {:ok, socket |> assign(:page_title, gettext("Groups")) |> assign(:groups, groups)} else {:ok, redirect(socket, to: ~p"/members")} end end @impl true def render(assigns) do ~H"""

{gettext("Groups")}

<%= if can?(@current_user, :create, Mv.Membership.Group) do %> <.button navigate={~p"/groups/new"} variant="primary"> <.icon name="hero-plus" class="size-4 mr-2" /> {gettext("Create Group")} <% end %>
<%= if Enum.empty?(@groups) do %>

{gettext("No groups")}

<% else %>
<%= for group <- @groups do %> <% end %>
{gettext("Name")} {gettext("Description")} {gettext("Members")} {gettext("Actions")}
{group.name} <%= if group.description do %> {group.description} <% else %> <% end %> <%= if group.member_count do %> {group.member_count} <% else %> 0 <% end %>
<.link navigate={~p"/groups/#{group.slug}"} class="btn btn-sm btn-ghost"> {gettext("View")} <%= if can?(@current_user, :update, Mv.Membership.Group) do %> <.link navigate={~p"/groups/#{group.slug}/edit"} class="btn btn-sm btn-ghost" > {gettext("Edit")} <% end %>
<% end %>
""" end @spec load_groups(map() | nil) :: [Mv.Membership.Group.t()] defp load_groups(actor) do require Ash.Query # Load groups without aggregates first (faster) query = Mv.Membership.Group |> Ash.Query.sort(:name) opts = ash_actor_opts(actor) case Ash.read(query, opts) do {:ok, groups} -> # Load all member counts in a single batch query (avoids N+1) member_counts = load_member_counts_batch(groups) # Attach counts to groups Enum.map(groups, fn group -> Map.put(group, :member_count, Map.get(member_counts, group.id, 0)) end) {:error, _error} -> require Logger Logger.warning("Failed to load groups in GroupLive.Index") [] end end # Loads all member counts for groups using DB-side aggregation for better performance # This avoids N+1 queries when loading member_count aggregate for each group @spec load_member_counts_batch([Mv.Membership.Group.t()]) :: %{ Ecto.UUID.t() => non_neg_integer() } defp load_member_counts_batch(groups) do group_ids = Enum.map(groups, & &1.id) if Enum.empty?(group_ids) do %{} else # Use Ecto directly for efficient GROUP BY COUNT query # This is much more performant than loading aggregates for each group individually # Note: We bypass Ash here for performance, but this is a simple read-only query import Ecto.Query query = from mg in Mv.Membership.MemberGroup, where: mg.group_id in ^group_ids, group_by: mg.group_id, select: {mg.group_id, count(mg.id)} results = Mv.Repo.all(query) results |> Enum.into(%{}, fn {group_id, count} -> {group_id, count} end) end end end