From b1c98b3623e3a8438605160de2a3d4551e63b494 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Thu, 2 Apr 2026 19:10:26 -0300 Subject: [PATCH 01/13] feat: add device_assignments table and device_id to check_ins Co-Authored-By: Claude Opus 4.6 (1M context) --- ...954_create_device_assignments_and_sync.exs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 pretex/priv/repo/migrations/20260402220954_create_device_assignments_and_sync.exs 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 From 94bf30b7bb6ed7ec23ec201cab436a88c503351d Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Thu, 2 Apr 2026 19:13:48 -0300 Subject: [PATCH 02/13] feat: add DeviceAssignment schema and device_id to CheckIn Co-Authored-By: Claude Opus 4.6 (1M context) --- pretex/lib/pretex/check_ins/check_in.ex | 3 ++- pretex/lib/pretex/devices/device.ex | 2 ++ .../lib/pretex/devices/device_assignment.ex | 20 +++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 pretex/lib/pretex/devices/device_assignment.ex 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/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 From 9e86600967d82f6fa9d2c9200c8dacb761a692f0 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Thu, 2 Apr 2026 19:15:22 -0300 Subject: [PATCH 03/13] feat: add device-to-event assignment CRUD Co-Authored-By: Claude Opus 4.6 (1M context) --- pretex/lib/pretex/devices.ex | 23 ++++++- .../devices/device_assignments_test.exs | 65 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 pretex/test/pretex/devices/device_assignments_test.exs diff --git a/pretex/lib/pretex/devices.ex b/pretex/lib/pretex/devices.ex index d1c5d57..72a16a6 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 @@ -109,6 +109,27 @@ 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 + defp generate_short_code do part = fn -> :crypto.strong_rand_bytes(2) 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 From b2c897fca2e7f090f2e7118a2204ade3ea18d6f4 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Thu, 2 Apr 2026 19:16:43 -0300 Subject: [PATCH 04/13] feat: add DeviceAuth plug for API token authentication Co-Authored-By: Claude Opus 4.6 (1M context) --- pretex/lib/pretex_web/plugs/device_auth.ex | 22 ++++++ .../pretex_web/plugs/device_auth_test.exs | 67 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 pretex/lib/pretex_web/plugs/device_auth.ex create mode 100644 pretex/test/pretex_web/plugs/device_auth_test.exs 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/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 From 379e52b598e2360999ce26c60f1f9cb0c9c5e0b2 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Thu, 2 Apr 2026 19:22:30 -0300 Subject: [PATCH 05/13] feat: add Sync context with manifest builder and upload processing Co-Authored-By: Claude Opus 4.6 (1M context) --- pretex/lib/pretex/sync.ex | 168 +++++++++++++++++++ pretex/test/pretex/sync_test.exs | 269 +++++++++++++++++++++++++++++++ 2 files changed, 437 insertions(+) create mode 100644 pretex/lib/pretex/sync.ex create mode 100644 pretex/test/pretex/sync_test.exs diff --git a/pretex/lib/pretex/sync.ex b/pretex/lib/pretex/sync.ex new file mode 100644 index 0000000..f3e0fe1 --- /dev/null +++ b/pretex/lib/pretex/sync.ex @@ -0,0 +1,168 @@ +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 = + Enum.map(event_ids, fn event_id -> + event = Repo.get!(Pretex.Events.Event, event_id) + 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 + summary = + Enum.reduce( + results, + %{processed: 0, inserted: 0, conflicts_resolved: 0, skipped: 0, errors: 0}, + fn entry, acc -> + acc = %{acc | processed: acc.processed + 1} + + 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 + ) + + {:ok, summary} + 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(event_id, &1)) + end + + defp fetch_attendees(event_id, since) do + build_attendee_query(event_id) + |> where([oi, _o], oi.updated_at > ^since) + |> Repo.all() + |> Enum.map(&format_attendee(event_id, &1)) + end + + defp build_attendee_query(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") + |> preload([oi, o], [:item, order: o]) + end + + defp format_attendee(event_id, order_item) do + check_in = + CheckIn + |> where( + [c], + c.order_item_id == ^order_item.id and + c.event_id == ^event_id and + is_nil(c.annulled_at) + ) + |> Repo.one() + + %{ + ticket_code: order_item.ticket_code, + attendee_name: order_item.attendee_name, + attendee_email: order_item.attendee_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 + 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 +end diff --git a/pretex/test/pretex/sync_test.exs b/pretex/test/pretex/sync_test.exs new file mode 100644 index 0000000..ddb2627 --- /dev/null +++ b/pretex/test/pretex/sync_test.exs @@ -0,0 +1,269 @@ +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"}) + 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 + 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 From 1e729ea7b323b50aefc40c0eb27b0ee1342da746 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Thu, 2 Apr 2026 19:29:54 -0300 Subject: [PATCH 06/13] feat: add sync API endpoints for manifest download and check-in upload Co-Authored-By: Claude Opus 4.6 (1M context) --- .../pretex_web/controllers/sync_controller.ex | 51 ++++++++ pretex/lib/pretex_web/router.ex | 14 ++ .../controllers/sync_controller_test.exs | 122 ++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 pretex/lib/pretex_web/controllers/sync_controller.ex create mode 100644 pretex/test/pretex_web/controllers/sync_controller_test.exs 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..7a6070f --- /dev/null +++ b/pretex/lib/pretex_web/controllers/sync_controller.ex @@ -0,0 +1,51 @@ +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 + + results = + Enum.map(checkins, fn entry -> + %{ + ticket_code: entry["ticket_code"], + event_id: entry["event_id"], + checked_in_at: parse_datetime!(entry["checked_in_at"]) + } + end) + + {:ok, summary} = Sync.process_upload(device.id, results) + + json(conn, summary) + end + + def upload(conn, _params) do + conn + |> put_status(:bad_request) + |> json(%{error: "Parâmetro checkins é obrigatório"}) + 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 + + defp parse_datetime!(str) do + {:ok, dt, _} = DateTime.from_iso8601(str) + dt + 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/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 From cefc97d5ceb340a5e2b4ff40e0dd0bc55562ad71 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Thu, 2 Apr 2026 19:35:08 -0300 Subject: [PATCH 07/13] feat: add device-to-event assignment UI in admin panel Co-Authored-By: Claude Opus 4.6 (1M context) --- pretex/lib/pretex/devices.ex | 9 +- .../live/admin/device_live/index.ex | 33 ++++++ .../live/admin/device_live/index.html.heex | 100 ++++++++++++------ 3 files changed, 109 insertions(+), 33 deletions(-) diff --git a/pretex/lib/pretex/devices.ex b/pretex/lib/pretex/devices.ex index 72a16a6..22475a1 100644 --- a/pretex/lib/pretex/devices.ex +++ b/pretex/lib/pretex/devices.ex @@ -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 @@ -130,6 +130,13 @@ defmodule Pretex.Devices do |> 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_web/live/admin/device_live/index.ex b/pretex/lib/pretex_web/live/admin/device_live/index.ex index de11b48..da84307 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}") @@ -54,6 +56,37 @@ defmodule PretexWeb.Admin.DeviceLive.Index do end 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) + + case Devices.assign_device_to_event(device_id, event_id) do + {:ok, _} -> + {:noreply, assign(socket, :devices, Devices.list_devices(socket.assigns.org.id))} + + {:error, _} -> + {: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) + + Devices.unassign_device_from_event(device_id, event_id) + {:noreply, assign(socket, :devices, Devices.list_devices(socket.assigns.org.id))} + 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..7ce7fc3 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,81 @@
-
-
+
+
+
+
+
+

