Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pretex/assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions pretex/assets/js/hooks/qr_scanner.js
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions pretex/assets/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions pretex/assets/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
152 changes: 152 additions & 0 deletions pretex/lib/pretex/check_ins.ex
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions pretex/lib/pretex/check_ins/check_in.ex
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion pretex/lib/pretex/events/event.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading
Loading