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