8000 Add capacity limits for Huddlz by mwoods79 · Pull Request #124 · huddlz-hq/huddlz · GitHub
[go: up one dir, main page]

Skip to content
Merged
9 changes: 9 additions & 0 deletions lib/huddlz/communities/huddl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ defmodule Huddlz.Communities.Huddl do
:virtual_link,
:is_private,
:thumbnail_url,
:max_attendees,
:creator_id,
:group_id,
:huddl_template_id
Expand Down Expand Up @@ -130,6 +131,7 @@ defmodule Huddlz.Communities.Huddl do
:virtual_link,
:is_private,
:thumbnail_url,
:max_attendees,
:huddl_template_id
]

Expand Down Expand Up @@ -160,6 +162,7 @@ defmodule Huddlz.Communities.Huddl do
change Huddlz.Geocoding.ApplyProvidedCoordinates
change {Huddlz.Geocoding.GeocodeChange, field: :physical_location}
change Huddlz.Communities.Huddl.Changes.DefaultLocationFromGroup
change Huddlz.Communities.Huddl.Changes.EnforceCapacityFloor
end

read :by_status do
Expand Down Expand Up @@ -393,6 +396,12 @@ defmodule Huddlz.Communities.Huddl do
public? true
end

attribute :max_attendees, :integer do
allow_nil? true
public? true
constraints min: 1
end

attribute :latitude, :float do
allow_nil? true
description "Geocoded latitude of physical_location or inherited from group"
Expand Down
42 changes: 42 additions & 0 deletions lib/huddlz/communities/huddl/changes/enforce_capacity_floor.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule Huddlz.Communities.Huddl.Changes.EnforceCapacityFloor do
@moduledoc """
Prevents organizers from reducing `max_attendees` below the current RSVP
count. Locks the huddl row inside the action's transaction so the check is
atomic with the update — no organizer/RSVP TOCTOU race.
"""
use Ash.Resource.Change

alias Huddlz.Communities.Huddl

require Ash.Query

def change(changeset, _opts, _context) do
Ash.Changeset.before_action(changeset, fn cs ->
with true <- Ash.Changeset.changing_attribute?(cs, :max_attendees),
new_max when not is_nil(new_max) <-
Ash.Changeset.get_attribute(cs, :max_attendees) do
check_floor(cs, new_max)
else
_ -> cs
end
end)
end

defp check_floor(cs, new_max) do
huddl =
Huddl
|> Ash.Query.filter(id == ^cs.data.id)
|> Ash.Query.lock("FOR UPDATE")
|> Ash.Query.load(:rsvp_count)
|> Ash.read_one!(authorize?: false)

if new_max < huddl.rsvp_count do
Ash.Changeset.add_error(cs,
field: :max_attendees,
message: "cannot be less than the current RSVP count"
)
else
cs
end
end
end
63 changes: 45 additions & 18 deletions lib/huddlz/communities/huddl/changes/rsvp.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,63 @@ defmodule Huddlz.Communities.Huddl.Changes.Rsvp do
@moduledoc """
Handles RSVP logic: creates an attendee record if one doesn't already exist.
The rsvp_count is computed as an aggregate, so no manual counter management is needed.

Concurrency: locks the huddl row inside the action's transaction so that
concurrent RSVPs serialize on capacity checks and cannot overbook.
"""
use Ash.Resource.Change

alias Huddlz.Communities.{Huddl, HuddlAttendee}

require Ash.Query

def change(changeset, _opts, %{actor: %{id: user_id}}) when not is_nil(user_id) do
huddl_id = changeset.data.id

existing =
Huddlz.Communities.HuddlAttendee
|> Ash.Query.for_read(:check_rsvp, %{huddl_id: huddl_id}, actor: %{id: user_id})
|> Ash.read_one(authorize?: false)
Ash.Changeset.before_action(changeset, &reserve_spot(&1, user_id))
end

