diff --git a/pretex/assets/js/app.js b/pretex/assets/js/app.js index 2501eef..fc6bf12 100644 --- a/pretex/assets/js/app.js +++ b/pretex/assets/js/app.js @@ -23,13 +23,14 @@ import "phoenix_html" import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import {hooks as colocatedHooks} from "phoenix-colocated/pretex" +import QRScanner from "./hooks/qr_scanner" import topbar from "../vendor/topbar" const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: {_csrf_token: csrfToken}, - hooks: {...colocatedHooks}, + hooks: {...colocatedHooks, QRScanner}, }) // Show progress bar on live navigation and form submits diff --git a/pretex/assets/js/hooks/qr_scanner.js b/pretex/assets/js/hooks/qr_scanner.js new file mode 100644 index 0000000..35a3e6c --- /dev/null +++ b/pretex/assets/js/hooks/qr_scanner.js @@ -0,0 +1,66 @@ +import { Html5Qrcode } from "html5-qrcode" + +const QRScanner = { + mounted() { + this.scanner = null + this.scanning = false + this.cooldown = false + + this.startButton = this.el.querySelector("[data-start-scan]") + this.stopButton = this.el.querySelector("[data-stop-scan]") + this.readerEl = this.el.querySelector("[data-qr-reader]") + + if (this.startButton) { + this.startButton.addEventListener("click", () => this.startScan()) + } + if (this.stopButton) { + this.stopButton.addEventListener("click", () => this.stopScan()) + } + }, + + async startScan() { + if (this.scanning) return + + const readerId = this.readerEl.id + this.scanner = new Html5Qrcode(readerId) + + try { + await this.scanner.start( + { facingMode: "environment" }, + { fps: 10, qrbox: { width: 250, height: 250 } }, + (decodedText) => { + if (this.cooldown) return + this.cooldown = true + this.pushEvent("scan", { code: decodedText }) + setTimeout(() => { this.cooldown = false }, 2000) + }, + (_errorMessage) => {} + ) + + this.scanning = true + if (this.startButton) this.startButton.classList.add("hidden") + if (this.stopButton) this.stopButton.classList.remove("hidden") + } catch (err) { + console.error("QR Scanner error:", err) + this.pushEvent("scan_error", { message: "Camera access denied or unavailable" }) + } + }, + + async stopScan() { + if (!this.scanning || !this.scanner) return + + try { + await this.scanner.stop() + } catch (_) {} + + this.scanning = false + if (this.startButton) this.startButton.classList.remove("hidden") + if (this.stopButton) this.stopButton.classList.add("hidden") + }, + + async destroyed() { + await this.stopScan() + } +} + +export default QRScanner diff --git a/pretex/assets/package-lock.json b/pretex/assets/package-lock.json new file mode 100644 index 0000000..40ca61d --- /dev/null +++ b/pretex/assets/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "assets", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "assets", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "html5-qrcode": "^2.3.8" + } + }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", + "license": "Apache-2.0" + } + } +} diff --git a/pretex/assets/package.json b/pretex/assets/package.json new file mode 100644 index 0000000..1cbdaa2 --- /dev/null +++ b/pretex/assets/package.json @@ -0,0 +1,16 @@ +{ + "name": "assets", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "html5-qrcode": "^2.3.8" + } +} diff --git a/pretex/lib/pretex/check_ins.ex b/pretex/lib/pretex/check_ins.ex new file mode 100644 index 0000000..5b55093 --- /dev/null +++ b/pretex/lib/pretex/check_ins.ex @@ -0,0 +1,152 @@ +defmodule Pretex.CheckIns do + @moduledoc "Manages event check-in operations." + + import Ecto.Query + + alias Pretex.Repo + alias Pretex.CheckIns.CheckIn + alias Pretex.Orders.{Order, OrderItem} + + def checkin_topic(event_id), do: "checkins:event:#{event_id}" + + def check_in_by_ticket_code(event_id, ticket_code, operator_id) do + 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 -> + validate_and_check_in(oi, event_id, operator_id) + + _wrong_event -> + {:error, :wrong_event} + end + end + + defp validate_and_check_in(%{order: %{status: status}}, _event_id, _operator_id) + when status != "confirmed" do + {:error, :ticket_cancelled} + end + + defp validate_and_check_in(order_item, event_id, operator_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) + ) + |> Repo.one() + + case existing do + nil -> + insert_check_in(order_item.id, event_id, operator_id) + + _already -> + {:error, :already_checked_in} + end + end + + defp insert_check_in(order_item_id, event_id, operator_id) do + now = DateTime.utc_now() |> DateTime.truncate(:microsecond) + + result = + %CheckIn{} + |> CheckIn.changeset(%{checked_in_at: now}) + |> 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) + |> Repo.insert() + + case result do + {:ok, check_in} -> + broadcast_check_in_update(event_id) + {:ok, check_in} + + {:error, changeset} -> + if has_unique_constraint_error?(changeset) do + {:error, :already_checked_in} + else + {:error, changeset} + end + end + end + + defp has_unique_constraint_error?(changeset) do + Enum.any?(changeset.errors, fn + {_field, {_msg, opts}} when is_list(opts) -> Keyword.get(opts, :constraint) == :unique + _ -> false + end) + end + + def annul_check_in(check_in_id, operator_id) do + check_in = Repo.get!(CheckIn, check_in_id) + + if check_in.annulled_at do + {:error, :already_annulled} + else + now = DateTime.utc_now() |> DateTime.truncate(:microsecond) + + result = + check_in + |> Ecto.Changeset.change(annulled_at: now, annulled_by_id: operator_id) + |> Repo.update() + + case result do + {:ok, annulled} -> + broadcast_check_in_update(annulled.event_id) + {:ok, annulled} + + error -> + error + end + end + end + + def search_attendees(event_id, query) do + term = "%#{query}%" + + OrderItem + |> join(:inner, [oi], o in Order, on: oi.order_id == o.id) + |> where([oi, o], o.event_id == ^event_id and o.status == "confirmed") + |> where( + [oi, o], + ilike(oi.attendee_name, ^term) or ilike(oi.attendee_email, ^term) or + ilike(o.name, ^term) or ilike(o.email, ^term) + ) + |> preload([oi, o], order: o, item: []) + |> Repo.all() + end + + def get_check_in_count(event_id) do + CheckIn + |> where([c], c.event_id == ^event_id and is_nil(c.annulled_at)) + |> Repo.aggregate(:count) + end + + def get_total_tickets(event_id) do + OrderItem + |> join(:inner, [oi], o in Order, on: oi.order_id == o.id) + |> where([oi, o], o.event_id == ^event_id and o.status == "confirmed") + |> Repo.aggregate(:sum, :quantity) || 0 + end + + def get_active_check_in(order_item_id, event_id) do + CheckIn + |> where( + [c], + c.order_item_id == ^order_item_id and c.event_id == ^event_id and is_nil(c.annulled_at) + ) + |> Repo.one() + 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}) + end +end diff --git a/pretex/lib/pretex/check_ins/check_in.ex b/pretex/lib/pretex/check_ins/check_in.ex new file mode 100644 index 0000000..e3a3543 --- /dev/null +++ b/pretex/lib/pretex/check_ins/check_in.ex @@ -0,0 +1,22 @@ +defmodule Pretex.CheckIns.CheckIn do + use Ecto.Schema + import Ecto.Changeset + + schema "check_ins" do + field(:checked_in_at, :utc_datetime_usec) + field(:annulled_at, :utc_datetime_usec) + + belongs_to(:order_item, Pretex.Orders.OrderItem) + 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) + + timestamps(type: :utc_datetime) + end + + def changeset(check_in, attrs) do + check_in + |> cast(attrs, [:checked_in_at, :annulled_at]) + |> validate_required([:checked_in_at]) + end +end diff --git a/pretex/lib/pretex/events/event.ex b/pretex/lib/pretex/events/event.ex index 4f3c201..9ec6bd7 100644 --- a/pretex/lib/pretex/events/event.ex +++ b/pretex/lib/pretex/events/event.ex @@ -19,6 +19,7 @@ defmodule Pretex.Events.Event do field(:accent_color, :string, default: "#f43f5e") field(:is_series, :boolean, default: false) + field(:multi_entry, :boolean, default: false) belongs_to(:organization, Pretex.Organizations.Organization) has_many(:sub_events, Pretex.Events.SubEvent, foreign_key: :parent_event_id) @@ -38,7 +39,8 @@ defmodule Pretex.Events.Event do :banner_url, :primary_color, :accent_color, - :is_series + :is_series, + :multi_entry ]) |> validate_required([:name, :starts_at, :ends_at]) |> validate_length(:name, min: 2, max: 255) 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 new file mode 100644 index 0000000..0ec2a96 --- /dev/null +++ b/pretex/lib/pretex_web/live/admin/check_in_live/index.ex @@ -0,0 +1,187 @@ +defmodule PretexWeb.Admin.CheckInLive.Index do + use PretexWeb, :live_view + + 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) + + if connected?(socket) do + Phoenix.PubSub.subscribe(Pretex.PubSub, CheckIns.checkin_topic(event.id)) + end + + checked_in_count = CheckIns.get_check_in_count(event.id) + total_tickets = CheckIns.get_total_tickets(event.id) + + socket = + socket + |> assign(:org, org) + |> assign(:event, event) + |> assign(:checked_in_count, checked_in_count) + |> assign(:total_tickets, total_tickets) + |> assign(:scan_result, nil) + |> assign(:search_query, "") + |> assign(:search_results, []) + |> assign(:page_title, "Check-in — #{event.name}") + + {:ok, socket} + end + + @impl true + def handle_event("scan", %{"code" => code}, socket) do + event = socket.assigns.event + operator_id = socket.assigns.current_user.id + + result = CheckIns.check_in_by_ticket_code(event.id, code, operator_id) + + scan_result = + case result do + {:ok, check_in} -> + order_item = + Pretex.Repo.preload(check_in, order_item: [:item]).order_item + + %{ + status: :success, + message: "Check-in realizado!", + attendee_name: order_item.attendee_name, + item_name: order_item.item.name, + ticket_code: order_item.ticket_code + } + + {:error, :invalid_ticket} -> + %{status: :error, message: "Ingresso inválido", attendee_name: nil} + + {:error, :wrong_event} -> + %{status: :error, message: "Ingresso não pertence a este evento", attendee_name: nil} + + {:error, :ticket_cancelled} -> + %{status: :error, message: "Ingresso cancelado", attendee_name: nil} + + {:error, :already_checked_in} -> + %{status: :error, message: "Já foi feito check-in", attendee_name: nil} + end + + socket = + socket + |> assign(:scan_result, scan_result) + |> assign(:checked_in_count, CheckIns.get_check_in_count(event.id)) + + {:noreply, socket} + end + + @impl true + def handle_event("scan_error", %{"message" => msg}, socket) do + {:noreply, put_flash(socket, :error, msg)} + end + + @impl true + def handle_event("search", %{"query" => query}, socket) do + results = + if String.length(query) >= 2 do + attendees = CheckIns.search_attendees(socket.assigns.event.id, query) + + Enum.map(attendees, fn oi -> + active_ci = CheckIns.get_active_check_in(oi.id, socket.assigns.event.id) + + %{ + order_item_id: oi.id, + attendee_name: oi.attendee_name, + attendee_email: oi.attendee_email, + ticket_code: oi.ticket_code, + item_name: oi.item.name, + checked_in: active_ci != nil, + check_in_id: active_ci && active_ci.id + } + end) + else + [] + end + + {:noreply, assign(socket, search_query: query, search_results: results)} + end + + @impl true + def handle_event("check_in_attendee", %{"ticket-code" => code}, socket) do + event = socket.assigns.event + operator_id = socket.assigns.current_user.id + + case CheckIns.check_in_by_ticket_code(event.id, code, operator_id) do + {:ok, _} -> + results = refresh_search(socket) + + {:noreply, + socket + |> assign(:search_results, results) + |> assign(:checked_in_count, CheckIns.get_check_in_count(event.id)) + |> put_flash(:info, "Check-in realizado!")} + + {:error, reason} -> + msg = + case reason do + :already_checked_in -> "Já foi feito check-in" + :ticket_cancelled -> "Ingresso cancelado" + _ -> "Erro ao fazer check-in" + end + + {:noreply, put_flash(socket, :error, msg)} + end + end + + @impl true + def handle_event("annul_check_in", %{"check-in-id" => check_in_id}, socket) do + operator_id = socket.assigns.current_user.id + {id, _} = Integer.parse(check_in_id) + + case CheckIns.annul_check_in(id, operator_id) do + {:ok, _} -> + results = refresh_search(socket) + + {:noreply, + socket + |> assign(:search_results, results) + |> assign(:checked_in_count, CheckIns.get_check_in_count(socket.assigns.event.id)) + |> put_flash(:info, "Check-in anulado.")} + + {:error, :already_annulled} -> + {:noreply, put_flash(socket, :error, "Check-in já foi anulado.")} + end + end + + @impl true + def handle_event("clear_scan_result", _, socket) do + {:noreply, assign(socket, :scan_result, nil)} + end + + @impl true + def handle_info({:check_in_updated, count}, socket) do + {:noreply, assign(socket, :checked_in_count, count)} + end + + defp refresh_search(socket) do + query = socket.assigns.search_query + + if String.length(query) >= 2 do + attendees = CheckIns.search_attendees(socket.assigns.event.id, query) + + Enum.map(attendees, fn oi -> + active_ci = CheckIns.get_active_check_in(oi.id, socket.assigns.event.id) + + %{ + order_item_id: oi.id, + attendee_name: oi.attendee_name, + attendee_email: oi.attendee_email, + ticket_code: oi.ticket_code, + item_name: oi.item.name, + checked_in: active_ci != nil, + check_in_id: active_ci && active_ci.id + } + end) + else + [] + end + end +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 new file mode 100644 index 0000000..84e560b --- /dev/null +++ b/pretex/lib/pretex_web/live/admin/check_in_live/index.html.heex @@ -0,0 +1,143 @@ +<.dashboard_layout + current_path={~p"/admin/organizations/#{@org}/events/#{@event}/check-in"} + org={@org} + flash={@flash} +> +
+ <%!-- 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 + +
+ +
+ <%!-- Stats bar --%> +
+
+
+ <.icon name="hero-qr-code" class="size-8" /> +
+
Check-ins
+
{@checked_in_count}
+
de {@total_tickets || 0} ingressos confirmados
+
+
+ + <%!-- Scan result feedback --%> +
+
+ <.icon + name={if(@scan_result.status == :success, do: "hero-check-circle", else: "hero-x-circle")} + class="size-8 shrink-0" + /> +
+

