Skip to content

Commit 2b2bf97

Browse files
committed
feat: backoffice and member validation
1 parent 92bc4c4 commit 2b2bf97

18 files changed

Lines changed: 854 additions & 1 deletion

File tree

lib/gallium/accounts.ex

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ defmodule Gallium.Accounts do
7676
7777
"""
7878
def register_user(attrs) do
79-
%User{}
79+
%User{type: "attendee"}
8080
|> User.email_changeset(attrs)
8181
|> Repo.insert()
8282
end
@@ -294,4 +294,12 @@ defmodule Gallium.Accounts do
294294
{:ok, attendee}
295295
end
296296
end
297+
298+
def student_number_already_used?(student_number) do
299+
query =
300+
from a in Attendee,
301+
where: fragment("LOWER(TRIM(?))", a.student_number) == ^student_number
302+
303+
Repo.exists?(query)
304+
end
297305
end

lib/gallium/accounts/user.ex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ defmodule Gallium.Accounts.User do
66
@foreign_key_type :binary_id
77
schema "users" do
88
field :email, :string
9+
field :type, :string
910
field :password, :string, virtual: true, redact: true
1011
field :hashed_password, :string, redact: true
1112
field :confirmed_at, :utc_datetime
@@ -133,4 +134,12 @@ defmodule Gallium.Accounts.User do
133134
Bcrypt.no_user_verify()
134135
false
135136
end
137+
138+
def admin?(%__MODULE__{type: "admin"}) do
139+
true
140+
end
141+
142+
def admin?(_user) do
143+
false
144+
end
136145
end

lib/gallium/members.ex

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
defmodule Gallium.Members do
2+
@moduledoc """
3+
The Members context.
4+
"""
5+
6+
import Ecto.Query, warn: false
7+
8+
alias Gallium.Accounts.Scope
9+
alias Gallium.Members.CesiumMember
10+
alias Gallium.Repo
11+
alias NimbleCSV.RFC4180, as: CSV
12+
13+
@doc """
14+
Subscribes to scoped notifications about any cesium_member changes.
15+
16+
The broadcasted messages match the pattern:
17+
18+
* {:created, %CesiumMember{}}
19+
* {:updated, %CesiumMember{}}
20+
* {:deleted, %CesiumMember{}}
21+
22+
"""
23+
def subscribe_cesium_members(%Scope{} = scope) do
24+
key = scope.user.id
25+
26+
Phoenix.PubSub.subscribe(Gallium.PubSub, "user:#{key}:cesium_members")
27+
end
28+
29+
defp broadcast_cesium_member(%Scope{} = scope, message) do
30+
key = scope.user.id
31+
32+
Phoenix.PubSub.broadcast(Gallium.PubSub, "user:#{key}:cesium_members", message)
33+
end
34+
35+
@doc """
36+
Returns the list of cesium_members.
37+
38+
## Examples
39+
40+
iex> list_cesium_members(scope)
41+
[%CesiumMember{}, ...]
42+
43+
"""
44+
def list_cesium_members(%Scope{} = scope) do
45+
Repo.all_by(CesiumMember, user_id: scope.user.id)
46+
end
47+
48+
@doc """
49+
Gets a single cesium_member.
50+
51+
Raises `Ecto.NoResultsError` if the Cesium member does not exist.
52+
53+
## Examples
54+
55+
iex> get_cesium_member!(scope, 123)
56+
%CesiumMember{}
57+
58+
iex> get_cesium_member!(scope, 456)
59+
** (Ecto.NoResultsError)
60+
61+
"""
62+
def get_cesium_member!(%Scope{} = scope, id) do
63+
Repo.get_by!(CesiumMember, id: id, user_id: scope.user.id)
64+
end
65+
66+
@doc """
67+
Creates a cesium_member.
68+
69+
## Examples
70+
71+
iex> create_cesium_member(scope, %{field: value})
72+
{:ok, %CesiumMember{}}
73+
74+
iex> create_cesium_member(scope, %{field: bad_value})
75+
{:error, %Ecto.Changeset{}}
76+
77+
"""
78+
def create_cesium_member(%Scope{} = scope, attrs) do
79+
with {:ok, cesium_member = %CesiumMember{}} <-
80+
%CesiumMember{}
81+
|> CesiumMember.changeset(attrs, scope)
82+
|> Repo.insert() do
83+
broadcast_cesium_member(scope, {:created, cesium_member})
84+
{:ok, cesium_member}
85+
end
86+
end
87+
88+
@doc """
89+
Updates a cesium_member.
90+
91+
## Examples
92+
93+
iex> update_cesium_member(scope, cesium_member, %{field: new_value})
94+
{:ok, %CesiumMember{}}
95+
96+
iex> update_cesium_member(scope, cesium_member, %{field: bad_value})
97+
{:error, %Ecto.Changeset{}}
98+
99+
"""
100+
def update_cesium_member(%Scope{} = scope, %CesiumMember{} = cesium_member, attrs) do
101+
true = cesium_member.user_id == scope.user.id
102+
103+
with {:ok, cesium_member = %CesiumMember{}} <-
104+
cesium_member
105+
|> CesiumMember.changeset(attrs, scope)
106+
|> Repo.update() do
107+
broadcast_cesium_member(scope, {:updated, cesium_member})
108+
{:ok, cesium_member}
109+
end
110+
end
111+
112+
@doc """
113+
Deletes a cesium_member.
114+
115+
## Examples
116+
117+
iex> delete_cesium_member(scope, cesium_member)
118+
{:ok, %CesiumMember{}}
119+
120+
iex> delete_cesium_member(scope, cesium_member)
121+
{:error, %Ecto.Changeset{}}
122+
123+
"""
124+
def delete_cesium_member(%Scope{} = scope, %CesiumMember{} = cesium_member) do
125+
true = cesium_member.user_id == scope.user.id
126+
127+
with {:ok, cesium_member = %CesiumMember{}} <-
128+
Repo.delete(cesium_member) do
129+
broadcast_cesium_member(scope, {:deleted, cesium_member})
130+
{:ok, cesium_member}
131+
end
132+
end
133+
134+
@doc """
135+
Returns an `%Ecto.Changeset{}` for tracking cesium_member changes.
136+
137+
## Examples
138+
139+
iex> change_cesium_member(scope, cesium_member)
140+
%Ecto.Changeset{data: %CesiumMember{}}
141+
142+
"""
143+
def change_cesium_member(%Scope{} = scope, %CesiumMember{} = cesium_member, attrs \\ %{}) do
144+
true = cesium_member.user_id == scope.user.id
145+
146+
CesiumMember.changeset(cesium_member, attrs, scope)
147+
end
148+
149+
@doc """
150+
Imports a list of cesium_members from a CSV file.
151+
The CSV must have the headers: id_socio, numero_aluno, nome
152+
Since the file serves as a sync, existing members for this scope are deleted.
153+
"""
154+
def import_cesium_members(%Scope{} = scope, file_path) do
155+
unless File.exists?(file_path) do
156+
{:error, "Ficheiro CSV não encontrado."}
157+
end
158+
159+
try do
160+
stream =
161+
file_path
162+
|> File.stream!()
163+
|> Stream.map(&String.replace(&1, "\uFEFF", ""))
164+
|> CSV.parse_stream(skip_headers: false)
165+
166+
format = detect_csv_format(stream)
167+
import_from_stream(scope, stream, format)
168+
rescue
169+
NimbleCSV.ParseError ->
170+
{:error,
171+
"Formato CSV inválido. Certifique-se de que o separador é um um ponto e vírgula (;) e não uma (,)."}
172+
end
173+
end
174+
175+
defp detect_csv_format(stream) do
176+
case Enum.take(stream, 1) do
177+
[[col1, col2, col3]]
178+
when col1 == "id_socio" and col2 == "numero_aluno" and col3 == "nome" ->
179+
:id_socio_first
180+
181+
[[col1, col2, col3]]
182+
when col1 == "nome" and col2 == "id_socio" and col3 == "numero_aluno" ->
183+
:nome_first
184+
185+
_ ->
186+
{:error, "O CSV não tem a estrutura requerida (id_socio, numero_aluno, nome)."}
187+
end
188+
end
189+
190+
defp import_from_stream(%Scope{} = _scope, _stream, {:error, _} = error), do: error
191+
192+
defp import_from_stream(%Scope{} = scope, stream, format) do
193+
# Entire operation is atomic - all or nothing
194+
Repo.transaction(fn ->
195+
# Remove existing members for this scope completely inserting the new ones
196+
Repo.delete_all(from(c in CesiumMember))
197+
198+
stream
199+
|> Stream.drop(1)
200+
|> Enum.each(&process_row(scope, format, &1))
201+
202+
"Sócios importados com sucesso!"
203+
end)
204+
|> case do
205+
{:ok, msg} -> {:ok, msg}
206+
{:error, reason} -> {:error, "Erro a importar sócios: #{inspect(reason)}"}
207+
end
208+
end
209+
210+
defp process_row(scope, format, row) do
211+
attrs = format_row_attrs(format, row)
212+
if attrs, do: create_cesium_member(scope, attrs)
213+
end
214+
215+
defp format_row_attrs(:id_socio_first, [id_socio, numero_aluno, nome]) do
216+
%{member_id: id_socio, student_number: numero_aluno, name: nome}
217+
end
218+
219+
defp format_row_attrs(:nome_first, [nome, id_socio, numero_aluno]) do
220+
%{name: nome, member_id: id_socio, student_number: numero_aluno}
221+
end
222+
223+
defp format_row_attrs(_, _), do: nil
224+
225+
def cesium_member?(student_number) do
226+
Repo.exists?(
227+
from m in CesiumMember,
228+
where: fragment("LOWER(TRIM(?))", m.student_number) == ^student_number
229+
)
230+
end
231+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
defmodule Gallium.Members.CesiumMember do
2+
use Ecto.Schema
3+
import Ecto.Changeset
4+
5+
@primary_key {:id, :binary_id, autogenerate: true}
6+
@foreign_key_type :binary_id
7+
schema "cesium_members" do
8+
field :name, :string
9+
field :member_id, :string
10+
field :student_number, :string
11+
field :user_id, :binary_id
12+
13+
timestamps(type: :utc_datetime)
14+
end
15+
16+
@doc false
17+
def changeset(cesium_member, attrs, user_scope) do
18+
cesium_member
19+
|> cast(attrs, [:name, :member_id, :student_number])
20+
|> validate_required([:name, :member_id, :student_number])
21+
|> put_change(:user_id, user_scope.user.id)
22+
end
23+
end

lib/gallium/ticketing/checkout_form.ex

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
defmodule Gallium.Ticketing.CheckoutForm do
22
use Ecto.Schema
33
import Ecto.Changeset
4+
alias Gallium.Accounts
5+
alias Gallium.Members
46

57
@primary_key false
68
embedded_schema do
@@ -29,6 +31,7 @@ defmodule Gallium.Ticketing.CheckoutForm do
2931
|> validate_format(:student_number, ~r/^(A|PG|a|pg)\d+$/,
3032
message: "Formato inválido (ex: A12345)"
3133
)
34+
|> validate_cesium_member(:student_number, :is_cesium_member)
3235
|> validate_format(:nif, ~r/^\d{9}$/, message: "O NIF tem de ter exatamente 9 números")
3336
|> cast_embed(:accompany, with: &accompany_changeset/2)
3437
end
@@ -48,4 +51,81 @@ defmodule Gallium.Ticketing.CheckoutForm do
4851
|> validate_required([:mbway_number], message: "Este campo é obrigatório")
4952
|> validate_format(:mbway_number, ~r/^\+?\d{9,15}$/, message: "Formato de número inválido")
5053
end
54+
55+
def validate_cesium_member(changeset, student_number_field, is_cesium_member_field) do
56+
student_number_value = get_field(changeset, student_number_field)
57+
58+
if is_binary(student_number_value) do
59+
formatted_number = format_student_number(student_number_value)
60+
is_member_claimed = get_field(changeset, is_cesium_member_field)
61+
62+
check_already_used(
63+
changeset,
64+
formatted_number,
65+
is_member_claimed,
66+
student_number_field,
67+
is_cesium_member_field
68+
)
69+
else
70+
changeset
71+
end
72+
end
73+
74+
defp format_student_number(number) do
75+
number
76+
|> String.trim()
77+
|> String.downcase()
78+
end
79+
80+
defp check_already_used(
81+
changeset,
82+
formatted_number,
83+
is_member_claimed,
84+
number_field,
85+
member_field
86+
) do
87+
if Accounts.student_number_already_used?(formatted_number) do
88+
add_error(
89+
changeset,
90+
number_field,
91+
"Este número de estudante já foi usado para comprar um bilhete."
92+
)
93+
else
94+
apply_membership_rules(
95+
changeset,
96+
formatted_number,
97+
is_member_claimed,
98+
number_field,
99+
member_field
100+
)
101+
end
102+
end
103+
104+
defp apply_membership_rules(changeset, formatted_number, true, number_field, _member_field) do
105+
if Members.cesium_member?(formatted_number) do
106+
put_change(changeset, number_field, formatted_number)
107+
else
108+
add_error(
109+
changeset,
110+
number_field,
111+
"O número de estudante não corresponde a um sócio válido do CeSIUM."
112+
)
113+
end
114+
end
115+
116+
defp apply_membership_rules(
117+
changeset,
118+
formatted_number,
119+
_claimed_false,
120+
number_field,
121+
member_field
122+
) do
123+
if Members.cesium_member?(formatted_number) do
124+
changeset
125+
|> put_change(member_field, true)
126+
|> put_change(number_field, formatted_number)
127+
else
128+
changeset
129+
end
130+
end
51131
end

0 commit comments

Comments
 (0)