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} +> +
+ {@scan_result.attendee_name} + + — {@scan_result.item_name} + +
+{attendee.attendee_name || "Sem nome"}
+{attendee.attendee_email}
++ {attendee.item_name} — {attendee.ticket_code} +
+= 2 && @search_results == []} + class="mt-4 text-sm text-base-content/50 text-center" + > + Nenhum participante encontrado. +
++ Escaneie QR codes ou busque participantes para validar ingressos na entrada do evento. +
+# {order_item.ticket_code}
+