{@scan_result.message}

+

+ {@scan_result.attendee_name} + + — {@scan_result.item_name} + +

+
+
+ +
+ + <%!-- QR Scanner --%> +
+
+

+ <.icon name="hero-qr-code" class="size-5" /> Scanner QR Code +

+
+
+
+ + +
+
+
+
+ + <%!-- Manual search --%> +
+
+

+ <.icon name="hero-magnifying-glass" class="size-5" /> Buscar Participante +

+
+ +
+ +
+
+
+

{attendee.attendee_name || "Sem nome"}

+

{attendee.attendee_email}

+

+ {attendee.item_name} — {attendee.ticket_code} +

+
+
+ <%= if attendee.checked_in do %> +
+ Checked in + +
+ <% else %> + + <% end %> +
+
+
+ +

= 2 && @search_results == []} + class="mt-4 text-sm text-base-content/50 text-center" + > + Nenhum participante encontrado. +

+
+
+
+
+ diff --git a/pretex/lib/pretex_web/live/admin/event_live/show.html.heex b/pretex/lib/pretex_web/live/admin/event_live/show.html.heex index 0a2d48b..53e1f06 100644 --- a/pretex/lib/pretex_web/live/admin/event_live/show.html.heex +++ b/pretex/lib/pretex_web/live/admin/event_live/show.html.heex @@ -384,6 +384,32 @@ + <%!-- Card de check-in --%> +
+
+
+

