From 2f22a3b441733843cd09a5b3c00e7528abe4ef67 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 6 Apr 2026 20:59:28 -0300 Subject: [PATCH 1/3] feat: add seating plans schema, context, and migration --- pretex/lib/pretex/events/event.ex | 1 + pretex/lib/pretex/seating.ex | 482 ++++++++++++++++++ pretex/lib/pretex/seating/seat.ex | 56 ++ pretex/lib/pretex/seating/seat_reservation.ex | 93 ++++ pretex/lib/pretex/seating/seating_plan.ex | 42 ++ pretex/lib/pretex/seating/seating_section.ex | 58 +++ .../20260406230912_create_seating_plans.exs | 68 +++ 7 files changed, 800 insertions(+) create mode 100644 pretex/lib/pretex/seating.ex create mode 100644 pretex/lib/pretex/seating/seat.ex create mode 100644 pretex/lib/pretex/seating/seat_reservation.ex create mode 100644 pretex/lib/pretex/seating/seating_plan.ex create mode 100644 pretex/lib/pretex/seating/seating_section.ex create mode 100644 pretex/priv/repo/migrations/20260406230912_create_seating_plans.exs diff --git a/pretex/lib/pretex/events/event.ex b/pretex/lib/pretex/events/event.ex index 9ec6bd7..339a364 100644 --- a/pretex/lib/pretex/events/event.ex +++ b/pretex/lib/pretex/events/event.ex @@ -22,6 +22,7 @@ defmodule Pretex.Events.Event do field(:multi_entry, :boolean, default: false) belongs_to(:organization, Pretex.Organizations.Organization) + belongs_to(:seating_plan, Pretex.Seating.SeatingPlan) has_many(:sub_events, Pretex.Events.SubEvent, foreign_key: :parent_event_id) timestamps(type: :utc_datetime) diff --git a/pretex/lib/pretex/seating.ex b/pretex/lib/pretex/seating.ex new file mode 100644 index 0000000..042aaa7 --- /dev/null +++ b/pretex/lib/pretex/seating.ex @@ -0,0 +1,482 @@ +defmodule Pretex.Seating do + @moduledoc """ + Context for managing venue seating plans, sections, seats, and reservations. + + ## Seating Plan Lifecycle + + 1. Organizer uploads a JSON layout via `create_seating_plan/2`. + 2. `parse_layout/1` validates the JSON structure and returns a list of section + maps, each containing a list of seat maps ready for bulk insertion. + 3. The plan is assigned to an event via `assign_plan_to_event/2`. + 4. Sections are mapped to catalog items via `map_section_to_item/3`. + + ## Seat Reservation Lifecycle + + During checkout: `hold_seat/3` creates a temporary held reservation linked to + the cart session. On cart expiry: `release_seat/2` frees the seat. On order + confirmation: `confirm_seat/3` upgrades the reservation to confirmed and links + it to the order item. + + ## Concurrency + + Concurrent hold attempts on the same seat are safely rejected by the partial + unique database index on `[seat_id, event_id]` where `status != 'released'`. + The caller receives `{:error, :already_reserved}` in that case. + """ + + import Ecto.Query + + alias Pretex.Repo + alias Pretex.Seating.Seat + alias Pretex.Seating.SeatingPlan + alias Pretex.Seating.SeatingSection + alias Pretex.Seating.SeatReservation + alias Pretex.Events.Event + + # --------------------------------------------------------------------------- + # Seating Plans + # --------------------------------------------------------------------------- + + @doc """ + Returns all seating plans belonging to an organization, ordered by name. + """ + @spec list_seating_plans(integer()) :: [SeatingPlan.t()] + def list_seating_plans(org_id) do + SeatingPlan + |> where([p], p.organization_id == ^org_id) + |> order_by([p], asc: p.name) + |> Repo.all() + end + + @doc """ + Fetches a seating plan by id, raising if not found. + Preloads sections and their seats. + """ + @spec get_seating_plan!(integer()) :: SeatingPlan.t() + def get_seating_plan!(id) do + SeatingPlan + |> preload(sections: [:seats, :item, :item_variation]) + |> Repo.get!(id) + end + + @doc """ + Creates a seating plan for an organization by parsing the given JSON layout. + + The `layout` key in `attrs` must be a map conforming to the expected JSON + structure (see `parse_layout/1`). Sections and seats are inserted within a + single database transaction. + + Returns `{:ok, seating_plan}` on success, or `{:error, reason}` where reason + is either an `Ecto.Changeset` or `:invalid_layout`. + """ + @spec create_seating_plan(integer(), map()) :: + {:ok, SeatingPlan.t()} | {:error, Ecto.Changeset.t() | :invalid_layout} + def create_seating_plan(org_id, attrs) do + layout = Map.get(attrs, :layout, Map.get(attrs, "layout")) + + with {:ok, sections_data} <- parse_layout(layout) do + Repo.transaction(fn -> + plan_attrs = Map.put(attrs, :organization_id, org_id) + + plan = + case %SeatingPlan{} + |> SeatingPlan.changeset(plan_attrs) + |> Repo.insert() do + {:ok, p} -> p + {:error, cs} -> Repo.rollback(cs) + end + + Enum.each(sections_data, fn section_data -> + {seats_data, section_attrs} = Map.pop!(section_data, :seats) + + section = + %SeatingSection{} + |> SeatingSection.changeset(Map.put(section_attrs, :seating_plan_id, plan.id)) + |> Repo.insert!() + + now = DateTime.utc_now() |> DateTime.truncate(:second) + + seat_rows = + Enum.map(seats_data, fn seat -> + seat + |> Map.put(:seating_section_id, section.id) + |> Map.put(:inserted_at, now) + |> Map.put(:updated_at, now) + end) + + Repo.insert_all(Seat, seat_rows) + end) + + get_seating_plan!(plan.id) + end) + end + end + + @doc """ + Updates an existing seating plan's name. + """ + @spec update_seating_plan(SeatingPlan.t(), map()) :: + {:ok, SeatingPlan.t()} | {:error, Ecto.Changeset.t()} + def update_seating_plan(%SeatingPlan{} = plan, attrs) do + plan + |> SeatingPlan.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a seating plan. Cascades to sections and seats via DB constraints. + """ + @spec delete_seating_plan(SeatingPlan.t()) :: + {:ok, SeatingPlan.t()} | {:error, Ecto.Changeset.t()} + def delete_seating_plan(%SeatingPlan{} = plan) do + Repo.delete(plan) + end + + @doc """ + Returns a changeset for a seating plan without persisting it. + """ + @spec change_seating_plan(SeatingPlan.t(), map()) :: Ecto.Changeset.t() + def change_seating_plan(%SeatingPlan{} = plan, attrs \\ %{}) do + SeatingPlan.changeset(plan, attrs) + end + + # --------------------------------------------------------------------------- + # Layout Parsing + # --------------------------------------------------------------------------- + + @doc """ + Parses a JSON layout map into a list of section data maps, each containing + pre-built seat maps ready for insertion. + + Expected input structure: + + %{ + "sections" => [ + %{ + "name" => "Orchestra", + "rows" => [ + %{"label" => "A", "seats" => 20}, + %{"label" => "B", "seats" => 20} + ] + } + ] + } + + Returns `{:ok, sections}` where each section map has the shape: + + %{name: "Orchestra", capacity: 40, row_count: 2, seats: [...]} + + Returns `{:error, :invalid_layout}` when the structure is missing required + keys or contains invalid values. + """ + @spec parse_layout(map() | nil) :: {:ok, [map()]} | {:error, :invalid_layout} + def parse_layout(nil), do: {:error, :invalid_layout} + + def parse_layout(%{"sections" => sections}) when is_list(sections) and sections != [] do + sections + |> Enum.reduce_while({:ok, []}, fn section, {:ok, acc} -> + case parse_section(section) do + {:ok, parsed} -> {:cont, {:ok, acc ++ [parsed]}} + :error -> {:halt, {:error, :invalid_layout}} + end + end) + end + + def parse_layout(_), do: {:error, :invalid_layout} + + defp parse_section(%{"name" => name, "rows" => rows}) + when is_binary(name) and name != "" and is_list(rows) and rows != [] do + rows + |> Enum.reduce_while({:ok, []}, fn row, {:ok, acc} -> + case parse_row(row) do + {:ok, seats} -> {:cont, {:ok, acc ++ seats}} + :error -> {:halt, :error} + end + end) + |> case do + {:ok, seats} -> + {:ok, + %{ + name: name, + row_count: length(rows), + capacity: length(seats), + seats: seats + }} + + :error -> + :error + end + end + + defp parse_section(_), do: :error + + defp parse_row(%{"label" => label, "seats" => count}) + when is_binary(label) and label != "" and is_integer(count) and count > 0 do + seats = + Enum.map(1..count, fn n -> + %{ + label: "#{label}-#{n}", + row: label, + number: n, + status: "available" + } + end) + + {:ok, seats} + end + + defp parse_row(_), do: :error + + # --------------------------------------------------------------------------- + # Plan–Event Assignment + # --------------------------------------------------------------------------- + + @doc """ + Assigns a seating plan to an event by setting the `seating_plan_id` foreign key. + """ + @spec assign_plan_to_event(integer(), integer()) :: + {:ok, Event.t()} | {:error, Ecto.Changeset.t() | :not_found} + def assign_plan_to_event(event_id, plan_id) do + case Repo.get(Event, event_id) do + nil -> + {:error, :not_found} + + event -> + event + |> Ecto.Changeset.change(seating_plan_id: plan_id) + |> Repo.update() + end + end + + # --------------------------------------------------------------------------- + # Section–Item Mapping + # --------------------------------------------------------------------------- + + @doc """ + Maps a seating section to a catalog item and optional item variation. + Pass `nil` for `variation_id` to clear the variation mapping. + """ + @spec map_section_to_item(integer(), integer(), integer() | nil) :: + {:ok, SeatingSection.t()} | {:error, Ecto.Changeset.t() | :not_found} + def map_section_to_item(section_id, item_id, variation_id \\ nil) do + case Repo.get(SeatingSection, section_id) do + nil -> + {:error, :not_found} + + section -> + section + |> SeatingSection.mapping_changeset(%{item_id: item_id, item_variation_id: variation_id}) + |> Repo.update() + end + end + + # --------------------------------------------------------------------------- + # Seat Availability + # --------------------------------------------------------------------------- + + @doc """ + Returns all seats in a section that are available for a given event — + i.e. not blocked and without an active (held or confirmed) reservation. + """ + @spec available_seats(integer(), integer()) :: [Seat.t()] + def available_seats(event_id, section_id) do + active_seat_ids = + SeatReservation + |> where([r], r.event_id == ^event_id and r.status != "released") + |> select([r], r.seat_id) + + Seat + |> where([s], s.seating_section_id == ^section_id) + |> where([s], s.status == "available") + |> where([s], s.id not in subquery(active_seat_ids)) + |> order_by([s], asc: s.row, asc: s.number) + |> Repo.all() + end + + # --------------------------------------------------------------------------- + # Seat Reservation Operations + # --------------------------------------------------------------------------- + + @doc """ + Temporarily holds a seat for a cart session during checkout. + + The hold expires at `held_until`. Returns `{:error, :already_reserved}` if + the seat already has an active (held or confirmed) reservation for the event. + """ + @spec hold_seat(integer(), integer(), integer()) :: + {:ok, SeatReservation.t()} | {:error, :already_reserved | Ecto.Changeset.t()} + def hold_seat(seat_id, event_id, cart_session_id) do + held_until = DateTime.add(DateTime.utc_now(), 15 * 60, :second) |> DateTime.truncate(:second) + + %SeatReservation{} + |> SeatReservation.hold_changeset(%{ + seat_id: seat_id, + event_id: event_id, + cart_session_id: cart_session_id, + held_until: held_until + }) + |> Repo.insert() + |> case do + {:ok, reservation} -> {:ok, reservation} + {:error, changeset} -> handle_reservation_conflict(changeset) + end + end + + @doc """ + Upgrades a held reservation to confirmed, linking it to an order item. + + Returns `{:error, :not_found}` if no active reservation exists for the seat + and event combination. + """ + @spec confirm_seat(integer(), integer(), integer()) :: + {:ok, SeatReservation.t()} | {:error, :not_found | Ecto.Changeset.t()} + def confirm_seat(seat_id, event_id, order_item_id) do + case get_active_reservation(seat_id, event_id) do + nil -> + {:error, :not_found} + + reservation -> + reservation + |> SeatReservation.confirm_changeset(%{order_item_id: order_item_id}) + |> Repo.update() + end + end + + @doc """ + Releases a held or confirmed reservation, making the seat available again. + + Returns `{:error, :not_found}` if no active reservation exists. + """ + @spec release_seat(integer(), integer()) :: + {:ok, SeatReservation.t()} | {:error, :not_found | Ecto.Changeset.t()} + def release_seat(seat_id, event_id) do + case get_active_reservation(seat_id, event_id) do + nil -> {:error, :not_found} + reservation -> reservation |> SeatReservation.release_changeset() |> Repo.update() + end + end + + @doc """ + Manually assigns a seat to an order item, bypassing the cart hold flow. + Used by organizers for manual seat assignment. + + Returns `{:error, :already_reserved}` if the seat is already taken. + """ + @spec assign_seat(integer(), integer(), integer()) :: + {:ok, SeatReservation.t()} | {:error, :already_reserved | Ecto.Changeset.t()} + def assign_seat(seat_id, event_id, order_item_id) do + %SeatReservation{} + |> SeatReservation.assign_changeset(%{ + seat_id: seat_id, + event_id: event_id, + order_item_id: order_item_id + }) + |> Repo.insert() + |> case do + {:ok, reservation} -> {:ok, reservation} + {:error, changeset} -> handle_reservation_conflict(changeset) + end + end + + @doc """ + Reassigns a seat from one seat to another for an existing order item. + + If the attendee for the order item has already checked in, the function + still performs the reassignment but returns `{:ok, reservation, :checked_in_warning}` + to alert the caller. + + Returns `{:error, :not_found}` if the old seat has no active reservation for + the event, or `{:error, :already_reserved}` if the new seat is taken. + """ + @spec reassign_seat(integer(), integer(), integer(), integer()) :: + {:ok, SeatReservation.t()} + | {:ok, SeatReservation.t(), :checked_in_warning} + | {:error, :not_found | :already_reserved | Ecto.Changeset.t()} + def reassign_seat(old_seat_id, new_seat_id, event_id, order_item_id) do + with {:old, reservation} when not is_nil(reservation) <- + {:old, get_active_reservation(old_seat_id, event_id)}, + {:new_free} <- check_seat_free(new_seat_id, event_id) do + checked_in? = has_check_in?(reservation.order_item_id) + + Repo.transaction(fn -> + reservation |> SeatReservation.release_changeset() |> Repo.update!() + + new_reservation = + %SeatReservation{} + |> Ecto.Changeset.change(%{ + seat_id: new_seat_id, + event_id: event_id, + order_item_id: order_item_id, + status: "confirmed" + }) + |> Repo.insert!() + + {new_reservation, checked_in?} + end) + |> case do + {:ok, {reservation, true}} -> {:ok, reservation, :checked_in_warning} + {:ok, {reservation, false}} -> {:ok, reservation} + {:error, reason} -> {:error, reason} + end + else + {:old, nil} -> {:error, :not_found} + {:new_taken} -> {:error, :already_reserved} + end + end + + @doc """ + Releases all held reservations whose `held_until` timestamp is in the past. + + Intended to be called periodically (e.g. via an Oban worker or scheduled task). + Returns the count of reservations released. + """ + @spec release_expired_holds() :: {integer(), nil} + def release_expired_holds do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + SeatReservation + |> where([r], r.status == "held" and r.held_until < ^now) + |> Repo.update_all(set: [status: "released", held_until: nil]) + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp get_active_reservation(seat_id, event_id) do + SeatReservation + |> where([r], r.seat_id == ^seat_id and r.event_id == ^event_id and r.status != "released") + |> Repo.one() + end + + defp check_seat_free(seat_id, event_id) do + case get_active_reservation(seat_id, event_id) do + nil -> {:new_free} + _reservation -> {:new_taken} + end + end + + defp has_check_in?(nil), do: false + + defp has_check_in?(order_item_id) do + Repo.exists?( + from c in "check_ins", + where: c.order_item_id == ^order_item_id and is_nil(c.annulled_at) + ) + end + + defp handle_reservation_conflict(%Ecto.Changeset{} = changeset) do + if unique_constraint_error?(changeset, :seat_id) do + {:error, :already_reserved} + else + {:error, changeset} + end + end + + defp unique_constraint_error?(%Ecto.Changeset{errors: errors}, field) do + Enum.any?(errors, fn + {^field, {_msg, opts}} -> Keyword.get(opts, :constraint) == :unique + _ -> false + end) + end +end diff --git a/pretex/lib/pretex/seating/seat.ex b/pretex/lib/pretex/seating/seat.ex new file mode 100644 index 0000000..38a736c --- /dev/null +++ b/pretex/lib/pretex/seating/seat.ex @@ -0,0 +1,56 @@ +defmodule Pretex.Seating.Seat do + @moduledoc """ + An individual seat within a seating section. + + Seats are identified by their row label and number (e.g. row "A", number 5), + and carry a human-readable label (e.g. "A-5"). The status field reflects + whether the seat is available for selection or permanently blocked by the organizer. + """ + + use Ecto.Schema + import Ecto.Changeset + + @statuses ~w(available blocked) + + @type t :: %__MODULE__{ + id: integer() | nil, + label: String.t() | nil, + row: String.t() | nil, + number: integer() | nil, + status: String.t() | nil, + seating_section_id: integer() | nil, + seating_section: Pretex.Seating.SeatingSection.t() | Ecto.Association.NotLoaded.t(), + reservations: [Pretex.Seating.SeatReservation.t()] | Ecto.Association.NotLoaded.t(), + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + schema "seats" do + field :label, :string + field :row, :string + field :number, :integer + field :status, :string, default: "available" + + belongs_to :seating_section, Pretex.Seating.SeatingSection + has_many :reservations, Pretex.Seating.SeatReservation + + timestamps(type: :utc_datetime) + end + + @doc "Changeset for creating a seat." + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(seat, attrs) do + seat + |> cast(attrs, [:label, :row, :number, :status, :seating_section_id]) + |> validate_required([:label, :row, :number, :status, :seating_section_id]) + |> validate_length(:label, min: 1, max: 20) + |> validate_length(:row, min: 1, max: 10) + |> validate_number(:number, greater_than: 0) + |> validate_inclusion(:status, @statuses) + |> unique_constraint([:seating_section_id, :row, :number]) + end + + @doc "Returns the list of valid seat statuses." + @spec statuses() :: [String.t()] + def statuses, do: @statuses +end diff --git a/pretex/lib/pretex/seating/seat_reservation.ex b/pretex/lib/pretex/seating/seat_reservation.ex new file mode 100644 index 0000000..60b6ef0 --- /dev/null +++ b/pretex/lib/pretex/seating/seat_reservation.ex @@ -0,0 +1,93 @@ +defmodule Pretex.Seating.SeatReservation do + @moduledoc """ + Tracks seat reservation state for a specific event. + + A reservation moves through three states: + - `held` — temporarily reserved during checkout; expires at `held_until` + - `confirmed` — permanently assigned to an order item after payment + - `released` — freed (cart expired, order cancelled, or manual release) + + The partial unique index on `[seat_id, event_id]` where `status != 'released'` + enforces that only one active reservation exists per seat per event at any time. + """ + + use Ecto.Schema + import Ecto.Changeset + + @statuses ~w(held confirmed released) + + @type t :: %__MODULE__{ + id: integer() | nil, + status: String.t() | nil, + held_until: DateTime.t() | nil, + seat_id: integer() | nil, + event_id: integer() | nil, + order_item_id: integer() | nil, + cart_session_id: integer() | nil, + seat: Pretex.Seating.Seat.t() | Ecto.Association.NotLoaded.t(), + event: Pretex.Events.Event.t() | Ecto.Association.NotLoaded.t(), + order_item: Pretex.Orders.OrderItem.t() | nil | Ecto.Association.NotLoaded.t(), + cart_session: Pretex.Orders.CartSession.t() | nil | Ecto.Association.NotLoaded.t(), + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + schema "seat_reservations" do + field :status, :string, default: "held" + field :held_until, :utc_datetime + + belongs_to :seat, Pretex.Seating.Seat + belongs_to :event, Pretex.Events.Event + belongs_to :order_item, Pretex.Orders.OrderItem + belongs_to :cart_session, Pretex.Orders.CartSession + + timestamps(type: :utc_datetime) + end + + @doc "Changeset for creating a held reservation during checkout." + @spec hold_changeset(t(), map()) :: Ecto.Changeset.t() + def hold_changeset(reservation, attrs) do + reservation + |> cast(attrs, [:seat_id, :event_id, :cart_session_id, :held_until, :status]) + |> validate_required([:seat_id, :event_id, :cart_session_id, :held_until]) + |> put_change(:status, "held") + |> unique_constraint([:seat_id, :event_id], + name: :seat_reservations_seat_id_event_id_active_index, + message: "assento já reservado para este evento" + ) + end + + @doc "Changeset for confirming a reservation after order payment." + @spec confirm_changeset(t(), map()) :: Ecto.Changeset.t() + def confirm_changeset(reservation, attrs) do + reservation + |> cast(attrs, [:order_item_id]) + |> validate_required([:order_item_id]) + |> put_change(:status, "confirmed") + |> put_change(:held_until, nil) + |> put_change(:cart_session_id, nil) + end + + @doc "Changeset for directly assigning a confirmed reservation (organizer manual assignment)." + @spec assign_changeset(t(), map()) :: Ecto.Changeset.t() + def assign_changeset(reservation, attrs) do + reservation + |> cast(attrs, [:seat_id, :event_id, :order_item_id]) + |> validate_required([:seat_id, :event_id, :order_item_id]) + |> put_change(:status, "confirmed") + |> unique_constraint([:seat_id, :event_id], + name: :seat_reservations_seat_id_event_id_active_index, + message: "assento já reservado para este evento" + ) + end + + @doc "Changeset for releasing a reservation (cart expired or order cancelled)." + @spec release_changeset(t()) :: Ecto.Changeset.t() + def release_changeset(reservation) do + change(reservation, status: "released", held_until: nil) + end + + @doc "Returns the list of valid reservation statuses." + @spec statuses() :: [String.t()] + def statuses, do: @statuses +end diff --git a/pretex/lib/pretex/seating/seating_plan.ex b/pretex/lib/pretex/seating/seating_plan.ex new file mode 100644 index 0000000..d7d991d --- /dev/null +++ b/pretex/lib/pretex/seating/seating_plan.ex @@ -0,0 +1,42 @@ +defmodule Pretex.Seating.SeatingPlan do + @moduledoc """ + Represents a venue seating plan consisting of named sections, rows, and seats. + + A seating plan belongs to an organization and may be assigned to multiple events. + The `layout` field stores the original uploaded JSON structure for reference. + """ + + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{ + id: integer() | nil, + name: String.t() | nil, + layout: map() | nil, + organization_id: integer() | nil, + sections: [Pretex.Seating.SeatingSection.t()] | Ecto.Association.NotLoaded.t(), + events: [Pretex.Events.Event.t()] | Ecto.Association.NotLoaded.t(), + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + schema "seating_plans" do + field :name, :string + field :layout, :map + + belongs_to :organization, Pretex.Organizations.Organization + has_many :sections, Pretex.Seating.SeatingSection + has_many :events, Pretex.Events.Event + + timestamps(type: :utc_datetime) + end + + @doc "Changeset for creating or updating a seating plan." + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(plan, attrs) do + plan + |> cast(attrs, [:name, :layout, :organization_id]) + |> validate_required([:name, :layout, :organization_id]) + |> validate_length(:name, min: 2, max: 255) + end +end diff --git a/pretex/lib/pretex/seating/seating_section.ex b/pretex/lib/pretex/seating/seating_section.ex new file mode 100644 index 0000000..f49ce81 --- /dev/null +++ b/pretex/lib/pretex/seating/seating_section.ex @@ -0,0 +1,58 @@ +defmodule Pretex.Seating.SeatingSection do + @moduledoc """ + A named section within a seating plan (e.g. "Orchestra", "Balcony"). + + Sections contain individual seats and may be mapped to a catalog item and optional + item variation, allowing organizers to sell different ticket types per section. + """ + + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{ + id: integer() | nil, + name: String.t() | nil, + row_count: integer() | nil, + capacity: integer() | nil, + seating_plan_id: integer() | nil, + item_id: integer() | nil, + item_variation_id: integer() | nil, + seating_plan: Pretex.Seating.SeatingPlan.t() | Ecto.Association.NotLoaded.t(), + seats: [Pretex.Seating.Seat.t()] | Ecto.Association.NotLoaded.t(), + item: Pretex.Catalog.Item.t() | nil | Ecto.Association.NotLoaded.t(), + item_variation: Pretex.Catalog.ItemVariation.t() | nil | Ecto.Association.NotLoaded.t(), + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + schema "seating_sections" do + field :name, :string + field :row_count, :integer + field :capacity, :integer + + belongs_to :seating_plan, Pretex.Seating.SeatingPlan + belongs_to :item, Pretex.Catalog.Item + belongs_to :item_variation, Pretex.Catalog.ItemVariation + + has_many :seats, Pretex.Seating.Seat + + timestamps(type: :utc_datetime) + end + + @doc "Changeset for creating a seating section." + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(section, attrs) do + section + |> cast(attrs, [:name, :row_count, :capacity, :seating_plan_id, :item_id, :item_variation_id]) + |> validate_required([:name, :capacity, :seating_plan_id]) + |> validate_length(:name, min: 1, max: 255) + |> validate_number(:capacity, greater_than: 0) + end + + @doc "Changeset for mapping a section to a catalog item and optional variation." + @spec mapping_changeset(t(), map()) :: Ecto.Changeset.t() + def mapping_changeset(section, attrs) do + section + |> cast(attrs, [:item_id, :item_variation_id]) + end +end diff --git a/pretex/priv/repo/migrations/20260406230912_create_seating_plans.exs b/pretex/priv/repo/migrations/20260406230912_create_seating_plans.exs new file mode 100644 index 0000000..cba989b --- /dev/null +++ b/pretex/priv/repo/migrations/20260406230912_create_seating_plans.exs @@ -0,0 +1,68 @@ +defmodule Pretex.Repo.Migrations.CreateSeatingPlans do + use Ecto.Migration + + def change do + create table(:seating_plans) do + add :name, :string, null: false + add :layout, :map, null: false + add :organization_id, references(:organizations, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:seating_plans, [:organization_id]) + + create table(:seating_sections) do + add :name, :string, null: false + add :row_count, :integer + add :capacity, :integer, null: false + add :seating_plan_id, references(:seating_plans, on_delete: :delete_all), null: false + add :item_id, references(:items, on_delete: :nilify_all) + add :item_variation_id, references(:item_variations, on_delete: :nilify_all) + + timestamps(type: :utc_datetime) + end + + create index(:seating_sections, [:seating_plan_id]) + create index(:seating_sections, [:item_id]) + + create table(:seats) do + add :label, :string, null: false + add :row, :string, null: false + add :number, :integer, null: false + add :status, :string, null: false, default: "available" + add :seating_section_id, references(:seating_sections, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:seats, [:seating_section_id]) + create unique_index(:seats, [:seating_section_id, :row, :number]) + + create table(:seat_reservations) do + add :status, :string, null: false, default: "held" + add :held_until, :utc_datetime + add :seat_id, references(:seats, on_delete: :delete_all), null: false + add :event_id, references(:events, on_delete: :delete_all), null: false + add :order_item_id, references(:order_items, on_delete: :nilify_all) + add :cart_session_id, references(:cart_sessions, on_delete: :nilify_all) + + timestamps(type: :utc_datetime) + end + + create index(:seat_reservations, [:seat_id]) + create index(:seat_reservations, [:event_id]) + create index(:seat_reservations, [:cart_session_id]) + create index(:seat_reservations, [:order_item_id]) + + # Partial unique index: only one active (held/confirmed) reservation per seat per event + create unique_index(:seat_reservations, [:seat_id, :event_id], + where: "status != 'released'", + name: :seat_reservations_seat_id_event_id_active_index + ) + + alter table(:events) do + add :seating_plan_id, references(:seating_plans, on_delete: :nilify_all) + end + end +end From c3f994165f877caa7aa39cacb8bbd6250a733b87 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 6 Apr 2026 20:59:36 -0300 Subject: [PATCH 2/3] feat: add seating plan admin UI and routes --- .../live/admin/seating_live/index.ex | 150 +++++++++++++++++ .../live/admin/seating_live/index.html.heex | 150 +++++++++++++++++ .../live/admin/seating_live/show.ex | 155 ++++++++++++++++++ .../live/admin/seating_live/show.html.heex | 155 ++++++++++++++++++ pretex/lib/pretex_web/router.ex | 4 + 5 files changed, 614 insertions(+) create mode 100644 pretex/lib/pretex_web/live/admin/seating_live/index.ex create mode 100644 pretex/lib/pretex_web/live/admin/seating_live/index.html.heex create mode 100644 pretex/lib/pretex_web/live/admin/seating_live/show.ex create mode 100644 pretex/lib/pretex_web/live/admin/seating_live/show.html.heex diff --git a/pretex/lib/pretex_web/live/admin/seating_live/index.ex b/pretex/lib/pretex_web/live/admin/seating_live/index.ex new file mode 100644 index 0000000..85c987a --- /dev/null +++ b/pretex/lib/pretex_web/live/admin/seating_live/index.ex @@ -0,0 +1,150 @@ +defmodule PretexWeb.Admin.SeatingLive.Index do + @moduledoc """ + Lists seating plans for an organization and allows uploading a new plan via + JSON layout. + """ + + use PretexWeb, :live_view + + alias Pretex.Seating + alias Pretex.Seating.SeatingPlan + alias Pretex.Organizations + + @impl true + def mount(%{"org_id" => org_id}, _session, socket) do + org = Organizations.get_organization!(org_id) + plans = Seating.list_seating_plans(org.id) + + socket = + socket + |> assign(:org, org) + |> assign(:page_title, "Plantas de Assentos — #{org.name}") + |> stream(:plans, plans) + |> allow_upload(:layout, + accept: ~w(.json), + max_entries: 1, + max_file_size: 1_000_000 + ) + + {:ok, socket} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:form, nil) + |> assign(:upload_error, nil) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "Nova Planta de Assentos") + |> assign(:form, to_form(Seating.change_seating_plan(%SeatingPlan{}))) + |> assign(:upload_error, nil) + end + + @impl true + def handle_event("validate", %{"seating_plan" => params}, socket) do + changeset = + %SeatingPlan{} + |> Seating.change_seating_plan(params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :form, to_form(changeset))} + end + + def handle_event("save", %{"seating_plan" => params}, socket) do + org = socket.assigns.org + + case consume_layout_upload(socket) do + {:ok, layout} -> + attrs = Map.put(params, "layout", layout) + + case Seating.create_seating_plan(org.id, attrs) do + {:ok, plan} -> + {:noreply, + socket + |> put_flash(:info, "Planta \"#{plan.name}\" criada com sucesso.") + |> stream_insert(:plans, plan) + |> push_patch(to: ~p"/admin/organizations/#{org}/seating")} + + {:error, :invalid_layout} -> + {:noreply, assign(socket, :upload_error, "O arquivo JSON tem formato inválido.")} + + {:error, changeset} -> + {:noreply, assign(socket, :form, to_form(changeset))} + end + + {:error, message} -> + {:noreply, assign(socket, :upload_error, message)} + end + end + + def handle_event("cancel_upload", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :layout, ref)} + end + + def handle_event("cancel", _params, socket) do + org = socket.assigns.org + {:noreply, push_patch(socket, to: ~p"/admin/organizations/#{org}/seating")} + end + + def handle_event("delete", %{"id" => id}, socket) do + plan = Seating.get_seating_plan!(id) + + case Seating.delete_seating_plan(plan) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "Planta removida.") + |> stream_delete(:plans, plan)} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Não foi possível remover a planta.")} + end + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp consume_layout_upload(socket) do + entries = socket.assigns.uploads.layout.entries + + case entries do + [] -> + {:error, "Selecione um arquivo JSON de layout."} + + [_entry] -> + result = + consume_uploaded_entries(socket, :layout, fn %{path: path}, _entry -> + case File.read(path) do + {:ok, content} -> + case Jason.decode(content) do + {:ok, json} -> {:ok, json} + {:error, _} -> {:ok, :invalid_json} + end + + {:error, _} -> + {:ok, :read_error} + end + end) + + case result do + [:invalid_json] -> {:error, "O arquivo não é um JSON válido."} + [:read_error] -> {:error, "Erro ao ler o arquivo."} + [json] -> {:ok, json} + _ -> {:error, "Erro inesperado ao processar o arquivo."} + end + end + end + + defp upload_error_to_string(:too_large), do: "Arquivo muito grande (máximo 1 MB)." + defp upload_error_to_string(:not_accepted), do: "Formato não aceito. Envie um arquivo .json." + defp upload_error_to_string(:too_many_files), do: "Envie apenas um arquivo por vez." + defp upload_error_to_string(_), do: "Erro ao processar o arquivo." +end diff --git a/pretex/lib/pretex_web/live/admin/seating_live/index.html.heex b/pretex/lib/pretex_web/live/admin/seating_live/index.html.heex new file mode 100644 index 0000000..03ef132 --- /dev/null +++ b/pretex/lib/pretex_web/live/admin/seating_live/index.html.heex @@ -0,0 +1,150 @@ +<.dashboard_layout + current_path={~p"/admin/organizations/#{@org}/seating"} + org={@org} + flash={@flash} +> +
+ <%!-- Barra superior --%> +
+
+

