From 83eda0a82791fd1289be5cb8e17bae0e37659533 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 23 Mar 2026 18:49:24 -0300 Subject: [PATCH 1/5] feat: add check_in_lists, gates tables and schemas --- pretex/lib/pretex/check_ins/check_in.ex | 1 + pretex/lib/pretex/check_ins/check_in_list.ex | 35 +++++++++++ .../pretex/check_ins/check_in_list_item.ex | 9 +++ pretex/lib/pretex/check_ins/gate.ex | 23 ++++++++ .../pretex/check_ins/gate_check_in_list.ex | 9 +++ ...214748_create_check_in_lists_and_gates.exs | 58 +++++++++++++++++++ 6 files changed, 135 insertions(+) create mode 100644 pretex/lib/pretex/check_ins/check_in_list.ex create mode 100644 pretex/lib/pretex/check_ins/check_in_list_item.ex create mode 100644 pretex/lib/pretex/check_ins/gate.ex create mode 100644 pretex/lib/pretex/check_ins/gate_check_in_list.ex create mode 100644 pretex/priv/repo/migrations/20260323214748_create_check_in_lists_and_gates.exs diff --git a/pretex/lib/pretex/check_ins/check_in.ex b/pretex/lib/pretex/check_ins/check_in.ex index e3a3543..83bcb88 100644 --- a/pretex/lib/pretex/check_ins/check_in.ex +++ b/pretex/lib/pretex/check_ins/check_in.ex @@ -10,6 +10,7 @@ defmodule Pretex.CheckIns.CheckIn do belongs_to(:event, Pretex.Events.Event) belongs_to(:checked_in_by, Pretex.Accounts.User, foreign_key: :checked_in_by_id) belongs_to(:annulled_by, Pretex.Accounts.User, foreign_key: :annulled_by_id) + belongs_to(:check_in_list, Pretex.CheckIns.CheckInList) timestamps(type: :utc_datetime) end diff --git a/pretex/lib/pretex/check_ins/check_in_list.ex b/pretex/lib/pretex/check_ins/check_in_list.ex new file mode 100644 index 0000000..dc8f36b --- /dev/null +++ b/pretex/lib/pretex/check_ins/check_in_list.ex @@ -0,0 +1,35 @@ +defmodule Pretex.CheckIns.CheckInList do + use Ecto.Schema + import Ecto.Changeset + + schema "check_in_lists" do + field(:name, :string) + field(:starts_at_time, :time) + field(:ends_at_time, :time) + + belongs_to(:event, Pretex.Events.Event) + has_many(:check_in_list_items, Pretex.CheckIns.CheckInListItem) + many_to_many(:gates, Pretex.CheckIns.Gate, join_through: "gate_check_in_lists") + + timestamps(type: :utc_datetime) + end + + def changeset(list, attrs) do + list + |> cast(attrs, [:name, :starts_at_time, :ends_at_time]) + |> validate_required([:name]) + |> validate_length(:name, min: 1, max: 255) + |> validate_time_window() + end + + defp validate_time_window(changeset) do + starts = get_field(changeset, :starts_at_time) + ends = get_field(changeset, :ends_at_time) + + if starts && ends && Time.compare(ends, starts) != :gt do + add_error(changeset, :ends_at_time, "must be after start time") + else + changeset + end + end +end diff --git a/pretex/lib/pretex/check_ins/check_in_list_item.ex b/pretex/lib/pretex/check_ins/check_in_list_item.ex new file mode 100644 index 0000000..20013ae --- /dev/null +++ b/pretex/lib/pretex/check_ins/check_in_list_item.ex @@ -0,0 +1,9 @@ +defmodule Pretex.CheckIns.CheckInListItem do + use Ecto.Schema + + schema "check_in_list_items" do + belongs_to(:check_in_list, Pretex.CheckIns.CheckInList) + belongs_to(:item, Pretex.Catalog.Item) + belongs_to(:item_variation, Pretex.Catalog.ItemVariation) + end +end diff --git a/pretex/lib/pretex/check_ins/gate.ex b/pretex/lib/pretex/check_ins/gate.ex new file mode 100644 index 0000000..ecdca83 --- /dev/null +++ b/pretex/lib/pretex/check_ins/gate.ex @@ -0,0 +1,23 @@ +defmodule Pretex.CheckIns.Gate do + use Ecto.Schema + import Ecto.Changeset + + schema "gates" do + field(:name, :string) + + belongs_to(:event, Pretex.Events.Event) + + many_to_many(:check_in_lists, Pretex.CheckIns.CheckInList, + join_through: "gate_check_in_lists" + ) + + timestamps(type: :utc_datetime) + end + + def changeset(gate, attrs) do + gate + |> cast(attrs, [:name]) + |> validate_required([:name]) + |> validate_length(:name, min: 1, max: 255) + end +end diff --git a/pretex/lib/pretex/check_ins/gate_check_in_list.ex b/pretex/lib/pretex/check_ins/gate_check_in_list.ex new file mode 100644 index 0000000..29ac973 --- /dev/null +++ b/pretex/lib/pretex/check_ins/gate_check_in_list.ex @@ -0,0 +1,9 @@ +defmodule Pretex.CheckIns.GateCheckInList do + use Ecto.Schema + + @primary_key false + schema "gate_check_in_lists" do + belongs_to(:gate, Pretex.CheckIns.Gate) + belongs_to(:check_in_list, Pretex.CheckIns.CheckInList) + end +end diff --git a/pretex/priv/repo/migrations/20260323214748_create_check_in_lists_and_gates.exs b/pretex/priv/repo/migrations/20260323214748_create_check_in_lists_and_gates.exs new file mode 100644 index 0000000..837cb3c --- /dev/null +++ b/pretex/priv/repo/migrations/20260323214748_create_check_in_lists_and_gates.exs @@ -0,0 +1,58 @@ +defmodule Pretex.Repo.Migrations.CreateCheckInListsAndGates do + use Ecto.Migration + + def change do + create table(:check_in_lists) do + add :name, :string, null: false + add :starts_at_time, :time + add :ends_at_time, :time + add :event_id, references(:events, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:check_in_lists, [:event_id]) + + create table(:check_in_list_items) do + add :check_in_list_id, references(:check_in_lists, on_delete: :delete_all), null: false + add :item_id, references(:items, on_delete: :delete_all), null: false + add :item_variation_id, references(:item_variations, on_delete: :delete_all) + end + + create unique_index(:check_in_list_items, [:check_in_list_id, :item_id, :item_variation_id], + name: :check_in_list_items_unique + ) + + create table(:gates) do + add :name, :string, null: false + add :event_id, references(:events, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:gates, [:event_id]) + + create table(:gate_check_in_lists, primary_key: false) do + add :gate_id, references(:gates, on_delete: :delete_all), null: false + add :check_in_list_id, references(:check_in_lists, on_delete: :delete_all), null: false + end + + create unique_index(:gate_check_in_lists, [:gate_id, :check_in_list_id]) + + alter table(:check_ins) do + add :check_in_list_id, references(:check_in_lists, on_delete: :restrict) + end + + drop unique_index(:check_ins, [:order_item_id, :event_id], + where: "annulled_at IS NULL", + name: :check_ins_active_unique + ) + + create unique_index(:check_ins, [:order_item_id, :event_id, "COALESCE(check_in_list_id, 0)"], + where: "annulled_at IS NULL", + name: :check_ins_active_unique + ) + + create index(:check_ins, [:check_in_list_id]) + end +end From d9ed5968eca869460681b0a9021ff4e69fde69f1 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 23 Mar 2026 18:54:38 -0300 Subject: [PATCH 2/5] feat: implement check-in lists and gates CRUD with tests --- pretex/lib/pretex/check_ins.ex | 159 +++++++++++++++++++++ pretex/test/pretex/check_in_lists_test.exs | 150 +++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 pretex/test/pretex/check_in_lists_test.exs diff --git a/pretex/lib/pretex/check_ins.ex b/pretex/lib/pretex/check_ins.ex index 5b55093..9591539 100644 --- a/pretex/lib/pretex/check_ins.ex +++ b/pretex/lib/pretex/check_ins.ex @@ -5,6 +5,7 @@ defmodule Pretex.CheckIns do alias Pretex.Repo alias Pretex.CheckIns.CheckIn + alias Pretex.CheckIns.{CheckInList, CheckInListItem, Gate, GateCheckInList} alias Pretex.Orders.{Order, OrderItem} def checkin_topic(event_id), do: "checkins:event:#{event_id}" @@ -145,6 +146,164 @@ defmodule Pretex.CheckIns do |> Repo.one() end + # --------------------------------------------------------------------------- + # Check-in Lists CRUD + # --------------------------------------------------------------------------- + + def create_check_in_list(event_id, attrs) do + item_ids = Map.get(attrs, :item_ids) || Map.get(attrs, "item_ids") || [] + + if item_ids == [] do + {:error, :no_items} + else + Repo.transaction(fn -> + changeset = + %CheckInList{} + |> CheckInList.changeset(attrs) + |> Ecto.Changeset.put_change(:event_id, event_id) + + list = + case Repo.insert(changeset) do + {:ok, l} -> l + {:error, cs} -> Repo.rollback(cs) + end + + Enum.each(item_ids, fn item_id -> + %CheckInListItem{} + |> Ecto.Changeset.change(%{check_in_list_id: list.id, item_id: item_id}) + |> Repo.insert!() + end) + + list + end) + end + end + + def update_check_in_list(list_id, attrs) do + list = Repo.get!(CheckInList, list_id) + item_ids = Map.get(attrs, :item_ids) || Map.get(attrs, "item_ids") + + Repo.transaction(fn -> + updated = + case list |> CheckInList.changeset(attrs) |> Repo.update() do + {:ok, l} -> l + {:error, cs} -> Repo.rollback(cs) + end + + if item_ids do + CheckInListItem + |> where([cli], cli.check_in_list_id == ^list.id) + |> Repo.delete_all() + + Enum.each(item_ids, fn item_id -> + %CheckInListItem{} + |> Ecto.Changeset.change(%{check_in_list_id: list.id, item_id: item_id}) + |> Repo.insert!() + end) + end + + updated + end) + end + + def delete_check_in_list(list_id) do + list = Repo.get!(CheckInList, list_id) + Repo.delete(list) + end + + def list_check_in_lists(event_id) do + CheckInList + |> where([l], l.event_id == ^event_id) + |> preload(:check_in_list_items) + |> order_by([l], asc: l.name) + |> Repo.all() + end + + def get_check_in_list!(id) do + CheckInList + |> preload(:check_in_list_items) + |> Repo.get!(id) + end + + # --------------------------------------------------------------------------- + # Gates CRUD + # --------------------------------------------------------------------------- + + def create_gate(event_id, attrs) do + list_ids = Map.get(attrs, :check_in_list_ids) || Map.get(attrs, "check_in_list_ids") || [] + + if list_ids == [] do + {:error, :no_check_in_lists} + else + Repo.transaction(fn -> + changeset = + %Gate{} + |> Gate.changeset(attrs) + |> Ecto.Changeset.put_change(:event_id, event_id) + + gate = + case Repo.insert(changeset) do + {:ok, g} -> g + {:error, cs} -> Repo.rollback(cs) + end + + Enum.each(list_ids, fn list_id -> + %GateCheckInList{} + |> Ecto.Changeset.change(%{gate_id: gate.id, check_in_list_id: list_id}) + |> Repo.insert!() + end) + + gate + end) + end + end + + def update_gate(gate_id, attrs) do + gate = Repo.get!(Gate, gate_id) + list_ids = Map.get(attrs, :check_in_list_ids) || Map.get(attrs, "check_in_list_ids") + + Repo.transaction(fn -> + updated = + case gate |> Gate.changeset(attrs) |> Repo.update() do + {:ok, g} -> g + {:error, cs} -> Repo.rollback(cs) + end + + if list_ids do + GateCheckInList + |> where([gcl], gcl.gate_id == ^gate.id) + |> Repo.delete_all() + + Enum.each(list_ids, fn list_id -> + %GateCheckInList{} + |> Ecto.Changeset.change(%{gate_id: gate.id, check_in_list_id: list_id}) + |> Repo.insert!() + end) + end + + updated + end) + end + + def delete_gate(gate_id) do + gate = Repo.get!(Gate, gate_id) + Repo.delete(gate) + end + + def list_gates(event_id) do + Gate + |> where([g], g.event_id == ^event_id) + |> preload(:check_in_lists) + |> order_by([g], asc: g.name) + |> Repo.all() + end + + def get_gate!(id) do + Gate + |> preload(check_in_lists: :check_in_list_items) + |> Repo.get!(id) + end + defp broadcast_check_in_update(event_id) do count = get_check_in_count(event_id) Phoenix.PubSub.broadcast(Pretex.PubSub, checkin_topic(event_id), {:check_in_updated, count}) diff --git a/pretex/test/pretex/check_in_lists_test.exs b/pretex/test/pretex/check_in_lists_test.exs new file mode 100644 index 0000000..fd06ce6 --- /dev/null +++ b/pretex/test/pretex/check_in_lists_test.exs @@ -0,0 +1,150 @@ +defmodule Pretex.CheckInListsTest do + use Pretex.DataCase, async: true + + import Pretex.OrganizationsFixtures + import Pretex.EventsFixtures + import Pretex.CatalogFixtures + + alias Pretex.CheckIns + alias Pretex.CheckIns.{CheckInList, Gate} + + describe "check-in lists CRUD" do + test "create_check_in_list/2 creates a list with items" do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event) + + assert {:ok, %CheckInList{} = list} = + CheckIns.create_check_in_list(event.id, %{ + name: "VIP Entrance", + item_ids: [item.id] + }) + + assert list.name == "VIP Entrance" + list = Pretex.Repo.preload(list, :check_in_list_items) + assert length(list.check_in_list_items) == 1 + end + + test "create_check_in_list/2 fails without items" do + org = org_fixture() + event = published_event_fixture(org) + + assert {:error, :no_items} = + CheckIns.create_check_in_list(event.id, %{name: "Empty", item_ids: []}) + end + + test "create_check_in_list/2 with time restrictions" do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event) + + assert {:ok, list} = + CheckIns.create_check_in_list(event.id, %{ + name: "Morning Only", + item_ids: [item.id], + starts_at_time: ~T[08:00:00], + ends_at_time: ~T[10:00:00] + }) + + assert list.starts_at_time == ~T[08:00:00] + assert list.ends_at_time == ~T[10:00:00] + end + + test "list_check_in_lists/1 returns all lists for event" do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event) + + {:ok, _} = CheckIns.create_check_in_list(event.id, %{name: "List A", item_ids: [item.id]}) + {:ok, _} = CheckIns.create_check_in_list(event.id, %{name: "List B", item_ids: [item.id]}) + + lists = CheckIns.list_check_in_lists(event.id) + assert length(lists) == 2 + end + + test "update_check_in_list/2 updates name and items" do + org = org_fixture() + event = published_event_fixture(org) + item1 = item_fixture(event) + item2 = item_fixture(event) + + {:ok, list} = CheckIns.create_check_in_list(event.id, %{name: "Old", item_ids: [item1.id]}) + + assert {:ok, updated} = + CheckIns.update_check_in_list(list.id, %{name: "New", item_ids: [item2.id]}) + + assert updated.name == "New" + updated = Pretex.Repo.preload(updated, :check_in_list_items) + assert length(updated.check_in_list_items) == 1 + assert hd(updated.check_in_list_items).item_id == item2.id + end + + test "delete_check_in_list/1 removes list" do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event) + + {:ok, list} = + CheckIns.create_check_in_list(event.id, %{name: "Delete Me", item_ids: [item.id]}) + + assert {:ok, _} = CheckIns.delete_check_in_list(list.id) + assert CheckIns.list_check_in_lists(event.id) == [] + end + end + + describe "gates CRUD" do + test "create_gate/2 creates a gate with check-in lists" do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event) + + {:ok, list} = + CheckIns.create_check_in_list(event.id, %{name: "General", item_ids: [item.id]}) + + assert {:ok, %Gate{} = gate} = + CheckIns.create_gate(event.id, %{name: "North Door", check_in_list_ids: [list.id]}) + + assert gate.name == "North Door" + gate = Pretex.Repo.preload(gate, :check_in_lists) + assert length(gate.check_in_lists) == 1 + end + + test "create_gate/2 fails without check-in lists" do + org = org_fixture() + event = published_event_fixture(org) + + assert {:error, :no_check_in_lists} = + CheckIns.create_gate(event.id, %{name: "Empty Gate", check_in_list_ids: []}) + end + + test "list_gates/1 returns all gates for event" do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event) + + {:ok, list} = + CheckIns.create_check_in_list(event.id, %{name: "General", item_ids: [item.id]}) + + {:ok, _} = CheckIns.create_gate(event.id, %{name: "Gate A", check_in_list_ids: [list.id]}) + {:ok, _} = CheckIns.create_gate(event.id, %{name: "Gate B", check_in_list_ids: [list.id]}) + + gates = CheckIns.list_gates(event.id) + assert length(gates) == 2 + end + + test "delete_gate/1 removes gate" do + org = org_fixture() + event = published_event_fixture(org) + item = item_fixture(event) + + {:ok, list} = + CheckIns.create_check_in_list(event.id, %{name: "General", item_ids: [item.id]}) + + {:ok, gate} = + CheckIns.create_gate(event.id, %{name: "Delete Me", check_in_list_ids: [list.id]}) + + assert {:ok, _} = CheckIns.delete_gate(gate.id) + assert CheckIns.list_gates(event.id) == [] + end + end +end From cf1bee3d8211a70bb75eedfac3a6903d9baed661 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 23 Mar 2026 18:57:23 -0300 Subject: [PATCH 3/5] feat: implement list-scoped check-in and gate check-in flow --- pretex/lib/pretex/check_ins.ex | 126 ++++++++++++++++++++--- pretex/test/pretex/check_ins_test.exs | 139 ++++++++++++++++++++++++++ 2 files changed, 250 insertions(+), 15 deletions(-) diff --git a/pretex/lib/pretex/check_ins.ex b/pretex/lib/pretex/check_ins.ex index 9591539..9f49b2d 100644 --- a/pretex/lib/pretex/check_ins.ex +++ b/pretex/lib/pretex/check_ins.ex @@ -10,7 +10,7 @@ defmodule Pretex.CheckIns do def checkin_topic(event_id), do: "checkins:event:#{event_id}" - def check_in_by_ticket_code(event_id, ticket_code, operator_id) do + def check_in_by_ticket_code(event_id, ticket_code, operator_id, check_in_list_id \\ nil) do order_item = OrderItem |> join(:inner, [oi], o in Order, on: oi.order_id == o.id) @@ -23,37 +23,128 @@ defmodule Pretex.CheckIns do {:error, :invalid_ticket} %{order: %{event_id: ^event_id}} = oi -> - validate_and_check_in(oi, event_id, operator_id) + validate_and_check_in(oi, event_id, operator_id, check_in_list_id) _wrong_event -> {:error, :wrong_event} end end - defp validate_and_check_in(%{order: %{status: status}}, _event_id, _operator_id) + def check_in_at_gate(event_id, ticket_code, operator_id, gate_id) do + gate = get_gate!(gate_id) + + order_item = + OrderItem + |> join(:inner, [oi], o in Order, on: oi.order_id == o.id) + |> where([oi, _o], oi.ticket_code == ^ticket_code) + |> preload([oi, o], order: o) + |> Repo.one() + + case order_item do + nil -> + {:error, :invalid_ticket} + + %{order: %{event_id: ^event_id}} = oi -> + matching_list = + Enum.find(gate.check_in_lists, fn list -> + item_on_list?(oi.item_id, list.id) and list_active?(list) + end) + + case matching_list do + nil -> {:error, :not_on_list} + list -> validate_and_check_in(oi, event_id, operator_id, list.id) + end + + _wrong_event -> + {:error, :wrong_event} + end + end + + defp validate_and_check_in(%{order: %{status: status}}, _event_id, _operator_id, _list_id) when status != "confirmed" do {:error, :ticket_cancelled} end - defp validate_and_check_in(order_item, event_id, operator_id) do - existing = - CheckIn + defp validate_and_check_in(order_item, event_id, operator_id, check_in_list_id) do + with :ok <- validate_on_list(order_item, check_in_list_id), + :ok <- validate_list_active(check_in_list_id) do + existing = + CheckIn + |> where( + [c], + c.order_item_id == ^order_item.id and c.event_id == ^event_id and is_nil(c.annulled_at) + ) + |> then(fn q -> + if check_in_list_id do + where(q, [c], c.check_in_list_id == ^check_in_list_id) + else + where(q, [c], is_nil(c.check_in_list_id)) + end + end) + |> Repo.one() + + case existing do + nil -> + insert_check_in(order_item.id, event_id, operator_id, check_in_list_id) + + _already -> + {:error, :already_checked_in} + end + end + end + + defp validate_on_list(_order_item, nil), do: :ok + + defp validate_on_list(order_item, check_in_list_id) do + exists = + CheckInListItem |> where( - [c], - c.order_item_id == ^order_item.id and c.event_id == ^event_id and is_nil(c.annulled_at) + [cli], + cli.check_in_list_id == ^check_in_list_id and cli.item_id == ^order_item.item_id ) - |> Repo.one() + |> Repo.exists?() - case existing do - nil -> - insert_check_in(order_item.id, event_id, operator_id) + if exists, do: :ok, else: {:error, :not_on_list} + end + + defp validate_list_active(nil), do: :ok - _already -> - {:error, :already_checked_in} + defp validate_list_active(check_in_list_id) do + list = Repo.get!(CheckInList, check_in_list_id) + + cond do + is_nil(list.starts_at_time) and is_nil(list.ends_at_time) -> + :ok + + true -> + now = Time.utc_now() + starts = list.starts_at_time || ~T[00:00:00] + ends = list.ends_at_time || ~T[23:59:59] + + if Time.compare(now, starts) != :lt and Time.compare(now, ends) != :gt do + :ok + else + {:error, :list_not_active} + end end end - defp insert_check_in(order_item_id, event_id, operator_id) do + defp item_on_list?(item_id, check_in_list_id) do + CheckInListItem + |> where([cli], cli.check_in_list_id == ^check_in_list_id and cli.item_id == ^item_id) + |> Repo.exists?() + end + + defp list_active?(%{starts_at_time: nil, ends_at_time: nil}), do: true + + defp list_active?(list) do + now = Time.utc_now() + starts = list.starts_at_time || ~T[00:00:00] + ends = list.ends_at_time || ~T[23:59:59] + Time.compare(now, starts) != :lt and Time.compare(now, ends) != :gt + end + + defp insert_check_in(order_item_id, event_id, operator_id, check_in_list_id) do now = DateTime.utc_now() |> DateTime.truncate(:microsecond) result = @@ -62,6 +153,11 @@ defmodule Pretex.CheckIns do |> Ecto.Changeset.put_change(:order_item_id, order_item_id) |> Ecto.Changeset.put_change(:event_id, event_id) |> Ecto.Changeset.put_change(:checked_in_by_id, operator_id) + |> then(fn cs -> + if check_in_list_id, + do: Ecto.Changeset.put_change(cs, :check_in_list_id, check_in_list_id), + else: cs + end) |> Repo.insert() case result do diff --git a/pretex/test/pretex/check_ins_test.exs b/pretex/test/pretex/check_ins_test.exs index 764c069..ea0da36 100644 --- a/pretex/test/pretex/check_ins_test.exs +++ b/pretex/test/pretex/check_ins_test.exs @@ -185,4 +185,143 @@ defmodule Pretex.CheckInsTest do assert CheckIns.get_check_in_count(event.id) == 0 end end + + describe "check_in_by_ticket_code/4 with check_in_list" do + test "checks in on a specific list" do + org = org_fixture() + event = published_event_fixture(org) + order = confirmed_order_fixture(event) + operator = user_fixture() + [order_item | _] = order.order_items + + {:ok, list} = + CheckIns.create_check_in_list(event.id, %{ + name: "Main Hall", + item_ids: [order_item.item_id] + }) + + assert {:ok, check_in} = + CheckIns.check_in_by_ticket_code( + event.id, + order_item.ticket_code, + operator.id, + list.id + ) + + assert check_in.check_in_list_id == list.id + end + + test "same ticket can be checked in on different lists independently" do + org = org_fixture() + event = published_event_fixture(org) + order = confirmed_order_fixture(event) + operator = user_fixture() + [order_item | _] = order.order_items + + {:ok, list1} = + CheckIns.create_check_in_list(event.id, %{name: "Hall A", item_ids: [order_item.item_id]}) + + {:ok, list2} = + CheckIns.create_check_in_list(event.id, %{name: "Hall B", item_ids: [order_item.item_id]}) + + assert {:ok, _} = + CheckIns.check_in_by_ticket_code( + event.id, + order_item.ticket_code, + operator.id, + list1.id + ) + + assert {:ok, _} = + CheckIns.check_in_by_ticket_code( + event.id, + order_item.ticket_code, + operator.id, + list2.id + ) + end + + test "returns :not_on_list when ticket item is not in check-in list" do + org = org_fixture() + event = published_event_fixture(org) + order = confirmed_order_fixture(event) + operator = user_fixture() + [order_item | _] = order.order_items + other_item = item_fixture(event, %{name: "VIP Only"}) + + {:ok, list} = + CheckIns.create_check_in_list(event.id, %{name: "VIP", item_ids: [other_item.id]}) + + assert {:error, :not_on_list} = + CheckIns.check_in_by_ticket_code( + event.id, + order_item.ticket_code, + operator.id, + list.id + ) + end + + test "returns :list_not_active when outside time window" do + org = org_fixture() + event = published_event_fixture(org) + order = confirmed_order_fixture(event) + operator = user_fixture() + [order_item | _] = order.order_items + + {:ok, list} = + CheckIns.create_check_in_list(event.id, %{ + name: "Morning", + item_ids: [order_item.item_id], + starts_at_time: ~T[01:00:00], + ends_at_time: ~T[01:01:00] + }) + + assert {:error, :list_not_active} = + CheckIns.check_in_by_ticket_code( + event.id, + order_item.ticket_code, + operator.id, + list.id + ) + end + end + + describe "check_in_at_gate/4" do + test "checks in via gate on matching list" do + org = org_fixture() + event = published_event_fixture(org) + order = confirmed_order_fixture(event) + operator = user_fixture() + [order_item | _] = order.order_items + + {:ok, list} = + CheckIns.create_check_in_list(event.id, %{name: "General", item_ids: [order_item.item_id]}) + + {:ok, gate} = + CheckIns.create_gate(event.id, %{name: "North Door", check_in_list_ids: [list.id]}) + + assert {:ok, check_in} = + CheckIns.check_in_at_gate(event.id, order_item.ticket_code, operator.id, gate.id) + + assert check_in.check_in_list_id == list.id + end + + test "returns :not_on_list when ticket doesn't match any gate list" do + org = org_fixture() + event = published_event_fixture(org) + order = confirmed_order_fixture(event) + operator = user_fixture() + [order_item | _] = order.order_items + other_item = item_fixture(event, %{name: "VIP Only"}) + + {:ok, list} = + CheckIns.create_check_in_list(event.id, %{name: "VIP", item_ids: [other_item.id]}) + + {:ok, gate} = + CheckIns.create_gate(event.id, %{name: "VIP Door", check_in_list_ids: [list.id]}) + + assert {:error, :not_on_list} = + CheckIns.check_in_at_gate(event.id, order_item.ticket_code, operator.id, gate.id) + end + end end From 8a321352f283f4e3b9b5a413a4486a77ed35919c Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 23 Mar 2026 19:01:26 -0300 Subject: [PATCH 4/5] feat: add check-in config LiveView for lists and gates management --- .../live/admin/check_in_config_live/index.ex | 235 ++++++++++++++ .../check_in_config_live/index.html.heex | 293 ++++++++++++++++++ pretex/lib/pretex_web/router.ex | 30 ++ 3 files changed, 558 insertions(+) create mode 100644 pretex/lib/pretex_web/live/admin/check_in_config_live/index.ex create mode 100644 pretex/lib/pretex_web/live/admin/check_in_config_live/index.html.heex diff --git a/pretex/lib/pretex_web/live/admin/check_in_config_live/index.ex b/pretex/lib/pretex_web/live/admin/check_in_config_live/index.ex new file mode 100644 index 0000000..dd60000 --- /dev/null +++ b/pretex/lib/pretex_web/live/admin/check_in_config_live/index.ex @@ -0,0 +1,235 @@ +defmodule PretexWeb.Admin.CheckInConfigLive.Index do + use PretexWeb, :live_view + + alias Pretex.Catalog + alias Pretex.CheckIns + + alias Pretex.Events + alias Pretex.Organizations + + @impl true + def mount(%{"org_id" => org_id, "event_id" => event_id}, _session, socket) do + org = Organizations.get_organization!(org_id) + event = Events.get_event!(event_id) + check_in_lists = CheckIns.list_check_in_lists(event.id) + gates = CheckIns.list_gates(event.id) + catalog_items = Catalog.list_items(event) + + socket = + socket + |> assign(:org, org) + |> assign(:event, event) + |> assign(:catalog_items, catalog_items) + |> assign(:check_in_lists, check_in_lists) + |> assign(:gates, gates) + |> assign(:form, nil) + |> assign(:selected_item_ids, []) + |> assign(:selected_list_ids, []) + |> assign(:page_title, "Configuração de Check-in — #{event.name}") + + {: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(:selected_item_ids, []) + |> assign(:selected_list_ids, []) + end + + defp apply_action(socket, :new_list, _params) do + form = to_form(%{"name" => "", "starts_at_time" => "", "ends_at_time" => ""}, as: :list) + + socket + |> assign(:form, form) + |> assign(:selected_item_ids, []) + end + + defp apply_action(socket, :edit_list, %{"list_id" => list_id}) do + list = CheckIns.get_check_in_list!(list_id) + item_ids = Enum.map(list.check_in_list_items, & &1.item_id) + + form = + to_form( + %{ + "name" => list.name, + "starts_at_time" => + if(list.starts_at_time, do: Time.to_string(list.starts_at_time), else: ""), + "ends_at_time" => if(list.ends_at_time, do: Time.to_string(list.ends_at_time), else: "") + }, + as: :list + ) + + socket + |> assign(:form, form) + |> assign(:editing_list_id, list_id) + |> assign(:selected_item_ids, item_ids) + end + + defp apply_action(socket, :new_gate, _params) do + form = to_form(%{"name" => ""}, as: :gate) + + socket + |> assign(:form, form) + |> assign(:selected_list_ids, []) + end + + defp apply_action(socket, :edit_gate, %{"gate_id" => gate_id}) do + gate = CheckIns.get_gate!(gate_id) + list_ids = Enum.map(gate.check_in_lists, & &1.id) + + form = to_form(%{"name" => gate.name}, as: :gate) + + socket + |> assign(:form, form) + |> assign(:editing_gate_id, gate_id) + |> assign(:selected_list_ids, list_ids) + end + + @impl true + def handle_event("toggle_item", %{"id" => id_str}, socket) do + id = String.to_integer(id_str) + current = socket.assigns.selected_item_ids + + updated = + if id in current, + do: List.delete(current, id), + else: [id | current] + + {:noreply, assign(socket, :selected_item_ids, updated)} + end + + @impl true + def handle_event("toggle_list", %{"id" => id_str}, socket) do + id = String.to_integer(id_str) + current = socket.assigns.selected_list_ids + + updated = + if id in current, + do: List.delete(current, id), + else: [id | current] + + {:noreply, assign(socket, :selected_list_ids, updated)} + end + + @impl true + def handle_event("save_list", %{"list" => params}, socket) do + event = socket.assigns.event + item_ids = socket.assigns.selected_item_ids + + attrs = %{ + name: params["name"], + item_ids: item_ids, + starts_at_time: parse_time(params["starts_at_time"]), + ends_at_time: parse_time(params["ends_at_time"]) + } + + result = + if socket.assigns.live_action == :edit_list do + CheckIns.update_check_in_list(socket.assigns.editing_list_id, attrs) + else + CheckIns.create_check_in_list(event.id, attrs) + end + + case result do + {:ok, _} -> + {:noreply, + socket + |> assign(:check_in_lists, CheckIns.list_check_in_lists(event.id)) + |> put_flash(:info, "Lista salva com sucesso.") + |> push_patch(to: config_path(socket))} + + {:error, :no_items} -> + {:noreply, put_flash(socket, :error, "Selecione pelo menos um item.")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Erro ao salvar lista.")} + end + end + + @impl true + def handle_event("save_gate", %{"gate" => params}, socket) do + event = socket.assigns.event + list_ids = socket.assigns.selected_list_ids + + attrs = %{ + name: params["name"], + check_in_list_ids: list_ids + } + + result = + if socket.assigns.live_action == :edit_gate do + CheckIns.update_gate(socket.assigns.editing_gate_id, attrs) + else + CheckIns.create_gate(event.id, attrs) + end + + case result do + {:ok, _} -> + {:noreply, + socket + |> assign(:gates, CheckIns.list_gates(event.id)) + |> put_flash(:info, "Portão salvo com sucesso.") + |> push_patch(to: config_path(socket))} + + {:error, :no_check_in_lists} -> + {:noreply, put_flash(socket, :error, "Selecione pelo menos uma lista.")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Erro ao salvar portão.")} + end + end + + @impl true + def handle_event("delete_list", %{"id" => id_str}, socket) do + id = String.to_integer(id_str) + {:ok, _} = CheckIns.delete_check_in_list(id) + + {:noreply, + socket + |> assign(:check_in_lists, CheckIns.list_check_in_lists(socket.assigns.event.id)) + |> assign(:gates, CheckIns.list_gates(socket.assigns.event.id)) + |> put_flash(:info, "Lista excluída.")} + end + + @impl true + def handle_event("delete_gate", %{"id" => id_str}, socket) do + id = String.to_integer(id_str) + {:ok, _} = CheckIns.delete_gate(id) + + {:noreply, + socket + |> assign(:gates, CheckIns.list_gates(socket.assigns.event.id)) + |> put_flash(:info, "Portão excluído.")} + end + + @impl true + def handle_event("close_modal", _, socket) do + {:noreply, push_patch(socket, to: config_path(socket))} + end + + defp config_path(socket) do + ~p"/admin/organizations/#{socket.assigns.org}/events/#{socket.assigns.event}/check-in/config" + end + + defp parse_time(""), do: nil + defp parse_time(nil), do: nil + + defp parse_time(str) do + case Time.from_iso8601(str <> ":00") do + {:ok, time} -> + time + + _ -> + case Time.from_iso8601(str) do + {:ok, time} -> time + _ -> nil + end + end + end +end diff --git a/pretex/lib/pretex_web/live/admin/check_in_config_live/index.html.heex b/pretex/lib/pretex_web/live/admin/check_in_config_live/index.html.heex new file mode 100644 index 0000000..707aa6d --- /dev/null +++ b/pretex/lib/pretex_web/live/admin/check_in_config_live/index.html.heex @@ -0,0 +1,293 @@ +<.dashboard_layout + current_path={~p"/admin/organizations/#{@org}/events/#{@event}/check-in/config"} + org={@org} + flash={@flash} +> +
+ <%!-- Back link --%> +
+ <.link + navigate={~p"/admin/organizations/#{@org}/events/#{@event}/check-in"} + 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 ao Check-in + +
+ +
+

