mitgliederverwaltung/lib/mv_web/live/group_live/index.ex
carla 91cf7cca6a
Some checks failed
continuous-integration/drone/push Build is failing
feat: conistent danger zone delete flow
2026-02-25 15:09:37 +01:00

143 lines
4.3 KiB
Elixir

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"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{gettext("Groups")}
<:actions>
<%= 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")}
</.button>
<% end %>
</:actions>
</.header>
<div class="mt-6 space-y-6">
<%= if Enum.empty?(@groups) do %>
<div class="text-center py-12">
<p class="text-base-content/60 italic">{gettext("No groups")}</p>
</div>
<% else %>
<.table
id="groups-table"
rows={@groups}
row_id={fn group -> "group-#{group.id}" end}
row_click={fn group -> JS.navigate(~p"/groups/#{group.slug}") end}
row_tooltip={gettext("Click for group details")}
>
<:col :let={group} label={gettext("Name")}>
{group.name}
</:col>
<:col :let={group} label={gettext("Description")}>
<%= if group.description do %>
{group.description}
<% else %>
<span class="text-base-content/50 italic">—</span>
<% end %>
</:col>
<:col :let={group} label={gettext("Members")} class="text-right">
{group.member_count || 0}
</:col>
</.table>
<% end %>
</div>
</Layouts.app>
"""
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