case existing do
{:ok, nil} ->
Huddlz.Communities.HuddlAttendee
|> Ash.Changeset.for_create(:rsvp, %{huddl_id: huddl_id, user_id: user_id})
|> Ash.create!(authorize?: false)
def change(changeset, _opts, _context) do
Ash.Changeset.add_error(changeset, "An actor is required to RSVP")
end

changeset
defp reserve_spot(cs, user_id) do
huddl = lock_huddl!(cs.data.id)

{:ok, _} ->
changeset
case fetch_existing_rsvp(huddl.id, user_id) do
{:ok, nil} -> claim_or_reject(cs, huddl, user_id)
{:ok, _attendee} 8000 -> cs
{:error, error} -> Ash.Changeset.add_error(cs, error)
end
end

{:error, error} ->
Ash.Changeset.add_error(changeset, error)
defp claim_or_reject(cs, huddl, user_id) do
if at_capacity?(huddl) do
Ash.Changeset.add_error(cs, "This huddl is full")
else
create_rsvp!(huddl.id, user_id)
cs
end
end

def change(changeset, _opts, _context) do
Ash.Changeset.add_error(changeset, "An actor is required to RSVP")
defp lock_huddl!(huddl_id) do
Huddl
|> Ash.Query.filter(id == ^huddl_id)
|> Ash.Query.lock("FOR UPDATE")
|> Ash.Query.load(:rsvp_count)
|> Ash.read_one!(authorize?: false)
end

defp fetch_existing_rsvp(huddl_id, user_id) do
HuddlAttendee
|> Ash.Query.for_read(:check_rsvp, %{huddl_id: huddl_id}, actor: %{id: user_id})
|> Ash.read_one(authorize?: false)
end

defp at_capacity?(%{max_attendees: nil}), do: false
defp at_capacity?(%{max_attendees: max, rsvp_count: count}), do: count >= max