Configuração de Check-in

+

{@event.name}

+
+ +
+ <%!-- Check-in Lists Section --%> +
+
+

+ <.icon name="hero-clipboard-document-list" class="size-5 text-base-content/40" /> + Listas de Check-in +

+ <.link + patch={~p"/admin/organizations/#{@org}/events/#{@event}/check-in/config/lists/new"} + class="btn btn-primary btn-sm gap-1" + > + <.icon name="hero-plus" class="size-4" /> Nova Lista + +
+ +
+
+ <.icon + name="hero-clipboard-document-list" + class="size-12 text-base-content/20 mx-auto mb-3" + /> +

Nenhuma lista de check-in configurada.

+

+ Crie listas para controlar quais ingressos são válidos em cada ponto de entrada. +

+
+ +
+
+

{list.name}

+

+ {length(list.check_in_list_items)} item(s) + <%= if list.starts_at_time do %> + · {Calendar.strftime(list.starts_at_time, "%H:%M")} — {Calendar.strftime(list.ends_at_time, "%H:%M")} + <% end %> +

+
+
+ <.link + patch={~p"/admin/organizations/#{@org}/events/#{@event}/check-in/config/lists/#{list.id}/edit"} + class="btn btn-ghost btn-xs" + > + Editar + + +
+
+
+
+ + <%!-- Gates Section --%> +
+
+

