diff --git a/pretex/lib/pretex/check_ins/check_in.ex b/pretex/lib/pretex/check_ins/check_in.ex
index e3a3543..95065bb 100644
--- a/pretex/lib/pretex/check_ins/check_in.ex
+++ b/pretex/lib/pretex/check_ins/check_in.ex
@@ -10,13 +10,14 @@ 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(:device, Pretex.Devices.Device)
timestamps(type: :utc_datetime)
end
def changeset(check_in, attrs) do
check_in
- |> cast(attrs, [:checked_in_at, :annulled_at])
+ |> cast(attrs, [:checked_in_at, :annulled_at, :device_id])
|> validate_required([:checked_in_at])
end
end
diff --git a/pretex/lib/pretex/devices.ex b/pretex/lib/pretex/devices.ex
index d1c5d57..22475a1 100644
--- a/pretex/lib/pretex/devices.ex
+++ b/pretex/lib/pretex/devices.ex
@@ -4,7 +4,7 @@ defmodule Pretex.Devices do
import Ecto.Query
alias Pretex.Repo
- alias Pretex.Devices.{Device, DeviceInitToken}
+ alias Pretex.Devices.{Device, DeviceAssignment, DeviceInitToken}
@token_expiry_hours 24
@@ -79,7 +79,7 @@ defmodule Pretex.Devices do
Device
|> where([d], d.organization_id == ^organization_id)
|> order_by([d], desc: d.provisioned_at)
- |> preload(:provisioned_by)
+ |> preload([:provisioned_by, device_assignments: :event])
|> Repo.all()
end
@@ -109,6 +109,34 @@ defmodule Pretex.Devices do
end
end
+ def assign_device_to_event(device_id, event_id) do
+ %DeviceAssignment{}
+ |> DeviceAssignment.changeset(%{device_id: device_id, event_id: event_id})
+ |> Repo.insert()
+ end
+
+ def unassign_device_from_event(device_id, event_id) do
+ DeviceAssignment
+ |> where([a], a.device_id == ^device_id and a.event_id == ^event_id)
+ |> Repo.delete_all()
+
+ :ok
+ end
+
+ def list_device_assignments(device_id) do
+ DeviceAssignment
+ |> where([a], a.device_id == ^device_id)
+ |> preload(:event)
+ |> Repo.all()
+ end
+
+ def list_org_events(organization_id) do
+ Pretex.Events.Event
+ |> where([e], e.organization_id == ^organization_id)
+ |> order_by([e], desc: e.starts_at)
+ |> Repo.all()
+ end
+
defp generate_short_code do
part = fn ->
:crypto.strong_rand_bytes(2)
diff --git a/pretex/lib/pretex/devices/device.ex b/pretex/lib/pretex/devices/device.ex
index e1151c6..4039270 100644
--- a/pretex/lib/pretex/devices/device.ex
+++ b/pretex/lib/pretex/devices/device.ex
@@ -14,6 +14,8 @@ defmodule Pretex.Devices.Device do
belongs_to(:organization, Pretex.Organizations.Organization)
belongs_to(:provisioned_by, Pretex.Accounts.User, foreign_key: :provisioned_by_id)
+ has_many(:device_assignments, Pretex.Devices.DeviceAssignment)
+
timestamps(type: :utc_datetime)
end
diff --git a/pretex/lib/pretex/devices/device_assignment.ex b/pretex/lib/pretex/devices/device_assignment.ex
new file mode 100644
index 0000000..27addb1
--- /dev/null
+++ b/pretex/lib/pretex/devices/device_assignment.ex
@@ -0,0 +1,20 @@
+defmodule Pretex.Devices.DeviceAssignment do
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "device_assignments" do
+ belongs_to(:device, Pretex.Devices.Device)
+ belongs_to(:event, Pretex.Events.Event)
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(assignment, attrs) do
+ assignment
+ |> cast(attrs, [:device_id, :event_id])
+ |> validate_required([:device_id, :event_id])
+ |> unique_constraint([:device_id, :event_id])
+ |> foreign_key_constraint(:device_id)
+ |> foreign_key_constraint(:event_id)
+ end
+end
diff --git a/pretex/lib/pretex/sync.ex b/pretex/lib/pretex/sync.ex
new file mode 100644
index 0000000..0c5fb10
--- /dev/null
+++ b/pretex/lib/pretex/sync.ex
@@ -0,0 +1,176 @@
+defmodule Pretex.Sync do
+ @moduledoc "Builds sync manifests and processes offline check-in uploads."
+
+ import Ecto.Query
+
+ alias Pretex.Repo
+ alias Pretex.Devices.DeviceAssignment
+ alias Pretex.Orders.{Order, OrderItem}
+ alias Pretex.CheckIns.CheckIn
+
+ def build_manifest(device_id, since) do
+ event_ids = assigned_event_ids(device_id)
+ server_timestamp = DateTime.utc_now() |> DateTime.truncate(:second)
+
+ events =
+ Pretex.Events.Event
+ |> where([e], e.id in ^event_ids)
+ |> Repo.all()
+ |> Enum.map(fn event ->
+ attendees = fetch_attendees(event.id, since)
+ removed = if since, do: fetch_removed_ticket_codes(event.id, since), else: []
+
+ %{
+ id: event.id,
+ name: event.name,
+ starts_at: event.starts_at,
+ ends_at: event.ends_at,
+ multi_entry: event.multi_entry,
+ attendees: attendees,
+ removed_ticket_codes: removed
+ }
+ end)
+
+ {:ok, %{events: events, server_timestamp: server_timestamp}}
+ end
+
+ def process_upload(device_id, results) do
+ allowed_event_ids = MapSet.new(assigned_event_ids(device_id))
+
+ Repo.transaction(fn ->
+ Enum.reduce(
+ results,
+ %{processed: 0, inserted: 0, conflicts_resolved: 0, skipped: 0, errors: 0},
+ fn entry, acc ->
+ acc = %{acc | processed: acc.processed + 1}
+
+ if entry.event_id not in allowed_event_ids do
+ %{acc | errors: acc.errors + 1}
+ else
+ case process_single_checkin(device_id, entry) do
+ :inserted -> %{acc | inserted: acc.inserted + 1}
+ :conflict_resolved -> %{acc | conflicts_resolved: acc.conflicts_resolved + 1}
+ :skipped -> %{acc | skipped: acc.skipped + 1}
+ :error -> %{acc | errors: acc.errors + 1}
+ end
+ end
+ end
+ )
+ end)
+ end
+
+ defp assigned_event_ids(device_id) do
+ DeviceAssignment
+ |> where([a], a.device_id == ^device_id)
+ |> select([a], a.event_id)
+ |> Repo.all()
+ end
+
+ defp fetch_attendees(event_id, nil) do
+ build_attendee_query(event_id)
+ |> Repo.all()
+ |> Enum.map(&format_attendee/1)
+ end
+
+ defp fetch_attendees(event_id, since) do
+ build_attendee_query(event_id)
+ |> where([oi, _o, _c], oi.updated_at > ^since)
+ |> Repo.all()
+ |> Enum.map(&format_attendee/1)
+ end
+
+ defp build_attendee_query(event_id) do
+ OrderItem
+ |> join(:inner, [oi], o in Order, on: oi.order_id == o.id)
+ |> join(:left, [oi, o], c in CheckIn,
+ on: c.order_item_id == oi.id and c.event_id == ^event_id and is_nil(c.annulled_at)
+ )
+ |> where([oi, o, _c], o.event_id == ^event_id and o.status == "confirmed")
+ |> preload([oi, o, _c], [:item, order: o])
+ |> select([oi, o, c], {oi, c})
+ end
+
+ defp format_attendee({order_item, check_in}) do
+ %{
+ ticket_code: order_item.ticket_code,
+ attendee_name: order_item.attendee_name || order_item.order.name,
+ attendee_email: order_item.attendee_email || order_item.order.email,
+ item_name: order_item.item.name,
+ checked_in_at: if(check_in, do: check_in.checked_in_at)
+ }
+ end
+
+ defp fetch_removed_ticket_codes(event_id, since) 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 == "cancelled" and
+ o.updated_at > ^since
+ )
+ |> select([oi, _o], oi.ticket_code)
+ |> Repo.all()
+ end
+
+ defp process_single_checkin(device_id, %{
+ ticket_code: ticket_code,
+ event_id: event_id,
+ checked_in_at: checked_in_at
+ }) do
+ order_item =
+ OrderItem
+ |> join(:inner, [oi], o in Order, on: oi.order_id == o.id)
+ |> where(
+ [oi, o],
+ oi.ticket_code == ^ticket_code and o.event_id == ^event_id and o.status == "confirmed"
+ )
+ |> Repo.one()
+
+ case order_item do
+ nil -> :error
+ oi -> upsert_check_in(oi.id, event_id, device_id, checked_in_at)
+ end
+ end
+
+ defp upsert_check_in(order_item_id, event_id, device_id, checked_in_at) do
+ checked_in_at = ensure_usec(checked_in_at)
+
+ 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 ->
+ %CheckIn{}
+ |> CheckIn.changeset(%{checked_in_at: checked_in_at, device_id: device_id})
+ |> Ecto.Changeset.put_change(:order_item_id, order_item_id)
+ |> Ecto.Changeset.put_change(:event_id, event_id)
+ |> Repo.insert!()
+
+ :inserted
+
+ check_in ->
+ if DateTime.compare(checked_in_at, check_in.checked_in_at) == :lt do
+ check_in
+ |> Ecto.Changeset.change(checked_in_at: checked_in_at, device_id: device_id)
+ |> Repo.update!()
+
+ :conflict_resolved
+ else
+ :skipped
+ end
+ end
+ end
+
+ defp ensure_usec(%DateTime{microsecond: {us, precision}} = dt) when precision < 6,
+ do: %{dt | microsecond: {us, 6}}
+
+ defp ensure_usec(%DateTime{} = dt), do: dt
+end
diff --git a/pretex/lib/pretex_web/controllers/sync_controller.ex b/pretex/lib/pretex_web/controllers/sync_controller.ex
new file mode 100644
index 0000000..eba374b
--- /dev/null
+++ b/pretex/lib/pretex_web/controllers/sync_controller.ex
@@ -0,0 +1,71 @@
+defmodule PretexWeb.SyncController do
+ use PretexWeb, :controller
+
+ alias Pretex.Sync
+
+ def manifest(conn, params) do
+ device = conn.assigns.current_device
+ since = parse_since(params["since"])
+
+ {:ok, manifest} = Sync.build_manifest(device.id, since)
+
+ json(conn, manifest)
+ end
+
+ def upload(conn, %{"checkins" => checkins}) do
+ device = conn.assigns.current_device
+
+ case parse_checkins(checkins) do
+ {:ok, results} ->
+ {:ok, summary} = Sync.process_upload(device.id, results)
+ json(conn, summary)
+
+ {:error, reason} ->
+ conn
+ |> put_status(:bad_request)
+ |> json(%{error: reason})
+ end
+ end
+
+ def upload(conn, _params) do
+ conn
+ |> put_status(:bad_request)
+ |> json(%{error: "Parâmetro checkins é obrigatório"})
+ end
+
+ defp parse_checkins(checkins) do
+ results =
+ Enum.reduce_while(checkins, {:ok, []}, fn entry, {:ok, acc} ->
+ case DateTime.from_iso8601(entry["checked_in_at"] || "") do
+ {:ok, dt, _} ->
+ {:cont,
+ {:ok,
+ [
+ %{
+ ticket_code: entry["ticket_code"],
+ event_id: entry["event_id"],
+ checked_in_at: dt
+ }
+ | acc
+ ]}}
+
+ {:error, _} ->
+ {:halt, {:error, "checked_in_at inválido: #{entry["checked_in_at"]}"}}
+ end
+ end)
+
+ case results do
+ {:ok, list} -> {:ok, Enum.reverse(list)}
+ error -> error
+ end
+ end
+
+ defp parse_since(nil), do: nil
+
+ defp parse_since(since_str) do
+ case DateTime.from_iso8601(since_str) do
+ {:ok, dt, _} -> dt
+ _ -> nil
+ end
+ end
+end
diff --git a/pretex/lib/pretex_web/live/admin/device_live/index.ex b/pretex/lib/pretex_web/live/admin/device_live/index.ex
index de11b48..f1f3c62 100644
--- a/pretex/lib/pretex_web/live/admin/device_live/index.ex
+++ b/pretex/lib/pretex_web/live/admin/device_live/index.ex
@@ -8,11 +8,13 @@ defmodule PretexWeb.Admin.DeviceLive.Index do
def mount(%{"org_id" => org_id}, _session, socket) do
org = Organizations.get_organization!(org_id)
devices = Devices.list_devices(org.id)
+ events = Devices.list_org_events(org.id)
socket =
socket
|> assign(:org, org)
|> assign(:devices, devices)
+ |> assign(:events, events)
|> assign(:generated_token, nil)
|> assign(:page_title, "Dispositivos — #{org.name}")
@@ -40,20 +42,75 @@ defmodule PretexWeb.Admin.DeviceLive.Index do
@impl true
def handle_event("revoke_device", %{"id" => id_str}, socket) do
- id = String.to_integer(id_str)
+ device_id = String.to_integer(id_str)
- case Devices.revoke_device(id) do
- {:ok, _} ->
- {:noreply,
- socket
- |> assign(:devices, Devices.list_devices(socket.assigns.org.id))
- |> put_flash(:info, "Acesso do dispositivo revogado.")}
+ with :ok <- verify_device_belongs_to_org(device_id, socket.assigns.org.id),
+ {:ok, _} <- Devices.revoke_device(device_id) do
+ {:noreply,
+ socket
+ |> assign(:devices, Devices.list_devices(socket.assigns.org.id))
+ |> put_flash(:info, "Acesso do dispositivo revogado.")}
+ else
+ _ -> {:noreply, put_flash(socket, :error, "Erro ao revogar dispositivo.")}
+ end
+ end
- {:error, _} ->
- {:noreply, put_flash(socket, :error, "Erro ao revogar dispositivo.")}
+ @impl true
+ def handle_event("assign_event", %{"event_id" => ""}, socket) do
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_event(
+ "assign_event",
+ %{"device_id" => device_id_str, "event_id" => event_id_str},
+ socket
+ ) do
+ device_id = String.to_integer(device_id_str)
+ event_id = String.to_integer(event_id_str)
+ org_id = socket.assigns.org.id
+
+ with :ok <- verify_device_belongs_to_org(device_id, org_id),
+ :ok <- verify_event_belongs_to_org(event_id, org_id),
+ {:ok, _} <- Devices.assign_device_to_event(device_id, event_id) do
+ {:noreply, assign(socket, :devices, Devices.list_devices(org_id))}
+ else
+ _ -> {:noreply, put_flash(socket, :error, "Dispositivo já atribuído a este evento.")}
end
end
+ @impl true
+ def handle_event(
+ "unassign_event",
+ %{"device_id" => device_id_str, "event_id" => event_id_str},
+ socket
+ ) do
+ device_id = String.to_integer(device_id_str)
+ event_id = String.to_integer(event_id_str)
+ org_id = socket.assigns.org.id
+
+ with :ok <- verify_device_belongs_to_org(device_id, org_id) do
+ Devices.unassign_device_from_event(device_id, event_id)
+ {:noreply, assign(socket, :devices, Devices.list_devices(org_id))}
+ else
+ _ -> {:noreply, put_flash(socket, :error, "Operação não permitida.")}
+ end
+ end
+
+ defp verify_device_belongs_to_org(device_id, org_id) do
+ device = Devices.get_device!(device_id)
+ if device.organization_id == org_id, do: :ok, else: :error
+ rescue
+ Ecto.NoResultsError -> :error
+ end
+
+ defp verify_event_belongs_to_org(event_id, org_id) do
+ event = Pretex.Events.get_event!(event_id)
+ if event.organization_id == org_id, do: :ok, else: :error
+ rescue
+ Ecto.NoResultsError -> :error
+ end
+
defp time_ago(nil), do: "Nunca"
defp time_ago(datetime) do
diff --git a/pretex/lib/pretex_web/live/admin/device_live/index.html.heex b/pretex/lib/pretex_web/live/admin/device_live/index.html.heex
index d801788..7eda355 100644
--- a/pretex/lib/pretex_web/live/admin/device_live/index.html.heex
+++ b/pretex/lib/pretex_web/live/admin/device_live/index.html.heex
@@ -45,45 +45,84 @@
-
-
+
+
+
+
+
+
{device.name}
+
+ Provisionado por {device.provisioned_by.name} ·
+ {Calendar.strftime(device.provisioned_at, "%d/%m/%Y %H:%M")}
+
+
-
-
{device.name}
-
- Provisionado por {device.provisioned_by.name} ·
- {Calendar.strftime(device.provisioned_at, "%d/%m/%Y %H:%M")}
-
-
-
-
-
-
Última atividade
-
{time_ago(device.last_seen_at)}
+
+
+
Última atividade
+
{time_ago(device.last_seen_at)}
+
+
+
+ {if device.status == "active", do: "Ativo", else: "Revogado"}
+
+
+
+
-
- {if device.status == "active", do: "Ativo", else: "Revogado"}
+ <%!-- Event assignments --%>
+
+
+ {assignment.event.name}
+
-
+
diff --git a/pretex/lib/pretex_web/plugs/device_auth.ex b/pretex/lib/pretex_web/plugs/device_auth.ex
new file mode 100644
index 0000000..b5eed75
--- /dev/null
+++ b/pretex/lib/pretex_web/plugs/device_auth.ex
@@ -0,0 +1,22 @@
+defmodule PretexWeb.Plugs.DeviceAuth do
+ @moduledoc "Authenticates device API requests via Bearer token."
+
+ import Plug.Conn
+
+ alias Pretex.Devices
+
+ def init(opts), do: opts
+
+ def call(conn, _opts) do
+ with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
+ {:ok, device} <- Devices.authenticate_device(token) do
+ assign(conn, :current_device, device)
+ else
+ _ ->
+ conn
+ |> put_status(401)
+ |> Phoenix.Controller.json(%{error: "Dispositivo não autenticado"})
+ |> halt()
+ end
+ end
+end
diff --git a/pretex/lib/pretex_web/router.ex b/pretex/lib/pretex_web/router.ex
index f73db44..8efa681 100644
--- a/pretex/lib/pretex_web/router.ex
+++ b/pretex/lib/pretex_web/router.ex
@@ -19,6 +19,11 @@ defmodule PretexWeb.Router do
plug(:accepts, ["json"])
end
+ pipeline :device_api do
+ plug :accepts, ["json"]
+ plug PretexWeb.Plugs.DeviceAuth
+ end
+
pipeline :require_customer_no_2fa do
plug(:require_authenticated_customer_no_2fa)
end
@@ -49,6 +54,15 @@ defmodule PretexWeb.Router do
post("/devices/provision", DeviceController, :provision)
end
+ # -- Device sync API (requires device token auth) ----------------------------
+
+ scope "/api/sync", PretexWeb do
+ pipe_through(:device_api)
+
+ get "/manifest", SyncController, :manifest
+ post "/checkins", SyncController, :upload
+ end
+
# -- Staff auth (magic link) -----------------------------------------------
scope "/staff", PretexWeb do
diff --git a/pretex/priv/repo/migrations/20260402220954_create_device_assignments_and_sync.exs b/pretex/priv/repo/migrations/20260402220954_create_device_assignments_and_sync.exs
new file mode 100644
index 0000000..2ec519a
--- /dev/null
+++ b/pretex/priv/repo/migrations/20260402220954_create_device_assignments_and_sync.exs
@@ -0,0 +1,21 @@
+defmodule Pretex.Repo.Migrations.CreateDeviceAssignmentsAndSync do
+ use Ecto.Migration
+
+ def change do
+ create table(:device_assignments) do
+ add :device_id, references(:devices, on_delete: :delete_all), null: false
+ add :event_id, references(:events, on_delete: :delete_all), null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create unique_index(:device_assignments, [:device_id, :event_id])
+ create index(:device_assignments, [:event_id])
+
+ alter table(:check_ins) do
+ add :device_id, references(:devices, on_delete: :nilify_all)
+ end
+
+ create index(:check_ins, [:device_id])
+ end
+end
diff --git a/pretex/priv/repo/migrations/20260406000001_allow_null_checked_in_by_id.exs b/pretex/priv/repo/migrations/20260406000001_allow_null_checked_in_by_id.exs
new file mode 100644
index 0000000..d74ee85
--- /dev/null
+++ b/pretex/priv/repo/migrations/20260406000001_allow_null_checked_in_by_id.exs
@@ -0,0 +1,9 @@
+defmodule Pretex.Repo.Migrations.AllowNullCheckedInById do
+ use Ecto.Migration
+
+ def change do
+ alter table(:check_ins) do
+ modify :checked_in_by_id, :bigint, null: true, from: {:bigint, null: false}
+ end
+ end
+end
diff --git a/pretex/test/pretex/devices/device_assignments_test.exs b/pretex/test/pretex/devices/device_assignments_test.exs
new file mode 100644
index 0000000..9ab296c
--- /dev/null
+++ b/pretex/test/pretex/devices/device_assignments_test.exs
@@ -0,0 +1,65 @@
+defmodule Pretex.Devices.DeviceAssignmentsTest do
+ use Pretex.DataCase, async: true
+
+ import Pretex.OrganizationsFixtures
+ import Pretex.AccountsFixtures
+ import Pretex.EventsFixtures
+
+ alias Pretex.Devices
+
+ defp provisioned_device_fixture(org) do
+ user = user_fixture()
+ {:ok, token_code} = Devices.generate_init_token(org.id, user.id)
+ {:ok, %{device: device}} = Devices.provision_device(token_code, "Test Device")
+ device
+ end
+
+ describe "assign_device_to_event/2" do
+ test "assigns a device to an event" do
+ org = org_fixture()
+ device = provisioned_device_fixture(org)
+ event = published_event_fixture(org)
+
+ assert {:ok, assignment} = Devices.assign_device_to_event(device.id, event.id)
+ assert assignment.device_id == device.id
+ assert assignment.event_id == event.id
+ end
+
+ test "prevents duplicate assignment" do
+ org = org_fixture()
+ device = provisioned_device_fixture(org)
+ event = published_event_fixture(org)
+
+ assert {:ok, _} = Devices.assign_device_to_event(device.id, event.id)
+ assert {:error, changeset} = Devices.assign_device_to_event(device.id, event.id)
+ assert errors_on(changeset)[:device_id]
+ end
+ end
+
+ describe "unassign_device_from_event/2" do
+ test "removes a device assignment" do
+ org = org_fixture()
+ device = provisioned_device_fixture(org)
+ event = published_event_fixture(org)
+
+ {:ok, _} = Devices.assign_device_to_event(device.id, event.id)
+ assert :ok = Devices.unassign_device_from_event(device.id, event.id)
+ assert [] = Devices.list_device_assignments(device.id)
+ end
+ end
+
+ describe "list_device_assignments/1" do
+ test "returns all events assigned to a device" do
+ org = org_fixture()
+ device = provisioned_device_fixture(org)
+ event1 = published_event_fixture(org)
+ event2 = published_event_fixture(org)
+
+ {:ok, _} = Devices.assign_device_to_event(device.id, event1.id)
+ {:ok, _} = Devices.assign_device_to_event(device.id, event2.id)
+
+ assignments = Devices.list_device_assignments(device.id)
+ assert length(assignments) == 2
+ end
+ end
+end
diff --git a/pretex/test/pretex/sync_test.exs b/pretex/test/pretex/sync_test.exs
new file mode 100644
index 0000000..6becced
--- /dev/null
+++ b/pretex/test/pretex/sync_test.exs
@@ -0,0 +1,278 @@
+defmodule Pretex.SyncTest do
+ use Pretex.DataCase, async: true
+
+ import Pretex.OrganizationsFixtures
+ import Pretex.AccountsFixtures
+ import Pretex.EventsFixtures
+ import Pretex.CatalogFixtures
+
+ alias Pretex.{Devices, Orders, Sync}
+
+ defp provisioned_device_fixture(org) do
+ user = user_fixture()
+ {:ok, token_code} = Devices.generate_init_token(org.id, user.id)
+ {:ok, %{device: device}} = Devices.provision_device(token_code, "Test Device")
+ device
+ end
+
+ defp confirmed_order_fixture(event, attrs \\ %{}) 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,
+ Enum.into(attrs, %{
+ name: "Jane Doe",
+ email: "jane@example.com",
+ payment_method: "pix"
+ })
+ )
+
+ {:ok, order} = Orders.confirm_order(order)
+ Orders.get_order!(order.id)
+ end
+
+ describe "build_manifest/2 (full sync)" do
+ test "returns event and attendee data for assigned events" do
+ org = org_fixture()
+ device = provisioned_device_fixture(org)
+ event = published_event_fixture(org)
+ order = confirmed_order_fixture(event)
+ [order_item | _] = order.order_items
+
+ {:ok, _} = Devices.assign_device_to_event(device.id, event.id)
+
+ {:ok, manifest} = Sync.build_manifest(device.id, nil)
+
+ assert length(manifest.events) == 1
+ [ev] = manifest.events
+ assert ev.id == event.id
+ assert ev.name == event.name
+ assert length(ev.attendees) >= 1
+
+ attendee = Enum.find(ev.attendees, &(&1.ticket_code == order_item.ticket_code))
+ assert attendee
+ assert attendee.attendee_name == "Jane Doe"
+ assert attendee.checked_in_at == nil
+ assert manifest.server_timestamp
+ end
+
+ test "returns empty events when device has no assignments" do
+ org = org_fixture()
+ device = provisioned_device_fixture(org)
+
+ {:ok, manifest} = Sync.build_manifest(device.id, nil)
+ assert manifest.events == []
+ end
+
+ test "excludes unconfirmed orders" do
+ org = org_fixture()
+ device = provisioned_device_fixture(org)
+ event = published_event_fixture(org)
+
+ {: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: "Pending Person",
+ email: "pending@example.com",
+ payment_method: "pix"
+ })
+
+ {:ok, _} = Devices.assign_device_to_event(device.id, event.id)
+
+ {:ok, manifest} = Sync.build_manifest(device.id, nil)
+ [ev] = manifest.events
+ assert Enum.all?(ev.attendees, &(&1.attendee_name != "Pending Person"))
+ end
+ end
+
+ describe "build_manifest/2 (incremental sync)" do
+ test "returns only attendees updated since given timestamp" do
+ org = org_fixture()
+ device = provisioned_device_fixture(org)
+ event = published_event_fixture(org)
+
+ order1 = confirmed_order_fixture(event, %{name: "Early Bird", email: "early@test.com"})
+
+ # Backdate order1's items so they fall before the since cutoff
+ past = DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.truncate(:second)
+
+ Enum.each(order1.order_items, fn oi ->
+ oi |> Ecto.Changeset.change(updated_at: past) |> Pretex.Repo.update!()
+ end)
+
+ since = DateTime.utc_now() |> DateTime.add(-1, :second)
+ _order2 = confirmed_order_fixture(event, %{name: "Late Comer", email: "late@test.com"})
+
+ {:ok, _} = Devices.assign_device_to_event(device.id, event.id)
+
+ {:ok, manifest} = Sync.build_manifest(device.id, since)
+ [ev] = manifest.events
+
+ names = Enum.map(ev.attendees, & &1.attendee_name)
+ assert "Late Comer" in names
+ refute "Early Bird" in names
+ end
+
+ test "returns cancelled ticket codes for removal" do
+ org = org_fixture()
+ device = provisioned_device_fixture(org)
+ event = published_event_fixture(org)
+
+ order = confirmed_order_fixture(event)
+ [order_item | _] = order.order_items
+ since = DateTime.utc_now() |> DateTime.add(-1, :second)
+
+ {:ok, _} = Orders.cancel_order(order)
+ {:ok, _} = Devices.assign_device_to_event(device.id, event.id)
+
+ {:ok, manifest} = Sync.build_manifest(device.id, since)
+ [ev] = manifest.events
+ assert order_item.ticket_code in ev.removed_ticket_codes
+ end
+ end
+
+ describe "process_upload/2" do
+ test "inserts check-ins from offline device" do
+ org = org_fixture()
+ device = provisioned_device_fixture(org)
+ event = published_event_fixture(org)
+ order = confirmed_order_fixture(event)
+ [order_item | _] = order.order_items
+
+ {:ok, _} = Devices.assign_device_to_event(device.id, event.id)
+
+ results = [
+ %{
+ ticket_code: order_item.ticket_code,
+ event_id: event.id,
+ checked_in_at: ~U[2026-04-02 09:15:00Z]
+ }
+ ]
+
+ assert {:ok, summary} = Sync.process_upload(device.id, results)
+ assert summary.inserted == 1
+ assert summary.conflicts_resolved == 0
+ assert summary.skipped == 0
+ end
+
+ test "resolves conflict by keeping earliest timestamp" do
+ org = org_fixture()
+ device = provisioned_device_fixture(org)
+ event = published_event_fixture(org)
+ order = confirmed_order_fixture(event)
+ operator = user_fixture()
+ [order_item | _] = order.order_items
+
+ {:ok, _} = Devices.assign_device_to_event(device.id, event.id)
+
+ {:ok, _} =
+ Pretex.CheckIns.check_in_by_ticket_code(
+ event.id,
+ order_item.ticket_code,
+ operator.id
+ )
+
+ results = [
+ %{
+ ticket_code: order_item.ticket_code,
+ event_id: event.id,
+ checked_in_at: ~U[2026-04-02 09:15:00Z]
+ }
+ ]
+
+ assert {:ok, summary} = Sync.process_upload(device.id, results)
+ assert summary.conflicts_resolved == 1
+
+ check_in = Pretex.CheckIns.get_active_check_in(order_item.id, event.id)
+ assert DateTime.compare(check_in.checked_in_at, ~U[2026-04-02 09:15:00Z]) == :eq
+ end
+
+ test "skips when existing check-in is earlier" do
+ org = org_fixture()
+ device = provisioned_device_fixture(org)
+ event = published_event_fixture(org)
+ order = confirmed_order_fixture(event)
+ operator = user_fixture()
+ [order_item | _] = order.order_items
+
+ {:ok, _} = Devices.assign_device_to_event(device.id, event.id)
+
+ {:ok, existing} =
+ Pretex.CheckIns.check_in_by_ticket_code(
+ event.id,
+ order_item.ticket_code,
+ operator.id
+ )
+
+ results = [
+ %{
+ ticket_code: order_item.ticket_code,
+ event_id: event.id,
+ checked_in_at: ~U[2030-01-01 12:00:00Z]
+ }
+ ]
+
+ assert {:ok, summary} = Sync.process_upload(device.id, results)
+ assert summary.skipped == 1
+
+ check_in = Pretex.CheckIns.get_active_check_in(order_item.id, event.id)
+ assert check_in.checked_in_at == existing.checked_in_at
+ end
+
+ test "handles multiple check-ins in one upload" do
+ org = org_fixture()
+ device = provisioned_device_fixture(org)
+ event = published_event_fixture(org)
+ order1 = confirmed_order_fixture(event, %{name: "Alice", email: "alice@test.com"})
+ order2 = confirmed_order_fixture(event, %{name: "Bob", email: "bob@test.com"})
+ [oi1 | _] = order1.order_items
+ [oi2 | _] = order2.order_items
+
+ {:ok, _} = Devices.assign_device_to_event(device.id, event.id)
+
+ results = [
+ %{
+ ticket_code: oi1.ticket_code,
+ event_id: event.id,
+ checked_in_at: ~U[2026-04-02 09:00:00Z]
+ },
+ %{
+ ticket_code: oi2.ticket_code,
+ event_id: event.id,
+ checked_in_at: ~U[2026-04-02 09:01:00Z]
+ }
+ ]
+
+ assert {:ok, summary} = Sync.process_upload(device.id, results)
+ assert summary.inserted == 2
+ assert summary.processed == 2
+ end
+
+ test "skips invalid ticket codes gracefully" do
+ org = org_fixture()
+ device = provisioned_device_fixture(org)
+ event = published_event_fixture(org)
+
+ {:ok, _} = Devices.assign_device_to_event(device.id, event.id)
+
+ results = [
+ %{ticket_code: "NONEXISTENT", event_id: event.id, checked_in_at: ~U[2026-04-02 09:00:00Z]}
+ ]
+
+ assert {:ok, summary} = Sync.process_upload(device.id, results)
+ assert summary.errors == 1
+ assert summary.inserted == 0
+ end
+ end
+end
diff --git a/pretex/test/pretex_web/controllers/sync_controller_test.exs b/pretex/test/pretex_web/controllers/sync_controller_test.exs
new file mode 100644
index 0000000..0820568
--- /dev/null
+++ b/pretex/test/pretex_web/controllers/sync_controller_test.exs
@@ -0,0 +1,122 @@
+defmodule PretexWeb.SyncControllerTest do
+ use PretexWeb.ConnCase, async: true
+
+ import Pretex.OrganizationsFixtures
+ import Pretex.AccountsFixtures
+ import Pretex.EventsFixtures
+ import Pretex.CatalogFixtures
+
+ alias Pretex.{Devices, Orders}
+
+ defp provisioned_device_with_token(org) do
+ user = user_fixture()
+ {:ok, token_code} = Devices.generate_init_token(org.id, user.id)
+
+ {:ok, %{device: device, api_token: api_token}} =
+ Devices.provision_device(token_code, "Test Device")
+
+ {device, api_token}
+ end
+
+ 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
+
+ defp auth_conn(conn, api_token) do
+ put_req_header(conn, "authorization", "Bearer #{api_token}")
+ end
+
+ describe "GET /api/sync/manifest" do
+ test "returns manifest for authenticated device", %{conn: conn} do
+ org = org_fixture()
+ {device, api_token} = provisioned_device_with_token(org)
+ event = published_event_fixture(org)
+ _order = confirmed_order_fixture(event)
+
+ {:ok, _} = Devices.assign_device_to_event(device.id, event.id)
+
+ conn =
+ conn
+ |> auth_conn(api_token)
+ |> get("/api/sync/manifest")
+
+ assert %{"events" => [ev], "server_timestamp" => _} = json_response(conn, 200)
+ assert ev["id"] == event.id
+ assert length(ev["attendees"]) >= 1
+ end
+
+ test "supports incremental sync with since param", %{conn: conn} do
+ org = org_fixture()
+ {device, api_token} = provisioned_device_with_token(org)
+ event = published_event_fixture(org)
+
+ {:ok, _} = Devices.assign_device_to_event(device.id, event.id)
+
+ conn1 =
+ conn
+ |> auth_conn(api_token)
+ |> get("/api/sync/manifest")
+
+ %{"server_timestamp" => ts} = json_response(conn1, 200)
+
+ conn2 =
+ build_conn()
+ |> auth_conn(api_token)
+ |> get("/api/sync/manifest", %{since: ts})
+
+ %{"events" => events} = json_response(conn2, 200)
+ assert Enum.all?(events, fn ev -> ev["attendees"] == [] end)
+ end
+
+ test "returns 401 without auth", %{conn: conn} do
+ conn = get(conn, "/api/sync/manifest")
+ assert json_response(conn, 401)
+ end
+ end
+
+ describe "POST /api/sync/checkins" do
+ test "uploads offline check-ins", %{conn: conn} do
+ org = org_fixture()
+ {device, api_token} = provisioned_device_with_token(org)
+ event = published_event_fixture(org)
+ order = confirmed_order_fixture(event)
+ [order_item | _] = order.order_items
+
+ {:ok, _} = Devices.assign_device_to_event(device.id, event.id)
+
+ conn =
+ conn
+ |> auth_conn(api_token)
+ |> post("/api/sync/checkins", %{
+ "checkins" => [
+ %{
+ "ticket_code" => order_item.ticket_code,
+ "event_id" => event.id,
+ "checked_in_at" => "2026-04-02T09:15:00Z"
+ }
+ ]
+ })
+
+ assert %{"inserted" => 1, "processed" => 1} = json_response(conn, 200)
+ end
+
+ test "returns 401 without auth", %{conn: conn} do
+ conn = post(conn, "/api/sync/checkins", %{"checkins" => []})
+ assert json_response(conn, 401)
+ end
+ end
+end
diff --git a/pretex/test/pretex_web/plugs/device_auth_test.exs b/pretex/test/pretex_web/plugs/device_auth_test.exs
new file mode 100644
index 0000000..baaf314
--- /dev/null
+++ b/pretex/test/pretex_web/plugs/device_auth_test.exs
@@ -0,0 +1,67 @@
+defmodule PretexWeb.Plugs.DeviceAuthTest do
+ use PretexWeb.ConnCase, async: true
+
+ import Pretex.OrganizationsFixtures
+ import Pretex.AccountsFixtures
+
+ alias PretexWeb.Plugs.DeviceAuth
+ alias Pretex.Devices
+
+ defp provisioned_device_with_token(org) do
+ user = user_fixture()
+ {:ok, token_code} = Devices.generate_init_token(org.id, user.id)
+
+ {:ok, %{device: device, api_token: api_token}} =
+ Devices.provision_device(token_code, "Test Device")
+
+ {device, api_token}
+ end
+
+ describe "call/2" do
+ test "assigns device when valid token provided" do
+ org = org_fixture()
+ {device, api_token} = provisioned_device_with_token(org)
+
+ conn =
+ build_conn()
+ |> put_req_header("authorization", "Bearer #{api_token}")
+ |> DeviceAuth.call([])
+
+ assert conn.assigns.current_device.id == device.id
+ refute conn.halted
+ end
+
+ test "returns 401 when no token provided" do
+ conn =
+ build_conn()
+ |> DeviceAuth.call([])
+
+ assert conn.status == 401
+ assert conn.halted
+ end
+
+ test "returns 401 when token is invalid" do
+ conn =
+ build_conn()
+ |> put_req_header("authorization", "Bearer invalid_token")
+ |> DeviceAuth.call([])
+
+ assert conn.status == 401
+ assert conn.halted
+ end
+
+ test "returns 401 when device is revoked" do
+ org = org_fixture()
+ {device, api_token} = provisioned_device_with_token(org)
+ {:ok, _} = Devices.revoke_device(device.id)
+
+ conn =
+ build_conn()
+ |> put_req_header("authorization", "Bearer #{api_token}")
+ |> DeviceAuth.call([])
+
+ assert conn.status == 401
+ assert conn.halted
+ end
+ end
+end