Plantas de Assentos

+

{@org.name}

+
+ <.link + patch={~p"/admin/organizations/#{@org}/seating/new"} + class="btn btn-primary btn-sm gap-1" + > + <.icon name="hero-plus" class="size-4" /> Nova Planta + +
+ + <%!-- Formulário de upload --%> +
+
+

Carregar Nova Planta

+ + <.form for={@form} phx-change="validate" phx-submit="save"> +
+
+ + <.input + field={@form[:name]} + type="text" + placeholder="Ex: Teatro Municipal" + class="input input-bordered w-full" + /> +
+ +
+ +
+ <.icon name="hero-arrow-up-tray" class="size-8 text-base-content/30 mx-auto mb-2" /> +

+ Arraste um arquivo JSON ou clique para selecionar +

+ <.live_file_input upload={@uploads.layout} class="hidden" /> + +
+ + <%!-- Preview de entradas --%> +
+ <%= for entry <- @uploads.layout.entries do %> +
+ <.icon name="hero-document-text" class="size-4 text-base-content/50 shrink-0" /> + {entry.client_name} + +
+ <%= for err <- upload_errors(@uploads.layout, entry) do %> +

{upload_error_to_string(err)}

+ <% end %> + <% end %> +
+ +

{@upload_error}

+
+
+ +
+ + +
+ +
+
+ + <%!-- Lista de plantas --%> +
+ + +
+
+
+