defp create_rsvp!(huddl_id, user_id) do
HuddlAttendee
|> Ash.Changeset.for_create(:rsvp, %{huddl_id: huddl_id, user_id: user_id})
|> Ash.create!(authorize?: false)
end
end
50 changes: 48 additions & 2 deletions lib/huddlz_web/components/community_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,14 @@ defmodule HuddlzWeb.CommunityComponents do
{@huddl.physical_location}
</span>
<% end %>
<%= if @huddl.rsvp_count > 0 do %>
<%= if @huddl.max_attendees || @huddl.rsvp_count > 0 do %>
<span class="flex items-center gap-1">
<.icon name="hero-user-group" class="h-3.5 w-3.5" />
{@huddl.rsvp_count} attending
<%= if @huddl.max_attendees do %>
{capacity_label(@huddl)}
<% else %>
{@huddl.rsvp_count} attending
<% end %>
</span>
<% end %>
<%= if @distance do %>
Expand All @@ -194,6 +198,16 @@ defmodule HuddlzWeb.CommunityComponents do
</span>
<% end %>
</div>
<%= if @huddl.max_attendees do %>
<div class="mt-3">
<div class="flex items-center justify-between text-xs">
<span class={capacity_status_class(@huddl)}>{capacity_status(@huddl)}</span>
</div>
<div class="mt-1.5 h-1.5 bg-base-200 overflow-hidden">
<div class="h-full bg-primary" style={"width: #{capacity_percent(@huddl)}%"}></div>
</div>
</div>
<% end %>
</div>
</.link>
"""
Expand Down Expand Up @@ -324,6 +338,38 @@ defmodule HuddlzWeb.CommunityComponents do
defp type_icon(:hybrid), do: "hero-globe-alt"
defp type_icon(_), do: "hero-calendar"

def event_full?(%{max_attendees: nil}), do: false
def event_full?(huddl), do: huddl.rsvp_count >= huddl.max_attendees

def capacity_label(huddl), do: "#{huddl.rsvp_count}/#{huddl.max_attendees} spots filled" 10000 ;

def capacity_percent(%{max_attendees: nil}), do: 0

def capacity_percent(huddl) do
min(round(huddl.rsvp_count / huddl.max_attendees * 100), 100)
end

def capacity_status(huddl), do: capacity_status_for_tier(capacity_tier(huddl))
def capacity_status_class(huddl), do: capacity_status_class_for_tier(capacity_tier(huddl))

defp capacity_tier(huddl) do
cond do
event_full?(huddl) -> :full
capacity_percent(huddl) >= 80 -> :almost_full
capacity_percent(huddl) >= 50 -> :filling_up
true -> :plenty
end
end

defp capacity_status_for_tier(:full), do: "Event Full"
defp capacity_status_for_tier(:almost_full), do: "Almost full"
defp capacity_status_for_tier(:filling_up), do: "Filling up"
defp capacity_status_for_tier(:plenty), do: "Plenty of space"

defp capacity_status_class_for_tier(:full), do: "font-semibold text-error"
defp capacity_status_class_for_tier(:almost_full), do: "font-semibold text-warning"
defp capacity_status_class_for_tier(_), do: "font-semibold text-success"

defp truncate(text, max_length) when is_binary(text) and byte_size(text) > max_length do
String.slice(text, 0, max_length) <> "..."
end
Expand Down
3 changes: 2 additions & 1 deletion lib/huddlz_web/live/huddl_live/edit.ex
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ defmodule HuddlzWeb.HuddlLive.Edit do
initial_params = %{
"date" => Date.to_iso8601(date),
"start_time" => Calendar.strftime(start_time, "%H:%M"),
"duration_minutes" => to_string(duration_minutes)
"duration_minutes" => to_string(duration_minutes),
"max_attendees" => if(huddl.max_attendees, do: to_string(huddl.max_attendees), else: "")
}

initial_params = maybe_add_recurring_params(initial_params, huddl)
Expand Down
8 changes: 8 additions & 0 deletions lib/huddlz_web/live/huddl_live/form_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ defmodule HuddlzWeb.HuddlLive.FormComponent do

<.duration_picker field={@form[:duration_minutes]} label="Duration" />

<.input
field={@form[:max_attendees]}
type="number"
label="Max Attendees"
min="1"
placeholder="Leave blank for unlimited"
/>

<%= if @calculated_end_time do %>
<div class="border border-primary/20 p-3 bg-primary/5 flex items-center gap-2 text-sm">
<.icon name="hero-clock" class="h-5 w-5" />
Expand Down
29 changes: 26 additions & 3 deletions lib/huddlz_web/live/huddl_live/show.ex
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,15 @@ defmodule HuddlzWeb.HuddlLive.Show do
</button>
</div>
<% else %>
<.button phx-click="rsvp">
RSVP to this huddl
</.button>
<%= if event_full?(@huddl) do %>
<div class="text-error font-semibold">
<.icon name="hero-x-circle" class="h-5 w-5 inline" /> Event Full
</div>
<% else %>
<.button phx-click="rsvp">
RSVP to this huddl
</.button>
<% end %>
<% end %>
<% end %>
</:actions>
Expand Down Expand Up @@ -120,6 +126,23 @@ defmodule HuddlzWeb.HuddlLive.Show do
{@huddl.description || "No description provided."}
</p>

<%= if @huddl.max_attendees do %>
<div class="mt-6 border border-base-300 p-4">
<div class="flex items-center justify-between gap-3 text-sm">
<span class="font-medium flex items-center gap-2">
<.icon name="hero-user-group" class="h-4 w-4" />
{capacity_label(@huddl)}
</span>
<span class={capacity_status_class(@huddl)}>
{capacity_status(@huddl)}
</span>
</div>
<div class="mt-3 h-2 bg-base-200 overflow-hidden">
<div class="h-full bg-primary" style={"width: #{capacity_percent(@huddl)}%"}></div>
</div>
</div>
<% end %>

<dl class="mt-6">
<div class="flex items-start gap-3 py-3">
<dt class="w-32 shrink-0 font-medium text-base-content/50">Status</dt>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
defmodule Huddlz.Repo.Migrations.AddMaxAttendeesToHuddlz 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(:huddlz) do
add :max_attendees, :bigint
< 6168 /td> end
end

def down do
alter table(:huddlz) do
remove :max_attendees
end
end
end
Loading
Loading
0