+ <.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 + +
+
+ <.icon + name="hero-information-circle" + class="size-5 shrink-0 text-base-content/40 mt-0.5" + /> +

+ Escaneie QR codes ou busque participantes para validar ingressos na entrada do evento. +

+
+
+
+ <%!-- Card de pedidos --%>
diff --git a/pretex/lib/pretex_web/live/events_live/confirmation.ex b/pretex/lib/pretex_web/live/events_live/confirmation.ex index 5b80bc7..51f776a 100644 --- a/pretex/lib/pretex_web/live/events_live/confirmation.ex +++ b/pretex/lib/pretex_web/live/events_live/confirmation.ex @@ -120,6 +120,12 @@ defmodule PretexWeb.EventsLive.Confirmation do

# {order_item.ticket_code}

+
+ {ticket_qr_svg(order_item.ticket_code) |> Phoenix.HTML.raw()} +
@@ -212,4 +218,10 @@ defmodule PretexWeb.EventsLive.Confirmation do cents_part = rem(cents, 100) "R$ #{dollars},#{String.pad_leading(Integer.to_string(cents_part), 2, "0")}" end + + defp ticket_qr_svg(ticket_code) do + ticket_code + |> EQRCode.encode() + |> EQRCode.svg(width: 160) + end end diff --git a/pretex/lib/pretex_web/router.ex b/pretex/lib/pretex_web/router.ex index 9f5ceca..4b23a43 100644 --- a/pretex/lib/pretex_web/router.ex +++ b/pretex/lib/pretex_web/router.ex @@ -166,6 +166,8 @@ defmodule PretexWeb.Router do live("/organizations/:org_id/events/:event_id/orders/new", OrderLive.NewManual, :new_manual) live("/organizations/:org_id/events/:event_id/orders/:id", OrderLive.Show, :show) + live("/organizations/:org_id/events/:event_id/check-in", CheckInLive.Index, :index) + 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) diff --git a/pretex/priv/repo/migrations/20260323174408_create_check_ins.exs b/pretex/priv/repo/migrations/20260323174408_create_check_ins.exs new file mode 100644 index 0000000..1f8b1b5 --- /dev/null +++ b/pretex/priv/repo/migrations/20260323174408_create_check_ins.exs @@ -0,0 +1,28 @@ +defmodule Pretex.Repo.Migrations.CreateCheckIns do + use Ecto.Migration + + def change do + alter table(:events) do + add :multi_entry, :boolean, default: false, null: false + end + + create table(:check_ins) do + add :order_item_id, references(:order_items, on_delete: :restrict), null: false + add :event_id, references(:events, on_delete: :restrict), null: false + add :checked_in_by_id, references(:users, on_delete: :restrict), null: false + add :checked_in_at, :utc_datetime_usec, null: false + add :annulled_at, :utc_datetime_usec + add :annulled_by_id, references(:users, on_delete: :restrict) + + timestamps(type: :utc_datetime) + end + + create index(:check_ins, [:event_id]) + create index(:check_ins, [:order_item_id]) + + create unique_index(:check_ins, [:order_item_id, :event_id], + where: "annulled_at IS NULL", + name: :check_ins_active_unique + ) + end +end diff --git a/pretex/test/pretex/check_ins_test.exs b/pretex/test/pretex/check_ins_test.exs new file mode 100644 index 0000000..764c069 --- /dev/null +++ b/pretex/test/pretex/check_ins_test.exs @@ -0,0 +1,188 @@ +defmodule Pretex.CheckInsTest do + use Pretex.DataCase, async: true + + import Pretex.OrganizationsFixtures + import Pretex.EventsFixtures + import Pretex.CatalogFixtures + import Pretex.AccountsFixtures + + alias Pretex.CheckIns + alias Pretex.CheckIns.CheckIn + alias Pretex.Orders + + defp confirmed_order_fixture(event) do + {:ok, cart} = Orders.create_cart(event) + cart = Orders.get_cart_by_token(cart.session_token) + item = item_fixture(event) + + {:ok, _} = Orders.add_to_cart(cart, item) + cart = Orders.get_cart_by_token(cart.session_token) + + {:ok, order} = + Orders.create_order_from_cart(cart, %{ + name: "Jane Doe", + email: "jane@example.com", + payment_method: "pix" + }) + + {:ok, order} = Orders.confirm_order(order) + Orders.get_order!(order.id) + end + + describe "check_in_by_ticket_code/3" do + test "checks in an attendee with a valid ticket code" do + org = org_fixture() + event = published_event_fixture(org) + order = confirmed_order_fixture(event) + operator = user_fixture() + [order_item | _] = order.order_items + + assert {:ok, %CheckIn{} = check_in} = + CheckIns.check_in_by_ticket_code(event.id, order_item.ticket_code, operator.id) + + assert check_in.order_item_id == order_item.id + assert check_in.event_id == event.id + assert check_in.checked_in_by_id == operator.id + assert check_in.checked_in_at != nil + assert check_in.annulled_at == nil + end + + test "returns :invalid_ticket for unknown ticket code" do + org = org_fixture() + event = published_event_fixture(org) + operator = user_fixture() + + assert {:error, :invalid_ticket} = + CheckIns.check_in_by_ticket_code(event.id, "ZZZZZZZZ", operator.id) + end + + test "returns :wrong_event when ticket belongs to different event" do + org = org_fixture() + event1 = published_event_fixture(org) + event2 = published_event_fixture(org) + order = confirmed_order_fixture(event1) + operator = user_fixture() + [order_item | _] = order.order_items + + assert {:error, :wrong_event} = + CheckIns.check_in_by_ticket_code(event2.id, order_item.ticket_code, operator.id) + end + + test "returns :ticket_cancelled when order is cancelled" do + org = org_fixture() + event = published_event_fixture(org) + order = confirmed_order_fixture(event) + {:ok, _} = Orders.cancel_order(order) + operator = user_fixture() + [order_item | _] = order.order_items + + assert {:error, :ticket_cancelled} = + CheckIns.check_in_by_ticket_code(event.id, order_item.ticket_code, operator.id) + end + + test "returns :already_checked_in on duplicate check-in" do + org = org_fixture() + event = published_event_fixture(org) + order = confirmed_order_fixture(event) + operator = user_fixture() + [order_item | _] = order.order_items + + assert {:ok, _} = + CheckIns.check_in_by_ticket_code(event.id, order_item.ticket_code, operator.id) + + assert {:error, :already_checked_in} = + CheckIns.check_in_by_ticket_code(event.id, order_item.ticket_code, operator.id) + end + + test "allows re-check-in after annulment when multi_entry is enabled" do + org = org_fixture() + event = published_event_fixture(org, %{multi_entry: true}) + order = confirmed_order_fixture(event) + operator = user_fixture() + [order_item | _] = order.order_items + + assert {:ok, ci1} = + CheckIns.check_in_by_ticket_code(event.id, order_item.ticket_code, operator.id) + + assert {:ok, _} = CheckIns.annul_check_in(ci1.id, operator.id) + + assert {:ok, _ci2} = + CheckIns.check_in_by_ticket_code(event.id, order_item.ticket_code, operator.id) + end + end + + describe "annul_check_in/2" do + test "annuls an active check-in" do + org = org_fixture() + event = published_event_fixture(org) + order = confirmed_order_fixture(event) + operator = user_fixture() + [order_item | _] = order.order_items + + {:ok, check_in} = + CheckIns.check_in_by_ticket_code(event.id, order_item.ticket_code, operator.id) + + assert {:ok, annulled} = CheckIns.annul_check_in(check_in.id, operator.id) + assert annulled.annulled_at != nil + assert annulled.annulled_by_id == operator.id + end + + test "returns error for already-annulled check-in" do + org = org_fixture() + event = published_event_fixture(org) + order = confirmed_order_fixture(event) + operator = user_fixture() + [order_item | _] = order.order_items + + {:ok, check_in} = + CheckIns.check_in_by_ticket_code(event.id, order_item.ticket_code, operator.id) + + {:ok, _} = CheckIns.annul_check_in(check_in.id, operator.id) + + assert {:error, :already_annulled} = CheckIns.annul_check_in(check_in.id, operator.id) + end + end + + describe "search_attendees/2" do + test "finds attendees by name" do + org = org_fixture() + event = published_event_fixture(org) + _order = confirmed_order_fixture(event) + + results = CheckIns.search_attendees(event.id, "Jane") + assert length(results) > 0 + result = hd(results) + # Match found via order_item.attendee_name or order.name + name = result.attendee_name || result.order.name + assert name =~ "Jane" + end + + test "returns empty list when no match" do + org = org_fixture() + event = published_event_fixture(org) + _order = confirmed_order_fixture(event) + + assert [] = CheckIns.search_attendees(event.id, "NOMATCH12345") + end + end + + describe "get_check_in_count/1" do + test "counts active (non-annulled) check-ins" do + org = org_fixture() + event = published_event_fixture(org) + order = confirmed_order_fixture(event) + operator = user_fixture() + [order_item | _] = order.order_items + + assert CheckIns.get_check_in_count(event.id) == 0 + + {:ok, check_in} = + CheckIns.check_in_by_ticket_code(event.id, order_item.ticket_code, operator.id) + + assert CheckIns.get_check_in_count(event.id) == 1 + + {:ok, _} = CheckIns.annul_check_in(check_in.id, operator.id) + assert CheckIns.get_check_in_count(event.id) == 0 + end + end +end