{plan.name}

+

+ Criada em {Calendar.strftime(plan.inserted_at, "%d/%m/%Y")} +

+
+ +
+ <.link + navigate={~p"/admin/organizations/#{@org}/seating/#{plan}"} + class="btn btn-ghost btn-sm" + > + Ver detalhes + + +
+
+
+
+
+ diff --git a/pretex/lib/pretex_web/live/admin/seating_live/show.ex b/pretex/lib/pretex_web/live/admin/seating_live/show.ex new file mode 100644 index 0000000..0fb2e73 --- /dev/null +++ b/pretex/lib/pretex_web/live/admin/seating_live/show.ex @@ -0,0 +1,155 @@ +defmodule PretexWeb.Admin.SeatingLive.Show do + @moduledoc """ + Displays the details of a seating plan: its sections, seat counts, and + item mappings. Allows organizers to: + - Map sections to catalog items/variations. + - Assign the plan to an event. + """ + + use PretexWeb, :live_view + + alias Pretex.Events + alias Pretex.Organizations + alias Pretex.Seating + + @impl true + def mount(%{"org_id" => org_id, "id" => plan_id}, _session, socket) do + org = Organizations.get_organization!(org_id) + plan = Seating.get_seating_plan!(plan_id) + events = Events.list_events(org) + + socket = + socket + |> assign(:org, org) + |> assign(:plan, plan) + |> assign(:events, events) + |> assign(:page_title, "Planta — #{plan.name}") + |> assign(:mapping_section_id, nil) + |> assign(:mapping_form, nil) + |> assign(:assign_event_form, nil) + + {:ok, socket} + end + + @impl true + def handle_params(_params, _url, socket) do + {:noreply, socket} + end + + @impl true + def handle_event("map_section", %{"section_id" => section_id_str}, socket) do + section_id = String.to_integer(section_id_str) + org = socket.assigns.org + event_items = list_items_for_org(org.id) + + {:noreply, + socket + |> assign(:mapping_section_id, section_id) + |> assign(:event_items, event_items) + |> assign(:mapping_form, to_form(%{"item_id" => "", "item_variation_id" => ""}))} + end + + def handle_event("cancel_mapping", _params, socket) do + {:noreply, + socket + |> assign(:mapping_section_id, nil) + |> assign(:mapping_form, nil)} + end + + def handle_event( + "save_mapping", + %{"mapping" => %{"item_id" => item_id_str, "item_variation_id" => var_id_str}}, + socket + ) do + section_id = socket.assigns.mapping_section_id + item_id = parse_optional_id(item_id_str) + variation_id = parse_optional_id(var_id_str) + + case Seating.map_section_to_item(section_id, item_id, variation_id) do + {:ok, _section} -> + plan = Seating.get_seating_plan!(socket.assigns.plan.id) + + {:noreply, + socket + |> assign(:plan, plan) + |> assign(:mapping_section_id, nil) + |> assign(:mapping_form, nil) + |> put_flash(:info, "Seção mapeada com sucesso.")} + + {:error, :not_found} -> + {:noreply, put_flash(socket, :error, "Seção não encontrada.")} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Erro ao salvar mapeamento.")} + end + end + + def handle_event("show_assign_event", _params, socket) do + {:noreply, assign(socket, :assign_event_form, to_form(%{"event_id" => ""}))} + end + + def handle_event("cancel_assign_event", _params, socket) do + {:noreply, assign(socket, :assign_event_form, nil)} + end + + def handle_event("assign_to_event", %{"assignment" => %{"event_id" => event_id_str}}, socket) do + plan = socket.assigns.plan + + case parse_optional_id(event_id_str) do + nil -> + {:noreply, put_flash(socket, :error, "Selecione um evento.")} + + event_id -> + case Seating.assign_plan_to_event(event_id, plan.id) do + {:ok, _event} -> + {:noreply, + socket + |> assign(:assign_event_form, nil) + |> put_flash(:info, "Planta atribuída ao evento com sucesso.")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Erro ao atribuir a planta ao evento.")} + end + end + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp list_items_for_org(org_id) do + alias Pretex.Repo + import Ecto.Query + + Pretex.Catalog.Item + |> join(:inner, [i], e in Pretex.Events.Event, on: i.event_id == e.id) + |> where([_i, e], e.organization_id == ^org_id) + |> where([i, _e], i.item_type == "ticket" and i.status == "active") + |> preload(:variations) + |> Repo.all() + end + + defp parse_optional_id(""), do: nil + defp parse_optional_id(nil), do: nil + + defp parse_optional_id(str) when is_binary(str) do + case Integer.parse(str) do + {id, ""} -> id + _ -> nil + end + end + + defp parse_optional_id(id) when is_integer(id), do: id + + defp section_item_label(nil), do: "—" + + defp section_item_label(item) do + item.name + end + + defp section_variation_label(nil), do: nil + + defp section_variation_label(variation) do + variation.name + end +end diff --git a/pretex/lib/pretex_web/live/admin/seating_live/show.html.heex b/pretex/lib/pretex_web/live/admin/seating_live/show.html.heex new file mode 100644 index 0000000..13d6332 --- /dev/null +++ b/pretex/lib/pretex_web/live/admin/seating_live/show.html.heex @@ -0,0 +1,155 @@ +<.dashboard_layout + current_path={~p"/admin/organizations/#{@org}/seating"} + org={@org} + flash={@flash} +> +
+ <%!-- Barra superior --%> +
+ <.link + navigate={~p"/admin/organizations/#{@org}/seating"} + class="inline-flex items-center gap-1 text-sm text-base-content/60 hover:text-primary transition-colors" + > + <.icon name="hero-arrow-left" class="size-4" /> Voltar às Plantas + + + +
+ + <%!-- Cabeçalho da planta --%> +
+

