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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pretex/lib/pretex/check_ins/check_in.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 30 additions & 2 deletions pretex/lib/pretex/devices.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions pretex/lib/pretex/devices/device.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions pretex/lib/pretex/devices/device_assignment.ex
Original file line number Diff line number Diff line change
@@ -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
176 changes: 176 additions & 0 deletions pretex/lib/pretex/sync.ex
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions pretex/lib/pretex_web/controllers/sync_controller.ex
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading