refactor: make phone number optional

This commit is contained in:
JasterV 2026-01-15 02:14:35 +01:00
parent 8dafc3e3fd
commit d461c45ddc
13 changed files with 423 additions and 27 deletions

View file

@ -83,7 +83,7 @@ defmodule SpazioSolazzo.Accounts.User do
change AshAuthentication.Strategy.MagicLink.SignInChange
# Conditionally validate name and phone_number for new users
change SpazioSolazzo.Accounts.User.Changes.ValidateRegistrationFields
change SpazioSolazzo.Accounts.User.Changes.ParseRegistrationFields
change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange,
strategy_name: :remember_me}
@ -149,7 +149,7 @@ defmodule SpazioSolazzo.Accounts.User do
end
attribute :phone_number, :string do
allow_nil? false
allow_nil? true
public? true
end
end

View file

@ -1,4 +1,4 @@
defmodule SpazioSolazzo.Accounts.User.Changes.ValidateRegistrationFields do
defmodule SpazioSolazzo.Accounts.User.Changes.ParseRegistrationFields do
@moduledoc """
Conditionally validates that name and phone_number are present for new user registrations.
For existing users (upserts), these fields are not required.
@ -11,28 +11,53 @@ defmodule SpazioSolazzo.Accounts.User.Changes.ValidateRegistrationFields do
case SpazioSolazzo.Accounts.get_user_by_email(email, authorize?: false) do
{:ok, %{phone_number: phone, name: name}} ->
# User is already registered, we'll just set the same values it had
changeset
|> Ash.Changeset.force_change_attribute(:name, name)
|> Ash.Changeset.force_change_attribute(:phone_number, phone)
_ ->
# User is not yet registered, we'll parse & validate the new values
name = Ash.Changeset.get_argument(changeset, :name)
phone = Ash.Changeset.get_argument(changeset, :phone_number)
changeset
|> validate_required_for_registration(:name, name)
|> validate_required_for_registration(:phone_number, phone)
|> parse_name(name)
|> parse_phone_number(phone)
end
end
defp validate_required_for_registration(changeset, field, value) do
if is_nil(value) || value == "" do
Ash.Changeset.add_error(
changeset,
Ash.Error.Changes.Required.exception(field: field, type: :argument)
)
defp parse_name(changeset, nil) do
Ash.Changeset.add_error(
changeset,
Ash.Error.Changes.Required.exception(field: :name, type: :argument)
)
end
defp parse_name(changeset, value) do
value = String.trim(value)
if value == "" do
parse_name(changeset, nil)
else
Ash.Changeset.change_attribute(changeset, field, value)
Ash.Changeset.change_attribute(changeset, :name, value)
end
end
defp parse_phone_number(changeset, nil) do
# The phone number is nullable, this is fine
Ash.Changeset.change_attribute(changeset, :phone_number, nil)
end
defp parse_phone_number(changeset, value) do
value = String.trim(value)
if value == "" do
# Instead of returning an error, we'll consider an empty phone number
# as if the user didn't want to set one, which is valid.
parse_name(changeset, nil)
else
Ash.Changeset.change_attribute(changeset, :phone_number, value)
end
end
end

View file

@ -52,7 +52,7 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
argument :date, :date, allow_nil?: false
argument :customer_name, :string, allow_nil?: false
argument :customer_email, :string, allow_nil?: false
argument :customer_phone, :string, allow_nil?: false
argument :customer_phone, :string, allow_nil?: true
argument :customer_comment, :string, allow_nil?: true
change manage_relationship(:time_slot_template_id, :time_slot_template,
@ -167,7 +167,7 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
attribute :customer_email, :string, allow_nil?: false
attribute :start_time, :time, allow_nil?: false
attribute :end_time, :time, allow_nil?: false
attribute :customer_phone, :string, allow_nil?: false
attribute :customer_phone, :string, allow_nil?: true
attribute :customer_comment, :string, allow_nil?: true
attribute :state, :atom do

View file

@ -3,7 +3,12 @@
<div style="margin-bottom: 20px;">
<p><strong>Customer:</strong> {@customer_name}</p>
<p><strong>Email:</strong> <a href={"mailto:#{@customer_email}"}>{@customer_email}</a></p>
<p><strong>Phone:</strong> <a href={"tel:#{@customer_phone}"}>{@customer_phone}</a></p>
<%= if @customer_phone && String.trim(@customer_phone) != "" do %>
<p><strong>Phone:</strong> <a href={"tel:#{@customer_phone}"}>{@customer_phone}</a></p>
<% else %>
<p><strong>Phone:</strong> <a href="#">N/A</a></p>
<% end %>
</div>
<.details_list>

View file

@ -7,7 +7,7 @@
<.detail_item label="Date">{@date}</.detail_item>
<.detail_item label="Time">{@start_time} - {@end_time}</.detail_item>
<.detail_item label="Email">{@customer_email}</.detail_item>
<.detail_item label="Phone">{@customer_phone}</.detail_item>
<.detail_item label="Phone">{@customer_phone || "N/A"}</.detail_item>
<.detail_item label="Note">{@customer_comment || "N/A"}</.detail_item>
</.details_list>

View file

@ -74,12 +74,17 @@ defmodule SpazioSolazzoWeb.AuthCallbackLive do
) do
%{token: token} = socket.assigns
remember_me = Map.get(args, "remember_me") == "on"
name = String.trim(name)
phone_number = String.trim(phone_number)
{:noreply,
redirect(socket,
to:
~p"/auth/magic/sign-in?token=#{token}&name=#{name}&phone_number=#{phone_number}&remember_me=#{remember_me}"
)}
url =
if phone_number == "" do
~p"/auth/magic/sign-in?token=#{token}&name=#{name}&remember_me=#{remember_me}"
else
~p"/auth/magic/sign-in?token=#{token}&name=#{name}&phone_number=#{phone_number}&remember_me=#{remember_me}"
end
{:noreply, redirect(socket, to: url)}
end
defp extract_email_from_token(token) do

View file

@ -98,20 +98,21 @@
for="phone_number"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
>
Phone Number <span class="text-rose-500">*</span>
Phone Number (Optional)
</label>
<div class="relative">
<input
type="tel"
name="phone_number"
id="phone_number"
required
class="w-full pl-11 pr-4 py-3 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-shadow"
placeholder="+39 123 456 7890"
placeholder="+39"
/>
<div class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none">
<.icon name="hero-phone" class="size-5 text-slate-400 dark:text-slate-500" />
</div>
<!-- TODO: Add a text here letting the user know that their phone number won't be used for any commercial purposes -->
<!-- but only to contact them personally in case of any problems with a booking -->
</div>
</div>

View file

@ -89,7 +89,7 @@ defmodule SpazioSolazzoWeb.BookingFormLiveComponent do
<.icon name="hero-phone" class="size-5 text-sky-600 dark:text-sky-400" />
</div>
<span class="text-sm font-medium text-slate-700 dark:text-slate-300 truncate">
{@current_user.phone_number}
{@current_user.phone_number || "-"}
</span>
</div>
</div>

View file

@ -26,10 +26,14 @@ defmodule SpazioSolazzoWeb.ProfileLive do
def handle_event("save_profile", %{"form" => form_params}, socket) do
case Form.submit(socket.assigns.profile_form, params: form_params) do
{:ok, updated_user} ->
form =
Accounts.form_to_update_profile(updated_user, actor: updated_user)
|> to_form()
{:noreply,
socket
|> assign(:current_user, updated_user)
|> assign(:profile_form, to_form(form_params))
|> assign(:profile_form, form)
|> put_flash(:info, "Profile updated successfully")}
{:error, form} ->

View file

@ -30,7 +30,8 @@
<.input
field={@profile_form[:name]}
type="text"
label="Full Name"
label="Full Name *"
required
placeholder="Enter your full name"
class="w-full bg-slate-50 dark:bg-slate-900/50 border-slate-200 dark:border-slate-700 rounded-xl px-4 py-3 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent transition-all outline-none"
/>

View file

@ -0,0 +1,29 @@
defmodule SpazioSolazzo.Repo.Migrations.SetPhoneNumberAsNullable do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:users) do
modify :phone_number, :text, null: true
end
alter table(:bookings) do
modify :customer_phone, :text, null: true
end
end
def down do
alter table(:bookings) do
modify :customer_phone, :text, null: false
end
alter table(:users) do
modify :phone_number, :text, null: false
end
end
end

View file

@ -0,0 +1,244 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "date",
"type": "date"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "customer_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "customer_email",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "start_time",
"type": "time"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "end_time",
"type": "time"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "customer_phone",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "customer_comment",
"type": "text"
},
{
"allow_nil?": false,
"default": "\"reserved\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "state",
"type": "text"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "bookings_asset_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "assets"
},
"scale": null,
"size": null,
"source": "asset_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "bookings_time_slot_template_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "time_slot_templates"
},
"scale": null,
"size": null,
"source": "time_slot_template_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": true,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "bookings_user_id_fkey",
"on_delete": "nilify",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "users"
},
"scale": null,
"size": null,
"source": "user_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "1480C13D76AD8CE079362CC851CF250063914A40A6CA48182E3D3B5D83CD174A",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.SpazioSolazzo.Repo",
"schema": null,
"table": "bookings"
}

View file

@ -0,0 +1,82 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "citext"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "phone_number",
"type": "text"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "6D20D84C076B09FEAFF7A68C69930AF48936A628BBEE432695F0ECC02B0F4EFA",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.SpazioSolazzo.Repo",
"schema": null,
"table": "users"
}