{@plan.name}

+

+ Criada em {Calendar.strftime(@plan.inserted_at, "%d/%m/%Y")} + · {@plan.sections |> length()} seções + · {@plan.sections |> Enum.flat_map(& &1.seats) |> length()} assentos no total +

+
+ + <%!-- Modal de atribuição a evento --%> +
+
+

Atribuir Planta a Evento

+ <.form for={@assign_event_form} phx-submit="assign_to_event"> +
+
+ + +
+ + +
+ +
+
+ + <%!-- Seções --%> +
+ <%= for section <- @plan.sections do %> +
+
+ <%!-- Cabeçalho da seção --%> +
+
+

{section.name}

+

+ {section.capacity} assentos · {section.row_count || 0} fileiras +

+
+ +
+ <%!-- Mapeamento atual --%> +
+

Tipo de ingresso

+

+ {section_item_label(section.item)} + <%= if variation = section_variation_label(section.item_variation) do %> + · {variation} + <% end %> +

+
+ + +
+
+ + <%!-- Formulário de mapeamento inline --%> +
+

+ Mapear seção a um tipo de ingresso +

+ <.form for={@mapping_form} phx-submit="save_mapping"> +
+
+ + +
+
+ + +
+ + +
+ +
+ + <%!-- Grade de assentos --%> +
+ <%= for seat <- section.seats do %> + + {seat.label} + + <% end %> +
+
+
+ <% end %> +
+
+ diff --git a/pretex/lib/pretex_web/router.ex b/pretex/lib/pretex_web/router.ex index f73db44..8ea18b2 100644 --- a/pretex/lib/pretex_web/router.ex +++ b/pretex/lib/pretex_web/router.ex @@ -205,6 +205,10 @@ defmodule PretexWeb.Router do live("/organizations/:org_id/memberships/:id/grant", MembershipLive.Index, :grant) live("/organizations/:org_id/devices", DeviceLive.Index, :index) + + live("/organizations/:org_id/seating", SeatingLive.Index, :index) + live("/organizations/:org_id/seating/new", SeatingLive.Index, :new) + live("/organizations/:org_id/seating/:id", SeatingLive.Show, :show) end end From 1847f365772a297b3cb1bb9494740bbb16dc217a Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 6 Apr 2026 20:59:43 -0300 Subject: [PATCH 3/3] test: add seating plans context and LiveView tests --- pretex/test/pretex/seating_test.exs | 701 ++++++++++++++++++ .../live/admin/seating_live_test.exs | 304 ++++++++ .../test/support/fixtures/seating_fixtures.ex | 36 + 3 files changed, 1041 insertions(+) create mode 100644 pretex/test/pretex/seating_test.exs create mode 100644 pretex/test/pretex_web/live/admin/seating_live_test.exs create mode 100644 pretex/test/support/fixtures/seating_fixtures.ex diff --git a/pretex/test/pretex/seating_test.exs b/pretex/test/pretex/seating_test.exs new file mode 100644 index 0000000..7635318 --- /dev/null +++ b/pretex/test/pretex/seating_test.exs @@ -0,0 +1,701 @@ +defmodule Pretex.SeatingTest do + use Pretex.DataCase, async: true + + import Pretex.OrganizationsFixtures + import Pretex.EventsFixtures + import Pretex.CatalogFixtures + import Pretex.SeatingFixtures + + alias Pretex.Seating + alias Pretex.Seating.SeatReservation + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + defp cart_session_fixture(event) do + {:ok, cart} = Pretex.Orders.create_cart(event) + cart + end + + defp order_item_fixture(event) do + item = item_fixture(event) + + now = DateTime.utc_now() |> DateTime.truncate(:second) + confirmation_code = "TEST#{System.unique_integer([:positive])}" + + {:ok, order} = + %Pretex.Orders.Order{} + |> Ecto.Changeset.change(%{ + event_id: event.id, + status: "confirmed", + total_cents: item.price_cents, + email: "test@example.com", + name: "Test User", + confirmation_code: confirmation_code, + expires_at: DateTime.add(now, 3600, :second) + }) + |> Pretex.Repo.insert() + + ticket_code = "TKT#{System.unique_integer([:positive])}" + + {:ok, order_item} = + %Pretex.Orders.OrderItem{} + |> Ecto.Changeset.change(%{ + order_id: order.id, + item_id: item.id, + quantity: 1, + unit_price_cents: item.price_cents, + ticket_code: ticket_code + }) + |> Pretex.Repo.insert() + + order_item + end + + # --------------------------------------------------------------------------- + # parse_layout/1 + # --------------------------------------------------------------------------- + + describe "parse_layout/1" do + test "parses a valid layout into section/seat maps" do + layout = %{ + "sections" => [ + %{ + "name" => "Pista", + "rows" => [ + %{"label" => "A", "seats" => 3}, + %{"label" => "B", "seats" => 2} + ] + } + ] + } + + assert {:ok, [section]} = Seating.parse_layout(layout) + assert section.name == "Pista" + assert section.row_count == 2 + assert section.capacity == 5 + assert length(section.seats) == 5 + + labels = Enum.map(section.seats, & &1.label) + assert "A-1" in labels + assert "A-3" in labels + assert "B-1" in labels + assert "B-2" in labels + end + + test "parses multiple sections" do + assert {:ok, sections} = Seating.parse_layout(valid_layout()) + assert length(sections) == 2 + names = Enum.map(sections, & &1.name) + assert "Orchestra" in names + assert "Balcony" in names + end + + test "returns error for nil" do + assert {:error, :invalid_layout} = Seating.parse_layout(nil) + end + + test "returns error for empty map" do + assert {:error, :invalid_layout} = Seating.parse_layout(%{}) + end + + test "returns error when sections key is missing" do + assert {:error, :invalid_layout} = Seating.parse_layout(%{"name" => "Test"}) + end + + test "returns error when sections is an empty list" do + assert {:error, :invalid_layout} = Seating.parse_layout(%{"sections" => []}) + end + + test "returns error when a section is missing name" do + layout = %{ + "sections" => [ + %{"rows" => [%{"label" => "A", "seats" => 5}]} + ] + } + + assert {:error, :invalid_layout} = Seating.parse_layout(layout) + end + + test "returns error when a section has empty name" do + layout = %{ + "sections" => [ + %{"name" => "", "rows" => [%{"label" => "A", "seats" => 5}]} + ] + } + + assert {:error, :invalid_layout} = Seating.parse_layout(layout) + end + + test "returns error when a row has invalid seat count" do + layout = %{ + "sections" => [ + %{"name" => "A", "rows" => [%{"label" => "A", "seats" => 0}]} + ] + } + + assert {:error, :invalid_layout} = Seating.parse_layout(layout) + end + + test "returns error when a row is missing label" do + layout = %{ + "sections" => [ + %{"name" => "A", "rows" => [%{"seats" => 5}]} + ] + } + + assert {:error, :invalid_layout} = Seating.parse_layout(layout) + end + end + + # --------------------------------------------------------------------------- + # create_seating_plan/2 + # --------------------------------------------------------------------------- + + describe "create_seating_plan/2" do + test "creates plan with sections and seats from valid layout" do + org = org_fixture() + + assert {:ok, plan} = + Seating.create_seating_plan(org.id, %{ + name: "Teatro Central", + layout: valid_layout() + }) + + assert plan.name == "Teatro Central" + assert plan.organization_id == org.id + assert length(plan.sections) == 2 + + total_seats = plan.sections |> Enum.flat_map(& &1.seats) |> length() + # Orchestra: 5+5=10, Balcony: 3 + assert total_seats == 13 + end + + test "returns :invalid_layout for malformed JSON map" do + org = org_fixture() + + assert {:error, :invalid_layout} = + Seating.create_seating_plan(org.id, %{ + name: "Bad Plan", + layout: %{"wrong" => "structure"} + }) + end + + test "returns changeset error when name is missing" do + org = org_fixture() + + assert {:error, changeset} = + Seating.create_seating_plan(org.id, %{layout: valid_layout()}) + + assert %{name: [_ | _]} = errors_on(changeset) + end + + test "returns changeset error when name is too short" do + org = org_fixture() + + assert {:error, changeset} = + Seating.create_seating_plan(org.id, %{name: "X", layout: valid_layout()}) + + assert %{name: [_ | _]} = errors_on(changeset) + end + end + + # --------------------------------------------------------------------------- + # list_seating_plans/1 + # --------------------------------------------------------------------------- + + describe "list_seating_plans/1" do + test "returns plans for the given org ordered by name" do + org = org_fixture() + seating_plan_fixture(org.id, %{name: "Zebra Hall"}) + seating_plan_fixture(org.id, %{name: "Alpha Arena"}) + + plans = Seating.list_seating_plans(org.id) + names = Enum.map(plans, & &1.name) + assert Enum.at(names, 0) == "Alpha Arena" + assert Enum.at(names, 1) == "Zebra Hall" + end + + test "does not return plans from another org" do + org1 = org_fixture() + org2 = org_fixture() + seating_plan_fixture(org1.id) + + assert Seating.list_seating_plans(org2.id) == [] + end + end + + # --------------------------------------------------------------------------- + # get_seating_plan!/1 + # --------------------------------------------------------------------------- + + describe "get_seating_plan!/1" do + test "returns plan with sections and seats" do + org = org_fixture() + plan = seating_plan_fixture(org.id) + + fetched = Seating.get_seating_plan!(plan.id) + assert fetched.id == plan.id + assert length(fetched.sections) == 2 + assert fetched.sections |> hd() |> Map.get(:seats) |> is_list() + end + + test "raises Ecto.NoResultsError for missing id" do + assert_raise Ecto.NoResultsError, fn -> Seating.get_seating_plan!(0) end + end + end + + # --------------------------------------------------------------------------- + # assign_plan_to_event/2 + # --------------------------------------------------------------------------- + + describe "assign_plan_to_event/2" do + test "links a seating plan to an event" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + + assert {:ok, updated_event} = Seating.assign_plan_to_event(event.id, plan.id) + assert updated_event.seating_plan_id == plan.id + end + + test "returns :not_found for missing event" do + org = org_fixture() + plan = seating_plan_fixture(org.id) + + assert {:error, :not_found} = Seating.assign_plan_to_event(0, plan.id) + end + end + + # --------------------------------------------------------------------------- + # map_section_to_item/3 + # --------------------------------------------------------------------------- + + describe "map_section_to_item/3" do + test "sets item_id on a section" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + item = item_fixture(event) + + section = hd(plan.sections) + + assert {:ok, updated} = Seating.map_section_to_item(section.id, item.id, nil) + assert updated.item_id == item.id + assert is_nil(updated.item_variation_id) + end + + test "sets item_id and item_variation_id on a section" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + item = item_fixture(event) + variation = variation_fixture(item) + + section = hd(plan.sections) + + assert {:ok, updated} = Seating.map_section_to_item(section.id, item.id, variation.id) + assert updated.item_id == item.id + assert updated.item_variation_id == variation.id + end + + test "clears item mapping when nil is passed" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + item = item_fixture(event) + + section = hd(plan.sections) + {:ok, _} = Seating.map_section_to_item(section.id, item.id, nil) + + assert {:ok, cleared} = Seating.map_section_to_item(section.id, nil, nil) + assert is_nil(cleared.item_id) + end + + test "returns :not_found for unknown section" do + assert {:error, :not_found} = Seating.map_section_to_item(0, 1, nil) + end + end + + # --------------------------------------------------------------------------- + # available_seats/2 + # --------------------------------------------------------------------------- + + describe "available_seats/2" do + test "returns all seats in a section when none are reserved" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + + section = hd(plan.sections) + seats = Seating.available_seats(event.id, section.id) + + assert length(seats) == section.capacity + end + + test "excludes held seats" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + cart = cart_session_fixture(event) + + section = hd(plan.sections) + seat = section.seats |> hd() + + {:ok, _} = Seating.hold_seat(seat.id, event.id, cart.id) + + available = Seating.available_seats(event.id, section.id) + available_ids = Enum.map(available, & &1.id) + refute seat.id in available_ids + end + + test "excludes confirmed seats" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + order_item = order_item_fixture(event) + + section = hd(plan.sections) + seat = section.seats |> hd() + + {:ok, _} = Seating.assign_seat(seat.id, event.id, order_item.id) + + available = Seating.available_seats(event.id, section.id) + available_ids = Enum.map(available, & &1.id) + refute seat.id in available_ids + end + + test "includes seats whose reservations are released" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + cart = cart_session_fixture(event) + + section = hd(plan.sections) + seat = section.seats |> hd() + + {:ok, _} = Seating.hold_seat(seat.id, event.id, cart.id) + {:ok, _} = Seating.release_seat(seat.id, event.id) + + available = Seating.available_seats(event.id, section.id) + available_ids = Enum.map(available, & &1.id) + assert seat.id in available_ids + end + end + + # --------------------------------------------------------------------------- + # hold_seat/3 + # --------------------------------------------------------------------------- + + describe "hold_seat/3" do + test "creates a held reservation with a future expiry" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + cart = cart_session_fixture(event) + + seat = plan.sections |> hd() |> Map.get(:seats) |> hd() + + assert {:ok, reservation} = Seating.hold_seat(seat.id, event.id, cart.id) + assert reservation.status == "held" + assert reservation.seat_id == seat.id + assert reservation.event_id == event.id + assert reservation.cart_session_id == cart.id + assert DateTime.compare(reservation.held_until, DateTime.utc_now()) == :gt + end + + test "returns :already_reserved for a seat that is already held" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + cart1 = cart_session_fixture(event) + cart2 = cart_session_fixture(event) + + seat = plan.sections |> hd() |> Map.get(:seats) |> hd() + + {:ok, _} = Seating.hold_seat(seat.id, event.id, cart1.id) + + assert {:error, :already_reserved} = Seating.hold_seat(seat.id, event.id, cart2.id) + end + + test "allows holding a seat for a different event" do + org = org_fixture() + event1 = event_fixture(org) + event2 = event_fixture(org) + plan = seating_plan_fixture(org.id) + cart1 = cart_session_fixture(event1) + cart2 = cart_session_fixture(event2) + + seat = plan.sections |> hd() |> Map.get(:seats) |> hd() + + assert {:ok, _} = Seating.hold_seat(seat.id, event1.id, cart1.id) + assert {:ok, _} = Seating.hold_seat(seat.id, event2.id, cart2.id) + end + end + + # --------------------------------------------------------------------------- + # confirm_seat/3 + # --------------------------------------------------------------------------- + + describe "confirm_seat/3" do + test "upgrades a held reservation to confirmed" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + cart = cart_session_fixture(event) + order_item = order_item_fixture(event) + + seat = plan.sections |> hd() |> Map.get(:seats) |> hd() + + {:ok, _} = Seating.hold_seat(seat.id, event.id, cart.id) + + assert {:ok, confirmed} = Seating.confirm_seat(seat.id, event.id, order_item.id) + assert confirmed.status == "confirmed" + assert confirmed.order_item_id == order_item.id + assert is_nil(confirmed.held_until) + end + + test "returns :not_found when no active reservation exists" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + + seat = plan.sections |> hd() |> Map.get(:seats) |> hd() + + assert {:error, :not_found} = Seating.confirm_seat(seat.id, event.id, 999) + end + end + + # --------------------------------------------------------------------------- + # release_seat/2 + # --------------------------------------------------------------------------- + + describe "release_seat/2" do + test "releases a held reservation" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + cart = cart_session_fixture(event) + + seat = plan.sections |> hd() |> Map.get(:seats) |> hd() + {:ok, _} = Seating.hold_seat(seat.id, event.id, cart.id) + + assert {:ok, released} = Seating.release_seat(seat.id, event.id) + assert released.status == "released" + end + + test "releases a confirmed reservation" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + order_item = order_item_fixture(event) + + seat = plan.sections |> hd() |> Map.get(:seats) |> hd() + {:ok, _} = Seating.assign_seat(seat.id, event.id, order_item.id) + + assert {:ok, released} = Seating.release_seat(seat.id, event.id) + assert released.status == "released" + end + + test "returns :not_found when no active reservation exists" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + + seat = plan.sections |> hd() |> Map.get(:seats) |> hd() + + assert {:error, :not_found} = Seating.release_seat(seat.id, event.id) + end + + test "allows re-holding a seat after it is released" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + cart1 = cart_session_fixture(event) + cart2 = cart_session_fixture(event) + + seat = plan.sections |> hd() |> Map.get(:seats) |> hd() + {:ok, _} = Seating.hold_seat(seat.id, event.id, cart1.id) + {:ok, _} = Seating.release_seat(seat.id, event.id) + + assert {:ok, new_hold} = Seating.hold_seat(seat.id, event.id, cart2.id) + assert new_hold.status == "held" + end + end + + # --------------------------------------------------------------------------- + # assign_seat/3 + # --------------------------------------------------------------------------- + + describe "assign_seat/3" do + test "creates a confirmed reservation directly" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + order_item = order_item_fixture(event) + + seat = plan.sections |> hd() |> Map.get(:seats) |> hd() + + assert {:ok, reservation} = Seating.assign_seat(seat.id, event.id, order_item.id) + assert reservation.status == "confirmed" + assert reservation.order_item_id == order_item.id + end + + test "returns :already_reserved when seat is held by another cart" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + cart = cart_session_fixture(event) + order_item = order_item_fixture(event) + + seat = plan.sections |> hd() |> Map.get(:seats) |> hd() + {:ok, _} = Seating.hold_seat(seat.id, event.id, cart.id) + + assert {:error, :already_reserved} = Seating.assign_seat(seat.id, event.id, order_item.id) + end + end + + # --------------------------------------------------------------------------- + # reassign_seat/4 + # --------------------------------------------------------------------------- + + describe "reassign_seat/4" do + test "moves a confirmed reservation to a new seat" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + order_item = order_item_fixture(event) + + [seat1, seat2 | _] = plan.sections |> hd() |> Map.get(:seats) + {:ok, _} = Seating.assign_seat(seat1.id, event.id, order_item.id) + + assert {:ok, new_reservation} = + Seating.reassign_seat(seat1.id, seat2.id, event.id, order_item.id) + + assert new_reservation.seat_id == seat2.id + assert new_reservation.status == "confirmed" + + # Old seat should be released (available again) + available = Seating.available_seats(event.id, hd(plan.sections).id) + available_ids = Enum.map(available, & &1.id) + assert seat1.id in available_ids + end + + test "returns :not_found when old seat has no active reservation" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + + [seat1, seat2 | _] = plan.sections |> hd() |> Map.get(:seats) + + assert {:error, :not_found} = + Seating.reassign_seat(seat1.id, seat2.id, event.id, 999) + end + + test "returns :already_reserved when target seat is taken" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + order_item1 = order_item_fixture(event) + order_item2 = order_item_fixture(event) + + [seat1, seat2 | _] = plan.sections |> hd() |> Map.get(:seats) + {:ok, _} = Seating.assign_seat(seat1.id, event.id, order_item1.id) + {:ok, _} = Seating.assign_seat(seat2.id, event.id, order_item2.id) + + assert {:error, :already_reserved} = + Seating.reassign_seat(seat1.id, seat2.id, event.id, order_item1.id) + end + end + + # --------------------------------------------------------------------------- + # release_expired_holds/0 + # --------------------------------------------------------------------------- + + describe "release_expired_holds/0" do + test "releases holds that have passed their held_until timestamp" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + cart = cart_session_fixture(event) + + seat = plan.sections |> hd() |> Map.get(:seats) |> hd() + {:ok, reservation} = Seating.hold_seat(seat.id, event.id, cart.id) + + # Manually backdate the held_until to simulate expiry + expired_at = DateTime.add(DateTime.utc_now(), -60, :second) |> DateTime.truncate(:second) + + reservation + |> Ecto.Changeset.change(held_until: expired_at) + |> Pretex.Repo.update!() + + {count, _} = Seating.release_expired_holds() + assert count >= 1 + + updated = Pretex.Repo.get!(SeatReservation, reservation.id) + assert updated.status == "released" + end + + test "does not release holds that are still within the time window" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + cart = cart_session_fixture(event) + + seat = plan.sections |> hd() |> Map.get(:seats) |> hd() + {:ok, reservation} = Seating.hold_seat(seat.id, event.id, cart.id) + + Seating.release_expired_holds() + + updated = Pretex.Repo.get!(SeatReservation, reservation.id) + assert updated.status == "held" + end + + test "does not release confirmed reservations" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + order_item = order_item_fixture(event) + + seat = plan.sections |> hd() |> Map.get(:seats) |> hd() + {:ok, reservation} = Seating.assign_seat(seat.id, event.id, order_item.id) + + Seating.release_expired_holds() + + updated = Pretex.Repo.get!(SeatReservation, reservation.id) + assert updated.status == "confirmed" + end + end + + # --------------------------------------------------------------------------- + # Concurrency: simultaneous seat holds + # --------------------------------------------------------------------------- + + describe "concurrent seat holds" do + test "only one hold succeeds when two processes race for the same seat" do + org = org_fixture() + event = event_fixture(org) + plan = seating_plan_fixture(org.id) + + [seat | _] = plan.sections |> hd() |> Map.get(:seats) + + cart1 = cart_session_fixture(event) + cart2 = cart_session_fixture(event) + + # Race both holds from separate tasks + results = + [ + Task.async(fn -> Seating.hold_seat(seat.id, event.id, cart1.id) end), + Task.async(fn -> Seating.hold_seat(seat.id, event.id, cart2.id) end) + ] + |> Task.await_many(5000) + + successes = Enum.count(results, &match?({:ok, _}, &1)) + failures = Enum.count(results, &match?({:error, :already_reserved}, &1)) + + assert successes == 1 + assert failures == 1 + end + end +end diff --git a/pretex/test/pretex_web/live/admin/seating_live_test.exs b/pretex/test/pretex_web/live/admin/seating_live_test.exs new file mode 100644 index 0000000..694b05b --- /dev/null +++ b/pretex/test/pretex_web/live/admin/seating_live_test.exs @@ -0,0 +1,304 @@ +defmodule PretexWeb.Admin.SeatingLiveTest do + use PretexWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Pretex.Events + alias Pretex.Organizations + alias Pretex.Seating + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + defp org_fixture(attrs \\ %{}) do + {:ok, org} = + attrs + |> Enum.into(%{name: "Test Org", slug: "test-org-#{System.unique_integer([:positive])}"}) + |> Organizations.create_organization() + + org + end + + defp event_fixture(org, attrs) do + base = %{ + name: "Test Event #{System.unique_integer([:positive])}", + starts_at: ~U[2030-06-01 10:00:00Z], + ends_at: ~U[2030-06-01 18:00:00Z], + venue: "Main Stage" + } + + {:ok, event} = Events.create_event(org, Enum.into(attrs, base)) + event + end + + defp seating_plan_fixture(org_id, attrs \\ %{}) do + layout = %{ + "sections" => [ + %{ + "name" => "Pista", + "rows" => [%{"label" => "A", "seats" => 3}] + } + ] + } + + attrs = + Enum.into(attrs, %{ + name: "Test Plan #{System.unique_integer([:positive])}", + layout: layout + }) + + {:ok, plan} = Seating.create_seating_plan(org_id, attrs) + plan + end + + # --------------------------------------------------------------------------- + # Index — list plans + # --------------------------------------------------------------------------- + + describe "Index — list plans" do + setup :register_and_log_in_user + + test "renders the seating index page", %{conn: conn} do + org = org_fixture() + + {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating") + + assert html =~ "Plantas de Assentos" + assert html =~ org.name + end + + test "shows empty state when no plans exist", %{conn: conn} do + org = org_fixture() + + {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating") + + assert html =~ "Nenhuma planta ainda." + end + + test "lists existing seating plans", %{conn: conn} do + org = org_fixture() + plan = seating_plan_fixture(org.id, %{name: "Teatro Principal"}) + + {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating") + + assert html =~ plan.name + end + + test "shows link to create new plan", %{conn: conn} do + org = org_fixture() + + {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating") + + assert html =~ "Nova Planta" + end + end + + # --------------------------------------------------------------------------- + # Index — new plan upload + # --------------------------------------------------------------------------- + + describe "Index — new plan form" do + setup :register_and_log_in_user + + test "renders the upload form when navigating to :new", %{conn: conn} do + org = org_fixture() + + {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating/new") + + assert html =~ "Carregar Nova Planta" + assert html =~ "Nome da planta" + end + + test "shows validation error for missing plan name", %{conn: conn} do + org = org_fixture() + + {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/new") + + html = + view + |> form("form", %{"seating_plan" => %{"name" => ""}}) + |> render_change() + + assert html =~ "can't be blank" + end + + test "shows error when no file is uploaded", %{conn: conn} do + org = org_fixture() + + {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/new") + + html = + view + |> form("form", %{"seating_plan" => %{"name" => "My Plan"}}) + |> render_submit() + + assert html =~ "Selecione um arquivo JSON" + end + + test "cancel navigates back to index", %{conn: conn} do + org = org_fixture() + + {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/new") + + view |> element("button", "Cancelar") |> render_click() + + assert_patch(view, ~p"/admin/organizations/#{org}/seating") + end + + test "deletes a plan from the list", %{conn: conn} do + org = org_fixture() + plan = seating_plan_fixture(org.id, %{name: "Planta Removível"}) + + {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating") + + assert render(view) =~ plan.name + + view + |> element("button[phx-click='delete'][phx-value-id='#{plan.id}']") + |> render_click() + + refute render(view) =~ plan.name + end + end + + # --------------------------------------------------------------------------- + # Show — plan details + # --------------------------------------------------------------------------- + + describe "Show — plan details" do + setup :register_and_log_in_user + + test "renders plan details with sections and seats", %{conn: conn} do + org = org_fixture() + plan = seating_plan_fixture(org.id, %{name: "Teatro Central"}) + + {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}") + + assert html =~ "Teatro Central" + assert html =~ "Pista" + # Seats like A-1 + assert html =~ "A-1" + assert html =~ "A-3" + end + + test "shows section capacity and row count", %{conn: conn} do + org = org_fixture() + plan = seating_plan_fixture(org.id) + + {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}") + + assert html =~ "3 assentos" + end + + test "shows back navigation link", %{conn: conn} do + org = org_fixture() + plan = seating_plan_fixture(org.id) + + {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}") + + assert html =~ "Voltar às Plantas" + end + + test "shows assign to event button", %{conn: conn} do + org = org_fixture() + plan = seating_plan_fixture(org.id) + + {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}") + + assert html =~ "Atribuir a Evento" + end + + test "shows map section button for each section", %{conn: conn} do + org = org_fixture() + plan = seating_plan_fixture(org.id) + + {:ok, _view, html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}") + + assert html =~ "Mapear" + end + + test "clicking map section opens inline mapping form", %{conn: conn} do + org = org_fixture() + plan = seating_plan_fixture(org.id) + section = hd(plan.sections) + + {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}") + + html = + view + |> element("button[phx-click='map_section'][phx-value-section_id='#{section.id}']") + |> render_click() + + assert html =~ "Mapear seção a um tipo de ingresso" + end + + test "cancel mapping hides the inline form", %{conn: conn} do + org = org_fixture() + plan = seating_plan_fixture(org.id) + section = hd(plan.sections) + + {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}") + + view + |> element("button[phx-click='map_section'][phx-value-section_id='#{section.id}']") + |> render_click() + + html = view |> element("button", "Cancelar") |> render_click() + + refute html =~ "Mapear seção a um tipo de ingresso" + end + + test "clicking assign to event shows event selection form", %{conn: conn} do + org = org_fixture() + plan = seating_plan_fixture(org.id) + _event = event_fixture(org, %{name: "Meu Evento"}) + + {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}") + + html = + view + |> element("button[phx-click='show_assign_event']") + |> render_click() + + assert html =~ "Atribuir Planta a Evento" + assert html =~ "Meu Evento" + end + + test "assigning to an event with no selection shows flash error", %{conn: conn} do + org = org_fixture() + plan = seating_plan_fixture(org.id) + + {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}") + + view |> element("button[phx-click='show_assign_event']") |> render_click() + + html = + view + |> form("form", %{"assignment" => %{"event_id" => ""}}) + |> render_submit() + + assert html =~ "Selecione um evento" + end + + test "successfully assigns plan to an event", %{conn: conn} do + org = org_fixture() + plan = seating_plan_fixture(org.id) + event = event_fixture(org, %{name: "Festival de Verão"}) + + {:ok, view, _html} = live(conn, ~p"/admin/organizations/#{org}/seating/#{plan}") + + view |> element("button[phx-click='show_assign_event']") |> render_click() + + view + |> form("form", %{"assignment" => %{"event_id" => to_string(event.id)}}) + |> render_submit() + + html = render(view) + assert html =~ "Planta atribuída ao evento" + + updated_event = Events.get_event!(event.id) + assert updated_event.seating_plan_id == plan.id + end + end +end diff --git a/pretex/test/support/fixtures/seating_fixtures.ex b/pretex/test/support/fixtures/seating_fixtures.ex new file mode 100644 index 0000000..fc8c4fe --- /dev/null +++ b/pretex/test/support/fixtures/seating_fixtures.ex @@ -0,0 +1,36 @@ +defmodule Pretex.SeatingFixtures do + @moduledoc "Fixtures for the Seating context tests." + + alias Pretex.Seating + + @valid_layout %{ + "sections" => [ + %{ + "name" => "Orchestra", + "rows" => [ + %{"label" => "A", "seats" => 5}, + %{"label" => "B", "seats" => 5} + ] + }, + %{ + "name" => "Balcony", + "rows" => [ + %{"label" => "A", "seats" => 3} + ] + } + ] + } + + def valid_layout, do: @valid_layout + + def seating_plan_fixture(org_id, attrs \\ %{}) do + attrs = + Enum.into(attrs, %{ + name: "Test Plan #{System.unique_integer([:positive])}", + layout: @valid_layout + }) + + {:ok, plan} = Seating.create_seating_plan(org_id, attrs) + plan + end +end