{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} + - +
+ + +
From 9b12820341e85e19e3acb279288b58ae9013c4fd Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Thu, 2 Apr 2026 19:53:18 -0300 Subject: [PATCH 08/13] fix: add submit button to event assignment form and validate event scope on upload Co-Authored-By: Claude Opus 4.6 (1M context) --- pretex/lib/pretex/sync.ex | 16 +++++++++++----- .../live/admin/device_live/index.html.heex | 5 ++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/pretex/lib/pretex/sync.ex b/pretex/lib/pretex/sync.ex index f3e0fe1..370e3c0 100644 --- a/pretex/lib/pretex/sync.ex +++ b/pretex/lib/pretex/sync.ex @@ -33,6 +33,8 @@ defmodule Pretex.Sync do end def process_upload(device_id, results) do + allowed_event_ids = MapSet.new(assigned_event_ids(device_id)) + summary = Enum.reduce( results, @@ -40,11 +42,15 @@ defmodule Pretex.Sync do fn entry, acc -> acc = %{acc | processed: acc.processed + 1} - 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} + 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 ) 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 7ce7fc3..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 @@ -105,7 +105,7 @@ -
+ +
From 2c2a2e2335893f8589525e0a0b62d89fd474b127 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 6 Apr 2026 17:15:13 -0300 Subject: [PATCH 09/13] fix: allow nullable checked_in_by_id for device-originated check-ins --- pretex/lib/pretex/sync.ex | 7 +++++-- .../20260406000001_allow_null_checked_in_by_id.exs | 9 +++++++++ pretex/test/pretex/sync_test.exs | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 pretex/priv/repo/migrations/20260406000001_allow_null_checked_in_by_id.exs diff --git a/pretex/lib/pretex/sync.ex b/pretex/lib/pretex/sync.ex index 370e3c0..0b85cbb 100644 --- a/pretex/lib/pretex/sync.ex +++ b/pretex/lib/pretex/sync.ex @@ -98,8 +98,8 @@ defmodule Pretex.Sync do %{ ticket_code: order_item.ticket_code, - attendee_name: order_item.attendee_name, - attendee_email: order_item.attendee_email, + 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) } @@ -139,6 +139,9 @@ defmodule Pretex.Sync do end defp upsert_check_in(order_item_id, event_id, device_id, checked_in_at) do + {s, _precision} = checked_in_at.microsecond + checked_in_at = %{checked_in_at | microsecond: {s, 6}} + existing = CheckIn |> where( 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/sync_test.exs b/pretex/test/pretex/sync_test.exs index ddb2627..eec64ae 100644 --- a/pretex/test/pretex/sync_test.exs +++ b/pretex/test/pretex/sync_test.exs @@ -103,7 +103,7 @@ defmodule Pretex.SyncTest do event = published_event_fixture(org) _order1 = confirmed_order_fixture(event, %{name: "Early Bird", email: "early@test.com"}) - since = DateTime.utc_now() |> DateTime.add(1, :second) + 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) From 6548be26c57469d1b55e82c1482dc0f27350703a Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 6 Apr 2026 19:57:57 -0300 Subject: [PATCH 10/13] fix: eliminate N+1 queries and add transaction to sync upload --- pretex/lib/pretex/sync.ex | 49 +++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/pretex/lib/pretex/sync.ex b/pretex/lib/pretex/sync.ex index 0b85cbb..0c5fb10 100644 --- a/pretex/lib/pretex/sync.ex +++ b/pretex/lib/pretex/sync.ex @@ -13,10 +13,12 @@ defmodule Pretex.Sync do server_timestamp = DateTime.utc_now() |> DateTime.truncate(:second) events = - Enum.map(event_ids, fn event_id -> - event = Repo.get!(Pretex.Events.Event, event_id) - attendees = fetch_attendees(event_id, since) - removed = if since, do: fetch_removed_ticket_codes(event_id, since), else: [] + 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, @@ -35,7 +37,7 @@ defmodule Pretex.Sync do def process_upload(device_id, results) do allowed_event_ids = MapSet.new(assigned_event_ids(device_id)) - summary = + Repo.transaction(fn -> Enum.reduce( results, %{processed: 0, inserted: 0, conflicts_resolved: 0, skipped: 0, errors: 0}, @@ -54,8 +56,7 @@ defmodule Pretex.Sync do end end ) - - {:ok, summary} + end) end defp assigned_event_ids(device_id) do @@ -68,34 +69,28 @@ defmodule Pretex.Sync do defp fetch_attendees(event_id, nil) do build_attendee_query(event_id) |> Repo.all() - |> Enum.map(&format_attendee(event_id, &1)) + |> Enum.map(&format_attendee/1) end defp fetch_attendees(event_id, since) do build_attendee_query(event_id) - |> where([oi, _o], oi.updated_at > ^since) + |> where([oi, _o, _c], oi.updated_at > ^since) |> Repo.all() - |> Enum.map(&format_attendee(event_id, &1)) + |> 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) - |> where([oi, o], o.event_id == ^event_id and o.status == "confirmed") - |> preload([oi, o], [:item, order: o]) + |> 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(event_id, order_item) do - check_in = - CheckIn - |> where( - [c], - c.order_item_id == ^order_item.id and - c.event_id == ^event_id and - is_nil(c.annulled_at) - ) - |> Repo.one() - + defp format_attendee({order_item, check_in}) do %{ ticket_code: order_item.ticket_code, attendee_name: order_item.attendee_name || order_item.order.name, @@ -139,8 +134,7 @@ defmodule Pretex.Sync do end defp upsert_check_in(order_item_id, event_id, device_id, checked_in_at) do - {s, _precision} = checked_in_at.microsecond - checked_in_at = %{checked_in_at | microsecond: {s, 6}} + checked_in_at = ensure_usec(checked_in_at) existing = CheckIn @@ -174,4 +168,9 @@ defmodule Pretex.Sync do 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 From b53cd416358f01d04276150d685b5ba8b7aef31e Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 6 Apr 2026 19:58:00 -0300 Subject: [PATCH 11/13] fix: validate datetime input and handle bad requests in sync API --- .../pretex_web/controllers/sync_controller.ex | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/pretex/lib/pretex_web/controllers/sync_controller.ex b/pretex/lib/pretex_web/controllers/sync_controller.ex index 7a6070f..eba374b 100644 --- a/pretex/lib/pretex_web/controllers/sync_controller.ex +++ b/pretex/lib/pretex_web/controllers/sync_controller.ex @@ -15,18 +15,16 @@ defmodule PretexWeb.SyncController do def upload(conn, %{"checkins" => checkins}) do device = conn.assigns.current_device - results = - Enum.map(checkins, fn entry -> - %{ - ticket_code: entry["ticket_code"], - event_id: entry["event_id"], - checked_in_at: parse_datetime!(entry["checked_in_at"]) - } - end) + case parse_checkins(checkins) do + {:ok, results} -> + {:ok, summary} = Sync.process_upload(device.id, results) + json(conn, summary) - {: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 @@ -35,6 +33,33 @@ defmodule PretexWeb.SyncController do |> 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 @@ -43,9 +68,4 @@ defmodule PretexWeb.SyncController do _ -> nil end end - - defp parse_datetime!(str) do - {:ok, dt, _} = DateTime.from_iso8601(str) - dt - end end From 16f75a7d6bf88a765ed3fe8d1872af84fef072ab Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 6 Apr 2026 19:58:04 -0300 Subject: [PATCH 12/13] fix: add org-scoped authorization to device LiveView actions --- .../live/admin/device_live/index.ex | 64 +++++++++++++------ 1 file changed, 44 insertions(+), 20 deletions(-) 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 da84307..f1f3c62 100644 --- a/pretex/lib/pretex_web/live/admin/device_live/index.ex +++ b/pretex/lib/pretex_web/live/admin/device_live/index.ex @@ -42,20 +42,24 @@ defmodule PretexWeb.Admin.DeviceLive.Index do @impl true def handle_event("revoke_device", %{"id" => id_str}, socket) do - 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.")} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Erro ao revogar dispositivo.")} + device_id = String.to_integer(id_str) + + 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 + @impl true + def handle_event("assign_event", %{"event_id" => ""}, socket) do + {:noreply, socket} + end + @impl true def handle_event( "assign_event", @@ -64,13 +68,14 @@ defmodule PretexWeb.Admin.DeviceLive.Index do ) do device_id = String.to_integer(device_id_str) event_id = String.to_integer(event_id_str) - - case Devices.assign_device_to_event(device_id, event_id) do - {:ok, _} -> - {:noreply, assign(socket, :devices, Devices.list_devices(socket.assigns.org.id))} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Dispositivo já atribuído a este evento.")} + 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 @@ -82,9 +87,28 @@ defmodule PretexWeb.Admin.DeviceLive.Index do ) 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 - Devices.unassign_device_from_event(device_id, event_id) - {:noreply, assign(socket, :devices, Devices.list_devices(socket.assigns.org.id))} + 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" From 0afe6dea5017c2679da8d10e6e2210ae04134065 Mon Sep 17 00:00:00 2001 From: Iago Cavalcante Date: Mon, 6 Apr 2026 19:58:08 -0300 Subject: [PATCH 13/13] test: improve incremental sync test with explicit backdating --- pretex/test/pretex/sync_test.exs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pretex/test/pretex/sync_test.exs b/pretex/test/pretex/sync_test.exs index eec64ae..6becced 100644 --- a/pretex/test/pretex/sync_test.exs +++ b/pretex/test/pretex/sync_test.exs @@ -102,7 +102,15 @@ defmodule Pretex.SyncTest do device = provisioned_device_fixture(org) event = published_event_fixture(org) - _order1 = confirmed_order_fixture(event, %{name: "Early Bird", email: "early@test.com"}) + 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"}) @@ -113,6 +121,7 @@ defmodule Pretex.SyncTest do 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