Skip to content

Commit 8464365

Browse files
feat: improve enrolment checking UX (#529)
1 parent cc4e872 commit 8464365

File tree

8 files changed

+405
-166
lines changed

8 files changed

+405
-166
lines changed

lib/safira/accounts/roles/permissions.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ defmodule Safira.Accounts.Roles.Permissions do
1717
"minigames" => ["show", "edit", "simulate"],
1818
"event" => ["show", "edit", "edit_faqs", "generate_credentials"],
1919
"spotlights" => ["edit"],
20-
"schedule" => ["edit"],
20+
"schedule" => ["show", "edit"],
2121
"statistics" => ["show"],
2222
"mailer" => ["send"],
2323
"scanner" => ["show"]

lib/safira/activities.ex

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ defmodule Safira.Activities do
55

66
use Safira.Context
77

8+
alias Safira.Accounts.{Attendee, User}
89
alias Safira.Activities.{Activity, ActivityCategory, Enrolment, Speaker}
910

1011
@doc """
@@ -42,6 +43,17 @@ defmodule Safira.Activities do
4243
|> Flop.validate_and_run(params, for: Activity)
4344
end
4445

46+
def list_enrolled_attendees(activity_id, params \\ %{}, opts \\ []) do
47+
User
48+
|> join(:inner, [u], at in Attendee, on: u.id == at.user_id)
49+
|> join(:inner, [u, at], e in Enrolment, on: e.attendee_id == at.id)
50+
|> where([u, at, e], e.activity_id == ^activity_id)
51+
|> select([u, at, e], u)
52+
|> preload(:attendee)
53+
|> apply_filters(opts)
54+
|> Flop.validate_and_run(params, for: User)
55+
end
56+
4557
@doc """
4658
Returns the count of activities.
4759
@@ -456,6 +468,20 @@ defmodule Safira.Activities do
456468
|> Repo.all()
457469
end
458470

471+
@doc """
472+
Checks if an attendee is enrolled for an activity.
473+
474+
## Examples
475+
476+
iex> attendee_enrolled?(activity_id, attendee_id)
477+
true
478+
"""
479+
def attendee_enrolled?(activity_id, attendee_id) do
480+
Enrolment
481+
|> where([e], e.attendee_id == ^attendee_id and e.activity_id == ^activity_id)
482+
|> Repo.exists?()
483+
end
484+
459485
@doc """
460486
Enrols an attendee in an activity
461487
@@ -500,15 +526,21 @@ defmodule Safira.Activities do
500526
iex> unenrol(attendee_id, activity_id)
501527
{:error, :struct, %Ecto.Changeset{}, %{}}
502528
"""
503-
def unenrol(enrolment) do
529+
def unenrol(activity_id, attendee_id) do
504530
Ecto.Multi.new()
505531
# We need to read the activity before updating the enrolment count to avoid
506532
# a race condition where the enrolment count changes after the activity was last
507533
# read from the database, and before this transaction began
508-
|> Ecto.Multi.one(:activity, Activity |> where([a], a.id == ^enrolment.activity_id))
509-
|> Ecto.Multi.delete(
534+
|> Ecto.Multi.one(
510535
:enrolment,
511-
enrolment
536+
Enrolment |> where([e], e.activity_id == ^activity_id and e.attendee_id == ^attendee_id)
537+
)
538+
|> Ecto.Multi.one(:activity, Activity |> where([a], a.id == ^activity_id))
539+
|> Ecto.Multi.delete(
540+
:deleted_enrolment,
541+
fn %{enrolment: enrolment} ->
542+
enrolment
543+
end
512544
)
513545
|> Ecto.Multi.update(:new_activity, fn %{activity: act} ->
514546
Activity.changeset(act, %{enrolment_count: act.enrolment_count - 1})
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
defmodule SafiraWeb.Backoffice.ScannerLive.EnrolmentLive.Index do
2+
use SafiraWeb, :backoffice_view
3+
4+
alias Safira.{Accounts, Activities}
5+
6+
@impl true
7+
def render(assigns) do
8+
~H"""
9+
<div>
10+
<div class="-translate-y-4 sm:translate-y-0">
11+
<.page>
12+
<div class="absolute flex justify-center inset-0 z-10 top-20 select-none">
13+
<span class="bg-dark text-light dark:bg-light dark:text-dark py-4 px-6 rounded-full font-semibold text-xl h-min">
14+
<%= gettext("Checking enrolments for %{activity_name}", activity_name: @activity.title) %>
15+
</span>
16+
</div>
17+
<div
18+
id="qr-scanner"
19+
phx-hook="QrScanner"
20+
data-ask_perm="permission-button"
21+
data-open_on_mount
22+
data-on_start="document.getElementById('scan-info').style.display = 'none'"
23+
data-on_success="scan"
24+
class="relative"
25+
>
26+
</div>
27+
<div id="scan-info" class="flex flex-col items-center gap-8 text-center py-40">
28+
<p id="loadingMessage">
29+
<%= gettext("Unable to access camera.") %>
30+
<%= gettext(
31+
"Make sure you allow the use of your camera on this browser and that it isn't being used elsewhere."
32+
) %>
33+
</p>
34+
<.button id="permission-button" type="button">
35+
<%= gettext("Request Permission") %>
36+
</.button>
37+
</div>
38+
</.page>
39+
</div>
40+
<.modal
41+
:if={@modal_data != nil}
42+
id="modal-scan-error"
43+
show
44+
on_cancel={JS.push("close-modal")}
45+
wrapper_class="px-4"
46+
>
47+
<div class="flex flex-row gap-4 items-center">
48+
<.icon name="hero-x-circle" class="text-red-500 w-8" />
49+
<p>
50+
<%= if @modal_data do %>
51+
<%= error_message(@modal_data) %>
52+
<% end %>
53+
</p>
54+
</div>
55+
</.modal>
56+
</div>
57+
"""
58+
end
59+
60+
@impl true
61+
def mount(_params, _session, socket) do
62+
{:ok,
63+
socket
64+
|> assign(:current_page, :scanner)
65+
|> assign(:modal_data, nil)
66+
|> assign(:given_list, [])}
67+
end
68+
69+
@impl true
70+
def handle_params(%{"id" => id}, _url, socket) do
71+
activity = Activities.get_activity!(id)
72+
{:noreply, socket |> assign(:activity, activity)}
73+
end
74+
75+
@impl true
76+
def handle_event("scan", data, socket) do
77+
case safely_extract_id_from_url(data) do
78+
{:ok, id} -> process_scan(id, socket)
79+
{:error, _} -> {:noreply, assign(socket, :modal_data, :invalid)}
80+
end
81+
end
82+
83+
@impl true
84+
def handle_event("close-modal", _, socket) do
85+
{:noreply, socket |> assign(:modal_data, nil)}
86+
end
87+
88+
defp process_scan(id, socket) do
89+
if id in socket.assigns.given_list do
90+
{:noreply, socket}
91+
else
92+
check_credential(id, socket)
93+
end
94+
end
95+
96+
defp check_credential(id, socket) do
97+
if Accounts.credential_exists?(id) do
98+
handle_attendee_lookup(id, socket)
99+
else
100+
{:noreply, assign(socket, :modal_data, :not_found)}
101+
end
102+
end
103+
104+
defp handle_attendee_lookup(id, socket) do
105+
case Accounts.get_attendee_from_credential(id) do
106+
nil ->
107+
{:noreply, assign(socket, :modal_data, :not_linked)}
108+
109+
attendee ->
110+
handle_enrol_check(
111+
%{
112+
activity_id: socket.assigns.activity.id,
113+
attendee_id: attendee.id,
114+
credential_id: id
115+
},
116+
socket
117+
)
118+
end
119+
end
120+
121+
defp handle_enrol_check(
122+
%{activity_id: activity_id, attendee_id: attendee_id, credential_id: credential_id},
123+
socket
124+
) do
125+
if Activities.attendee_enrolled?(activity_id, attendee_id) do
126+
{:noreply,
127+
socket
128+
|> assign(:modal_data, nil)
129+
|> assign(:given_list, [credential_id | socket.assigns.given_list])}
130+
else
131+
{:noreply,
132+
socket
133+
|> assign(:modal_data, :not_enrolled)}
134+
end
135+
end
136+
137+
defp error_message(:not_enrolled),
138+
do: gettext("Attendee not enrolled! (404)")
139+
140+
defp error_message(:not_found),
141+
do: gettext("This credential is not registered in the event's system! (404)")
142+
143+
defp error_message(:not_linked),
144+
do: gettext("This credential is not linked to any attendee! (400)")
145+
146+
defp error_message(:invalid), do: gettext("Not a valid credential! (400)")
147+
end

0 commit comments

Comments
 (0)