+ <.icon name="hero-building-storefront" class="size-5 text-base-content/40" /> + Portões +

+ <.link + patch={~p"/admin/organizations/#{@org}/events/#{@event}/check-in/config/gates/new"} + class="btn btn-primary btn-sm gap-1" + > + <.icon name="hero-plus" class="size-4" /> Novo Portão + +
+ +
+
+ <.icon + name="hero-building-storefront" + class="size-12 text-base-content/20 mx-auto mb-3" + /> +

Nenhum portão configurado.

+

+ Portões representam pontos de entrada físicos vinculados a listas de check-in. +

+
+ +
+
+

{gate.name}

+

+ {Enum.map_join(gate.check_in_lists, ", ", & &1.name)} +

+
+
+ <.link + patch={~p"/admin/organizations/#{@org}/events/#{@event}/check-in/config/gates/#{gate.id}/edit"} + class="btn btn-ghost btn-xs" + > + Editar + + +
+
+
+
+
+
+ + <%!-- Modal: New/Edit List --%> +
+
+ + +
+ + <%!-- Modal: New/Edit Gate --%> +
+
+ + +
+ diff --git a/pretex/lib/pretex_web/router.ex b/pretex/lib/pretex_web/router.ex index 4b23a43..3965721 100644 --- a/pretex/lib/pretex_web/router.ex +++ b/pretex/lib/pretex_web/router.ex @@ -168,6 +168,36 @@ defmodule PretexWeb.Router do live("/organizations/:org_id/events/:event_id/check-in", CheckInLive.Index, :index) + live( + "/organizations/:org_id/events/:event_id/check-in/config", + CheckInConfigLive.Index, + :index + ) + + live( + "/organizations/:org_id/events/:event_id/check-in/config/lists/new", + CheckInConfigLive.Index, + :new_list + ) + + live( + "/organizations/:org_id/events/:event_id/check-in/config/lists/:list_id/edit", + CheckInConfigLive.Index, + :edit_list + ) + + live( + "/organizations/:org_id/events/:event_id/check-in/config/gates/new", + CheckInConfigLive.Index, + :new_gate + ) + + live( + "/organizations/:org_id/events/:event_id/check-in/config/gates/:gate_id/edit", + CheckInConfigLive.Index, + :edit_gate + ) + live("/organizations/:org_id/events/:event_id/fees", FeeLive.Index, :index) live("/organizations/:org_id/events/:event_id/fees/new", FeeLive.Index, :new) live("/organizations/:org_id/events/:event_id/fees/:id/edit", FeeLive.Index, :edit) From 10cbce19dea32597366367fcaeadf5926ee413a1 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 23 Mar 2026 19:03:58 -0300 Subject: [PATCH 5/5] feat: add gate selector to check-in page and config nav links --- .../live/admin/check_in_live/index.ex | 48 ++++++++++++++++++- .../live/admin/check_in_live/index.html.heex | 23 ++++++++- .../live/admin/event_live/show.html.heex | 20 +++++--- 3 files changed, 82 insertions(+), 9 deletions(-) diff --git a/pretex/lib/pretex_web/live/admin/check_in_live/index.ex b/pretex/lib/pretex_web/live/admin/check_in_live/index.ex index 0ec2a96..9a1c2c2 100644 --- a/pretex/lib/pretex_web/live/admin/check_in_live/index.ex +++ b/pretex/lib/pretex_web/live/admin/check_in_live/index.ex @@ -16,6 +16,7 @@ defmodule PretexWeb.Admin.CheckInLive.Index do checked_in_count = CheckIns.get_check_in_count(event.id) total_tickets = CheckIns.get_total_tickets(event.id) + gates = CheckIns.list_gates(event.id) socket = socket @@ -26,17 +27,36 @@ defmodule PretexWeb.Admin.CheckInLive.Index do |> assign(:scan_result, nil) |> assign(:search_query, "") |> assign(:search_results, []) + |> assign(:gates, gates) + |> assign(:selected_gate_id, nil) |> assign(:page_title, "Check-in — #{event.name}") {:ok, socket} end + @impl true + def handle_event("select_gate", %{"gate_id" => gate_id}, socket) do + selected = if gate_id == "", do: nil, else: String.to_integer(gate_id) + + {:noreply, + socket + |> assign(:selected_gate_id, selected) + |> assign(:search_query, "") + |> assign(:search_results, [])} + end + @impl true def handle_event("scan", %{"code" => code}, socket) do event = socket.assigns.event operator_id = socket.assigns.current_user.id + gate_id = socket.assigns.selected_gate_id - result = CheckIns.check_in_by_ticket_code(event.id, code, operator_id) + result = + if gate_id do + CheckIns.check_in_at_gate(event.id, code, operator_id, gate_id) + else + CheckIns.check_in_by_ticket_code(event.id, code, operator_id) + end scan_result = case result do @@ -63,6 +83,20 @@ defmodule PretexWeb.Admin.CheckInLive.Index do {:error, :already_checked_in} -> %{status: :error, message: "Já foi feito check-in", attendee_name: nil} + + {:error, :not_on_list} -> + %{ + status: :error, + message: "Ingresso não válido para este ponto de entrada", + attendee_name: nil + } + + {:error, :list_not_active} -> + %{ + status: :error, + message: "Lista de check-in fora do horário ativo", + attendee_name: nil + } end socket = @@ -108,8 +142,16 @@ defmodule PretexWeb.Admin.CheckInLive.Index do def handle_event("check_in_attendee", %{"ticket-code" => code}, socket) do event = socket.assigns.event operator_id = socket.assigns.current_user.id + gate_id = socket.assigns.selected_gate_id + + result = + if gate_id do + CheckIns.check_in_at_gate(event.id, code, operator_id, gate_id) + else + CheckIns.check_in_by_ticket_code(event.id, code, operator_id) + end - case CheckIns.check_in_by_ticket_code(event.id, code, operator_id) do + case result do {:ok, _} -> results = refresh_search(socket) @@ -124,6 +166,8 @@ defmodule PretexWeb.Admin.CheckInLive.Index do case reason do :already_checked_in -> "Já foi feito check-in" :ticket_cancelled -> "Ingresso cancelado" + :not_on_list -> "Ingresso não válido para este ponto de entrada" + :list_not_active -> "Lista fora do horário ativo" _ -> "Erro ao fazer check-in" end diff --git a/pretex/lib/pretex_web/live/admin/check_in_live/index.html.heex b/pretex/lib/pretex_web/live/admin/check_in_live/index.html.heex index 84e560b..349a650 100644 --- a/pretex/lib/pretex_web/live/admin/check_in_live/index.html.heex +++ b/pretex/lib/pretex_web/live/admin/check_in_live/index.html.heex @@ -5,13 +5,19 @@ >
<%!-- Back link --%> -
+
<.link navigate={~p"/admin/organizations/#{@org}/events/#{@event}"} 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 ao Evento + <.link + navigate={~p"/admin/organizations/#{@org}/events/#{@event}/check-in/config"} + class="inline-flex items-center gap-1 text-sm text-base-content/60 hover:text-primary transition-colors" + > + <.icon name="hero-cog-6-tooth" class="size-4" /> Configurar +
@@ -27,6 +33,21 @@
+ <%!-- Gate selector --%> +
+ + +
+ <%!-- Scan result feedback --%>
<.icon name="hero-qr-code" class="size-4 text-base-content/40" /> Check-in - <.link - navigate={~p"/admin/organizations/#{@org}/events/#{@event}/check-in"} - class="btn btn-ghost btn-xs gap-1" - > - <.icon name="hero-arrow-right" class="size-3" /> Abrir Check-in - +
+ <.link + navigate={~p"/admin/organizations/#{@org}/events/#{@event}/check-in/config"} + class="btn btn-ghost btn-xs gap-1" + > + <.icon name="hero-cog-6-tooth" class="size-3" /> Configurar + + <.link + navigate={~p"/admin/organizations/#{@org}/events/#{@event}/check-in"} + class="btn btn-ghost btn-xs gap-1" + > + <.icon name="hero-arrow-right" class="size-3" /> Abrir Check-in + +
<.icon