refactor: make phone number optional (#9)

This pull request makes phone numbers optional for both user registrations and bookings, and updates validation, database schema, and UI to reflect this change. The main focus is to ensure that users are no longer required to provide a phone number, and that the application gracefully handles cases where a phone number is absent.

**Database & Resource Model Updates**

* Made the `phone_number` attribute in the `users` table and the `customer_phone` attribute in the `bookings` table nullable, including migration and resource snapshot updates. [[1]](diffhunk://#diff-baa6aed3674c4d6cbbebeafb076662df02dc4c25231dbd9dc9c8f0534ed1a1bfR1-R29) [[2]](diffhunk://#diff-a401f66b2ae5bfb798eb1bc2221bfeeac943e258950c90d59570b0bae05d3664R1-R244) [[3]](diffhunk://#diff-0c1180d6f6abc19b5987c8703bdee9ef67905535202f950e8327c32bd5b89d8aR1-R82)
* Updated Ash resource definitions in `user.ex` and `booking.ex` to allow `phone_number` and `customer_phone` to be `nil`. [[1]](diffhunk://#diff-9194b9d80dce091f6dcb56f784217272ae160e35454c4b4ccc8850ad5ee06e38L152-R152) [[2]](diffhunk://#diff-4b1ddd6d86899f2144c69d142883b8719c755e32c03dbda5da2188208a5ad503L55-R55) [[3]](diffhunk://#diff-4b1ddd6d86899f2144c69d142883b8719c755e32c03dbda5da2188208a5ad503L170-R170)

**Validation & Parsing Logic**

* Renamed and refactored user registration field validation to `ParseRegistrationFields`, allowing phone numbers to be omitted and trimming input values. Empty phone numbers are now treated as absent rather than as errors. [[1]](diffhunk://#diff-8ffdd76e260e3cda6f0816c8e585ae76b993a90d2519c38185a5fe22b4b49e47L1-R1) [[2]](diffhunk://#diff-8ffdd76e260e3cda6f0816c8e585ae76b993a90d2519c38185a5fe22b4b49e47R14-R60)
* Updated the authentication callback logic to trim input values and omit the phone number parameter if it is blank.

**User Interface Improvements**

* Updated registration and booking forms to indicate that phone numbers are optional, removed the required attribute, and improved placeholder text. [[1]](diffhunk://#diff-f356eb84970d8c9ee6ff1992c297b0cae07bade37ff967c1e6e0de6f8b67081cL101-R115) [[2]](diffhunk://#diff-43c0e1f7a869ee5c43a911bc10dc80cbb265a8672340ef0fa7c1d3009c047f02L92-R92)
* Updated email templates and confirmation screens to display "N/A" or "-" when phone numbers are missing. [[1]](diffhunk://#diff-48468ef2d1bb2c33b5ffb40457b77532815c7faf1830932661f665bff58b2177R6-R11) [[2]](diffhunk://#diff-3f33187b4021450b481ce53fe13166addea582c627f2cfbc99c75c7ce5c34857L10-R10) [[3]](diffhunk://#diff-43c0e1f7a869ee5c43a911bc10dc80cbb265a8672340ef0fa7c1d3009c047f02L92-R92)

**Profile Management**

* Improved profile update flow to ensure the form reflects the latest user data after saving changes.
* Made the "Full Name" field explicitly required in the profile form UI.
This commit is contained in:
Víctor Martínez 2026-01-15 15:57:54 +01:00 committed by GitHub
parent 8dafc3e3fd
commit 2cbce8ec39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 699 additions and 137 deletions

View file

@ -89,7 +89,7 @@
# If you don't want TODO comments to cause `mix credo` to fail, just
# set this value to 0 (zero).
#
{Credo.Check.Design.TagTODO, [exit_status: 2]},
{Credo.Check.Design.TagTODO, [exit_status: 0]},
#
## Readability Checks

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,21 +98,23 @@
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 123456789"
/>
<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>
</div>
<p class="mt-2 text-xs text-slate-500 dark:text-slate-400">
Your number will only be used to contact you personally about booking issues, never for marketing.
</p>
</div>
<label class="flex items-start gap-3 cursor-pointer group p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-900/50 transition-colors">

View file

@ -55,7 +55,7 @@ defmodule SpazioSolazzoWeb.AssetBookingLive do
{:noreply, assign(socket, show_success_modal: false)}
end
def handle_info({:create_booking, comment}, socket) do
def handle_info({:create_booking, booking_data}, socket) do
current_user = socket.assigns.current_user
result =
@ -64,10 +64,10 @@ defmodule SpazioSolazzoWeb.AssetBookingLive do
socket.assigns.asset.id,
current_user.id,
socket.assigns.selected_date,
current_user.name,
booking_data.customer_name,
current_user.email,
current_user.phone_number,
comment
booking_data.customer_phone,
booking_data.customer_comment
)
case result do

View file

@ -9,6 +9,8 @@ defmodule SpazioSolazzoWeb.BookingFormLiveComponent do
def update(assigns, socket) do
initial_data = %{
"customer_name" => assigns.current_user.name,
"customer_phone" => assigns.current_user.phone_number || "",
"customer_comment" => ""
}
@ -25,8 +27,13 @@ defmodule SpazioSolazzoWeb.BookingFormLiveComponent do
end
def handle_event("submit_booking", params, socket) do
comment = params["customer_comment"] || ""
send(self(), {:create_booking, comment})
booking_data = %{
customer_name: params["customer_name"] || "",
customer_phone: params["customer_phone"] || "",
customer_comment: params["customer_comment"] || ""
}
send(self(), {:create_booking, booking_data})
{:noreply, socket}
end
@ -53,16 +60,25 @@ defmodule SpazioSolazzoWeb.BookingFormLiveComponent do
>
<div class="mt-6 space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Name
<label
for="customer_name"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
>
Name <span class="text-rose-500">*</span>
</label>
<div class="flex items-center gap-3 p-4 bg-gradient-to-r from-slate-50 to-sky-50/50 dark:from-slate-900 dark:to-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div class="flex-shrink-0">
<.icon name="hero-user" class="size-5 text-sky-600 dark:text-sky-400" />
<div class="relative">
<input
type="text"
name="customer_name"
id="customer_name"
value={@form[:customer_name].value}
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="Your full name"
/>
<div class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none">
<.icon name="hero-user" class="size-5 text-slate-400 dark:text-slate-500" />
</div>
<span class="text-sm font-medium text-slate-700 dark:text-slate-300 truncate">
{@current_user.name}
</span>
</div>
</div>
@ -81,26 +97,56 @@ defmodule SpazioSolazzoWeb.BookingFormLiveComponent do
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Phone
<label
for="customer_phone"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
>
Phone (Optional)
</label>
<div class="flex items-center gap-3 p-4 bg-gradient-to-r from-slate-50 to-sky-50/50 dark:from-slate-900 dark:to-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div class="flex-shrink-0">
<.icon name="hero-phone" class="size-5 text-sky-600 dark:text-sky-400" />
<div class="relative">
<input
type="tel"
name="customer_phone"
id="customer_phone"
value={@form[:customer_phone].value}
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 123456789"
/>
<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>
<span class="text-sm font-medium text-slate-700 dark:text-slate-300 truncate">
{@current_user.phone_number}
</span>
</div>
</div>
<.input
field={@form[:customer_comment]}
type="textarea"
label="Comments (Optional)"
placeholder="Any special requests or notes..."
rows="4"
/>
<div>
<label
for="customer_comment"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
>
Comments (Optional)
</label>
<textarea
name="customer_comment"
id="customer_comment"
placeholder="Any special requests or notes..."
rows="4"
class="w-full px-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 resize-none"
>{@form[:customer_comment].value}</textarea>
</div>
</div>
<div class="mt-6 p-4 bg-sky-50 dark:bg-sky-900/20 border border-sky-200 dark:border-sky-800 rounded-xl">
<div class="flex gap-3">
<div class="flex-shrink-0">
<.icon name="hero-information-circle" class="size-5 text-sky-600 dark:text-sky-400" />
</div>
<div class="text-xs text-slate-600 dark:text-slate-300 space-y-1">
<ul class="list-disc list-inside space-y-0.5 ml-1">
<li>Cancel anytime with no commitment</li>
<li>Payment upon arrival only</li>
</ul>
</div>
</div>
</div>
<div class="mt-6 flex items-center gap-3">

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"
/>
@ -63,7 +64,7 @@
field={@profile_form[:phone_number]}
type="tel"
label="Phone Number"
placeholder="+39"
placeholder="+39 123456789"
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"
/>
</div>

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"
}

View file

@ -9,7 +9,7 @@ defmodule SpazioSolazzo.Accounts.UserTest do
describe "update_profile" do
test "allows user to update their own name and phone_number" do
user = create_test_user("test@example.com")
user = register_user("test@example.com")
{:ok, updated_user} =
Accounts.update_profile(user, "Updated Name", "+9876543210", actor: user)
@ -20,8 +20,8 @@ defmodule SpazioSolazzo.Accounts.UserTest do
end
test "prevents user from updating another user's profile" do
user1 = create_test_user("user1@example.com")
user2 = create_test_user("user2@example.com")
user1 = register_user("user1@example.com")
user2 = register_user("user2@example.com")
result =
Accounts.update_profile(user2, "Hacker", "1235837", actor: user1)
@ -30,7 +30,7 @@ defmodule SpazioSolazzo.Accounts.UserTest do
end
test "validates that name is present" do
user = create_test_user("test@example.com")
user = register_user("test@example.com")
result =
Accounts.update_profile(user, "", "+9876543210", actor: user)
@ -42,7 +42,7 @@ defmodule SpazioSolazzo.Accounts.UserTest do
describe "terminate_account with delete_history: false (anonymization)" do
test "deletes user but preserves bookings with nullified user_id" do
user = create_test_user("delete@example.com")
user = register_user("delete@example.com")
{_space, asset, time_slot} = create_booking_fixtures()
{:ok, booking1} =
@ -87,7 +87,7 @@ defmodule SpazioSolazzo.Accounts.UserTest do
end
test "cancels future confirmed bookings before anonymizing" do
user = create_test_user("cancel@example.com")
user = register_user("cancel@example.com")
{_space, asset, time_slot} = create_booking_fixtures()
future_date = Date.add(Date.utc_today(), 7)
@ -133,7 +133,7 @@ defmodule SpazioSolazzo.Accounts.UserTest do
describe "terminate_account with delete_history: true (hard delete)" do
test "deletes user and all associated bookings permanently" do
user = create_test_user("harddelete@example.com")
user = register_user("harddelete@example.com")
{_space, asset, time_slot} = create_booking_fixtures()
{:ok, booking1} =
@ -173,8 +173,8 @@ defmodule SpazioSolazzo.Accounts.UserTest do
describe "terminate_account authorization" do
test "prevents user from deleting another user's account" do
user1 = create_test_user("user1@example.com")
user2 = create_test_user("user2@example.com")
user1 = register_user("user1@example.com")
user2 = register_user("user2@example.com")
result = Accounts.terminate_account(user2, false, actor: user1)
@ -182,18 +182,6 @@ defmodule SpazioSolazzo.Accounts.UserTest do
end
end
defp create_test_user(email) do
{:ok, user} =
SpazioSolazzo.Repo.insert(%User{
id: Ash.UUID.generate(),
email: email,
name: "Test User",
phone_number: "+1234567890"
})
user
end
defp create_booking_fixtures do
unique_id = :erlang.unique_integer([:positive, :monotonic])

View file

@ -4,8 +4,6 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
alias SpazioSolazzo.BookingSystem
alias SpazioSolazzo.BookingSystem.Booking
alias SpazioSolazzo.Accounts.User
alias SpazioSolazzo.BookingSystem.Booking.EmailWorker
setup do
@ -20,13 +18,7 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
space.id
)
{:ok, user} =
SpazioSolazzo.Repo.insert(%User{
id: Ash.UUID.generate(),
email: "test@example.com",
name: "Test User",
phone_number: "+1234567890"
})
user = register_user("test@example.com")
%{space: space, asset: asset, time_slot: time_slot, user: user}
end
@ -236,7 +228,7 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
# Load the booking with the user relationship
{:ok, booking_with_user} = Ash.load(booking, :user, authorize?: false)
assert booking_with_user.user.id == user.id
assert to_string(booking_with_user.user.email) == user.email
assert booking_with_user.user.email == user.email
assert booking_with_user.user.name == user.name
end
end

View file

@ -3,7 +3,6 @@ defmodule SpazioSolazzoWeb.BookingControllerTest do
alias SpazioSolazzo.BookingSystem
alias SpazioSolazzo.BookingSystem.Booking.Token
alias SpazioSolazzo.Accounts.User
setup do
unique_id = :erlang.unique_integer([:positive, :monotonic])
@ -21,13 +20,7 @@ defmodule SpazioSolazzoWeb.BookingControllerTest do
space.id
)
{:ok, user} =
SpazioSolazzo.Repo.insert(%User{
id: Ash.UUID.generate(),
email: "test@example.com",
name: "Test User",
phone_number: "+1234567890"
})
user = register_user("test@example.com", "Test User", "+1234567890")
%{space: space, asset: asset, time_slot: time_slot, user: user}
end

View file

@ -3,7 +3,6 @@ defmodule SpazioSolazzoWeb.BookingLive.AssetBookingTest do
import Phoenix.LiveViewTest
alias SpazioSolazzo.BookingSystem
alias SpazioSolazzo.Accounts.User
setup %{conn: conn} do
{:ok, space} = BookingSystem.create_space("TestSpace", "test-space", "Test description")
@ -20,28 +19,12 @@ defmodule SpazioSolazzoWeb.BookingLive.AssetBookingTest do
space.id
)
conn = Plug.Test.init_test_session(conn, %{})
conn = log_in_user(conn)
user = register_user("test@example.com", "Test User", "+1234567890")
conn = log_in_user(conn, user)
%{space: space, asset: asset, slot: slot, conn: conn}
end
defp log_in_user(conn) do
{:ok, user} =
SpazioSolazzo.Repo.insert(%User{
id: Ash.UUID.generate(),
email: "test@example.com",
name: "Test User",
phone_number: "+1234567890"
})
{:ok, token, _claims} = AshAuthentication.Jwt.token_for_user(user)
conn
|> Plug.Test.init_test_session(%{})
|> Plug.Conn.put_session("user_token", token)
end
describe "AssetBooking mount" do
test "renders asset booking page with available time slots", %{
conn: conn,
@ -105,7 +88,11 @@ defmodule SpazioSolazzoWeb.BookingLive.AssetBookingTest do
view
|> element("#booking-form")
|> render_submit(%{"customer_comment" => "test comment"})
|> render_submit(%{
"customer_name" => "Test User",
"customer_phone" => "+1234567890",
"customer_comment" => "test comment"
})
assert has_element?(view, "#success-modal")
@ -224,4 +211,113 @@ defmodule SpazioSolazzoWeb.BookingLive.AssetBookingTest do
assert html =~ ~s(<div class="p-2"></div>)
end
end
describe "AssetBooking without phone number" do
setup %{conn: conn} do
# Create a separate connection with a user without phone number
user = register_user("nophone@example.com", "User Without Phone", nil)
conn = log_in_user(conn, user)
%{conn: conn}
end
test "user without phone number can view booking form", %{conn: conn, asset: asset} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
view
|> element("button[phx-click='select_slot']")
|> render_click()
assert has_element?(view, "#booking-modal")
assert has_element?(view, "input[name='customer_name']")
assert has_element?(view, "input[name='customer_phone']")
assert has_element?(view, "textarea[name='customer_comment']")
end
test "user without phone number can create booking without providing phone", %{
conn: conn,
asset: asset
} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
view
|> element("button[phx-click='select_slot']")
|> render_click()
assert has_element?(view, "#booking-modal")
# Submit booking with name but no phone
view
|> element("#booking-form")
|> render_submit(%{
"customer_name" => "User Without Phone",
"customer_phone" => "",
"customer_comment" => "test comment"
})
assert has_element?(view, "#success-modal")
assert {:ok, [booking]} =
BookingSystem.list_active_asset_bookings_by_date(asset.id, Date.utc_today())
assert booking.customer_email == "nophone@example.com"
assert booking.customer_name == "User Without Phone"
assert booking.customer_phone == nil or booking.customer_phone == ""
assert booking.customer_comment == "test comment"
end
test "user without phone number can edit name in booking form", %{
conn: conn,
asset: asset
} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
view
|> element("button[phx-click='select_slot']")
|> render_click()
# Change the name
view
|> element("#booking-form")
|> render_submit(%{
"customer_name" => "Different Name",
"customer_phone" => "",
"customer_comment" => ""
})
assert has_element?(view, "#success-modal")
assert {:ok, [booking]} =
BookingSystem.list_active_asset_bookings_by_date(asset.id, Date.utc_today())
assert booking.customer_name == "Different Name"
end
test "user without phone number can optionally add phone during booking", %{
conn: conn,
asset: asset
} do
{:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
view
|> element("button[phx-click='select_slot']")
|> render_click()
# Add phone number during booking
view
|> element("#booking-form")
|> render_submit(%{
"customer_name" => "User Without Phone",
"customer_phone" => "+39 123 456 789",
"customer_comment" => ""
})
assert has_element?(view, "#success-modal")
assert {:ok, [booking]} =
BookingSystem.list_active_asset_bookings_by_date(asset.id, Date.utc_today())
assert booking.customer_phone == "+39 123 456 789"
end
end
end

View file

@ -8,8 +8,9 @@ defmodule SpazioSolazzoWeb.ProfileLiveTest do
alias SpazioSolazzo.BookingSystem.Booking
setup %{conn: conn} do
conn = Plug.Test.init_test_session(conn, %{})
{conn, user} = log_in_user(conn)
user = register_user("test@example.com", "Test User", "+123456789")
conn = log_in_user(conn, user)
%{user: user, conn: conn}
end
@ -191,25 +192,6 @@ defmodule SpazioSolazzoWeb.ProfileLiveTest do
end
end
defp log_in_user(conn) do
user =
SpazioSolazzo.Repo.insert!(%User{
id: Ash.UUID.generate(),
email: "test@example.com",
name: "Test User",
phone_number: "+1234567890"
})
{:ok, token, _claims} = AshAuthentication.Jwt.token_for_user(user)
conn =
conn
|> Plug.Test.init_test_session(%{})
|> Plug.Conn.put_session("user_token", token)
{conn, user}
end
defp create_booking_fixtures do
unique_id = :erlang.unique_integer([:positive, :monotonic])

View file

@ -0,0 +1,64 @@
defmodule SpazioSolazzo.AuthHelpers do
@moduledoc """
Authentication helper functions for tests.
Provides utilities to create and authenticate users via the magic link flow,
simulating realistic authentication behavior in tests.
"""
alias SpazioSolazzo.Accounts
@doc """
Creates a test session and logs the user into it.
## Parameters
- `conn` - The test connection
- `user` - User to log in
## Examples
conn = log_in_user(conn, user)
"""
def log_in_user(conn, user) do
conn
|> Plug.Test.init_test_session(%{})
|> AshAuthentication.Phoenix.Plug.store_in_session(user)
end
@doc """
Creates a user via magic link authentication without attaching to a connection.
Useful for tests that need a user object but don't need an authenticated connection.
## Parameters
- `email` - User's email address
- `name` - Optional user's full name (defaults to "Test User")
- `phone_number` - Optional phone number (defaults to nil)
## Examples
user = register_user("test@example.com", "Test User", "+1234567890")
user = register_user("user@example.com", "User Name")
user = register_user("user@example.com")
"""
def register_user(email, name \\ "Test user", phone_number \\ nil) do
strategy = AshAuthentication.Info.strategy!(SpazioSolazzo.Accounts.User, :magic_link)
{:ok, token} =
AshAuthentication.Strategy.MagicLink.request_token_for_identity(strategy, email)
# Sign in with magic link
{:ok, user} =
Accounts.sign_in_with_magic_link(
token,
false,
name,
phone_number,
authorize?: false
)
user
end
end

View file

@ -31,6 +31,9 @@ defmodule SpazioSolazzoWeb.ConnCase do
# Import DataCase helpers for email testing
import SpazioSolazzo.DataCase, only: [pop_email: 0, pop_email: 2]
# Import authentication helpers
import SpazioSolazzo.AuthHelpers
end
end

View file

@ -26,6 +26,7 @@ defmodule SpazioSolazzo.DataCase do
import Ecto.Changeset
import Ecto.Query
import SpazioSolazzo.DataCase
import SpazioSolazzo.AuthHelpers
end
end