Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improve enrolment checking UX #529

Merged
merged 6 commits into from
Feb 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/safira/accounts/roles/permissions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ defmodule Safira.Accounts.Roles.Permissions do
"minigames" => ["show", "edit", "simulate"],
"event" => ["show", "edit", "edit_faqs", "generate_credentials"],
"spotlights" => ["edit"],
"schedule" => ["edit"],
"schedule" => ["show", "edit"],
"statistics" => ["show"],
"mailer" => ["send"],
"scanner" => ["show"]
Expand Down
40 changes: 36 additions & 4 deletions lib/safira/activities.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule Safira.Activities do

use Safira.Context

alias Safira.Accounts.{Attendee, User}
alias Safira.Activities.{Activity, ActivityCategory, Enrolment, Speaker}

@doc """
Expand Down Expand Up @@ -42,6 +43,17 @@ defmodule Safira.Activities do
|> Flop.validate_and_run(params, for: Activity)
end

def list_enrolled_attendees(activity_id, params \\ %{}, opts \\ []) do
User
|> join(:inner, [u], at in Attendee, on: u.id == at.user_id)
|> join(:inner, [u, at], e in Enrolment, on: e.attendee_id == at.id)
|> where([u, at, e], e.activity_id == ^activity_id)
|> select([u, at, e], u)
|> preload(:attendee)
|> apply_filters(opts)
|> Flop.validate_and_run(params, for: User)
end

@doc """
Returns the count of activities.

Expand Down Expand Up @@ -456,6 +468,20 @@ defmodule Safira.Activities do
|> Repo.all()
end

@doc """
Checks if an attendee is enrolled for an activity.

## Examples

iex> attendee_enrolled?(activity_id, attendee_id)
true
"""
def attendee_enrolled?(activity_id, attendee_id) do
Enrolment
|> where([e], e.attendee_id == ^attendee_id and e.activity_id == ^activity_id)
|> Repo.exists?()
end

@doc """
Enrols an attendee in an activity

Expand Down Expand Up @@ -500,15 +526,21 @@ defmodule Safira.Activities do
iex> unenrol(attendee_id, activity_id)
{:error, :struct, %Ecto.Changeset{}, %{}}
"""
def unenrol(enrolment) do
def unenrol(activity_id, attendee_id) do
Ecto.Multi.new()
# We need to read the activity before updating the enrolment count to avoid
# a race condition where the enrolment count changes after the activity was last
# read from the database, and before this transaction began
|> Ecto.Multi.one(:activity, Activity |> where([a], a.id == ^enrolment.activity_id))
|> Ecto.Multi.delete(
|> Ecto.Multi.one(
:enrolment,
enrolment
Enrolment |> where([e], e.activity_id == ^activity_id and e.attendee_id == ^attendee_id)
)
|> Ecto.Multi.one(:activity, Activity |> where([a], a.id == ^activity_id))
|> Ecto.Multi.delete(
:deleted_enrolment,
fn %{enrolment: enrolment} ->
enrolment
end
)
|> Ecto.Multi.update(:new_activity, fn %{activity: act} ->
Activity.changeset(act, %{enrolment_count: act.enrolment_count - 1})
Expand Down
147 changes: 147 additions & 0 deletions lib/safira_web/live/backoffice/scanner_live/enrolment_live/index.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
defmodule SafiraWeb.Backoffice.ScannerLive.EnrolmentLive.Index do
use SafiraWeb, :backoffice_view

alias Safira.{Accounts, Activities}

@impl true
def render(assigns) do
~H"""
<div>
<div class="-translate-y-4 sm:translate-y-0">
<.page>
<div class="absolute flex justify-center inset-0 z-10 top-20 select-none">
<span class="bg-dark text-light dark:bg-light dark:text-dark py-4 px-6 rounded-full font-semibold text-xl h-min">
<%= gettext("Checking enrolments for %{activity_name}", activity_name: @activity.title) %>
</span>
</div>
<div
id="qr-scanner"
phx-hook="QrScanner"
data-ask_perm="permission-button"
data-open_on_mount
data-on_start="document.getElementById('scan-info').style.display = 'none'"
data-on_success="scan"
class="relative"
>
</div>
<div id="scan-info" class="flex flex-col items-center gap-8 text-center py-40">
<p id="loadingMessage">
<%= gettext("Unable to access camera.") %>
<%= gettext(
"Make sure you allow the use of your camera on this browser and that it isn't being used elsewhere."
) %>
</p>
<.button id="permission-button" type="button">
<%= gettext("Request Permission") %>
</.button>
</div>
</.page>
</div>
<.modal
:if={@modal_data != nil}
id="modal-scan-error"
show
on_cancel={JS.push("close-modal")}
wrapper_class="px-4"
>
<div class="flex flex-row gap-4 items-center">
<.icon name="hero-x-circle" class="text-red-500 w-8" />
<p>
<%= if @modal_data do %>
<%= error_message(@modal_data) %>
<% end %>
</p>
</div>
</.modal>
</div>
"""
end

@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:current_page, :scanner)
|> assign(:modal_data, nil)
|> assign(:given_list, [])}
end

@impl true
def handle_params(%{"id" => id}, _url, socket) do
activity = Activities.get_activity!(id)
{:noreply, socket |> assign(:activity, activity)}
end

@impl true
def handle_event("scan", data, socket) do
case safely_extract_id_from_url(data) do
{:ok, id} -> process_scan(id, socket)
{:error, _} -> {:noreply, assign(socket, :modal_data, :invalid)}
end
end

@impl true
def handle_event("close-modal", _, socket) do
{:noreply, socket |> assign(:modal_data, nil)}
end

defp process_scan(id, socket) do
if id in socket.assigns.given_list do
{:noreply, socket}
else
check_credential(id, socket)
end
end

defp check_credential(id, socket) do
if Accounts.credential_exists?(id) do
handle_attendee_lookup(id, socket)
else
{:noreply, assign(socket, :modal_data, :not_found)}
end
end

defp handle_attendee_lookup(id, socket) do
case Accounts.get_attendee_from_credential(id) do
nil ->
{:noreply, assign(socket, :modal_data, :not_linked)}

attendee ->
handle_enrol_check(
%{
activity_id: socket.assigns.activity.id,
attendee_id: attendee.id,
credential_id: id
},
socket
)
end
end

defp handle_enrol_check(
%{activity_id: activity_id, attendee_id: attendee_id, credential_id: credential_id},
socket
) do
if Activities.attendee_enrolled?(activity_id, attendee_id) do
{:noreply,
socket
|> assign(:modal_data, nil)
|> assign(:given_list, [credential_id | socket.assigns.given_list])}
else
{:noreply,
socket
|> assign(:modal_data, :not_enrolled)}
end
end

defp error_message(:not_enrolled),
do: gettext("Attendee not enrolled! (404)")

defp error_message(:not_found),
do: gettext("This credential is not registered in the event's system! (404)")

defp error_message(:not_linked),
do: gettext("This credential is not linked to any attendee! (400)")

defp error_message(:invalid), do: gettext("Not a valid credential! (400)")
end
Loading