diff --git a/.formatter.exs b/.formatter.exs
index 8650321..6d5034f 100644
--- a/.formatter.exs
+++ b/.formatter.exs
@@ -1,7 +1,6 @@
[
import_deps: [
:oban,
- :ash_admin,
:ash_authentication_phoenix,
:ash_authentication,
:ash_postgres,
diff --git a/lib/spazio_solazzo/accounts.ex b/lib/spazio_solazzo/accounts.ex
index 1c6fedf..22b26d8 100644
--- a/lib/spazio_solazzo/accounts.ex
+++ b/lib/spazio_solazzo/accounts.ex
@@ -20,6 +20,7 @@ defmodule SpazioSolazzo.Accounts do
define :get_user_by_email, action: :read, get_by: [:email]
define :terminate_account, action: :terminate_account, args: [:delete_history]
define :update_profile, action: :update_profile, args: [:name, :phone_number]
+ define :make_admin, action: :make_admin, args: []
end
end
end
diff --git a/lib/spazio_solazzo/accounts/user.ex b/lib/spazio_solazzo/accounts/user.ex
index aecf91b..88f6c4e 100644
--- a/lib/spazio_solazzo/accounts/user.ex
+++ b/lib/spazio_solazzo/accounts/user.ex
@@ -104,6 +104,11 @@ defmodule SpazioSolazzo.Accounts.User do
require_atomic? false
end
+ update :make_admin do
+ accept []
+ change set_attribute(:role, :admin)
+ end
+
destroy :terminate_account do
description "Delete user account with optional booking data removal"
require_atomic? false
@@ -122,6 +127,10 @@ defmodule SpazioSolazzo.Accounts.User do
authorize_if always()
end
+ policy action(:make_admin) do
+ authorize_if never()
+ end
+
policy action_type(:read) do
authorize_if always()
end
diff --git a/lib/spazio_solazzo/accounts/user/changes/handle_bookings_on_account_deletion.ex b/lib/spazio_solazzo/accounts/user/changes/handle_bookings_on_account_deletion.ex
index 20ee745..7c87df3 100644
--- a/lib/spazio_solazzo/accounts/user/changes/handle_bookings_on_account_deletion.ex
+++ b/lib/spazio_solazzo/accounts/user/changes/handle_bookings_on_account_deletion.ex
@@ -2,7 +2,7 @@ defmodule SpazioSolazzo.Accounts.User.Changes.HandleBookingsOnAccountDeletion do
@moduledoc """
Handles booking cleanup when a user account is terminated.
- - Cancels all future reserved bookings
+ - Cancels all future requested/accepted bookings with a reason
- Either deletes all bookings or lets the database nullify them based on delete_history argument
"""
use Ash.Resource.Change
@@ -18,14 +18,14 @@ defmodule SpazioSolazzo.Accounts.User.Changes.HandleBookingsOnAccountDeletion do
Booking
|> Ash.Query.filter(
- user_id == ^user.id and state == :reserved and date >= ^Date.utc_today()
+ user_id == ^user.id and state in [:requested, :accepted] and date >= ^Date.utc_today()
)
- |> BookingSystem.cancel_booking!()
+ |> BookingSystem.cancel_booking!("Account deleted by user")
if delete_history do
Booking
|> Ash.Query.filter(user_id == ^user.id)
- |> BookingSystem.delete_booking!(authorize?: false)
+ |> Ash.bulk_destroy!(:destroy, %{}, authorize?: false)
end
changeset
diff --git a/lib/spazio_solazzo/accounts/user/changes/validate_registration_fields.ex b/lib/spazio_solazzo/accounts/user/changes/parse_registration_fields.ex
similarity index 100%
rename from lib/spazio_solazzo/accounts/user/changes/validate_registration_fields.ex
rename to lib/spazio_solazzo/accounts/user/changes/parse_registration_fields.ex
diff --git a/lib/spazio_solazzo/booking_system.ex b/lib/spazio_solazzo/booking_system.ex
index d71c454..423bf46 100644
--- a/lib/spazio_solazzo/booking_system.ex
+++ b/lib/spazio_solazzo/booking_system.ex
@@ -1,6 +1,6 @@
defmodule SpazioSolazzo.BookingSystem do
@moduledoc """
- Manages bookings, spaces, assets and time slots for the booking system.
+ Manages bookings, spaces, and time slots for the booking system.
"""
use Ash.Domain,
@@ -9,14 +9,10 @@ defmodule SpazioSolazzo.BookingSystem do
resources do
resource SpazioSolazzo.BookingSystem.Space do
define :get_space_by_slug, action: :read, get_by: [:slug]
- define :create_space, action: :create, args: [:name, :slug, :description]
- end
- resource SpazioSolazzo.BookingSystem.Asset do
- define :get_asset_by_id, action: :read, get_by: [:id]
- define :get_asset_by_space_id, action: :read, get_by: [:space_id]
- define :get_space_assets, action: :get_space_assets, args: [:space_id]
- define :create_asset, action: :create, args: [:name, :space_id]
+ define :create_space,
+ action: :create,
+ args: [:name, :slug, :description, :capacity]
end
resource SpazioSolazzo.BookingSystem.TimeSlotTemplate do
@@ -30,26 +26,46 @@ defmodule SpazioSolazzo.BookingSystem do
end
resource SpazioSolazzo.BookingSystem.Booking do
- define :list_active_asset_bookings_by_date,
- action: :list_active_asset_bookings_by_date,
- args: [:asset_id, :date]
+ define :read_pending_bookings,
+ action: :read_pending_bookings,
+ args: [:space_id, :email, :date]
+
+ define :read_booking_history,
+ action: :read_booking_history,
+ args: [:space_id, :email, :date]
+
+ define :search_bookings,
+ action: :search,
+ args: [:space_id, :start_datetime, :end_datetime, :states, :select]
define :create_booking,
action: :create,
args: [
- :time_slot_template_id,
- :asset_id,
+ :space_id,
:user_id,
:date,
+ :start_time,
+ :end_time,
:customer_name,
:customer_email,
:customer_phone,
:customer_comment
]
- define :confirm_booking, action: :confirm_booking, args: []
- define :cancel_booking, action: :cancel, args: []
- define :delete_booking, action: :destroy, args: []
+ define :create_walk_in,
+ action: :create_walk_in,
+ args: [
+ :space_id,
+ :start_datetime,
+ :end_datetime,
+ :customer_name,
+ :customer_email,
+ :customer_phone
+ ]
+
+ define :approve_booking, action: :approve, args: []
+ define :reject_booking, action: :reject, args: [:reason]
+ define :cancel_booking, action: :cancel, args: [:reason]
end
end
end
diff --git a/lib/spazio_solazzo/booking_system/asset.ex b/lib/spazio_solazzo/booking_system/asset.ex
deleted file mode 100644
index cd16f12..0000000
--- a/lib/spazio_solazzo/booking_system/asset.ex
+++ /dev/null
@@ -1,43 +0,0 @@
-defmodule SpazioSolazzo.BookingSystem.Asset do
- @moduledoc """
- Represents bookable assets within a space, such as rooms or equipment.
- """
-
- use Ash.Resource,
- otp_app: :spazio_solazzo,
- domain: SpazioSolazzo.BookingSystem,
- data_layer: AshPostgres.DataLayer
-
- postgres do
- table "assets"
- repo SpazioSolazzo.Repo
- end
-
- actions do
- defaults [:read, create: :*]
-
- read :get_space_assets do
- argument :space_id, :string do
- allow_nil? false
- end
-
- filter expr(space_id == ^arg(:space_id))
- end
- end
-
- attributes do
- uuid_primary_key :id
- attribute :name, :string, allow_nil?: false, public?: true
- end
-
- relationships do
- belongs_to :space, SpazioSolazzo.BookingSystem.Space do
- allow_nil? false
- public? true
- end
- end
-
- identities do
- identity :unique_name_per_space, [:name, :space_id]
- end
-end
diff --git a/lib/spazio_solazzo/booking_system/booking.ex b/lib/spazio_solazzo/booking_system/booking.ex
index 8545d94..80e61fc 100644
--- a/lib/spazio_solazzo/booking_system/booking.ex
+++ b/lib/spazio_solazzo/booking_system/booking.ex
@@ -11,7 +11,13 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
authorizers: [Ash.Policy.Authorizer],
extensions: [AshStateMachine]
- alias SpazioSolazzo.BookingSystem.Booking.EmailWorker
+ require Ash.Query
+
+ alias SpazioSolazzo.BookingSystem.Booking.{
+ AdminActionEmailWorker,
+ RequestCreatedEmailWorker,
+ UserCancellationEmailWorker
+ }
postgres do
table "bookings"
@@ -20,112 +26,335 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
references do
reference :user, on_delete: :nilify, index?: true
end
+
+ custom_indexes do
+ # Composite index for space + datetime range queries (most common pattern)
+ index [:space_id, :start_datetime, :end_datetime]
+
+ # Composite index for space + state queries (filtering by status)
+ index [:space_id, :state]
+
+ # Single indexes for datetime overlap queries
+ index [:start_datetime]
+ index [:end_datetime]
+ end
end
state_machine do
- initial_states([:reserved])
- default_initial_state(:reserved)
+ initial_states([:requested])
+ default_initial_state(:requested)
transitions do
- transition(:confirm_booking, from: :reserved, to: :completed)
- transition(:cancel, from: :reserved, to: :cancelled)
+ transition(:approve, from: :requested, to: :accepted)
+ transition(:reject, from: :requested, to: :rejected)
+ transition(:cancel, from: [:requested, :accepted], to: :cancelled)
end
end
actions do
defaults [:read]
- read :list_active_asset_bookings_by_date do
- argument :asset_id, :uuid, allow_nil?: false
- argument :date, :date, allow_nil?: false
+ read :search do
+ description "Fetch bookings within a date/time range with optional filters"
- filter expr(
- asset_id == ^arg(:asset_id) and date == ^arg(:date) and
- state in [:reserved, :completed]
- )
+ argument :space_id, :uuid, allow_nil?: true
+ argument :start_datetime, :datetime, allow_nil?: false
+ argument :end_datetime, :datetime, allow_nil?: false
+ argument :states, {:array, :atom}, allow_nil?: true
+ argument :select, {:array, :atom}, allow_nil?: true
+
+ prepare fn query, _ctx ->
+ start_dt = Ash.Query.get_argument(query, :start_datetime)
+ end_dt = Ash.Query.get_argument(query, :end_datetime)
+
+ # Base datetime overlap filter
+ query =
+ Ash.Query.filter(
+ query,
+ start_datetime < ^end_dt and end_datetime > ^start_dt
+ )
+
+ # Optional space filter
+ query =
+ case Ash.Query.get_argument(query, :space_id) do
+ nil -> query
+ space_id -> Ash.Query.filter(query, space_id == ^space_id)
+ end
+
+ # Optional states filter
+ query =
+ case Ash.Query.get_argument(query, :states) do
+ nil -> query
+ [] -> query
+ states -> Ash.Query.filter(query, state in ^states)
+ end
+
+ case Ash.Query.get_argument(query, :select) do
+ nil -> query
+ [] -> query
+ select -> Ash.Query.select(query, select)
+ end
+ end
+ end
+
+ read :read_pending_bookings do
+ description "Fetch pending bookings for admin dashboard with pagination"
+
+ argument :space_id, :uuid, allow_nil?: true
+ argument :email, :string, allow_nil?: true
+ argument :date, :date, allow_nil?: true
+
+ # Only requested bookings
+ filter expr(state == :requested)
+
+ pagination do
+ required? false
+ offset? true
+ countable true
+ default_limit 10
+ max_page_size 50
+ end
+
+ # Apply shared admin filters preparation
+ prepare SpazioSolazzo.BookingSystem.Booking.Preparations.ApplyAdminFilters
+
+ prepare fn query, _ctx ->
+ Ash.Query.sort(query, inserted_at: :desc)
+ end
+ end
+
+ read :read_booking_history do
+ description "Fetch historical bookings (accepted/rejected/cancelled) with pagination"
+
+ argument :space_id, :uuid, allow_nil?: true
+ argument :email, :string, allow_nil?: true
+ argument :date, :date, allow_nil?: true
+
+ # Non-pending states
+ filter expr(state in [:accepted, :rejected, :cancelled])
+
+ pagination do
+ required? false
+ offset? true
+ countable true
+ default_limit 25
+ max_page_size 100
+ end
+
+ # Apply shared admin filters preparation
+ prepare SpazioSolazzo.BookingSystem.Booking.Preparations.ApplyAdminFilters
+
+ prepare fn query, _ctx ->
+ Ash.Query.sort(query, start_datetime: :desc)
+ end
end
create :create do
- argument :time_slot_template_id, :uuid, allow_nil?: false
- argument :asset_id, :uuid, allow_nil?: false
- argument :user_id, :uuid, allow_nil?: false
+ argument :space_id, :uuid, allow_nil?: false
+ argument :user_id, :uuid, allow_nil?: true
argument :date, :date, allow_nil?: false
+ argument :start_time, :time, allow_nil?: false
+ argument :end_time, :time, allow_nil?: false
argument :customer_name, :string, allow_nil?: false
argument :customer_email, :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,
- type: :append_and_remove
- )
-
- change manage_relationship(:asset_id, :asset, type: :append_and_remove)
-
+ change manage_relationship(:space_id, :space, type: :append_and_remove)
change manage_relationship(:user_id, :user, type: :append_and_remove, authorize?: false)
+ validate {SpazioSolazzo.BookingSystem.Validations.FutureDate, field: :date}
+
+ validate {SpazioSolazzo.BookingSystem.Validations.ChronologicalOrder,
+ start: :start_time, end: :end_time}
+
+ validate {SpazioSolazzo.BookingSystem.Validations.Email, field: :customer_email}
+
change fn changeset, _ctx ->
- template_id = Ash.Changeset.get_argument(changeset, :time_slot_template_id)
+ date = Ash.Changeset.get_argument(changeset, :date)
+ start_time = Ash.Changeset.get_argument(changeset, :start_time)
+ end_time = Ash.Changeset.get_argument(changeset, :end_time)
- case Ash.get(SpazioSolazzo.BookingSystem.TimeSlotTemplate, template_id) do
- {:ok, template} ->
- changeset
- |> Ash.Changeset.force_change_attribute(:start_time, template.start_time)
- |> Ash.Changeset.force_change_attribute(:end_time, template.end_time)
- |> Ash.Changeset.force_change_attribute(
- :date,
- Ash.Changeset.get_argument(changeset, :date)
- )
- |> Ash.Changeset.force_change_attribute(
- :customer_name,
- Ash.Changeset.get_argument(changeset, :customer_name)
- )
- |> Ash.Changeset.force_change_attribute(
- :customer_email,
- Ash.Changeset.get_argument(changeset, :customer_email)
- )
- |> Ash.Changeset.force_change_attribute(
- :customer_phone,
- Ash.Changeset.get_argument(changeset, :customer_phone)
- )
- |> Ash.Changeset.force_change_attribute(
- :customer_comment,
- Ash.Changeset.get_argument(changeset, :customer_comment)
- )
+ start_datetime = DateTime.new!(date, start_time, "Etc/UTC")
+ end_datetime = DateTime.new!(date, end_time, "Etc/UTC")
- {:error, _} ->
- Ash.Changeset.add_error(changeset,
- field: :time_slot_template_id,
- message: "Template not found"
- )
- end
+ changeset
+ |> Ash.Changeset.force_change_attribute(:start_datetime, start_datetime)
+ |> Ash.Changeset.force_change_attribute(:end_datetime, end_datetime)
+ |> Ash.Changeset.force_change_attribute(:date, date)
+ |> Ash.Changeset.force_change_attribute(:start_time, start_time)
+ |> Ash.Changeset.force_change_attribute(:end_time, end_time)
+ |> Ash.Changeset.force_change_attribute(
+ :customer_name,
+ Ash.Changeset.get_argument(changeset, :customer_name)
+ )
+ |> Ash.Changeset.force_change_attribute(
+ :customer_email,
+ Ash.Changeset.get_argument(changeset, :customer_email)
+ )
+ |> Ash.Changeset.force_change_attribute(
+ :customer_phone,
+ Ash.Changeset.get_argument(changeset, :customer_phone)
+ )
+ |> Ash.Changeset.force_change_attribute(
+ :customer_comment,
+ Ash.Changeset.get_argument(changeset, :customer_comment)
+ )
end
change after_action(fn _changeset, booking, _ctx ->
+ booking = Ash.load!(booking, [:space])
+
%{
booking_id: booking.id,
customer_name: booking.customer_name,
customer_email: booking.customer_email,
customer_phone: booking.customer_phone,
customer_comment: booking.customer_comment,
- date: Calendar.strftime(booking.date, "%A, %B %d"),
- start_time: booking.start_time,
- end_time: booking.end_time
+ space_name: booking.space.name,
+ start_datetime: booking.start_datetime,
+ end_datetime: booking.end_datetime
}
- |> EmailWorker.new()
+ |> RequestCreatedEmailWorker.new()
|> Oban.insert!()
{:ok, booking}
end)
end
- update :confirm_booking do
+ create :create_walk_in do
+ argument :space_id, :uuid, allow_nil?: false
+ argument :start_datetime, :datetime, allow_nil?: false
+ argument :end_datetime, :datetime, allow_nil?: false
+ argument :customer_name, :string, allow_nil?: false
+ argument :customer_email, :string, allow_nil?: false
+ argument :customer_phone, :string, allow_nil?: true
+
+ change manage_relationship(:space_id, :space, type: :append_and_remove)
+
+ validate {SpazioSolazzo.BookingSystem.Validations.FutureDate, field: :end_datetime}
+
+ validate {SpazioSolazzo.BookingSystem.Validations.ChronologicalOrder,
+ start: :start_datetime, end: :end_datetime}
+
+ validate {SpazioSolazzo.BookingSystem.Validations.Email, field: :customer_email}
+
+ change fn changeset, _ctx ->
+ start_datetime = Ash.Changeset.get_argument(changeset, :start_datetime)
+ end_datetime = Ash.Changeset.get_argument(changeset, :end_datetime)
+
+ date = DateTime.to_date(start_datetime)
+ start_time = DateTime.to_time(start_datetime)
+ end_time = DateTime.to_time(end_datetime)
+
+ changeset
+ |> Ash.Changeset.force_change_attribute(:start_datetime, start_datetime)
+ |> Ash.Changeset.force_change_attribute(:end_datetime, end_datetime)
+ |> Ash.Changeset.force_change_attribute(:date, date)
+ |> Ash.Changeset.force_change_attribute(:start_time, start_time)
+ |> Ash.Changeset.force_change_attribute(:end_time, end_time)
+ |> Ash.Changeset.force_change_attribute(:state, :accepted)
+ |> Ash.Changeset.force_change_attribute(
+ :customer_name,
+ Ash.Changeset.get_argument(changeset, :customer_name)
+ )
+ |> Ash.Changeset.force_change_attribute(
+ :customer_email,
+ Ash.Changeset.get_argument(changeset, :customer_email)
+ )
+ |> Ash.Changeset.force_change_attribute(
+ :customer_phone,
+ Ash.Changeset.get_argument(changeset, :customer_phone)
+ )
+ end
+ end
+
+ update :approve do
accept []
- change transition_state(:completed)
+ require_atomic? false
+ change transition_state(:accepted)
+
+ change after_action(fn _changeset, booking, _ctx ->
+ booking = Ash.load!(booking, [:space])
+
+ %{
+ booking_id: booking.id,
+ customer_name: booking.customer_name,
+ customer_email: booking.customer_email,
+ customer_phone: booking.customer_phone,
+ space_name: booking.space.name,
+ start_datetime: booking.start_datetime,
+ end_datetime: booking.end_datetime,
+ action: "accepted"
+ }
+ |> AdminActionEmailWorker.new()
+ |> Oban.insert!()
+
+ {:ok, booking}
+ end)
+ end
+
+ update :reject do
+ accept [:rejection_reason]
+ argument :reason, :string, allow_nil?: false
+ require_atomic? false
+
+ change fn changeset, _ctx ->
+ reason = Ash.Changeset.get_argument(changeset, :reason)
+ Ash.Changeset.force_change_attribute(changeset, :rejection_reason, reason)
+ end
+
+ change transition_state(:rejected)
+
+ change after_action(fn _changeset, booking, _ctx ->
+ booking = Ash.load!(booking, [:space])
+
+ %{
+ customer_name: booking.customer_name,
+ customer_email: booking.customer_email,
+ customer_phone: booking.customer_phone,
+ space_name: booking.space.name,
+ start_datetime: booking.start_datetime,
+ end_datetime: booking.end_datetime,
+ action: "rejected",
+ rejection_reason: booking.rejection_reason
+ }
+ |> AdminActionEmailWorker.new()
+ |> Oban.insert!()
+
+ {:ok, booking}
+ end)
end
update :cancel do
- accept []
+ accept [:cancellation_reason]
+ argument :reason, :string, allow_nil?: false
+ require_atomic? false
+
+ change fn changeset, _ctx ->
+ reason = Ash.Changeset.get_argument(changeset, :reason)
+ Ash.Changeset.force_change_attribute(changeset, :cancellation_reason, reason)
+ end
+
change transition_state(:cancelled)
+
+ change after_action(fn _changeset, booking, _ctx ->
+ booking = Ash.load!(booking, [:space])
+
+ %{
+ customer_name: booking.customer_name,
+ customer_email: booking.customer_email,
+ customer_phone: booking.customer_phone,
+ space_name: booking.space.name,
+ start_datetime: booking.start_datetime,
+ end_datetime: booking.end_datetime,
+ cancellation_reason: booking.cancellation_reason
+ }
+ |> UserCancellationEmailWorker.new()
+ |> Oban.insert!()
+
+ {:ok, booking}
+ end)
end
destroy :destroy do
@@ -135,7 +364,7 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
end
policies do
- policy action([:cancel, :confirm_booking]) do
+ policy action([:cancel, :approve, :reject]) do
authorize_if always()
end
@@ -157,11 +386,16 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
prefix "booking"
publish :create, ["created"]
+ publish :create_walk_in, ["created"]
+ publish :approve, ["approved"]
+ publish :reject, ["rejected"]
publish :cancel, ["cancelled"]
end
attributes do
uuid_primary_key :id
+ attribute :start_datetime, :datetime, allow_nil?: false
+ attribute :end_datetime, :datetime, allow_nil?: false
attribute :date, :date, allow_nil?: false
attribute :customer_name, :string, allow_nil?: false
attribute :customer_email, :string, allow_nil?: false
@@ -169,12 +403,14 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
attribute :end_time, :time, allow_nil?: false
attribute :customer_phone, :string, allow_nil?: true
attribute :customer_comment, :string, allow_nil?: true
+ attribute :cancellation_reason, :string, allow_nil?: true
+ attribute :rejection_reason, :string, allow_nil?: true
attribute :state, :atom do
allow_nil? false
- default :reserved
+ default :requested
public? true
- constraints one_of: [:reserved, :completed, :cancelled]
+ constraints one_of: [:requested, :accepted, :rejected, :cancelled]
end
create_timestamp :inserted_at
@@ -182,8 +418,10 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
end
relationships do
- belongs_to :asset, SpazioSolazzo.BookingSystem.Asset
- belongs_to :time_slot_template, SpazioSolazzo.BookingSystem.TimeSlotTemplate
+ belongs_to :space, SpazioSolazzo.BookingSystem.Space do
+ allow_nil? false
+ public? true
+ end
belongs_to :user, SpazioSolazzo.Accounts.User do
allow_nil? true
diff --git a/lib/spazio_solazzo/booking_system/booking/admin_action_email_worker.ex b/lib/spazio_solazzo/booking_system/booking/admin_action_email_worker.ex
new file mode 100644
index 0000000..b62844c
--- /dev/null
+++ b/lib/spazio_solazzo/booking_system/booking/admin_action_email_worker.ex
@@ -0,0 +1,76 @@
+defmodule SpazioSolazzo.BookingSystem.Booking.AdminActionEmailWorker do
+ @moduledoc """
+ Sends emails when an admin approves or rejects a booking request.
+ """
+
+ use Oban.Worker, queue: :booking_email, max_attempts: 3
+
+ alias SpazioSolazzo.BookingSystem.Booking.Email
+ alias SpazioSolazzo.CalendarExt
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{
+ args: %{
+ "booking_id" => booking_id,
+ "customer_name" => customer_name,
+ "customer_email" => customer_email,
+ "customer_phone" => customer_phone,
+ "space_name" => space_name,
+ "start_datetime" => start_datetime_str,
+ "end_datetime" => end_datetime_str,
+ "action" => "accepted"
+ }
+ }) do
+ {:ok, start_datetime, _} = DateTime.from_iso8601(start_datetime_str)
+ {:ok, end_datetime, _} = DateTime.from_iso8601(end_datetime_str)
+
+ %{
+ booking_id: booking_id,
+ customer_name: customer_name,
+ customer_email: customer_email,
+ customer_phone: customer_phone,
+ space_name: space_name,
+ start_datetime: start_datetime,
+ end_datetime: end_datetime,
+ date: CalendarExt.format_datetime_date_only(start_datetime),
+ start_time: DateTime.to_time(start_datetime),
+ end_time: DateTime.to_time(end_datetime)
+ }
+ |> Email.booking_request_approved()
+ |> SpazioSolazzo.Mailer.deliver!()
+
+ :ok
+ end
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{
+ args: %{
+ "customer_name" => customer_name,
+ "customer_email" => customer_email,
+ "space_name" => space_name,
+ "start_datetime" => start_datetime_str,
+ "end_datetime" => end_datetime_str,
+ "action" => "rejected",
+ "rejection_reason" => rejection_reason
+ }
+ }) do
+ {:ok, start_datetime, _} = DateTime.from_iso8601(start_datetime_str)
+ {:ok, end_datetime, _} = DateTime.from_iso8601(end_datetime_str)
+
+ %{
+ customer_name: customer_name,
+ customer_email: customer_email,
+ space_name: space_name,
+ start_datetime: start_datetime,
+ end_datetime: end_datetime,
+ date: CalendarExt.format_datetime_date_only(start_datetime),
+ start_time: DateTime.to_time(start_datetime),
+ end_time: DateTime.to_time(end_datetime),
+ rejection_reason: rejection_reason
+ }
+ |> Email.booking_request_rejected()
+ |> SpazioSolazzo.Mailer.deliver!()
+
+ :ok
+ end
+end
diff --git a/lib/spazio_solazzo/booking_system/booking/email.ex b/lib/spazio_solazzo/booking_system/booking/email.ex
index 432874c..d442eec 100644
--- a/lib/spazio_solazzo/booking_system/booking/email.ex
+++ b/lib/spazio_solazzo/booking_system/booking/email.ex
@@ -11,12 +11,13 @@ defmodule SpazioSolazzo.BookingSystem.Booking.Email do
use SpazioSolazzoWeb, :verified_routes
alias SpazioSolazzo.BookingSystem.Booking.Token
- def customer_confirmation(%{
+ def user_booking_request_confirmation(%{
booking_id: booking_id,
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
customer_comment: customer_comment,
+ space_name: space_name,
date: date,
start_time: start_time,
end_time: end_time
@@ -29,56 +30,144 @@ defmodule SpazioSolazzo.BookingSystem.Booking.Email do
customer_email: customer_email,
customer_phone: customer_phone,
customer_comment: customer_comment,
+ space_name: space_name,
date: date,
start_time: start_time,
end_time: end_time,
cancel_url: cancel_url,
front_office_phone_number: front_office_phone_number(),
- subject: "Booking Confirmed: #{date}"
+ subject: "Request Received: #{date}"
}
new()
|> to({customer_name, customer_email})
|> from({"Spazio Solazzo", spazio_solazzo_email()})
|> subject(assigns.subject)
- |> render_body("customer_confirmation.html", assigns)
+ |> render_body("user_booking_request_confirmation.html", assigns)
end
- # --- Admin Email ---
- def admin_notification(%{
- booking_id: booking_id,
+ def admin_incoming_booking_request(%{
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
customer_comment: customer_comment,
+ space_name: space_name,
date: date,
start_time: start_time,
end_time: end_time,
admin_email: admin_email
}) do
- tokens = Token.generate_admin_tokens(booking_id)
-
- confirm_url = url(~p"/bookings/confirm?token=#{tokens.confirm_token}")
- cancel_url = url(~p"/bookings/cancel?token=#{tokens.cancel_token}")
+ dashboard_url = url(~p"/admin/bookings")
assigns = %{
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
customer_comment: customer_comment,
+ space_name: space_name,
date: date,
start_time: start_time,
end_time: end_time,
- confirm_url: confirm_url,
- cancel_url: cancel_url,
- subject: "New Booking Action Required: #{customer_name}"
+ dashboard_url: dashboard_url,
+ subject: "New Booking Request: #{customer_name}"
}
new()
|> to(admin_email)
|> from({"Spazio Solazzo", spazio_solazzo_email()})
|> subject(assigns.subject)
- |> render_body("admin_notification.html", assigns)
+ |> render_body("admin_incoming_booking_request.html", assigns)
+ end
+
+ def booking_cancelled(%{
+ customer_name: customer_name,
+ customer_email: customer_email,
+ customer_phone: customer_phone,
+ space_name: space_name,
+ date: date,
+ start_time: start_time,
+ end_time: end_time,
+ cancellation_reason: cancellation_reason,
+ admin_email: admin_email
+ }) do
+ assigns = %{
+ customer_name: customer_name,
+ customer_email: customer_email,
+ customer_phone: customer_phone,
+ space_name: space_name,
+ date: date,
+ start_time: start_time,
+ end_time: end_time,
+ cancellation_reason: cancellation_reason,
+ subject: "Booking Cancelled: #{customer_name}"
+ }
+
+ new()
+ |> to(admin_email)
+ |> from({"Spazio Solazzo", spazio_solazzo_email()})
+ |> subject(assigns.subject)
+ |> render_body("booking_cancelled.html", assigns)
+ end
+
+ def booking_request_approved(%{
+ booking_id: booking_id,
+ customer_name: customer_name,
+ customer_email: customer_email,
+ customer_phone: customer_phone,
+ space_name: space_name,
+ date: date,
+ start_time: start_time,
+ end_time: end_time
+ }) do
+ cancel_token = Token.generate_customer_cancel_token(booking_id)
+ cancel_url = url(~p"/bookings/cancel?token=#{cancel_token}")
+
+ assigns = %{
+ customer_name: customer_name,
+ customer_email: customer_email,
+ customer_phone: customer_phone,
+ space_name: space_name,
+ date: date,
+ start_time: start_time,
+ end_time: end_time,
+ cancel_url: cancel_url,
+ front_office_phone_number: front_office_phone_number(),
+ subject: "Booking Approved: #{date}"
+ }
+
+ new()
+ |> to({customer_name, customer_email})
+ |> from({"Spazio Solazzo", spazio_solazzo_email()})
+ |> subject(assigns.subject)
+ |> render_body("booking_request_approved.html", assigns)
+ end
+
+ def booking_request_rejected(%{
+ customer_name: customer_name,
+ customer_email: customer_email,
+ space_name: space_name,
+ date: date,
+ start_time: start_time,
+ end_time: end_time,
+ rejection_reason: rejection_reason
+ }) do
+ assigns = %{
+ customer_name: customer_name,
+ customer_email: customer_email,
+ space_name: space_name,
+ date: date,
+ start_time: start_time,
+ end_time: end_time,
+ rejection_reason: rejection_reason,
+ front_office_phone_number: front_office_phone_number(),
+ subject: "Booking Request Update: #{date}"
+ }
+
+ new()
+ |> to({customer_name, customer_email})
+ |> from({"Spazio Solazzo", spazio_solazzo_email()})
+ |> subject(assigns.subject)
+ |> render_body("booking_request_rejected.html", assigns)
end
defp spazio_solazzo_email do
diff --git a/lib/spazio_solazzo/booking_system/booking/email_worker.ex b/lib/spazio_solazzo/booking_system/booking/email_worker.ex
deleted file mode 100644
index bb92308..0000000
--- a/lib/spazio_solazzo/booking_system/booking/email_worker.ex
+++ /dev/null
@@ -1,49 +0,0 @@
-defmodule SpazioSolazzo.BookingSystem.Booking.EmailWorker do
- @moduledoc """
- Sends booking confirmation emails to customers and notification emails to administrators.
- """
-
- use Oban.Worker, queue: :booking_email, max_attempts: 1
-
- alias SpazioSolazzo.BookingSystem.Booking.Email
-
- @impl Oban.Worker
- def perform(%Oban.Job{
- args: %{
- "booking_id" => booking_id,
- "customer_name" => customer_name,
- "customer_email" => customer_email,
- "customer_phone" => customer_phone,
- "customer_comment" => customer_comment,
- "date" => date,
- "start_time" => start_time,
- "end_time" => end_time
- }
- }) do
- email_data = %{
- booking_id: booking_id,
- customer_name: customer_name,
- customer_email: customer_email,
- customer_phone: customer_phone,
- customer_comment: customer_comment,
- date: date,
- start_time: start_time,
- end_time: end_time,
- admin_email: admin_email()
- }
-
- email_data
- |> Email.customer_confirmation()
- |> SpazioSolazzo.Mailer.deliver!()
-
- email_data
- |> Email.admin_notification()
- |> SpazioSolazzo.Mailer.deliver!()
-
- :ok
- end
-
- defp admin_email do
- Application.get_env(:spazio_solazzo, :admin_email)
- end
-end
diff --git a/lib/spazio_solazzo/booking_system/booking/preparations/apply_admin_filters.ex b/lib/spazio_solazzo/booking_system/booking/preparations/apply_admin_filters.ex
new file mode 100644
index 0000000..84a3856
--- /dev/null
+++ b/lib/spazio_solazzo/booking_system/booking/preparations/apply_admin_filters.ex
@@ -0,0 +1,40 @@
+defmodule SpazioSolazzo.BookingSystem.Booking.Preparations.ApplyAdminFilters do
+ @moduledoc """
+ Ash Preparation that applies common admin filters (space_id, email, date) to booking queries.
+ """
+ use Ash.Resource.Preparation
+
+ @impl true
+ def prepare(query, _opts, _context) do
+ query
+ |> apply_space_filter()
+ |> apply_email_filter()
+ |> apply_date_filter()
+ end
+
+ defp apply_space_filter(query) do
+ case Ash.Query.get_argument(query, :space_id) do
+ nil -> query
+ space_id -> Ash.Query.filter(query, space_id == ^space_id)
+ end
+ end
+
+ defp apply_email_filter(query) do
+ case Ash.Query.get_argument(query, :email) do
+ nil -> query
+ email -> Ash.Query.filter(query, customer_email == ^email)
+ end
+ end
+
+ defp apply_date_filter(query) do
+ case Ash.Query.get_argument(query, :date) do
+ nil ->
+ query
+
+ date ->
+ day_start = DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
+ day_end = DateTime.new!(date, ~T[23:59:59], "Etc/UTC")
+ Ash.Query.filter(query, start_datetime < ^day_end and end_datetime > ^day_start)
+ end
+ end
+end
diff --git a/lib/spazio_solazzo/booking_system/booking/request_created_email_worker.ex b/lib/spazio_solazzo/booking_system/booking/request_created_email_worker.ex
new file mode 100644
index 0000000..1a881c4
--- /dev/null
+++ b/lib/spazio_solazzo/booking_system/booking/request_created_email_worker.ex
@@ -0,0 +1,57 @@
+defmodule SpazioSolazzo.BookingSystem.Booking.RequestCreatedEmailWorker do
+ @moduledoc """
+ Sends booking request confirmation emails to customers and notification emails to administrators.
+ Triggered when a new booking request is created.
+ """
+
+ use Oban.Worker, queue: :booking_email, max_attempts: 3
+
+ alias SpazioSolazzo.BookingSystem.Booking.Email
+ alias SpazioSolazzo.CalendarExt
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{
+ args: %{
+ "booking_id" => booking_id,
+ "customer_name" => customer_name,
+ "customer_email" => customer_email,
+ "customer_phone" => customer_phone,
+ "customer_comment" => customer_comment,
+ "space_name" => space_name,
+ "start_datetime" => start_datetime_str,
+ "end_datetime" => end_datetime_str
+ }
+ }) do
+ {:ok, start_datetime, _} = DateTime.from_iso8601(start_datetime_str)
+ {:ok, end_datetime, _} = DateTime.from_iso8601(end_datetime_str)
+
+ email_data = %{
+ booking_id: booking_id,
+ customer_name: customer_name,
+ customer_email: customer_email,
+ customer_phone: customer_phone,
+ customer_comment: customer_comment,
+ space_name: space_name,
+ start_datetime: start_datetime,
+ end_datetime: end_datetime,
+ date: CalendarExt.format_datetime_date_only(start_datetime),
+ start_time: DateTime.to_time(start_datetime),
+ end_time: DateTime.to_time(end_datetime),
+ admin_email: admin_email()
+ }
+
+ email_data
+ |> Email.user_booking_request_confirmation()
+ |> SpazioSolazzo.Mailer.deliver!()
+
+ email_data
+ |> Email.admin_incoming_booking_request()
+ |> SpazioSolazzo.Mailer.deliver!()
+
+ :ok
+ end
+
+ defp admin_email do
+ Application.get_env(:spazio_solazzo, :admin_email)
+ end
+end
diff --git a/lib/spazio_solazzo/booking_system/booking/user_cancellation_email_worker.ex b/lib/spazio_solazzo/booking_system/booking/user_cancellation_email_worker.ex
new file mode 100644
index 0000000..e28c458
--- /dev/null
+++ b/lib/spazio_solazzo/booking_system/booking/user_cancellation_email_worker.ex
@@ -0,0 +1,48 @@
+defmodule SpazioSolazzo.BookingSystem.Booking.UserCancellationEmailWorker do
+ @moduledoc """
+ Sends cancellation notification emails to administrators when a customer cancels a booking.
+ """
+
+ use Oban.Worker, queue: :booking_email, max_attempts: 3
+
+ alias SpazioSolazzo.BookingSystem.Booking.Email
+ alias SpazioSolazzo.CalendarExt
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{
+ args: %{
+ "customer_name" => customer_name,
+ "customer_email" => customer_email,
+ "customer_phone" => customer_phone,
+ "space_name" => space_name,
+ "start_datetime" => start_datetime_str,
+ "end_datetime" => end_datetime_str,
+ "cancellation_reason" => cancellation_reason
+ }
+ }) do
+ {:ok, start_datetime, _} = DateTime.from_iso8601(start_datetime_str)
+ {:ok, end_datetime, _} = DateTime.from_iso8601(end_datetime_str)
+
+ %{
+ customer_name: customer_name,
+ customer_email: customer_email,
+ customer_phone: customer_phone,
+ space_name: space_name,
+ start_datetime: start_datetime,
+ end_datetime: end_datetime,
+ date: CalendarExt.format_datetime_date_only(start_datetime),
+ start_time: DateTime.to_time(start_datetime),
+ end_time: DateTime.to_time(end_datetime),
+ cancellation_reason: cancellation_reason,
+ admin_email: admin_email()
+ }
+ |> Email.booking_cancelled()
+ |> SpazioSolazzo.Mailer.deliver!()
+
+ :ok
+ end
+
+ defp admin_email do
+ Application.get_env(:spazio_solazzo, :admin_email)
+ end
+end
diff --git a/lib/spazio_solazzo/booking_system/space.ex b/lib/spazio_solazzo/booking_system/space.ex
index 2366bb6..1865168 100644
--- a/lib/spazio_solazzo/booking_system/space.ex
+++ b/lib/spazio_solazzo/booking_system/space.ex
@@ -6,7 +6,8 @@ defmodule SpazioSolazzo.BookingSystem.Space do
use Ash.Resource,
otp_app: :spazio_solazzo,
domain: SpazioSolazzo.BookingSystem,
- data_layer: AshPostgres.DataLayer
+ data_layer: AshPostgres.DataLayer,
+ authorizers: [Ash.Policy.Authorizer]
postgres do
table "spaces"
@@ -14,7 +15,31 @@ defmodule SpazioSolazzo.BookingSystem.Space do
end
actions do
- defaults [:read, create: :*]
+ defaults [:read]
+
+ create :create do
+ accept [:name, :description, :slug, :capacity]
+
+ validate fn changeset, _ctx ->
+ capacity = Ash.Changeset.get_attribute(changeset, :capacity)
+
+ if capacity && capacity <= 0 do
+ {:error, field: :capacity, message: "must be greater than 0"}
+ else
+ :ok
+ end
+ end
+ end
+ end
+
+ policies do
+ policy action_type(:read) do
+ authorize_if always()
+ end
+
+ policy action_type(:create) do
+ authorize_if always()
+ end
end
attributes do
@@ -22,6 +47,7 @@ defmodule SpazioSolazzo.BookingSystem.Space do
attribute :name, :string, allow_nil?: false, public?: true
attribute :description, :string, allow_nil?: false, public?: true
attribute :slug, :string, allow_nil?: false, public?: true
+ attribute :capacity, :integer, allow_nil?: false, public?: true
end
identities do
diff --git a/lib/spazio_solazzo/booking_system/time_slot_template.ex b/lib/spazio_solazzo/booking_system/time_slot_template.ex
index 9f24745..4b5c194 100644
--- a/lib/spazio_solazzo/booking_system/time_slot_template.ex
+++ b/lib/spazio_solazzo/booking_system/time_slot_template.ex
@@ -20,6 +20,10 @@ defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplate do
create :create do
accept [:start_time, :end_time, :space_id, :day_of_week]
+
+ validate {SpazioSolazzo.BookingSystem.Validations.ChronologicalOrder,
+ start: :start_time, end: :end_time}
+
change {Changes.PreventCreationOverlap, []}
end
@@ -55,4 +59,17 @@ defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplate do
public? true
end
end
+
+ calculations do
+ calculate :booking_stats,
+ :map,
+ {SpazioSolazzo.BookingSystem.TimeSlotTemplate.Calculations.SlotBookingStats, []} do
+ description "Calculates booking counts and availability for this time slot on a specific date"
+
+ argument :date, :date, allow_nil?: false
+ argument :space_id, :uuid, allow_nil?: false
+ argument :capacity, :integer, allow_nil?: false
+ argument :user_id, :uuid, allow_nil?: true
+ end
+ end
end
diff --git a/lib/spazio_solazzo/booking_system/time_slot_template/calculations/slot_booking_stats.ex b/lib/spazio_solazzo/booking_system/time_slot_template/calculations/slot_booking_stats.ex
new file mode 100644
index 0000000..7a14549
--- /dev/null
+++ b/lib/spazio_solazzo/booking_system/time_slot_template/calculations/slot_booking_stats.ex
@@ -0,0 +1,66 @@
+defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplate.Calculations.SlotBookingStats do
+ @moduledoc """
+ Calculates booking statistics for time slots by fetching all bookings for the day once,
+ then filtering in memory. This eliminates N+1 query problems.
+ """
+
+ use Ash.Resource.Calculation
+
+ @impl true
+ def load(_query, _opts, _context) do
+ [:start_time, :end_time, :space_id]
+ end
+
+ @impl true
+ def calculate(records, _opts, %{arguments: arguments}) do
+ date = Map.get(arguments, :date)
+ space_id = Map.get(arguments, :space_id)
+ capacity = Map.get(arguments, :capacity)
+ user_id = Map.get(arguments, :user_id)
+
+ # Fetch all bookings for the entire day ONCE
+ day_start = DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
+ day_end = DateTime.new!(date, ~T[23:59:59], "Etc/UTC")
+
+ {:ok, all_bookings} =
+ SpazioSolazzo.BookingSystem.search_bookings(
+ space_id,
+ day_start,
+ day_end,
+ [:requested, :accepted],
+ [:start_datetime, :end_datetime, :state, :user_id]
+ )
+
+ # Calculate stats for each slot using the cached bookings
+ Enum.map(records, fn slot ->
+ slot_start = DateTime.new!(date, slot.start_time, "Etc/UTC")
+ slot_end = DateTime.new!(date, slot.end_time, "Etc/UTC")
+
+ # Filter bookings that overlap with this slot
+ overlapping =
+ Enum.filter(all_bookings, fn booking ->
+ DateTime.compare(booking.start_datetime, slot_end) == :lt and
+ DateTime.compare(booking.end_datetime, slot_start) == :gt
+ end)
+
+ requested_count = Enum.count(overlapping, &(&1.state == :requested))
+ accepted_count = Enum.count(overlapping, &(&1.state == :accepted))
+
+ user_has_booking =
+ if user_id do
+ Enum.any?(overlapping, &(&1.user_id == user_id))
+ else
+ false
+ end
+
+ availability = if accepted_count >= capacity, do: :over_capacity, else: :available
+
+ %{
+ requested_count: requested_count,
+ accepted_count: accepted_count,
+ user_has_booking: user_has_booking,
+ availability_status: availability
+ }
+ end)
+ end
+end
diff --git a/lib/spazio_solazzo/booking_system/time_slot_template/changes/prevent_overlap.ex b/lib/spazio_solazzo/booking_system/time_slot_template/changes/prevent_overlap.ex
index a8eb907..ed397e9 100644
--- a/lib/spazio_solazzo/booking_system/time_slot_template/changes/prevent_overlap.ex
+++ b/lib/spazio_solazzo/booking_system/time_slot_template/changes/prevent_overlap.ex
@@ -18,28 +18,32 @@ defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplate.Changes.PreventCreationOv
end_time = Ash.Changeset.get_attribute(changeset, :end_time)
day_of_week = Ash.Changeset.get_attribute(changeset, :day_of_week)
- overlapping =
- TimeSlotTemplate
- |> Ash.Query.filter(space_id == ^space_id)
- |> Ash.Query.filter(day_of_week == ^day_of_week)
- |> Ash.Query.filter(start_time < ^end_time and end_time > ^start_time)
- |> Ash.read()
+ if is_nil(day_of_week) do
+ changeset
+ else
+ overlapping =
+ TimeSlotTemplate
+ |> Ash.Query.filter(space_id == ^space_id)
+ |> Ash.Query.filter(day_of_week == ^day_of_week)
+ |> Ash.Query.filter(start_time < ^end_time and end_time > ^start_time)
+ |> Ash.read()
- case overlapping do
- {:ok, []} ->
- changeset
+ case overlapping do
+ {:ok, []} ->
+ changeset
- {:ok, _} ->
- Changeset.add_error(changeset,
- field: :base,
- message: "time slot overlaps with existing template for this space and day"
- )
+ {:ok, _} ->
+ Changeset.add_error(changeset,
+ field: :base,
+ message: "time slot overlaps with existing template for this space and day"
+ )
- {:error, err} ->
- Changeset.add_error(changeset,
- field: :base,
- message: "failed to validate overlap: #{inspect(err)}"
- )
+ {:error, err} ->
+ Changeset.add_error(changeset,
+ field: :base,
+ message: "failed to validate overlap: #{inspect(err)}"
+ )
+ end
end
end
end
diff --git a/lib/spazio_solazzo/booking_system/validations/chronological_order.ex b/lib/spazio_solazzo/booking_system/validations/chronological_order.ex
new file mode 100644
index 0000000..b793148
--- /dev/null
+++ b/lib/spazio_solazzo/booking_system/validations/chronological_order.ex
@@ -0,0 +1,38 @@
+defmodule SpazioSolazzo.BookingSystem.Validations.ChronologicalOrder do
+ @moduledoc """
+ Validates that an end time/datetime occurs after a start time/datetime.
+ """
+ use Ash.Resource.Validation
+
+ @impl true
+ def init(opts) do
+ if Keyword.has_key?(opts, :start) && Keyword.has_key?(opts, :end) do
+ {:ok, opts}
+ else
+ {:error, "Both `start` and `end` options are required."}
+ end
+ end
+
+ @impl true
+ def validate(changeset, opts, _context) do
+ start_field = opts[:start]
+ end_field = opts[:end]
+
+ start_val = get_value(changeset, start_field)
+ end_val = get_value(changeset, end_field)
+
+ if start_val && end_val && !after?(end_val, start_val) do
+ {:error, field: end_field, message: "must be after #{start_field}"}
+ else
+ :ok
+ end
+ end
+
+ defp after?(%Time{} = a, %Time{} = b), do: Time.compare(a, b) == :gt
+ defp after?(%DateTime{} = a, %DateTime{} = b), do: DateTime.compare(a, b) == :gt
+ defp after?(_, _), do: true
+
+ defp get_value(changeset, field) do
+ Ash.Changeset.get_argument(changeset, field) || Ash.Changeset.get_attribute(changeset, field)
+ end
+end
diff --git a/lib/spazio_solazzo/booking_system/validations/email.ex b/lib/spazio_solazzo/booking_system/validations/email.ex
new file mode 100644
index 0000000..0f24041
--- /dev/null
+++ b/lib/spazio_solazzo/booking_system/validations/email.ex
@@ -0,0 +1,31 @@
+defmodule SpazioSolazzo.BookingSystem.Validations.Email do
+ @moduledoc """
+ Validates that a field contains a valid email address.
+ """
+ use Ash.Resource.Validation
+
+ @impl true
+ def init(opts) do
+ if Keyword.has_key?(opts, :field) do
+ {:ok, opts}
+ else
+ {:error, "The `field` option is required."}
+ end
+ end
+
+ @impl true
+ def validate(changeset, opts, _context) do
+ field = opts[:field]
+ value = get_value(changeset, field)
+
+ if value && !String.match?(value, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/) do
+ {:error, field: field, message: "must be a valid email"}
+ else
+ :ok
+ end
+ end
+
+ defp get_value(changeset, field) do
+ Ash.Changeset.get_argument(changeset, field) || Ash.Changeset.get_attribute(changeset, field)
+ end
+end
diff --git a/lib/spazio_solazzo/booking_system/validations/future_date.ex b/lib/spazio_solazzo/booking_system/validations/future_date.ex
new file mode 100644
index 0000000..0c82910
--- /dev/null
+++ b/lib/spazio_solazzo/booking_system/validations/future_date.ex
@@ -0,0 +1,35 @@
+defmodule SpazioSolazzo.BookingSystem.Validations.FutureDate do
+ @moduledoc """
+ Validates that a date or datetime is in the future relative to UTC now/today.
+ """
+ use Ash.Resource.Validation
+
+ @impl true
+ def init(opts) do
+ if Keyword.has_key?(opts, :field) do
+ {:ok, opts}
+ else
+ {:error, "The `field` option is required."}
+ end
+ end
+
+ @impl true
+ def validate(changeset, opts, _context) do
+ field = opts[:field]
+ value = get_value(changeset, field)
+
+ if value && in_past?(value) do
+ {:error, field: field, message: "cannot be in the past"}
+ else
+ :ok
+ end
+ end
+
+ defp in_past?(%Date{} = date), do: Date.compare(date, Date.utc_today()) == :lt
+ defp in_past?(%DateTime{} = dt), do: DateTime.compare(dt, DateTime.utc_now()) == :lt
+ defp in_past?(_), do: false
+
+ defp get_value(changeset, field) do
+ Ash.Changeset.get_argument(changeset, field) || Ash.Changeset.get_attribute(changeset, field)
+ end
+end
diff --git a/lib/spazio_solazzo/data/calendar_ext.ex b/lib/spazio_solazzo/data/calendar_ext.ex
index 937a4bb..6e8eeb8 100644
--- a/lib/spazio_solazzo/data/calendar_ext.ex
+++ b/lib/spazio_solazzo/data/calendar_ext.ex
@@ -13,4 +13,132 @@ defmodule SpazioSolazzo.CalendarExt do
"#{start_time} - #{end_time}"
end
+
+ @doc """
+ Formats a datetime as "Feb 10, 2026"
+ """
+ def format_datetime_date(%DateTime{} = datetime) do
+ Calendar.strftime(datetime, "%b %d, %Y")
+ end
+
+ @doc """
+ Formats a datetime as "Monday, February 10, 2026"
+ """
+ def format_datetime_date_long(%DateTime{} = datetime) do
+ Calendar.strftime(datetime, "%A, %B %d, %Y")
+ end
+
+ @doc """
+ Formats a datetime as "Monday, February 10" (for emails)
+ """
+ def format_datetime_date_only(%DateTime{} = datetime) do
+ Calendar.strftime(datetime, "%A, %B %d")
+ end
+
+ @doc """
+ Formats a time or datetime as "9:00 AM"
+ """
+ def format_time(%DateTime{} = datetime) do
+ datetime
+ |> DateTime.to_time()
+ |> format_time()
+ end
+
+ def format_time(%Time{} = time) do
+ Calendar.strftime(time, "%I:%M %p")
+ end
+
+ @doc """
+ Formats a time range as "9:00 AM - 5:00 PM"
+ Takes two Time or DateTime structs
+ """
+ def format_time_range(%DateTime{} = start_dt, %DateTime{} = end_dt) do
+ start_time = DateTime.to_time(start_dt)
+ end_time = DateTime.to_time(end_dt)
+ format_time_range(start_time, end_time)
+ end
+
+ def format_time_range(%Time{} = start_time, %Time{} = end_time) do
+ "#{format_time(start_time)} - #{format_time(end_time)}"
+ end
+
+ @doc """
+ Checks if a booking spans multiple days
+ """
+ def multi_day?(%DateTime{} = start_datetime, %DateTime{} = end_datetime) do
+ start_date = DateTime.to_date(start_datetime)
+ end_date = DateTime.to_date(end_datetime)
+ Date.compare(start_date, end_date) != :eq
+ end
+
+ @doc """
+ Formats a datetime range handling both single-day and multi-day bookings.
+
+ Single-day: "Feb 10, 2026 9:00 AM - 5:00 PM"
+ Multi-day: "Feb 10, 2026 9:00 AM - Feb 15, 2026 5:00 PM"
+ """
+ def format_datetime_range(%DateTime{} = start_datetime, %DateTime{} = end_datetime) do
+ if multi_day?(start_datetime, end_datetime) do
+ "#{format_datetime_date(start_datetime)} #{format_time(start_datetime)} - #{format_datetime_date(end_datetime)} #{format_time(end_datetime)}"
+ else
+ "#{format_datetime_date(start_datetime)} #{format_time_range(start_datetime, end_datetime)}"
+ end
+ end
+
+ @doc """
+ Formats the start portion of a datetime range for table display
+
+ Single-day: "Feb 10, 2026 9:00 AM"
+ Multi-day: "Feb 10, 2026 9:00 AM"
+ """
+ def format_datetime_range_start(%DateTime{} = datetime) do
+ "#{format_datetime_date(datetime)} #{format_time(datetime)}"
+ end
+
+ @doc """
+ Formats the end portion of a datetime range for table display
+
+ Single-day: "5:00 PM" (date not shown)
+ Multi-day: "Feb 15, 2026 5:00 PM"
+ """
+ def format_datetime_range_end(%DateTime{} = start_datetime, %DateTime{} = end_datetime) do
+ if multi_day?(start_datetime, end_datetime) do
+ "#{format_datetime_date(end_datetime)} #{format_time(end_datetime)}"
+ else
+ format_time(end_datetime)
+ end
+ end
+
+ # There are 7 days displayed in the calendar
+ @grid_cols 7
+ # The calendar can show max 6 weeks for one month
+ @grid_rows 6
+
+ @doc """
+ Build a list containing all the dates to be displayed in a
+ Calendar grid.
+
+ 6 weeks * 7 days = 42 cells
+ """
+ def build_calendar_grid(date) do
+ first_day = Date.beginning_of_month(date)
+ # Mon=1, Sun=7
+ start_day_of_week = Date.day_of_week(first_day)
+
+ # Calculate days to subtract to get to the previous Monday
+ # If starts on Mon (1), sub 0. If Sun (7), sub 6.
+ days_to_sub = start_day_of_week - 1
+ start_date = Date.add(first_day, -days_to_sub)
+
+ # 6 weeks * 7 days = 42 grid cells
+ Enum.map(0..(@grid_cols * @grid_rows - 1), fn i -> Date.add(start_date, i) end)
+ end
+
+ @doc "Checks if a date is within a start/end range (inclusive)"
+ def date_in_range?(date, start_date, end_date)
+ when not is_nil(start_date) and not is_nil(end_date) do
+ Date.compare(date, start_date) != :lt and Date.compare(date, end_date) != :gt
+ end
+
+ def date_in_range?(_date, _start, _end), do: false
end
diff --git a/lib/spazio_solazzo_web/components/admin/booking_management_components.ex b/lib/spazio_solazzo_web/components/admin/booking_management_components.ex
new file mode 100644
index 0000000..6ca9be1
--- /dev/null
+++ b/lib/spazio_solazzo_web/components/admin/booking_management_components.ex
@@ -0,0 +1,233 @@
+defmodule SpazioSolazzoWeb.AdminBookingManagementComponents do
+ @moduledoc """
+ Reusable components for the admin booking management interface.
+ """
+ use Phoenix.Component
+ import SpazioSolazzoWeb.CoreComponents
+
+ attr :title, :string, required: true
+ attr :bookings, :list, required: true
+ attr :page, :map, required: true
+ attr :current_page, :integer, required: true
+ attr :event_prefix, :string, required: true
+ attr :expanded_booking_ids, :any, required: true
+ attr :show_actions, :boolean, default: false
+ attr :show_cancellation_details, :boolean, default: false
+
+ def bookings_table(assigns) do
+ ~H"""
+
+
{@title}
+
+
+
+
+
+
+
+
+ Space
+
+
+ Start
+
+
+ End
+
+
+ Customer
+
+
+ Status
+
+ <%= if @show_actions do %>
+
+ Actions
+
+ <% end %>
+
+
+
+ <%= for booking <- @bookings do %>
+ <% is_expanded = MapSet.member?(@expanded_booking_ids, booking.id) %>
+
+
+
+ <.icon
+ name="hero-chevron-down"
+ class={[
+ "w-4 h-4 transition-transform",
+ if(is_expanded, do: "rotate-180", else: "")
+ ]}
+ />
+
+
+
+
+
+ <.icon name="hero-building-office" class="w-4 h-4" />
+
+
+
+ {booking.space.name}
+
+
+
+
+
+
+ {SpazioSolazzo.CalendarExt.format_datetime_range_start(booking.start_datetime)}
+
+
+
+
+ {SpazioSolazzo.CalendarExt.format_datetime_range_end(
+ booking.start_datetime,
+ booking.end_datetime
+ )}
+
+
+
+
+
+ {booking.customer_name}
+
+
+ {booking.customer_email}
+
+
+
+
+
+ <.icon name={status_icon(booking.state)} class="w-3.5 h-3.5" />
+ {status_label(booking.state)}
+
+
+ <%= if @show_actions do %>
+
+
+
+ Reject
+
+
+ Confirm
+
+
+
+ <% end %>
+
+ <%= if is_expanded do %>
+
+
+
+
+
+
+ Phone:
+
+ <%= if booking.customer_phone do %>
+ {booking.customer_phone}
+ <% else %>
+ Not provided
+ <% end %>
+
+
+
+ Note:
+
+ <%= if booking.customer_comment do %>
+ {booking.customer_comment}
+ <% else %>
+ Not provided
+ <% end %>
+
+ <%= if @show_cancellation_details && booking.state == :rejected do %>
+
+
+ Rejection Reason:
+
+ <%= if booking.rejection_reason do %>
+ {booking.rejection_reason}
+ <% else %>
+ Not provided
+ <% end %>
+
+ <% end %>
+ <%= if @show_cancellation_details && booking.state == :cancelled do %>
+
+
+ Cancellation Reason:
+
+ <%= if booking.cancellation_reason do %>
+ {booking.cancellation_reason}
+ <% else %>
+ Not provided
+ <% end %>
+
+ <% end %>
+
+
+
+ <% end %>
+ <% end %>
+
+
+
+ <.pagination_controls
+ page={@page}
+ current_page={@current_page}
+ event_prefix={@event_prefix}
+ />
+
+
+ """
+ end
+
+ defp status_badge_classes(:requested) do
+ "bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200"
+ end
+
+ defp status_badge_classes(:accepted) do
+ "bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-200"
+ end
+
+ defp status_badge_classes(:rejected) do
+ "bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-200"
+ end
+
+ defp status_badge_classes(:cancelled) do
+ "bg-slate-100 dark:bg-slate-900/40 text-slate-800 dark:text-slate-200"
+ end
+
+ defp status_badge_classes(_), do: "bg-slate-100 text-slate-800"
+
+ defp status_icon(:requested), do: "hero-clock"
+ defp status_icon(:accepted), do: "hero-check-circle"
+ defp status_icon(:rejected), do: "hero-x-circle"
+ defp status_icon(:cancelled), do: "hero-minus-circle"
+ defp status_icon(_), do: "hero-question-mark-circle"
+
+ defp status_label(:requested), do: "Pending"
+ defp status_label(:accepted), do: "Confirmed"
+ defp status_label(:rejected), do: "Rejected"
+ defp status_label(:cancelled), do: "Cancelled"
+ defp status_label(_), do: "Unknown"
+end
diff --git a/lib/spazio_solazzo_web/components/admin/dashboard_components.ex b/lib/spazio_solazzo_web/components/admin/dashboard_components.ex
new file mode 100644
index 0000000..4e805b1
--- /dev/null
+++ b/lib/spazio_solazzo_web/components/admin/dashboard_components.ex
@@ -0,0 +1,65 @@
+defmodule SpazioSolazzoWeb.AdminDashboardComponents do
+ @moduledoc """
+ Reusable components for the booking flow.
+ """
+ use Phoenix.Component
+
+ import SpazioSolazzoWeb.CoreComponents, only: [icon: 1]
+
+ attr :title, :string, required: true
+ attr :description, :string, required: true
+ attr :color, :atom, values: [:primary, :secondary], required: true
+ attr :icon, :string, required: true
+ attr :navigate, :string, required: true
+
+ @doc """
+ Renders a tool card to be displayed in the admin dashboard
+ """
+ def tool_card(assigns) do
+ ~H"""
+ <.link
+ navigate={@navigate}
+ class={"group bg-white dark:bg-slate-800 rounded-3xl p-8 border-2 border-slate-200 dark:border-slate-700 shadow-xl shadow-slate-200/50 dark:shadow-none #{container_color_class(@color)} transition-all duration-300 hover:scale-[1.02]"}
+ >
+
+
+
+ <.icon name={@icon} class="w-8 h-8" />
+
+
+
+
+ {@title}
+
+
+
+ {@description}
+
+
+
+ Open Tool
+ <.icon
+ name="hero-arrow-right"
+ class="w-5 h-5 group-hover:translate-x-1 transition-transform"
+ />
+
+
+
+ """
+ end
+
+ defp container_color_class(:primary), do: "hover:border-primary dark:hover:border-primary"
+ defp container_color_class(:secondary), do: "hover:border-sky-500 dark:hover:border-sky-400"
+
+ defp icon_color_class(:primary),
+ do: "bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400"
+
+ defp icon_color_class(:secondary),
+ do: "bg-sky-100 dark:bg-sky-900/30 text-sky-600 dark:text-sky-400 "
+
+ defp title_color_class(:primary), do: "group-hover:text-primary dark:group-hover:text-primary"
+ defp title_color_class(:secondary), do: "group-hover:text-sky-500 dark:group-hover:text-sky-400"
+
+ defp tooltip_color_class(:primary), do: "text-primary dark:text-primary-hover"
+ defp tooltip_color_class(:secondary), do: "text-sky-500 dark:text-sky-400"
+end
diff --git a/lib/spazio_solazzo_web/components/admin_components.ex b/lib/spazio_solazzo_web/components/admin_components.ex
deleted file mode 100644
index 44a392c..0000000
--- a/lib/spazio_solazzo_web/components/admin_components.ex
+++ /dev/null
@@ -1,38 +0,0 @@
-defmodule SpazioSolazzoWeb.AdminComponents do
- @moduledoc """
- Reusable components for admin pages (dashboard & tools).
- """
- use Phoenix.Component
-
- import SpazioSolazzoWeb.CoreComponents, only: [icon: 1, button: 1]
- import Phoenix.Component
-
- use Phoenix.VerifiedRoutes,
- endpoint: SpazioSolazzoWeb.Endpoint,
- router: SpazioSolazzoWeb.Router,
- statics: SpazioSolazzoWeb.static_paths()
-
- @doc """
- Cards displayed for each tool available to admins
- """
- attr :id, :string, doc: "optional id for the tool"
- attr :title, :string
- attr :icon, :string, doc: "Icon used to represent the type of tool"
- attr :description, :string
-
- def tool_card(assigns) do
- ~H"""
-
-
-
- <.icon name={@icon} class="size-6 text-secondary" /> {@title}
-
-
{@description}
-
- <.button class="btn btn-primary btn-sm">Open
-
-
-
- """
- end
-end
diff --git a/lib/spazio_solazzo_web/components/booking_components.ex b/lib/spazio_solazzo_web/components/booking_components.ex
index f6411b2..b476f86 100644
--- a/lib/spazio_solazzo_web/components/booking_components.ex
+++ b/lib/spazio_solazzo_web/components/booking_components.ex
@@ -3,110 +3,95 @@ defmodule SpazioSolazzoWeb.BookingComponents do
Reusable components for the booking flow.
"""
use Phoenix.Component
- alias SpazioSolazzo.CalendarExt
- attr :id, :string, required: true
- attr :show, :boolean, default: false
- attr :on_close, :any, required: true
-
- @doc """
- Success modal displayed when a booking is completed.
- """
- def booking_confirmation_modal(assigns) do
- ~H"""
-
-
-
-
-
-
-
-
-
- Booking Successful!
-
-
-
- Your booking has been confirmed. You will receive a confirmation email shortly.
-
-
-
-
-
-
- Got it!
-
-
-
-
-
-
- """
- end
+ import SpazioSolazzoWeb.CoreComponents, only: [icon: 1]
attr :time_slot, :map, required: true
- attr :booked, :boolean, required: true
@doc """
- Renders time slot buttons in different sizes showing availability status.
+ Renders a detailed time slot card showing availability status and booking counts.
"""
- def time_slot(assigns) do
+ def time_slot_card(assigns) do
+ assigns =
+ assigns
+ |> assign(:availability, assigns.time_slot.booking_stats.availability_status)
+ |> assign(:requested_count, assigns.time_slot.booking_stats.requested_count)
+ |> assign(:accepted_count, assigns.time_slot.booking_stats.accepted_count)
+ |> assign(:user_has_booking, assigns.time_slot.booking_stats.user_has_booking)
+
~H"""
-
- {CalendarExt.format_time_range(@time_slot)}
-
-
- {if @booked, do: "Booked", else: "Available"}
-
+
+
+
+ {Calendar.strftime(@time_slot.start_time, "%H:%M")} - {Calendar.strftime(
+ @time_slot.end_time,
+ "%H:%M"
+ )}
+
+ <%= if @user_has_booking do %>
+
+ Already Requested
+
+ <% else %>
+ <%= if @availability == :available do %>
+
+ Available - Request Booking
+
+ <% else %>
+
+ High Demand - Join Waitlist
+
+ <% end %>
+ <% end %>
+
+ <%= if @requested_count > 0 do %>
+
+ <.icon name="hero-clock" class="w-3.5 h-3.5" />
+ {@requested_count} pending
+
+ <% end %>
+ <%= if @accepted_count > 0 do %>
+
+ <.icon name="hero-check-circle" class="w-3.5 h-3.5" />
+ {@accepted_count} booked
+
+ <% end %>
+
+
+ <.icon
+ name={if @user_has_booking, do: "hero-check", else: "hero-arrow-right"}
+ class={[
+ "w-5 h-5",
+ if(@user_has_booking,
+ do: "text-slate-400 dark:text-slate-500",
+ else:
+ if(@availability == :available,
+ do: "text-green-500 dark:text-green-400",
+ else: "text-yellow-500 dark:text-yellow-400"
+ )
+ )
+ ]}
+ />
+
"""
end
diff --git a/lib/spazio_solazzo_web/components/core_components.ex b/lib/spazio_solazzo_web/components/core_components.ex
index 5bdea1d..4020ec3 100644
--- a/lib/spazio_solazzo_web/components/core_components.ex
+++ b/lib/spazio_solazzo_web/components/core_components.ex
@@ -108,7 +108,7 @@ defmodule SpazioSolazzoWeb.CoreComponents do
<.button phx-click="go" variant="primary">Send!
<.button navigate={~p"/"}>Home
"""
- attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
+ attr :rest, :global, include: ~w(href navigate patch method download name value disabled form)
attr :class, :any
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
@@ -671,6 +671,123 @@ defmodule SpazioSolazzoWeb.CoreComponents do
end
end
+ @doc """
+ Renders pagination controls for Ash offset pagination.
+
+ ## Examples
+
+ <.pagination_controls
+ page={@page}
+ current_page={@current_page_number}
+ event_prefix="bookings"
+ />
+ """
+ attr :page, :map, required: true, doc: "The Ash.Page.Offset struct"
+ attr :current_page, :integer, required: true, doc: "Current page number (1-indexed)"
+ attr :event_prefix, :string, required: true, doc: "Prefix for phx-click event"
+
+ def pagination_controls(assigns) do
+ total_count = assigns.page.count || 0
+ limit = assigns.page.limit || 10
+ current = assigns.current_page
+
+ start_item = if total_count == 0, do: 0, else: (current - 1) * limit + 1
+ end_item = min(current * limit, total_count)
+ total_pages = if limit > 0, do: ceil(total_count / limit), else: 1
+
+ # Calculate visible pages (show max 7 with ellipsis)
+ visible_pages =
+ cond do
+ total_pages <= 7 ->
+ Enum.to_list(1..total_pages)
+
+ current <= 4 ->
+ [1, 2, 3, 4, 5, :ellipsis, total_pages]
+
+ current >= total_pages - 3 ->
+ [1, :ellipsis | Enum.to_list((total_pages - 4)..total_pages)]
+
+ true ->
+ [1, :ellipsis, current - 1, current, current + 1, :ellipsis, total_pages]
+ end
+
+ assigns =
+ assign(assigns,
+ total_count: total_count,
+ start_item: start_item,
+ end_item: end_item,
+ total_pages: total_pages,
+ visible_pages: visible_pages
+ )
+
+ ~H"""
+
+
+
+ Showing {@start_item}-{@end_item} of {@total_count}
+
+
+ <%= if @total_pages > 1 do %>
+
+
+ <.icon name="hero-chevron-left" class="w-4 h-4" />
+
+
+ <%= for page_num <- @visible_pages do %>
+ <%= if page_num == :ellipsis do %>
+ ...
+ <% else %>
+
+ {page_num}
+
+ <% end %>
+ <% end %>
+
+
+ <.icon name="hero-chevron-right" class="w-4 h-4" />
+
+
+ <% end %>
+
+
+ """
+ end
+
@doc """
Translates the errors for a field from a keyword list of errors.
"""
diff --git a/lib/spazio_solazzo_web/controllers/booking_controller.ex b/lib/spazio_solazzo_web/controllers/booking_controller.ex
deleted file mode 100644
index 655c974..0000000
--- a/lib/spazio_solazzo_web/controllers/booking_controller.ex
+++ /dev/null
@@ -1,77 +0,0 @@
-defmodule SpazioSolazzoWeb.BookingController do
- use SpazioSolazzoWeb, :controller
-
- alias SpazioSolazzo.BookingSystem
- alias SpazioSolazzo.BookingSystem.Booking
- alias SpazioSolazzo.BookingSystem.Booking.Token
-
- def confirm(conn, %{"token" => token}) do
- case Token.verify(token) do
- {:ok, %{booking_id: booking_id, role: :admin, action: :confirm}} ->
- case Ash.get(Booking, booking_id, error?: false) do
- {:ok, nil} ->
- conn
- |> put_flash(:error, "Booking not found, cancelling aborted.")
- |> redirect(to: "/")
-
- {:ok, booking} ->
- action_result = BookingSystem.confirm_booking(booking)
- build_response(conn, action_result, :confirm)
-
- {:error, _} ->
- conn
- |> put_flash(:error, "Unexpected error occurred, couldn't cancel booking.")
- |> redirect(to: "/")
- end
-
- _ ->
- conn
- |> put_flash(:error, "Invalid or expired link.")
- |> redirect(to: "/")
- end
- end
-
- def cancel(conn, %{"token" => token}) do
- case Token.verify(token) do
- {:ok, %{booking_id: booking_id, role: _, action: :cancel}} ->
- case Ash.get(Booking, booking_id, error?: false) do
- {:ok, nil} ->
- conn
- |> put_flash(:error, "Booking not found, cancelling aborted.")
- |> redirect(to: "/")
-
- {:ok, booking} ->
- action_result = BookingSystem.cancel_booking(booking)
- build_response(conn, action_result, :cancel)
-
- {:error, _} ->
- conn
- |> put_flash(:error, "Unexpected error occurred, couldn't cancel booking.")
- |> redirect(to: "/")
- end
-
- _ ->
- conn
- |> put_flash(:error, "Invalid or expired link.")
- |> redirect(to: "/")
- end
- end
-
- defp build_response(conn, action_result, action_name) do
- case action_result do
- {:ok, _booking} ->
- conn
- |> put_flash(:info, success_message(action_name))
- |> redirect(to: "/")
-
- {:error, _} ->
- conn
- |> put_flash(:error, "Action could not be completed (e.g. already processed).")
- |> redirect(to: "/")
- end
- end
-
- defp success_message(:cancel), do: "The booking has been cancelled."
- defp success_message(:confirm), do: "The booking has been confirmed."
- defp success_message(_), do: "Action completed successfully."
-end
diff --git a/lib/spazio_solazzo_web/emails/email_templates/admin_notification.html.heex b/lib/spazio_solazzo_web/emails/email_templates/admin_incoming_booking_request.html.heex
similarity index 72%
rename from lib/spazio_solazzo_web/emails/email_templates/admin_notification.html.heex
rename to lib/spazio_solazzo_web/emails/email_templates/admin_incoming_booking_request.html.heex
index 5a52af4..cda7942 100644
--- a/lib/spazio_solazzo_web/emails/email_templates/admin_notification.html.heex
+++ b/lib/spazio_solazzo_web/emails/email_templates/admin_incoming_booking_request.html.heex
@@ -1,4 +1,4 @@
-🔔 New Booking Received
+🔔 New Booking Request
Customer: {@customer_name}
@@ -7,11 +7,12 @@
<%= if @customer_phone && String.trim(@customer_phone) != "" do %>
Phone: {@customer_phone}
<% else %>
-
Phone: N/A
+
Phone: N/A
<% end %>
<.details_list>
+ <.detail_item label="Space">{@space_name}
<.detail_item label="Date">{@date}
<.detail_item label="Time">{@start_time} - {@end_time}
@@ -21,7 +22,7 @@
<%= if @customer_comment && String.trim(@customer_comment) != "" do %>
- “{@customer_comment}”
+ "{@customer_comment}"
<% else %>
@@ -32,13 +33,9 @@
-
Admin Actions
-
Please confirm arrival or cancel the booking.
+
Action Required
+
Please review and manage this request in the admin dashboard.
-<.email_button href={@confirm_url} variant={:primary}>
- Confirm Arrival
-
-
-<.email_button href={@cancel_url} variant={:danger}>
- Cancel Booking
+<.email_button href={@dashboard_url} variant={:primary}>
+ Manage Request
diff --git a/lib/spazio_solazzo_web/emails/email_templates/booking_cancelled.html.heex b/lib/spazio_solazzo_web/emails/email_templates/booking_cancelled.html.heex
new file mode 100644
index 0000000..89140db
--- /dev/null
+++ b/lib/spazio_solazzo_web/emails/email_templates/booking_cancelled.html.heex
@@ -0,0 +1,31 @@
+
🚫 Booking Request Cancelled
+
+
A booking request has been cancelled by the customer.
+
+
+
Customer: {@customer_name}
+
Email: {@customer_email}
+
+ <%= if @customer_phone && String.trim(@customer_phone) != "" do %>
+
Phone: {@customer_phone}
+ <% else %>
+
Phone: N/A
+ <% end %>
+
+
+<.details_list>
+ <.detail_item label="Space">{@space_name}
+ <.detail_item label="Date">{@date}
+ <.detail_item label="Time">{@start_time} - {@end_time}
+
+
+
+
Cancellation Reason:
+
+ {@cancellation_reason}
+
+
+
+
+ This is an automated notification. No action is required.
+
diff --git a/lib/spazio_solazzo_web/emails/email_templates/customer_confirmation.html.heex b/lib/spazio_solazzo_web/emails/email_templates/booking_request_approved.html.heex
similarity index 69%
rename from lib/spazio_solazzo_web/emails/email_templates/customer_confirmation.html.heex
rename to lib/spazio_solazzo_web/emails/email_templates/booking_request_approved.html.heex
index 436db04..4680198 100644
--- a/lib/spazio_solazzo_web/emails/email_templates/customer_confirmation.html.heex
+++ b/lib/spazio_solazzo_web/emails/email_templates/booking_request_approved.html.heex
@@ -1,18 +1,26 @@
-
🎉 Booking Confirmed!
+
🎉 Booking Request Approved!
Hello <%= @customer_name %> ,
-
Thank you for choosing Spazio Solazzo! Your booking has been successfully confirmed.
+
+ Great news! Your booking request has been approved. We look forward to seeing you at Spazio Solazzo!
+
<.details_list>
<.detail_item label="Date">{@date}
<.detail_item label="Time">{@start_time} - {@end_time}
+ <.detail_item label="Space">{@space_name}
<.detail_item label="Email">{@customer_email}
<.detail_item label="Phone">{@customer_phone || "N/A"}
- <.detail_item label="Note">{@customer_comment || "N/A"}
+
+
+ ✅ Your booking is confirmed! Please arrive on time.
+
+
+
- If you need to manage or cancel this booking, please use the link below:
+ If you need to cancel this booking, please use the link below:
<.email_button href={@cancel_url} variant={:danger}>
diff --git a/lib/spazio_solazzo_web/emails/email_templates/booking_request_rejected.html.heex b/lib/spazio_solazzo_web/emails/email_templates/booking_request_rejected.html.heex
new file mode 100644
index 0000000..4cb301c
--- /dev/null
+++ b/lib/spazio_solazzo_web/emails/email_templates/booking_request_rejected.html.heex
@@ -0,0 +1,37 @@
+
❌ Booking Request Not Approved
+
+
Hello <%= @customer_name %> ,
+
We regret to inform you that your booking request could not be approved at this time.
+
+<.details_list>
+ <.detail_item label="Date">{@date}
+ <.detail_item label="Time">{@start_time} - {@end_time}
+ <.detail_item label="Space">{@space_name}
+
+
+
+
Reason for Rejection:
+
+ {@rejection_reason}
+
+
+
+
+ We apologize for any inconvenience. If you have any questions or would like to discuss alternative options, please don't hesitate to contact us.
+
+
+
+
+
diff --git a/lib/spazio_solazzo_web/emails/email_templates/user_booking_request_confirmation.html.heex b/lib/spazio_solazzo_web/emails/email_templates/user_booking_request_confirmation.html.heex
new file mode 100644
index 0000000..83eaa2f
--- /dev/null
+++ b/lib/spazio_solazzo_web/emails/email_templates/user_booking_request_confirmation.html.heex
@@ -0,0 +1,46 @@
+
✅ Request Received!
+
+
Hello <%= @customer_name %> ,
+
+ Thank you for choosing Spazio Solazzo! Your booking request has been received and is pending approval.
+
+
+<.details_list>
+ <.detail_item label="Date">{@date}
+ <.detail_item label="Time">{@start_time} - {@end_time}
+ <.detail_item label="Space">{@space_name}
+ <.detail_item label="Email">{@customer_email}
+ <.detail_item label="Phone">{@customer_phone || "N/A"}
+ <.detail_item label="Note">{@customer_comment || "N/A"}
+
+
+
+
+ ⏳ Your request is pending approval. You will receive an email once an administrator reviews your request.
+
+
+
+
+ If you need to cancel this request, please use the link below:
+
+
+<.email_button href={@cancel_url} variant={:danger}>
+ Cancel Request
+
+
+
+
+
+
Need Help?
+
+ Do you have questions or need to update your request details?
+ Our Front Office is available to assist you at any time.
+
+
+
+ 📞 {@front_office_phone_number}
+
+
diff --git a/lib/spazio_solazzo_web/live/admin/admin_calendar_component.ex b/lib/spazio_solazzo_web/live/admin/admin_calendar_component.ex
new file mode 100644
index 0000000..cfe8957
--- /dev/null
+++ b/lib/spazio_solazzo_web/live/admin/admin_calendar_component.ex
@@ -0,0 +1,248 @@
+defmodule SpazioSolazzoWeb.Admin.AdminCalendarComponent do
+ @moduledoc """
+ Admin calendar for managing bookings, visualizing capacity, and selecting date ranges.
+ """
+ use SpazioSolazzoWeb, :live_component
+
+ alias SpazioSolazzo.CalendarExt
+
+ @doc "Resets the calendar selection state."
+ def reset(id) do
+ send_update(__MODULE__, id: id, reset: true)
+ end
+
+ def update(%{reset: true}, socket) do
+ socket =
+ socket
+ |> assign(start_date: nil)
+ |> assign(end_date: nil)
+ |> assign(selected_date: nil)
+
+ {:ok, socket}
+ end
+
+ def update(assigns, socket) do
+ first_day =
+ assigns[:first_day_of_month] ||
+ socket.assigns[:first_day_of_month] ||
+ Date.utc_today() |> Date.beginning_of_month()
+
+ grid = CalendarExt.build_calendar_grid(first_day)
+
+ socket =
+ socket
+ |> assign(assigns)
+ |> assign_new(:booking_counts, fn -> %{} end)
+ |> assign_new(:multi_day_mode, fn -> false end)
+ |> assign_new(:start_date, fn -> nil end)
+ |> assign_new(:end_date, fn -> nil end)
+ |> assign_new(:selected_date, fn -> nil end)
+ |> assign(first_day_of_month: first_day)
+ |> assign(grid: grid)
+
+ {:ok, socket}
+ end
+
+ def handle_event("prev_month", _, socket) do
+ new_date = Date.shift(socket.assigns.first_day_of_month, month: -1)
+
+ grid = CalendarExt.build_calendar_grid(new_date)
+
+ socket =
+ socket
+ |> assign(first_day_of_month: new_date)
+ |> assign(grid: grid)
+
+ send(self(), {:change_month, new_date})
+
+ {:noreply, socket}
+ end
+
+ def handle_event("next_month", _, socket) do
+ new_date = Date.shift(socket.assigns.first_day_of_month, month: 1)
+
+ grid = CalendarExt.build_calendar_grid(new_date)
+
+ socket =
+ socket
+ |> assign(first_day_of_month: new_date)
+ |> assign(grid: grid)
+
+ send(self(), {:change_month, new_date})
+
+ {:noreply, socket}
+ end
+
+ def handle_event("toggle_multi_day", _, socket) do
+ new_mode = !socket.assigns.multi_day_mode
+
+ send(self(), {:multi_day_mode_toggle, new_mode})
+
+ {:noreply,
+ assign(socket,
+ multi_day_mode: new_mode,
+ start_date: nil,
+ end_date: nil,
+ selected_date: nil
+ )}
+ end
+
+ def handle_event("select_date", %{"date" => d}, socket) do
+ date = Date.from_iso8601!(d)
+
+ if socket.assigns.multi_day_mode do
+ handle_multi_select(socket, date)
+ else
+ send(self(), {:date_selected, date, date})
+ {:noreply, assign(socket, selected_date: date, start_date: nil, end_date: nil)}
+ end
+ end
+
+ defp handle_multi_select(
+ %{assigns: %{start_date: start_date, end_date: end_date}} = socket,
+ date
+ ) do
+ cond do
+ is_nil(start_date) ->
+ # Start Selection
+ {:noreply, assign(socket, start_date: date)}
+
+ is_nil(end_date) ->
+ # End Selection (Order correctly)
+ {new_start, new_end} =
+ if Date.compare(date, start_date) == :lt,
+ do: {date, start_date},
+ else: {start_date, date}
+
+ send(self(), {:date_selected, new_start, new_end})
+ {:noreply, assign(socket, start_date: new_start, end_date: new_end)}
+
+ true ->
+ # Reset
+ {:noreply, assign(socket, start_date: date, end_date: nil)}
+ end
+ end
+
+ def render(assigns) do
+ ~H"""
+
+ <%!-- Toolbar --%>
+
+
+
+ Enable Multi-Day Selection
+
+
+
+
+
+
+ <.icon name="hero-chevron-left" />
+
+
+ {Calendar.strftime(@first_day_of_month, "%B %Y")}
+
+
+ <.icon name="hero-chevron-right" />
+
+
+
+
+ Su Mo Tu We Th Fr Sa
+
+
+
+ <%= for date <- @grid do %>
+ <% # Uses the booking_counts passed from parent
+ count = Map.get(@booking_counts, date, 0)
+ is_current = date.month == @first_day_of_month.month %>
+
+
+ <%!-- Header Row: Date & Badge --%>
+
+ {date.day}
+ <%= if count > 0 and is_current do %>
+ <.link
+ navigate={~p"/admin/bookings?date=#{Date.to_string(date)}"}
+ class="badge badge-info badge-xs text-white font-bold hover:scale-110 transition-transform"
+ title={"#{count} bookings"}
+ >
+ {count}
+
+ <% end %>
+
+
+ <%= if @multi_day_mode do %>
+ {if @start_date == date, do: echo_label("Start")}
+ {if @end_date == date, do: echo_label("End")}
+ <% end %>
+
+ <%= if Date.compare(date, Date.utc_today()) != :lt do %>
+
+
+ <% end %>
+
+ <% end %>
+
+
+
+ """
+ end
+
+ defp day_classes(date, %{
+ start_date: start_date,
+ end_date: end_date,
+ selected_date: selected_date,
+ multi_day_mode: multi
+ }) do
+ is_past = Date.compare(date, Date.utc_today()) == :lt
+ is_start = start_date == date
+ is_end = end_date == date
+ is_sel = selected_date == date
+ in_range = CalendarExt.date_in_range?(date, start_date, end_date)
+
+ base =
+ "relative aspect-square flex flex-col p-2 transition-all border border-slate-200 dark:border-slate-700 "
+
+ cond do
+ is_past ->
+ base <> "bg-slate-50 dark:bg-slate-800/50 text-slate-300 cursor-not-allowed"
+
+ is_start ->
+ base <> "bg-primary text-white rounded-l-lg z-10 shadow-md"
+
+ is_end ->
+ base <> "bg-primary text-white rounded-r-lg z-10 shadow-md"
+
+ in_range && multi ->
+ base <> "bg-primary/20 text-slate-900 dark:text-white"
+
+ is_sel ->
+ base <> "bg-primary text-white rounded-lg shadow-md"
+
+ true ->
+ base <> "bg-white dark:bg-slate-800 hover:bg-slate-50 text-slate-700 dark:text-slate-200"
+ end
+ end
+
+ defp echo_label(text) do
+ assigns = %{text: text}
+
+ ~H"""
+
{@text}
+ """
+ end
+end
diff --git a/lib/spazio_solazzo_web/live/admin/booking_management_live.ex b/lib/spazio_solazzo_web/live/admin/booking_management_live.ex
new file mode 100644
index 0000000..058465e
--- /dev/null
+++ b/lib/spazio_solazzo_web/live/admin/booking_management_live.ex
@@ -0,0 +1,251 @@
+defmodule SpazioSolazzoWeb.Admin.BookingManagementLive do
+ @moduledoc """
+ Admin booking management tool for reviewing and managing all booking requests.
+ Refactored to use URL as the Single Source of Truth.
+ """
+
+ use SpazioSolazzoWeb, :live_view
+
+ import SpazioSolazzoWeb.AdminBookingManagementComponents
+ alias SpazioSolazzo.BookingSystem
+
+ @pending_limit 10
+ @history_limit 10
+
+ def mount(_params, _session, socket) do
+ {:ok, spaces} = Ash.read(SpazioSolazzo.BookingSystem.Space)
+
+ if connected?(socket) do
+ Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:created")
+ Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:approved")
+ Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:cancelled")
+ Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:rejected")
+ end
+
+ {:ok,
+ assign(socket,
+ spaces: spaces,
+ expanded_booking_ids: MapSet.new(),
+ show_reject_modal: false,
+ rejecting_booking_id: nil,
+ rejection_reason: ""
+ )}
+ end
+
+ def handle_params(params, _uri, socket) do
+ # Destructure params with defaults. This ensures all keys exist.
+ %{
+ "space" => space_slug,
+ "email" => email,
+ "date" => date_str,
+ "pending_page" => pending_str,
+ "history_page" => history_str
+ } =
+ Map.merge(
+ %{
+ "space" => nil,
+ "email" => "",
+ "date" => nil,
+ "pending_page" => "1",
+ "history_page" => "1"
+ },
+ params
+ )
+
+ socket =
+ socket
+ |> assign(
+ filter_space: parse_string(space_slug),
+ filter_email: email,
+ filter_date: parse_date(date_str),
+ pending_page_number: parse_page(pending_str),
+ history_page_number: parse_page(history_str)
+ )
+ |> fetch_bookings()
+
+ {:noreply, socket}
+ end
+
+ def handle_event(
+ "filter_bookings",
+ %{"date" => date, "space" => space, "email" => email},
+ socket
+ ) do
+ params = %{
+ "date" => date,
+ "space" => space,
+ "email" => email,
+ # Reset pagination to page 1 when filtering
+ "pending_page" => "1",
+ "history_page" => "1"
+ }
+
+ {:noreply, push_patch(socket, to: build_filter_path(socket, params), replace: true)}
+ end
+
+ def handle_event("clear_filters", _, socket) do
+ {:noreply, push_patch(socket, to: ~p"/admin/bookings", replace: true)}
+ end
+
+ def handle_event("pending_page_change", %{"page" => page}, socket) do
+ {:noreply, push_patch(socket, to: build_filter_path(socket, %{"pending_page" => page}))}
+ end
+
+ def handle_event("history_page_change", %{"page" => page}, socket) do
+ {:noreply, push_patch(socket, to: build_filter_path(socket, %{"history_page" => page}))}
+ end
+
+ def handle_event("toggle_expand", %{"booking_id" => booking_id}, socket) do
+ expanded =
+ if MapSet.member?(socket.assigns.expanded_booking_ids, booking_id) do
+ MapSet.delete(socket.assigns.expanded_booking_ids, booking_id)
+ else
+ MapSet.put(socket.assigns.expanded_booking_ids, booking_id)
+ end
+
+ {:noreply, assign(socket, expanded_booking_ids: expanded)}
+ end
+
+ def handle_event("approve_booking", %{"booking_id" => booking_id}, socket) do
+ case Ash.get(BookingSystem.Booking, booking_id) do
+ {:ok, booking} ->
+ BookingSystem.approve_booking(booking)
+ {:noreply, socket}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, "Booking not found")}
+ end
+ end
+
+ def handle_event("show_reject_modal", %{"booking_id" => booking_id}, socket) do
+ {:noreply,
+ assign(socket,
+ show_reject_modal: true,
+ rejecting_booking_id: booking_id,
+ rejection_reason: ""
+ )}
+ end
+
+ def handle_event("hide_reject_modal", _, socket) do
+ {:noreply, assign(socket, show_reject_modal: false, rejecting_booking_id: nil)}
+ end
+
+ def handle_event("stop_propagation", _, socket), do: {:noreply, socket}
+
+ def handle_event("update_rejection_reason", %{"reason" => reason}, socket) do
+ {:noreply, assign(socket, rejection_reason: reason)}
+ end
+
+ def handle_event("confirm_reject", _, socket) do
+ %{rejecting_booking_id: id, rejection_reason: reason} = socket.assigns
+
+ if String.trim(reason) == "" do
+ {:noreply, put_flash(socket, :error, "Please provide a rejection reason")}
+ else
+ case Ash.get(BookingSystem.Booking, id) do
+ {:ok, booking} ->
+ BookingSystem.reject_booking(booking, reason)
+
+ socket =
+ socket
+ |> assign(show_reject_modal: false, rejecting_booking_id: nil)
+ |> put_flash(:info, "Booking rejected")
+
+ {:noreply, socket}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, "Booking not found")}
+ end
+ end
+ end
+
+ def handle_info(%{topic: "booking:" <> _}, socket) do
+ {:noreply, fetch_bookings(socket)}
+ end
+
+ def handle_info(_msg, socket), do: {:noreply, socket}
+
+ defp fetch_bookings(socket) do
+ %{
+ filter_space: space_slug,
+ filter_email: email,
+ filter_date: date,
+ pending_page_number: pending_page,
+ history_page_number: history_page,
+ spaces: spaces
+ } = socket.assigns
+
+ space_id =
+ spaces
+ |> Enum.find(%{}, fn s -> s.slug == space_slug end)
+ |> Map.get(:id, nil)
+
+ query_email = parse_string(email)
+ pending_offset = (pending_page - 1) * @pending_limit
+ history_offset = (history_page - 1) * @history_limit
+
+ {:ok, pending_data} =
+ BookingSystem.read_pending_bookings(space_id, query_email, date,
+ page: [limit: @pending_limit, offset: pending_offset, count: true],
+ load: [:space, :user]
+ )
+
+ {:ok, history_data} =
+ BookingSystem.read_booking_history(space_id, query_email, date,
+ page: [limit: @history_limit, offset: history_offset, count: true],
+ load: [:space, :user]
+ )
+
+ assign(socket, pending_page: pending_data, history_page: history_data)
+ end
+
+ defp build_filter_path(socket, overrides) do
+ current = %{
+ "space" => socket.assigns.filter_space,
+ "email" => socket.assigns.filter_email,
+ "date" => socket.assigns.filter_date,
+ "pending_page" => socket.assigns.pending_page_number,
+ "history_page" => socket.assigns.history_page_number
+ }
+
+ # Merge overrides -> Filter empty values -> Encode
+ params =
+ current
+ |> Map.merge(overrides)
+ |> Enum.reduce(%{}, fn
+ {_k, nil}, acc -> acc
+ {_k, ""}, acc -> acc
+ {"date", %Date{} = d}, acc -> Map.put(acc, "date", Date.to_iso8601(d))
+ {k, v}, acc -> Map.put(acc, k, v)
+ end)
+
+ ~p"/admin/bookings"
+ |> URI.parse()
+ |> URI.append_query(URI.encode_query(params))
+ |> URI.to_string()
+ end
+
+ defp parse_page(nil), do: 1
+ defp parse_page(""), do: 1
+
+ defp parse_page(value) do
+ case Integer.parse(value) do
+ {n, _} when n > 0 -> n
+ _ -> 1
+ end
+ end
+
+ defp parse_date(nil), do: nil
+ defp parse_date(""), do: nil
+
+ defp parse_date(value) do
+ case Date.from_iso8601(value) do
+ {:ok, date} -> date
+ _ -> nil
+ end
+ end
+
+ defp parse_string(nil), do: nil
+ defp parse_string(""), do: nil
+ defp parse_string(value), do: value
+end
diff --git a/lib/spazio_solazzo_web/live/admin/booking_management_live.html.heex b/lib/spazio_solazzo_web/live/admin/booking_management_live.html.heex
new file mode 100644
index 0000000..c5ce7e3
--- /dev/null
+++ b/lib/spazio_solazzo_web/live/admin/booking_management_live.html.heex
@@ -0,0 +1,196 @@
+
+
+
+ <.back_to_link
+ navigate={~p"/admin/dashboard"}
+ value="Back to Dashboard"
+ />
+
+ <%!-- Title and stats --%>
+
+
+
+
+ Review reservations and booking history. Pending requests require approval.
+
+
+
+
+
+ Pending
+
+ {@pending_page.count}
+
+
+
+
+ <%!-- Filters --%>
+
+
+ <%= if @pending_page.count == 0 && @history_page.count == 0 do %>
+
+ <.icon name="hero-inbox" class="w-16 h-16 text-slate-400 mx-auto mb-4" />
+
No bookings found
+
+ <% end %>
+
+ <%!-- Pending Bookings Table --%>
+ <%= if @pending_page.count > 0 do %>
+ <.bookings_table
+ title="Pending Requests"
+ bookings={@pending_page.results}
+ page={@pending_page}
+ current_page={@pending_page_number}
+ event_prefix="pending"
+ expanded_booking_ids={@expanded_booking_ids}
+ show_actions={true}
+ show_cancellation_details={false}
+ />
+ <% end %>
+
+ <%!-- Past Bookings Table --%>
+ <%= if @history_page.count > 0 do %>
+ <.bookings_table
+ title="Booking History"
+ bookings={@history_page.results}
+ page={@history_page}
+ current_page={@history_page_number}
+ event_prefix="history"
+ expanded_booking_ids={@expanded_booking_ids}
+ show_actions={false}
+ show_cancellation_details={true}
+ />
+ <% end %>
+
+
+
+ <%!-- Reject Modal --%>
+ <%= if @show_reject_modal do %>
+
+
+
Reject Booking
+
+
+ Provide a reason for rejecting this booking. The customer will receive this in their email.
+
+
+
+
+
+ <% end %>
+
diff --git a/lib/spazio_solazzo_web/live/admin/dashboard_live.ex b/lib/spazio_solazzo_web/live/admin/dashboard_live.ex
index b8085b5..fdf5271 100644
--- a/lib/spazio_solazzo_web/live/admin/dashboard_live.ex
+++ b/lib/spazio_solazzo_web/live/admin/dashboard_live.ex
@@ -1,21 +1,13 @@
defmodule SpazioSolazzoWeb.Admin.DashboardLive do
@moduledoc """
- Admin dashboard home page. Lists the available tools that admins have
+ Admin dashboard home page showing available management tools.
"""
use SpazioSolazzoWeb, :live_view
- alias SpazioSolazzo.BookingSystem
- import SpazioSolazzoWeb.AdminComponents
+ import SpazioSolazzoWeb.AdminDashboardComponents
def mount(_params, _session, socket) do
- {:ok, coworking_space} = BookingSystem.get_space_by_slug("coworking", not_found_error?: false)
- {:ok, meeting_space} = BookingSystem.get_space_by_slug("meeting", not_found_error?: false)
-
- {:ok,
- assign(socket,
- coworking_space: coworking_space,
- meeting_space: meeting_space
- )}
+ {:ok, socket}
end
end
diff --git a/lib/spazio_solazzo_web/live/admin/dashboard_live.html.heex b/lib/spazio_solazzo_web/live/admin/dashboard_live.html.heex
index 1fd9803..67cbbd9 100644
--- a/lib/spazio_solazzo_web/live/admin/dashboard_live.html.heex
+++ b/lib/spazio_solazzo_web/live/admin/dashboard_live.html.heex
@@ -1,28 +1,32 @@
-
- <.back_to_link
- navigate={~p"/"}
- value="Back to Home"
- />
+
+
+
+
+ Admin Dashboard
+
+
+ Welcome to Spazio Solazzo management center.
+
+
-
Admin Dashboard
-
-
- <%= if @meeting_space do %>
+
<.tool_card
- title={@meeting_space.name}
- description="Create walk-in bookings for the space"
- icon="hero-user-group"
+ title="Booking Management"
+ description="Review and manage pending booking requests. Approve or reject reservations and view booking history."
+ color={:primary}
+ icon="hero-clipboard-document-list"
+ navigate={~p"/admin/bookings"}
/>
- <% end %>
- <%= if @coworking_space do %>
<.tool_card
- title={@coworking_space.name}
- description="Create walk-in bookings for the space"
- icon="hero-user-group"
+ title="Arcipelago Walk-in Booking"
+ description="Create instant bookings for walk-in customers at the coworking space."
+ color={:secondary}
+ icon="hero-building-office-2"
+ navigate={~p"/admin/walk-in"}
/>
- <% end %>
+
-
+
diff --git a/lib/spazio_solazzo_web/live/admin/walk_in_live.ex b/lib/spazio_solazzo_web/live/admin/walk_in_live.ex
new file mode 100644
index 0000000..ce1611b
--- /dev/null
+++ b/lib/spazio_solazzo_web/live/admin/walk_in_live.ex
@@ -0,0 +1,180 @@
+defmodule SpazioSolazzoWeb.Admin.WalkInLive do
+ @moduledoc """
+ Admin walk-in booking tool for the coworking space.
+ """
+
+ use SpazioSolazzoWeb, :live_view
+ alias SpazioSolazzo.BookingSystem
+
+ def mount(_params, _session, socket) do
+ {:ok, space} = BookingSystem.get_space_by_slug("coworking")
+
+ today = Date.utc_today()
+ first_day = Date.beginning_of_month(today)
+ booking_counts = fetch_booking_counts(space.id, first_day)
+
+ if connected?(socket) do
+ Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:created")
+ Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:approved")
+ Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:cancelled")
+ Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:rejected")
+ end
+
+ {:ok,
+ assign(socket,
+ space: space,
+ first_day_of_month: first_day,
+ booking_counts: booking_counts,
+ start_date: nil,
+ end_date: nil,
+ start_time: ~T[09:00:00],
+ end_time: ~T[18:00:00],
+ multi_day_mode: false,
+ customer_details_form: customer_details_form()
+ )}
+ end
+
+ def handle_event("update_start_time", %{"value" => time_str}, socket) do
+ case Time.from_iso8601(time_str <> ":00") do
+ {:ok, time} -> {:noreply, assign(socket, start_time: time)}
+ _ -> {:noreply, socket}
+ end
+ end
+
+ def handle_event("update_end_time", %{"value" => time_str}, socket) do
+ case Time.from_iso8601(time_str <> ":00") do
+ {:ok, time} -> {:noreply, assign(socket, end_time: time)}
+ _ -> {:noreply, socket}
+ end
+ end
+
+ def handle_event("validate_customer_details", form, socket) do
+ {:noreply, assign(socket, customer_details_form: to_form(form))}
+ end
+
+ def handle_event("create_booking", _, %{assigns: %{start_date: s, end_date: e}} = socket)
+ when is_nil(s) or is_nil(e) do
+ {:noreply, put_flash(socket, :error, "Please fill in all required fields and select a date")}
+ end
+
+ def handle_event("create_booking", form, socket) do
+ case parse_submitted_form(form) do
+ {:error, error} -> {:noreply, put_flash(socket, :error, error)}
+ {:ok, form} -> create_walk_in(form, socket)
+ end
+ end
+
+ def handle_info({:change_month, new_date}, socket) do
+ booking_counts = fetch_booking_counts(socket.assigns.space.id, new_date)
+ {:noreply, assign(socket, first_day_of_month: new_date, booking_counts: booking_counts)}
+ end
+
+ def handle_info({:date_selected, start_date, end_date}, socket) do
+ {:noreply, assign(socket, start_date: start_date, end_date: end_date)}
+ end
+
+ def handle_info({:multi_day_mode_toggle, mode}, socket) do
+ {:noreply, assign(socket, multi_day_mode: mode)}
+ end
+
+ def handle_info(%{topic: "booking:" <> _}, socket) do
+ booking_counts =
+ fetch_booking_counts(socket.assigns.space.id, socket.assigns.first_day_of_month)
+
+ {:noreply, assign(socket, booking_counts: booking_counts)}
+ end
+
+ def handle_info(_msg, socket) do
+ {:noreply, socket}
+ end
+
+ defp fetch_booking_counts(space_id, date) do
+ start_dt = DateTime.new!(Date.beginning_of_month(date), ~T[00:00:00])
+ end_dt = DateTime.new!(Date.end_of_month(date), ~T[23:59:59])
+
+ {:ok, bookings} =
+ BookingSystem.search_bookings(
+ space_id,
+ start_dt,
+ end_dt,
+ [:accepted],
+ [:start_datetime, :end_datetime]
+ )
+
+ Enum.reduce(bookings, %{}, fn booking, acc ->
+ range =
+ Date.range(
+ DateTime.to_date(booking.start_datetime),
+ DateTime.to_date(booking.end_datetime)
+ )
+
+ Enum.reduce(range, acc, fn d, count_acc ->
+ Map.update(count_acc, d, 1, &(&1 + 1))
+ end)
+ end)
+ end
+
+ defp create_walk_in(form, socket) do
+ %{
+ start_date: start_date,
+ end_date: end_date,
+ start_time: start_time,
+ end_time: end_time,
+ space: space
+ } = socket.assigns
+
+ start_datetime = DateTime.new!(start_date, start_time, "Etc/UTC")
+ end_datetime = DateTime.new!(end_date, end_time, "Etc/UTC")
+
+ case BookingSystem.create_walk_in(
+ space.id,
+ start_datetime,
+ end_datetime,
+ form.customer_name,
+ form.customer_email,
+ form.customer_phone
+ ) do
+ {:ok, _booking} ->
+ SpazioSolazzoWeb.Admin.AdminCalendarComponent.reset("walk-in-calendar")
+
+ {:noreply,
+ socket
+ |> assign(
+ customer_details_form: customer_details_form(),
+ start_date: nil,
+ end_date: nil
+ )
+ |> put_flash(:info, "Walk-in booking created successfully")}
+
+ {:error, error} ->
+ {:noreply, put_flash(socket, :error, "Failed to create walk-in: #{inspect(error)}")}
+ end
+ end
+
+ defp parse_submitted_form(%{
+ "customer_name" => name,
+ "customer_email" => email,
+ "customer_phone" => phone
+ }) do
+ name = String.trim(name)
+ email = String.trim(email)
+ phone = String.trim(phone)
+
+ if name == "" || email == "" do
+ {:error, "Please fill in all required fields and select a date"}
+ else
+ {:ok, %{customer_name: name, customer_email: email, customer_phone: phone}}
+ end
+ end
+
+ defp days_selected(nil, nil), do: 0
+ defp days_selected(start_date, nil) when not is_nil(start_date), do: 1
+
+ defp days_selected(start_date, end_date) when not is_nil(start_date) and not is_nil(end_date),
+ do: Date.diff(end_date, start_date) + 1
+
+ defp days_selected(_, _), do: 0
+
+ defp customer_details_form(),
+ do: to_form(%{"customer_name" => "", "customer_email" => "", "customer_phone" => ""})
+end
diff --git a/lib/spazio_solazzo_web/live/admin/walk_in_live.html.heex b/lib/spazio_solazzo_web/live/admin/walk_in_live.html.heex
new file mode 100644
index 0000000..6f9d9cc
--- /dev/null
+++ b/lib/spazio_solazzo_web/live/admin/walk_in_live.html.heex
@@ -0,0 +1,245 @@
+
+
+
+ <.back_to_link
+ navigate={~p"/admin/dashboard"}
+ value="Back to Dashboard"
+ />
+
+ <%!-- Title --%>
+
+
+ New Arcipelago Walk-in Booking
+
+
+ Create a walk-in booking for the Arcipelago space.
+
+
+
+
+ <%!-- Date & Time --%>
+
+
+
+
+ <%!-- Calendar --%>
+
+ <.live_component
+ module={SpazioSolazzoWeb.Admin.AdminCalendarComponent}
+ id="walk-in-calendar"
+ space_id={@space.id}
+ booking_counts={@booking_counts}
+ />
+
+
+ <%!-- Selected dates and time inputs --%>
+
+ <%!-- Selected interval --%>
+
+
+ Selected Interval
+
+
+
+
+ Start Date
+
+
+ <%= if @start_date do %>
+ {Calendar.strftime(@start_date, "%b %d, %Y")}
+ <% else %>
+ Not selected
+ <% end %>
+
+
+
+
+
+ End Date
+
+
+ <%= if @end_date do %>
+ {Calendar.strftime(@end_date, "%b %d, %Y")}
+ <% else %>
+ Not selected
+ <% end %>
+
+
+
+ <.icon
+ name={
+ if @multi_day_mode && @end_date,
+ do: "hero-calendar-days",
+ else: "hero-calendar"
+ }
+ class="w-4 h-4"
+ />
+
+ <%= if @multi_day_mode && @start_date && @end_date do %>
+ {days_selected(@start_date, @end_date)} Days total
+ <% else %>
+ Single Day
+ <% end %>
+
+
+
+
+
+ <%!-- Daily schedule --%>
+
+
+ Daily Schedule
+
+
+ <%= if @multi_day_mode do %>
+ <%!-- Multi-day mode: Show info card --%>
+
+
+ <.icon name="hero-calendar-days" class="w-5 h-5" />
+
+
+ Full day booking applied for the selected range
+
+
+ Start and end time inputs are disabled for multiday selections.
+
+
+ <% else %>
+ <%!-- Single-day mode: Show time inputs --%>
+
+
+
+ Start Time
+
+
+
+ <.icon name="hero-clock" class="w-4 h-4" />
+
+
+
+
+
+
+
+ End Time
+
+
+
+ <.icon name="hero-clock" class="w-4 h-4" />
+
+
+
+
+
+ <% end %>
+
+
+
+
+
+ <%!-- Customer Details --%>
+
+
+
+ <.form
+ for={@customer_details_form}
+ id="customer-details-form"
+ phx-change="validate_customer_details"
+ phx-submit="create_booking"
+ class="space-y-4 max-w-2xl"
+ >
+
+
+ <.icon name="hero-user" class="w-5 h-5" />
+
+ <.input
+ field={@customer_details_form["customer_name"]}
+ class="w-full bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white text-sm rounded-xl pl-10 pr-4 py-3 focus:ring-2 focus:ring-primary focus:border-primary transition-all placeholder:text-slate-400"
+ placeholder="Customer Name"
+ type="text"
+ required
+ />
+
+
+
+
+ <.icon name="hero-envelope" class="w-5 h-5" />
+
+ <.input
+ field={@customer_details_form["customer_email"]}
+ class="w-full bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white text-sm rounded-xl pl-10 pr-4 py-3 focus:ring-2 focus:ring-primary focus:border-primary transition-all placeholder:text-slate-400"
+ placeholder="customer@example.com"
+ type="email"
+ required
+ />
+
+
+
+
+ <.icon name="hero-phone" class="w-5 h-5" />
+
+ <.input
+ field={@customer_details_form["customer_phone"]}
+ class="w-full bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-slate-900 dark:text-white text-sm rounded-xl pl-10 pr-4 py-3 focus:ring-2 focus:ring-primary focus:border-primary transition-all placeholder:text-slate-400"
+ placeholder="Customer Phone Number (Optional)"
+ type="tel"
+ />
+
+
+
+
+
+ <.button
+ type="submit"
+ form="customer-details-form"
+ class="w-full sm:w-auto flex items-center justify-center gap-2 overflow-hidden rounded-xl h-12 px-10 bg-primary hover:bg-primary/90 transition-colors text-white text-base font-bold shadow-lg shadow-primary/30 cursor-pointer"
+ >
+ Create Booking
+
+
+
+
+
+
diff --git a/lib/spazio_solazzo_web/live/booking/asset_booking_live.html.heex b/lib/spazio_solazzo_web/live/booking/asset_booking_live.html.heex
deleted file mode 100644
index 91abd08..0000000
--- a/lib/spazio_solazzo_web/live/booking/asset_booking_live.html.heex
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
- <.back_to_link
- navigate={"/#{@space.slug}"}
- value={"Back to #{@space.name}"}
- />
-
-
-
-
- {@asset.name}
-
-
- {@space.name} - Flexible booking options available
-
-
-
-
-
- Available Time Slots
-
-
-
- <.live_component
- module={SpazioSolazzoWeb.CalendarLiveComponent}
- id="booking-calendar"
- selected_date={@selected_date}
- />
-
-
-
- Selected day:
-
- {SpazioSolazzo.CalendarExt.format_date(@selected_date)}
-
-
-
-
- <%= if @time_slots == [] do %>
-
- No time slots available for this date
-
- <% else %>
- <%= for time_slot <- @time_slots do %>
- <% booked = slot_booked?(time_slot.id, @bookings) %>
- <.time_slot booked={booked} time_slot={time_slot} />
- <% end %>
- <% end %>
-
-
-
-
-
-
- <.icon name="hero-credit-card" class="w-5 h-5" /> Payment due upon arrival.
-
-
-
-
-
- <.live_component
- module={SpazioSolazzoWeb.BookingFormLiveComponent}
- id="booking-modal"
- show={@show_booking_modal}
- selected_time_slot={@selected_time_slot}
- asset={@asset}
- selected_date={@selected_date}
- current_user={@current_user}
- on_cancel={JS.push("cancel_booking")}
- />
-
- <.booking_confirmation_modal
- id="success-modal"
- show={@show_success_modal}
- on_close={JS.push("close_success_modal")}
- />
-
diff --git a/lib/spazio_solazzo_web/live/booking/booking_cancellation_live.ex b/lib/spazio_solazzo_web/live/booking/booking_cancellation_live.ex
new file mode 100644
index 0000000..32cc5dc
--- /dev/null
+++ b/lib/spazio_solazzo_web/live/booking/booking_cancellation_live.ex
@@ -0,0 +1,63 @@
+defmodule SpazioSolazzoWeb.BookingCancellationLive do
+ use SpazioSolazzoWeb, :live_view
+
+ alias SpazioSolazzo.BookingSystem
+ alias SpazioSolazzo.BookingSystem.Booking.Token
+
+ def mount(%{"token" => token}, _session, socket) do
+ case Token.verify(token) do
+ {:ok, %{booking_id: booking_id, action: :cancel}} ->
+ handle_booking(booking_id, socket)
+
+ {:error, _} ->
+ {:ok,
+ socket
+ |> put_flash(:error, "Invalid or expired cancellation link")
+ |> push_navigate(to: "/")}
+ end
+ end
+
+ defp handle_booking(booking_id, socket) do
+ case Ash.get(SpazioSolazzo.BookingSystem.Booking, booking_id, load: [:space]) do
+ {:ok, %{state: state} = booking} when state in [:requested, :accepted] ->
+ {:ok,
+ assign(socket,
+ booking: booking,
+ cancellation_reason: "",
+ show_success: false
+ )}
+
+ {:ok, _} ->
+ {:ok,
+ socket
+ |> put_flash(:error, "This booking has already been cancelled or completed")
+ |> push_navigate(to: "/")}
+
+ {:error, _} ->
+ {:ok,
+ socket
+ |> put_flash(:error, "Booking not found")
+ |> push_navigate(to: "/")}
+ end
+ end
+
+ def handle_event("validate", %{"reason" => reason}, socket) do
+ {:noreply, assign(socket, cancellation_reason: reason)}
+ end
+
+ def handle_event("cancel_booking", %{"reason" => reason}, socket) do
+ if String.trim(reason) == "" do
+ {:noreply, put_flash(socket, :error, "Please provide a reason for cancellation")}
+ else
+ booking = socket.assigns.booking
+
+ case BookingSystem.cancel_booking(booking, reason) do
+ {:ok, _cancelled_booking} ->
+ {:noreply, assign(socket, show_success: true)}
+
+ {:error, _error} ->
+ {:noreply, put_flash(socket, :error, "Failed to cancel booking. Please try again.")}
+ end
+ end
+ end
+end
diff --git a/lib/spazio_solazzo_web/live/booking/booking_cancellation_live.html.heex b/lib/spazio_solazzo_web/live/booking/booking_cancellation_live.html.heex
new file mode 100644
index 0000000..e9a80c9
--- /dev/null
+++ b/lib/spazio_solazzo_web/live/booking/booking_cancellation_live.html.heex
@@ -0,0 +1,97 @@
+
+
+
+ <%= if @show_success do %>
+
+
+
+
Booking Cancelled
+
+ Your booking has been successfully cancelled. The administrator has been notified.
+
+
+
+ <.back_to_link
+ navigate={~p"/"}
+ value="Return to Home"
+ />
+
+ <% else %>
+
+
Cancel Booking
+
+
+
Booking Details
+
+
+ Space:
+ {@booking.space.name}
+
+
+ Date:
+ {Calendar.strftime(@booking.date, "%A, %B %d, %Y")}
+
+
+ Time:
+ {@booking.start_time} - {@booking.end_time}
+
+
+ Customer:
+ {@booking.customer_name}
+
+
+
+
+
+
+
+ Cancellation Reason *
+
+
+
+ This helps us improve our service and understand your needs better.
+
+
+
+
+
+ Confirm Cancellation
+
+ <.link
+ navigate="/"
+ class="flex-1 bg-slate-200 text-slate-700 px-6 py-3 rounded-xl font-semibold hover:bg-slate-300 transition-colors text-center"
+ >
+ Keep Booking
+
+
+
+
+ <% end %>
+
+
+
diff --git a/lib/spazio_solazzo_web/live/booking/booking_form_live_component.ex b/lib/spazio_solazzo_web/live/booking/booking_form_live_component.ex
index 0f56df5..023efe9 100644
--- a/lib/spazio_solazzo_web/live/booking/booking_form_live_component.ex
+++ b/lib/spazio_solazzo_web/live/booking/booking_form_live_component.ex
@@ -8,9 +8,11 @@ defmodule SpazioSolazzoWeb.BookingFormLiveComponent do
alias SpazioSolazzo.CalendarExt
def update(assigns, socket) do
+ current_user = assigns.current_user
+
initial_data = %{
- "customer_name" => assigns.current_user.name,
- "customer_phone" => assigns.current_user.phone_number || "",
+ "customer_name" => current_user.name,
+ "customer_phone" => current_user.phone_number || "",
"customer_comment" => ""
}
@@ -28,9 +30,9 @@ defmodule SpazioSolazzoWeb.BookingFormLiveComponent do
def handle_event("submit_booking", params, socket) do
booking_data = %{
- customer_name: params["customer_name"] || "",
- customer_phone: params["customer_phone"] || "",
- customer_comment: params["customer_comment"] || ""
+ customer_name: String.trim(params["customer_name"] || ""),
+ customer_phone: String.trim(params["customer_phone"] || ""),
+ customer_comment: String.trim(params["customer_comment"] || "")
}
send(self(), {:create_booking, booking_data})
@@ -44,13 +46,33 @@ defmodule SpazioSolazzoWeb.BookingFormLiveComponent do
<:title>Complete Your Booking
<:subtitle>
<%= if @selected_time_slot do %>
- {@asset.name} | {CalendarExt.format_time_range(@selected_time_slot)} on {CalendarExt.format_date(
+ {@space.name} | {CalendarExt.format_time_range(@selected_time_slot)} on {CalendarExt.format_date(
@selected_date
)}
<% end %>
+ <%= if @slot_availability == :over_capacity do %>
+
+
+
+ <.icon
+ name="hero-exclamation-triangle"
+ class="size-5 text-yellow-600 dark:text-yellow-400"
+ />
+
+
+
+ High Demand Time Slot
+
+
+ This time slot is popular. Your request will be subject to admin approval based on availability.
+
+
+
+
+ <% end %>
<.form
for={@form}
id="booking-form"
diff --git a/lib/spazio_solazzo_web/live/booking/calendar_live_component.ex b/lib/spazio_solazzo_web/live/booking/calendar_live_component.ex
index 11de78d..733b592 100644
--- a/lib/spazio_solazzo_web/live/booking/calendar_live_component.ex
+++ b/lib/spazio_solazzo_web/live/booking/calendar_live_component.ex
@@ -1,134 +1,111 @@
-defmodule SpazioSolazzoWeb.CalendarLiveComponent do
+defmodule SpazioSolazzoWeb.BookingCalendarLiveComponent do
@moduledoc """
- LiveView component for rendering booking calendars.
+ The calendar displayed in the space booking view.
+ It allows users to select a date in a beautifully-styled calendar grid.
"""
use SpazioSolazzoWeb, :live_component
-
- # There are 7 days displayed in the calendar
- @grid_cols 7
- # The calendar can show max 6 weeks for one month
- @grid_rows 6
+ alias SpazioSolazzo.CalendarExt
def update(assigns, socket) do
- # Initialize navigation date to today's month if not already viewing a month
- beginning_of_month =
- socket.assigns[:beginning_of_month] ||
- Date.utc_today()
- |> Date.beginning_of_month()
+ first_day =
+ assigns[:first_day_of_month] ||
+ socket.assigns[:first_day_of_month] ||
+ Date.utc_today() |> Date.beginning_of_month()
- selected_date = assigns[:selected_date] || Date.utc_today()
+ grid = CalendarExt.build_calendar_grid(first_day)
{:ok,
socket
|> assign(assigns)
- |> assign(:beginning_of_month, beginning_of_month)
- |> assign(:selected_date, selected_date)
- |> assign(:today, Date.utc_today())
- |> assign_calendar_grid()}
+ |> assign(:first_day_of_month, first_day)
+ |> assign(:grid, grid)}
end
- def handle_event("prev-month", _params, socket) do
- new_beginning_of_month =
- socket.assigns.beginning_of_month
+ def handle_event("prev-month", _, socket) do
+ new_date =
+ socket.assigns.first_day_of_month
|> Date.shift(month: -1)
|> Date.beginning_of_month()
{:noreply,
- socket
- |> assign(:beginning_of_month, new_beginning_of_month)
- |> assign_calendar_grid()}
+ assign(socket, first_day_of_month: new_date, grid: CalendarExt.build_calendar_grid(new_date))}
end
- def handle_event("next-month", _params, socket) do
- new_beginning_of_month =
- socket.assigns.beginning_of_month
+ def handle_event("next-month", _, socket) do
+ new_date =
+ socket.assigns.first_day_of_month
|> Date.shift(month: 1)
|> Date.beginning_of_month()
{:noreply,
- socket
- |> assign(:beginning_of_month, new_beginning_of_month)
- |> assign_calendar_grid()}
+ assign(socket, first_day_of_month: new_date, grid: CalendarExt.build_calendar_grid(new_date))}
end
+ # --- Selection (Parent IS notified) ---
+
def handle_event("select-date", %{"date" => date_str}, socket) do
date = Date.from_iso8601!(date_str)
+
send(self(), {:date_selected, date})
+
{:noreply, assign(socket, :selected_date, date)}
end
- defp assign_calendar_grid(socket) do
- first = socket.assigns.beginning_of_month
- # Calculate offset to start grid on Monday (Monday = 1)
- day_of_week = Date.day_of_week(socket.assigns.beginning_of_month)
- days_before = day_of_week - 1
- start_date = Date.add(first, -days_before)
- grid = Enum.map(0..(@grid_cols * @grid_rows - 1), fn n -> Date.add(start_date, n) end)
-
- assign(socket, :grid, grid)
- end
-
def render(assigns) do
~H"""
+ <%!-- Header --%>
<.icon name="hero-chevron-left" class="w-5 h-5" />
-
- {Calendar.strftime(@beginning_of_month, "%B %Y")}
+
+ {Calendar.strftime(@first_day_of_month, "%B %Y")}
<.icon name="hero-chevron-right" class="w-5 h-5" />
-
+
Mo Tu We Th Fr Sa Su
-
+
<%= for date <- @grid do %>
- <% is_selected = Date.compare(date, @selected_date) == :eq
- is_past = Date.compare(date, @today) == :lt
- is_beginning_of_month = date.month == @beginning_of_month.month %>
+ <% is_current_month = date.month == @first_day_of_month.month
+ is_selected = @selected_date == date
+ is_past = Date.compare(date, Date.utc_today()) == :lt %>
- <%= if is_beginning_of_month do %>
+ <%= if is_current_month do %>
{date.day}
<% else %>
-
+
<% end %>
<% end %>
diff --git a/lib/spazio_solazzo_web/live/booking/asset_booking_live.ex b/lib/spazio_solazzo_web/live/booking/space_booking_live.ex
similarity index 50%
rename from lib/spazio_solazzo_web/live/booking/asset_booking_live.ex
rename to lib/spazio_solazzo_web/live/booking/space_booking_live.ex
index 0bfe020..b56e0b0 100644
--- a/lib/spazio_solazzo_web/live/booking/asset_booking_live.ex
+++ b/lib/spazio_solazzo_web/live/booking/space_booking_live.ex
@@ -1,32 +1,28 @@
-defmodule SpazioSolazzoWeb.AssetBookingLive do
+defmodule SpazioSolazzoWeb.SpaceBookingLive do
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
import SpazioSolazzoWeb.BookingComponents
- require Ash.Query
- def mount(%{"asset_id" => asset_id}, _session, socket) do
- case BookingSystem.get_asset_by_id(asset_id, load: [:space]) do
- {:ok, asset} ->
+ def mount(%{"space_slug" => space_slug}, _session, socket) do
+ case BookingSystem.get_space_by_slug(space_slug) do
+ {:ok, space} ->
selected_date = Date.utc_today()
+ current_user = socket.assigns[:current_user]
- {:ok, time_slots} =
- BookingSystem.get_space_time_slots_by_date(asset.space.id, selected_date)
-
- {:ok, bookings} =
- BookingSystem.list_active_asset_bookings_by_date(asset.id, selected_date)
+ time_slots = load_time_slots_with_stats(space, selected_date, current_user)
if connected?(socket) do
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:created")
+ Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:approved")
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:cancelled")
+ Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:rejected")
end
{:ok,
socket
|> assign(
- asset: asset,
- space: asset.space,
- bookings: bookings,
+ space: space,
selected_date: selected_date,
selected_time_slot: nil,
show_booking_modal: false,
@@ -37,7 +33,7 @@ defmodule SpazioSolazzoWeb.AssetBookingLive do
{:error, _error} ->
{:ok,
socket
- |> put_flash(:error, "Asset not found")
+ |> put_flash(:error, "Space not found")
|> push_navigate(to: "/")}
end
end
@@ -56,14 +52,50 @@ defmodule SpazioSolazzoWeb.AssetBookingLive do
end
def handle_info({:create_booking, booking_data}, socket) do
+ case parse_booking_data(booking_data) do
+ {:error, error} -> {:noreply, put_flash(socket, :error, error)}
+ {:ok, booking_data} -> create_booking(booking_data, socket)
+ end
+ end
+
+ def handle_info({:date_selected, date}, socket) do
+ time_slots =
+ load_time_slots_with_stats(socket.assigns.space, date, socket.assigns.current_user)
+
+ {:noreply,
+ socket
+ |> assign(
+ selected_date: date,
+ time_slots: time_slots
+ )}
+ end
+
+ def handle_info(
+ %{topic: "booking:" <> _event, payload: %{data: %{space_id: space_id, date: date}}},
+ %{assigns: %{space: %{id: space_id}, selected_date: date}} = socket
+ ) do
+ time_slots =
+ load_time_slots_with_stats(socket.assigns.space, date, socket.assigns.current_user)
+
+ {:noreply,
+ socket
+ |> assign(time_slots: time_slots)}
+ end
+
+ def handle_info(_msg, socket) do
+ {:noreply, socket}
+ end
+
+ defp create_booking(booking_data, socket) do
current_user = socket.assigns.current_user
result =
BookingSystem.create_booking(
- socket.assigns.selected_time_slot.id,
- socket.assigns.asset.id,
+ socket.assigns.space.id,
current_user.id,
socket.assigns.selected_date,
+ socket.assigns.selected_time_slot.start_time,
+ socket.assigns.selected_time_slot.end_time,
booking_data.customer_name,
current_user.email,
booking_data.customer_phone,
@@ -83,48 +115,34 @@ defmodule SpazioSolazzoWeb.AssetBookingLive do
{:noreply,
socket
|> assign(show_booking_modal: false)
- |> put_flash(:error, "Failed to create booking.}")}
+ |> put_flash(:error, "Failed to create booking request.")}
end
end
- def handle_info(
- %{topic: "booking:created", payload: %{data: %{asset_id: asset_id, date: date}}},
- %{assigns: %{asset: %{id: asset_id}, selected_date: date}} = socket
- ) do
- {:ok, bookings} = BookingSystem.list_active_asset_bookings_by_date(asset_id, date)
- {:noreply, assign(socket, bookings: bookings)}
+ defp parse_booking_data(%{customer_name: ""}) do
+ {:error, "Please fill all the required fields to create a booking request."}
end
- def handle_info(
- %{topic: "booking:cancelled", payload: %{data: %{asset_id: asset_id, date: date}}},
- %{assigns: %{asset: %{id: asset_id}, selected_date: date}} = socket
- ) do
- {:ok, bookings} = BookingSystem.list_active_asset_bookings_by_date(asset_id, date)
- {:noreply, assign(socket, bookings: bookings)}
+ defp parse_booking_data(
+ %{
+ customer_name: _,
+ customer_phone: _,
+ customer_comment: _
+ } = form
+ ) do
+ {:ok, form}
end
- def handle_info({:date_selected, date}, socket) do
- {:ok, time_slots} =
- BookingSystem.get_space_time_slots_by_date(socket.assigns.space.id, date)
-
- {:ok, bookings} =
- BookingSystem.list_active_asset_bookings_by_date(socket.assigns.asset.id, date)
-
- {:noreply,
- assign(socket,
- selected_date: date,
- time_slots: time_slots,
- bookings: bookings
- )}
- end
-
- def handle_info(_msg, socket) do
- {:noreply, socket}
- end
-
- defp slot_booked?(time_slot_id, bookings) do
- Enum.any?(bookings, fn booking ->
- booking.time_slot_template_id == time_slot_id
- end)
+ defp load_time_slots_with_stats(space, date, current_user) do
+ BookingSystem.get_space_time_slots_by_date!(space.id, date,
+ load: [
+ booking_stats: %{
+ date: date,
+ space_id: space.id,
+ capacity: space.capacity,
+ user_id: current_user.id
+ }
+ ]
+ )
end
end
diff --git a/lib/spazio_solazzo_web/live/booking/space_booking_live.html.heex b/lib/spazio_solazzo_web/live/booking/space_booking_live.html.heex
new file mode 100644
index 0000000..6a3b295
--- /dev/null
+++ b/lib/spazio_solazzo_web/live/booking/space_booking_live.html.heex
@@ -0,0 +1,117 @@
+
+
+ <.back_to_link
+ navigate={"/#{@space.slug}"}
+ value={"Back to #{@space.name}"}
+ />
+
+
+
+ {@space.name}
+
+
+ {@space.description}
+
+
+
+
+
+ Available Time Slots
+
+
+
+ <.live_component
+ module={SpazioSolazzoWeb.BookingCalendarLiveComponent}
+ id="booking-calendar"
+ selected_date={@selected_date}
+ />
+
+
+
+ Selected day:
+
+ {Calendar.strftime(@selected_date, "%A, %B %d, %Y")}
+
+
+
+
+ <%= if @time_slots == [] do %>
+
+ No time slots available for this date
+
+ <% else %>
+ <%= for time_slot <- @time_slots do %>
+ <.time_slot_card time_slot={time_slot} />
+ <% end %>
+ <% end %>
+
+
+
+
+
+
+ <.icon name="hero-credit-card" class="w-5 h-5" /> Payment due upon arrival.
+
+
+
+
+
+ <.live_component
+ module={SpazioSolazzoWeb.BookingFormLiveComponent}
+ id="booking-modal"
+ show={@show_booking_modal}
+ selected_time_slot={@selected_time_slot}
+ space={@space}
+ selected_date={@selected_date}
+ current_user={@current_user}
+ slot_availability={
+ if @selected_time_slot do
+ @selected_time_slot.booking_stats.availability_status
+ else
+ :available
+ end
+ }
+ on_cancel={JS.push("cancel_booking")}
+ />
+
+ <%= if @show_success_modal do %>
+
+
+
+
+
+ Request Submitted!
+
+
+ Your booking request is pending approval. You'll receive an email confirmation shortly.
+
+
+
+
+ Close
+
+
+
+ <% end %>
+
diff --git a/lib/spazio_solazzo_web/live/carousel_live_component.ex b/lib/spazio_solazzo_web/live/components/carousel_live_component.ex
similarity index 100%
rename from lib/spazio_solazzo_web/live/carousel_live_component.ex
rename to lib/spazio_solazzo_web/live/components/carousel_live_component.ex
diff --git a/lib/spazio_solazzo_web/live/landing/coworking_live.ex b/lib/spazio_solazzo_web/live/landing/coworking_live.ex
index 7cab141..2666665 100644
--- a/lib/spazio_solazzo_web/live/landing/coworking_live.ex
+++ b/lib/spazio_solazzo_web/live/landing/coworking_live.ex
@@ -2,24 +2,12 @@ defmodule SpazioSolazzoWeb.CoworkingLive do
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
+ # Landing page components
import SpazioSolazzoWeb.LandingComponents
def mount(_params, _session, socket) do
{:ok, space} = BookingSystem.get_space_by_slug("coworking")
- {:ok, assets} = BookingSystem.get_space_assets(space.id)
- images = [
- "https://lh3.googleusercontent.com/aida-public/AB6AXuDmh_AkVuUoICqpHk1NdLuLdi0xQBOC8Hy9PrsSNz956igHFRhbNGsB8k0vSLe2U2NW1sxRVZm_dwR27Q4Db_f21XbYkLtfiRYob-j4ran1rTBB0bQAz4QLFSO1yL_cPhDIpAyvC069mDQ33-ckZgZ_yvFsIK_-_0Jj2NEOnDie684uaR7vKuiBWlsr-JmAsPzUp7Aik7Qbzozune348nBz1bvWkBNMCpMO3JV8hrYBo1i6JlUiGSuP3-5fWXKt8dKhxPUN-amjLFgh",
- "https://lh3.googleusercontent.com/aida-public/AB6AXuCh5O9cz1ruQFH0Pq3MzC_1HsWrLPHbWlfYEdB2dmPi0YDn2L23R5hseUZmb19XlEju1n4a24oD6pH5qiG4SvIemrD45PfKwvNlckpOG59IYz5WYrHzroq7L4Uq9Hxl0PTzU5m8R5k625w_MrdZKidyfM6OnzNJfM5J3XftFI5A9J7wD_BDHRKxq8gxAukUCesuYX8lGm3AhQAZQTjaUY5yeobjt-NCSrlfTzxmcUmibJSTnKZuwx-li4QtFr0wQrzHVLUZYiAhA251",
- "https://lh3.googleusercontent.com/aida-public/AB6AXuCanfiWzXqH3fBrE6U3phirIFZo5bgKG1aa8wnXCRC12yOXkcgnGUTRhxppIk61QUdQWF9KuFAtjhDEI9AACV-pM7yXyPKbOKognCARD-qbffFtCwGLidcLkoprLnNAW12C7TeRL6gOEBas3RI7jCf30JmzMmSqCjMx3lixgrOr6qlpbHZA4Eog_P41y5zXtn9Nqlq2eB6c7RYhiOIJzXVpMmfLR_qf0HTmOnx2poDbqKcLDcCM-p4S6aAwLxC-GYBmvEfWQ4meToCL"
- ]
-
- {:ok,
- socket
- |> assign(
- space: space,
- assets: assets,
- images: images
- )}
+ {:ok, assign(socket, space: space)}
end
end
diff --git a/lib/spazio_solazzo_web/live/landing/coworking_live.html.heex b/lib/spazio_solazzo_web/live/landing/coworking_live.html.heex
index 3b8b7c0..aa68d4b 100644
--- a/lib/spazio_solazzo_web/live/landing/coworking_live.html.heex
+++ b/lib/spazio_solazzo_web/live/landing/coworking_live.html.heex
@@ -1,11 +1,15 @@
<.page_header
- booking_path="#interactive-floor-plan"
- booking_label="Explore Desks & Book"
+ booking_path={~p"/book/space/#{@space.slug}"}
+ booking_label="Book Space"
price="€25"
price_unit="day"
- capacity="5 Desks"
- images={@images}
+ capacity={@space.capacity}
+ images={[
+ "https://lh3.googleusercontent.com/aida-public/AB6AXuDmh_AkVuUoICqpHk1NdLuLdi0xQBOC8Hy9PrsSNz956igHFRhbNGsB8k0vSLe2U2NW1sxRVZm_dwR27Q4Db_f21XbYkLtfiRYob-j4ran1rTBB0bQAz4QLFSO1yL_cPhDIpAyvC069mDQ33-ckZgZ_yvFsIK_-_0Jj2NEOnDie684uaR7vKuiBWlsr-JmAsPzUp7Aik7Qbzozune348nBz1bvWkBNMCpMO3JV8hrYBo1i6JlUiGSuP3-5fWXKt8dKhxPUN-amjLFgh",
+ "https://lh3.googleusercontent.com/aida-public/AB6AXuCh5O9cz1ruQFH0Pq3MzC_1HsWrLPHbWlfYEdB2dmPi0YDn2L23R5hseUZmb19XlEju1n4a24oD6pH5qiG4SvIemrD45PfKwvNlckpOG59IYz5WYrHzroq7L4Uq9Hxl0PTzU5m8R5k625w_MrdZKidyfM6OnzNJfM5J3XftFI5A9J7wD_BDHRKxq8gxAukUCesuYX8lGm3AhQAZQTjaUY5yeobjt-NCSrlfTzxmcUmibJSTnKZuwx-li4QtFr0wQrzHVLUZYiAhA251",
+ "https://lh3.googleusercontent.com/aida-public/AB6AXuCanfiWzXqH3fBrE6U3phirIFZo5bgKG1aa8wnXCRC12yOXkcgnGUTRhxppIk61QUdQWF9KuFAtjhDEI9AACV-pM7yXyPKbOKognCARD-qbffFtCwGLidcLkoprLnNAW12C7TeRL6gOEBas3RI7jCf30JmzMmSqCjMx3lixgrOr6qlpbHZA4Eog_P41y5zXtn9Nqlq2eB6c7RYhiOIJzXVpMmfLR_qf0HTmOnx2poDbqKcLDcCM-p4S6aAwLxC-GYBmvEfWQ4meToCL"
+ ]}
>
<:title>{@space.name}
<:description>
@@ -38,43 +42,6 @@
/>
-
-
-
-
- Interactive Floor Plan
-
-
- Select any desk to customize your booking details on the next page, where availability is confirmed.
-
-
-
-
- <.icon name="hero-building-office-2" class="w-32 h-32 text-secondary" />
-
-
-
- <.link
- :for={asset <- @assets}
- navigate={~p"/book/asset/#{asset.id}"}
- class="group relative flex flex-col items-center gap-3 cursor-pointer"
- >
-
- <.icon
- name="hero-computer-desktop"
- class="w-12 h-12 text-base-300 group-hover:text-secondary transition-colors"
- />
-
-
- {asset.name}
-
-
-
-
-
-
-
-
<.house_rules title="Coworking House Rules">
<:rule>Please keep phone calls to the dedicated booths.
<:rule>Clean your desk area before leaving.
diff --git a/lib/spazio_solazzo_web/live/landing/meeting_live.ex b/lib/spazio_solazzo_web/live/landing/meeting_live.ex
index bb0e668..64f48cb 100644
--- a/lib/spazio_solazzo_web/live/landing/meeting_live.ex
+++ b/lib/spazio_solazzo_web/live/landing/meeting_live.ex
@@ -2,24 +2,12 @@ defmodule SpazioSolazzoWeb.MeetingLive do
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
+ # Landing page components
import SpazioSolazzoWeb.LandingComponents
def mount(_params, _session, socket) do
{:ok, space} = BookingSystem.get_space_by_slug("meeting")
- {:ok, asset} = BookingSystem.get_asset_by_space_id(space.id)
- images = [
- "https://lh3.googleusercontent.com/aida-public/AB6AXuDmh_AkVuUoICqpHk1NdLuLdi0xQBOC8Hy9PrsSNz956igHFRhbNGsB8k0vSLe2U2NW1sxRVZm_dwR27Q4Db_f21XbYkLtfiRYob-j4ran1rTBB0bQAz4QLFSO1yL_cPhDIpAyvC069mDQ33-ckZgZ_yvFsIK_-_0Jj2NEOnDie684uaR7vKuiBWlsr-JmAsPzUp7Aik7Qbzozune348nBz1bvWkBNMCpMO3JV8hrYBo1i6JlUiGSuP3-5fWXKt8dKhxPUN-amjLFgh",
- "https://lh3.googleusercontent.com/aida-public/AB6AXuCh5O9cz1ruQFH0Pq3MzC_1HsWrLPHbWlfYEdB2dmPi0YDn2L23R5hseUZmb19XlEju1n4a24oD6pH5qiG4SvIemrD45PfKwvNlckpOG59IYz5WYrHzroq7L4Uq9Hxl0PTzU5m8R5k625w_MrdZKidyfM6OnzNJfM5J3XftFI5A9J7wD_BDHRKxq8gxAukUCesuYX8lGm3AhQAZQTjaUY5yeobjt-NCSrlfTzxmcUmibJSTnKZuwx-li4QtFr0wQrzHVLUZYiAhA251",
- "https://lh3.googleusercontent.com/aida-public/AB6AXuCanfiWzXqH3fBrE6U3phirIFZo5bgKG1aa8wnXCRC12yOXkcgnGUTRhxppIk61QUdQWF9KuFAtjhDEI9AACV-pM7yXyPKbOKognCARD-qbffFtCwGLidcLkoprLnNAW12C7TeRL6gOEBas3RI7jCf30JmzMmSqCjMx3lixgrOr6qlpbHZA4Eog_P41y5zXtn9Nqlq2eB6c7RYhiOIJzXVpMmfLR_qf0HTmOnx2poDbqKcLDcCM-p4S6aAwLxC-GYBmvEfWQ4meToCL"
- ]
-
- {:ok,
- socket
- |> assign(
- space: space,
- asset: asset,
- images: images
- )}
+ {:ok, assign(socket, space: space)}
end
end
diff --git a/lib/spazio_solazzo_web/live/landing/meeting_live.html.heex b/lib/spazio_solazzo_web/live/landing/meeting_live.html.heex
index 172f1f6..3bdcc27 100644
--- a/lib/spazio_solazzo_web/live/landing/meeting_live.html.heex
+++ b/lib/spazio_solazzo_web/live/landing/meeting_live.html.heex
@@ -1,62 +1,49 @@
<.page_header
- booking_path={~p"/book/asset/#{@asset.id}"}
- price="€40"
+ booking_path={~p"/book/space/#{@space.slug}"}
+ booking_label="Book Space"
+ price="€35"
price_unit="hour"
- capacity="Up to 8 People"
- images={@images}
+ capacity={@space.capacity}
+ images={[
+ "https://lh3.googleusercontent.com/aida-public/AB6AXuDmh_AkVuUoICqpHk1NdLuLdi0xQBOC8Hy9PrsSNz956igHFRhbNGsB8k0vSLe2U2NW1sxRVZm_dwR27Q4Db_f21XbYkLtfiRYob-j4ran1rTBB0bQAz4QLFSO1yL_cPhDIpAyvC069mDQ33-ckZgZ_yvFsIK_-_0Jj2NEOnDie684uaR7vKuiBWlsr-JmAsPzUp7Aik7Qbzozune348nBz1bvWkBNMCpMO3JV8hrYBo1i6JlUiGSuP3-5fWXKt8dKhxPUN-amjLFgh",
+ "https://lh3.googleusercontent.com/aida-public/AB6AXuCh5O9cz1ruQFH0Pq3MzC_1HsWrLPHbWlfYEdB2dmPi0YDn2L23R5hseUZmb19XlEju1n4a24oD6pH5qiG4SvIemrD45PfKwvNlckpOG59IYz5WYrHzroq7L4Uq9Hxl0PTzU5m8R5k625w_MrdZKidyfM6OnzNJfM5J3XftFI5A9J7wD_BDHRKxq8gxAukUCesuYX8lGm3AhQAZQTjaUY5yeobjt-NCSrlfTzxmcUmibJSTnKZuwx-li4QtFr0wQrzHVLUZYiAhA251",
+ "https://lh3.googleusercontent.com/aida-public/AB6AXuCanfiWzXqH3fBrE6U3phirIFZo5bgKG1aa8wnXCRC12yOXkcgnGUTRhxppIk61QUdQWF9KuFAtjhDEI9AACV-pM7yXyPKbOKognCARD-qbffFtCwGLidcLkoprLnNAW12C7TeRL6gOEBas3RI7jCf30JmzMmSqCjMx3lixgrOr6qlpbHZA4Eog_P41y5zXtn9Nqlq2eB6c7RYhiOIJzXVpMmfLR_qf0HTmOnx2poDbqKcLDcCM-p4S6aAwLxC-GYBmvEfWQ4meToCL"
+ ]}
>
<:title>{@space.name}
<:description>
- A private, sun-drenched sanctuary designed for focus and collaboration. Step into a space where Sicilian charm meets modern productivity.
+ A private, professional space equipped for your team meetings, client presentations, or brainstorming sessions.
<.features_section
- title="Everything you need to succeed"
- description="We've equipped the Meeting Room with top-tier amenities so you can focus on the agenda, not the logistics."
+ title="Built for collaboration"
+ description="A dedicated meeting environment with all the professional amenities you need."
>
<:feature
- icon="hero-tv"
- title="4K Presentation"
- description="Crystal clear 65" monitor ready for your slide decks. Connect via HDMI or wireless casting."
- color="sky"
+ icon="hero-presentation-chart-line"
+ title="Pro Presentation Setup"
+ description="75-inch 4K display, wireless presentation tools, and high-quality audio system."
+ color="blue"
/>
<:feature
icon="hero-video-camera"
title="Video Conferencing"
- description="Logitech Rally bar with AI framing and noise cancellation for seamless remote meetings."
- color="orange"
- />
- <:feature
- icon="hero-pencil"
- title="Creative Tools"
- description="Wall-to-wall glass whiteboard, sticky notes, and markers to capture every brainstorming session."
- color="yellow"
- />
- <:feature
- icon="hero-wifi"
- title="Fiber Internet"
- description="Dedicated 1Gbps symmetrical fiber line ensuring you never drop a call or buffer a video."
+ description="Professional webcam and microphone setup optimized for hybrid meetings."
color="emerald"
/>
- <:feature
- icon="hero-home"
- title="Ergonomic Comfort"
- description="Herman Miller chairs and a solid oak table designed to keep you comfortable during long sessions."
- color="indigo"
- />
<:feature
icon="hero-cake"
- title="Catering Available"
- description="Pre-order coffee carafes, Sicilian pastries, or light lunch options from local partners."
+ title="Hospitality Station"
+ description="Coffee, tea, and water service to keep your team refreshed and focused."
color="purple"
/>
- <.house_rules title="House Rules">
- <:rule>Please clean the whiteboard after use.
- <:rule>Outside food is allowed, but please be tidy.
- <:rule>Cancel up to 24 hours before for a full refund.
+ <.house_rules title="Meeting Room Guidelines">
+ <:rule>Please arrive on time and end on schedule.
+ <:rule>Reset the room to its original setup after use.
+ <:rule>Technical support is available upon request.
diff --git a/lib/spazio_solazzo_web/live/landing/music_live.ex b/lib/spazio_solazzo_web/live/landing/music_live.ex
index 0ac939f..f80e91c 100644
--- a/lib/spazio_solazzo_web/live/landing/music_live.ex
+++ b/lib/spazio_solazzo_web/live/landing/music_live.ex
@@ -2,25 +2,12 @@ defmodule SpazioSolazzoWeb.MusicLive do
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
-
+ # Landing page components
import SpazioSolazzoWeb.LandingComponents
def mount(_params, _session, socket) do
{:ok, space} = BookingSystem.get_space_by_slug("music")
- {:ok, asset} = BookingSystem.get_asset_by_space_id(space.id)
- images = [
- "https://lh3.googleusercontent.com/aida-public/AB6AXuD1wkxK48dk7i5XYX6JL-O1egrsdLjcmOg7N4EB76QtUvhzR7lZQadprIT9rLPsroUjazftFRpp_z26wb8lHaUW9XyucGlKG3qG40oT5iaKWwqVI1drNKJDJgVBkmNjw4u_D5vig_C1pf6bgGZnPaOV2tnnmlexxHJIDQYZzfg1GGwgywBvpGLz_u2_jvkbyMo3_m5roM09PjonFEfGIHxjm0vClW1DAOX45IrT87A85OdAXEu2EPyB8oW9WzmolOn4DFj22vKWSbVD",
- "https://lh3.googleusercontent.com/aida-public/AB6AXuB3fJu4mgZaw8GP1OC2SjquJZJmnRlY_OHD4fO4AAd_KHd5BYnW1i0egrskoEfK_uCdK4pQu5kMf8pF9h_KXE0wYQAROnTBTJ4YmBpHui9nv8wz44VENo2p-lA3rW8xhQhiYzlAhHJlhgOdZluVp9eYvsZxGM76QkDXMcBQz1Ka5ZfRMNgddo1RS76IPaxbQIvpOh_55uW87bAiGAvhcE8GrIi2ugpiJ64Rdou1uZLD1bPWxUvyLtpTFFLr2vfVjq7OpVYiGnLaGstS",
- "https://lh3.googleusercontent.com/aida-public/AB6AXuBVY6j_kvSUC0trHVnwvszpxZa58CpY0sGTF6m6lPQJkFlN-GnK1ofNaSn8PU1JnmPPAl7B196LoYq4SfawlDFrg2ADKKr65cOE0jq2L9w-cXrkPxE4poylrIeKX8zP1JsIS5obvU5_HAG074RjeYSWFsV5Z7wQF0ktZlYL6m462hsl-xdLQWQiBLZOHNsBf6jrZieUst9dKUlec6hzWOqcbIXuugBJW5fklJmMti9CDQynn1XZ5I5gZYEL47tW2Ku9u_zEEpfqmEKF"
- ]
-
- {:ok,
- socket
- |> assign(
- space: space,
- asset: asset,
- images: images
- )}
+ {:ok, assign(socket, space: space)}
end
end
diff --git a/lib/spazio_solazzo_web/live/landing/music_live.html.heex b/lib/spazio_solazzo_web/live/landing/music_live.html.heex
index 00b20ca..f9fde8a 100644
--- a/lib/spazio_solazzo_web/live/landing/music_live.html.heex
+++ b/lib/spazio_solazzo_web/live/landing/music_live.html.heex
@@ -1,62 +1,49 @@
<.page_header
- booking_path={~p"/book/asset/#{@asset.id}"}
- price="€25"
+ booking_path={~p"/book/space/#{@space.slug}"}
+ booking_label="Book Space"
+ price="€50"
price_unit="hour"
- capacity="Up to 5 People"
- images={@images}
+ capacity={@space.capacity}
+ images={[
+ "https://lh3.googleusercontent.com/aida-public/AB6AXuD1wkxK48dk7i5XYX6JL-O1egrsdLjcmOg7N4EB76QtUvhzR7lZQadprIT9rLPsroUjazftFRpp_z26wb8lHaUW9XyucGlKG3qG40oT5iaKWwqVI1drNKJDJgVBkmNjw4u_D5vig_C1pf6bgGZnPaOV2tnnmlexxHJIDQYZzfg1GGwgywBvpGLz_u2_jvkbyMo3_m5roM09PjonFEfGIHxjm0vClW1DAOX45IrT87A85OdAXEu2EPyB8oW9WzmolOn4DFj22vKWSbVD",
+ "https://lh3.googleusercontent.com/aida-public/AB6AXuB3fJu4mgZaw8GP1OC2SjquJZJmnRlY_OHD4fO4AAd_KHd5BYnW1i0egrskoEfK_uCdK4pQu5kMf8pF9h_KXE0wYQAROnTBTJ4YmBpHui9nv8wz44VENo2p-lA3rW8xhQhiYzlAhHJlhgOdZluVp9eYvsZxGM76QkDXMcBQz1Ka5ZfRMNgddo1RS76IPaxbQIvpOh_55uW87bAiGAvhcE8GrIi2ugpiJ64Rdou1uZLD1bPWxUvyLtpTFFLr2vfVjq7OpVYiGnLaGstS",
+ "https://lh3.googleusercontent.com/aida-public/AB6AXuBVY6j_kvSUC0trHVnwvszpxZa58CpY0sGTF6m6lPQJkFlN-GnK1ofNaSn8PU1JnmPPAl7B196LoYq4SfawlDFrg2ADKKr65cOE0jq2L9w-cXrkPxE4poylrIeKX8zP1JsIS5obvU5_HAG074RjeYSWFsV5Z7wQF0ktZlYL6m462hsl-xdLQWQiBLZOHNsBf6jrZieUst9dKUlec6hzWOqcbIXuugBJW5fklJmMti9CDQynn1XZ5I5gZYEL47tW2Ku9u_zEEpfqmEKF"
+ ]}
>
<:title>{@space.name}
<:description>
- A relaxed, creative space for jamming, practice sessions, or just unwinding with instruments. A casual vibe, not a soundproof studio, perfect for connecting through music.
+ A professionally designed music rehearsal and recording space in the heart of Palermo. Perfect for bands, solo artists, and content creators.
<.features_section
- title="Jam, Practice, Create"
- description="The Music Room is equipped with essentials for a good session. It's not a pro studio, but it has soul and everything you need to start playing."
+ title="Professional Audio Environment"
+ description="State-of-the-art equipment and acoustically treated space for your creative sessions."
>
- <:feature
- icon="hero-musical-note"
- title="House Instruments"
- description="Includes a digital piano, acoustic guitar, and a basic drum kit ready for your use."
- color="sky"
- />
<:feature
icon="hero-speaker-wave"
- title="PA System"
- description="Two active speakers and a simple 4-channel mixer to plug in vocals or keyboards."
- color="orange"
- />
- <:feature
- icon="hero-home"
- title="Relaxed Vibe"
- description="Comfortable seating, warm lighting, and rugs creates a cozy atmosphere for creative flow."
- color="yellow"
+ title="Premium Sound System"
+ description="High-end monitors, professional mixing console, and complete PA system ready to use."
+ color="violet"
/>
<:feature
icon="hero-microphone"
- title="Basic Mics"
- description="Two Shure SM58 microphones with stands available for vocals or acoustic instruments."
- color="emerald"
- />
- <:feature
- icon="hero-speaker-x-mark"
- title="Not Soundproof"
- description="Please note this is a community space. Some sound bleed occurs; keep volumes reasonable."
- color="indigo"
+ title="Recording Ready"
+ description="Multi-track recording capability with professional condenser microphones and interfaces."
+ color="pink"
/>
<:feature
icon="hero-musical-note"
- title="Bring Your Gear"
- description="Feel free to bring your own amps, pedals, or specific instruments to dial in your tone."
- color="purple"
+ title="Full Backline"
+ description="Drum kit, bass and guitar amplifiers, keyboards - everything you need to play."
+ color="orange"
/>
- <.house_rules title="Jam Session Rules">
- <:rule>Reset instruments to their original places.
- <:rule>No drinks on top of the piano or amps.
- <:rule>Be mindful of volume for our coworking neighbors.
+ <.house_rules title="Music Studio Guidelines">
+ <:rule>Respect noise level limits and time slots.
+ <:rule>Handle equipment with care and return items to their places.
+ <:rule>Clean up after your session.
diff --git a/lib/spazio_solazzo_web/live/user/profile_live.html.heex b/lib/spazio_solazzo_web/live/user/profile_live.html.heex
index d8d22d5..7ec3312 100644
--- a/lib/spazio_solazzo_web/live/user/profile_live.html.heex
+++ b/lib/spazio_solazzo_web/live/user/profile_live.html.heex
@@ -1,11 +1,9 @@
-
- <.back_to_link
- navigate={~p"/"}
- value="Back to Home"
- />
-
+ <.back_to_link
+ navigate={~p"/"}
+ value="Back to Home"
+ />
diff --git a/lib/spazio_solazzo_web/router.ex b/lib/spazio_solazzo_web/router.ex
index 59397de..41b70d0 100644
--- a/lib/spazio_solazzo_web/router.ex
+++ b/lib/spazio_solazzo_web/router.ex
@@ -25,8 +25,6 @@ defmodule SpazioSolazzoWeb.Router do
scope "/", SpazioSolazzoWeb do
pipe_through :browser
- get "/bookings/confirm", BookingController, :confirm
- get "/bookings/cancel", BookingController, :cancel
get "/sign-out", AuthController, :sign_out
get "/auth/magic/sign-in", AuthController, :magic_sign_in
get "/auth/failure", AuthController, :auth_failure
@@ -53,7 +51,8 @@ defmodule SpazioSolazzoWeb.Router do
on_mount: [
{SpazioSolazzoWeb.LiveUserAuth, :live_user_required}
] do
- live "/book/asset/:asset_id", AssetBookingLive
+ live "/book/space/:space_slug", SpaceBookingLive
+ live "/bookings/cancel", BookingCancellationLive
live "/profile", ProfileLive
end
@@ -62,6 +61,8 @@ defmodule SpazioSolazzoWeb.Router do
{SpazioSolazzoWeb.LiveUserAuth, :live_admin_required}
] do
live "/admin/dashboard", Admin.DashboardLive
+ live "/admin/bookings", Admin.BookingManagementLive
+ live "/admin/walk-in", Admin.WalkInLive
end
end
@@ -81,14 +82,4 @@ defmodule SpazioSolazzoWeb.Router do
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
-
- if Application.compile_env(:spazio_solazzo, :dev_routes) do
- import AshAdmin.Router
-
- scope "/admin" do
- pipe_through :browser
-
- ash_admin "/"
- end
- end
end
diff --git a/mix.exs b/mix.exs
index 84fbe4c..0c718aa 100644
--- a/mix.exs
+++ b/mix.exs
@@ -50,7 +50,6 @@ defmodule SpazioSolazzo.MixProject do
defp deps do
[
{:ash, "~> 3.0"},
- {:ash_admin, "~> 0.13"},
{:ash_authentication, "~> 4.0"},
{:ash_authentication_phoenix, "~> 2.0"},
{:ash_phoenix, "~> 2.0"},
diff --git a/mix.lock b/mix.lock
index 007fe94..a6074e9 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,6 +1,5 @@
%{
"ash": {:hex, :ash, "3.12.0", "5b78000df650d86b446d88977ef8aa5c9d9f7ffa1193fa3c4b901c60bff2d130", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7cf45b4eb83aa0ab5e6707d6e4ea4a10c29ab20613c87f06344f7953b2ca5e18"},
- "ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"},
"ash_authentication": {:hex, :ash_authentication, "4.13.6", "95b17f0bfc00bd6e229145b90c7026f784ae81639e832de4b5c96a738de5ed46", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "27ded84bdc61fd267794dee17a6cbe6e52d0f365d3e8ea0460d95977b82ac6f1"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.14.1", "60d127a73c2144b39fa3dab045cc3f7fce0c3ccd2dd3e8534288f5da65f0c1db", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "3cd57aee855be3ccf2960ce0b005ad209c97fbfc81faa71212bcfbd6a4a90cae"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.18", "fad1b8af1405758888086de568f08650c2911ee97074cfe2e325b14854bc43dd", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7ec28f9216221e83b90d9c3605e9d1cdd228984e09a1a86c9b9d393cebf25222"},
diff --git a/priv/repo/migrations/20260115004442_set_phone_number_as_nullable.exs b/priv/repo/migrations/20260115004442_set_phone_number_as_nullable.exs
deleted file mode 100644
index 8ddc4d0..0000000
--- a/priv/repo/migrations/20260115004442_set_phone_number_as_nullable.exs
+++ /dev/null
@@ -1,29 +0,0 @@
-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
diff --git a/priv/repo/migrations/20260118203753_add_role_to_users.exs b/priv/repo/migrations/20260118203753_add_role_to_users.exs
deleted file mode 100644
index 72fa527..0000000
--- a/priv/repo/migrations/20260118203753_add_role_to_users.exs
+++ /dev/null
@@ -1,21 +0,0 @@
-defmodule SpazioSolazzo.Repo.Migrations.AddRoleToUsers 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
- add :role, :text, null: false, default: "customer"
- end
- end
-
- def down do
- alter table(:users) do
- remove :role
- end
- end
-end
diff --git a/priv/repo/migrations/20260109010248_add_oban.exs b/priv/repo/migrations/20260202013632_add_oban.exs
similarity index 100%
rename from priv/repo/migrations/20260109010248_add_oban.exs
rename to priv/repo/migrations/20260202013632_add_oban.exs
diff --git a/priv/repo/migrations/20260109010256_setup_resources_extensions_1.exs b/priv/repo/migrations/20260202013642_create_base_resources_extensions_1.exs
similarity index 98%
rename from priv/repo/migrations/20260109010256_setup_resources_extensions_1.exs
rename to priv/repo/migrations/20260202013642_create_base_resources_extensions_1.exs
index f079638..44071ac 100644
--- a/priv/repo/migrations/20260109010256_setup_resources_extensions_1.exs
+++ b/priv/repo/migrations/20260202013642_create_base_resources_extensions_1.exs
@@ -1,4 +1,4 @@
-defmodule SpazioSolazzo.Repo.Migrations.SetupResourcesExtensions1 do
+defmodule SpazioSolazzo.Repo.Migrations.CreateBaseResourcesExtensions1 do
@moduledoc """
Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback
diff --git a/priv/repo/migrations/20260109010257_setup_resources.exs b/priv/repo/migrations/20260202013643_create_base_resources.exs
similarity index 65%
rename from priv/repo/migrations/20260109010257_setup_resources.exs
rename to priv/repo/migrations/20260202013643_create_base_resources.exs
index ec5129f..839a93b 100644
--- a/priv/repo/migrations/20260109010257_setup_resources.exs
+++ b/priv/repo/migrations/20260202013643_create_base_resources.exs
@@ -1,4 +1,4 @@
-defmodule SpazioSolazzo.Repo.Migrations.SetupResources do
+defmodule SpazioSolazzo.Repo.Migrations.CreateBaseResources do
@moduledoc """
Updates resources based on their most recent snapshots.
@@ -12,7 +12,8 @@ defmodule SpazioSolazzo.Repo.Migrations.SetupResources do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
add :email, :citext, null: false
add :name, :text, null: false
- add :phone_number, :text, null: false
+ add :phone_number, :text
+ add :role, :text, null: false, default: "customer"
end
create unique_index(:users, [:email], name: "users_unique_email_index")
@@ -59,6 +60,7 @@ defmodule SpazioSolazzo.Repo.Migrations.SetupResources do
add :name, :text, null: false
add :description, :text, null: false
add :slug, :text, null: false
+ add :capacity, :bigint, null: false
end
create unique_index(:spaces, [:name], name: "spaces_unique_name_index")
@@ -67,14 +69,18 @@ defmodule SpazioSolazzo.Repo.Migrations.SetupResources do
create table(:bookings, primary_key: false) do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
+ add :start_datetime, :utc_datetime, null: false
+ add :end_datetime, :utc_datetime, null: false
add :date, :date, null: false
add :customer_name, :text, null: false
add :customer_email, :text, null: false
add :start_time, :time, null: false
add :end_time, :time, null: false
- add :customer_phone, :text, null: false
+ add :customer_phone, :text
add :customer_comment, :text
- add :state, :text, null: false, default: "reserved"
+ add :cancellation_reason, :text
+ add :rejection_reason, :text
+ add :state, :text, null: false, default: "requested"
add :inserted_at, :utc_datetime_usec,
null: false,
@@ -84,87 +90,34 @@ defmodule SpazioSolazzo.Repo.Migrations.SetupResources do
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
- add :asset_id, :uuid
- add :time_slot_template_id, :uuid
- add :user_id, :uuid
- end
-
- create table(:assets, primary_key: false) do
- add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
- end
-
- alter table(:bookings) do
- modify :asset_id,
- references(:assets,
- column: :id,
- name: "bookings_asset_id_fkey",
- type: :uuid,
- prefix: "public"
- )
-
- modify :time_slot_template_id,
- references(:time_slot_templates,
- column: :id,
- name: "bookings_time_slot_template_id_fkey",
- type: :uuid,
- prefix: "public"
- )
-
- modify :user_id,
- references(:users,
- column: :id,
- name: "bookings_user_id_fkey",
- type: :uuid,
- prefix: "public",
- on_delete: :nilify_all
- )
- end
-
- create index(:bookings, [:user_id])
-
- alter table(:assets) do
- add :name, :text, null: false
-
add :space_id,
references(:spaces,
column: :id,
- name: "assets_space_id_fkey",
+ name: "bookings_space_id_fkey",
type: :uuid,
prefix: "public"
), null: false
+
+ add :user_id,
+ references(:users,
+ column: :id,
+ name: "bookings_user_id_fkey",
+ type: :uuid,
+ prefix: "public",
+ on_delete: :nilify_all
+ )
end
- create unique_index(:assets, [:name, :space_id], name: "assets_unique_name_per_space_index")
+ create index(:bookings, [:user_id])
end
def down do
- drop_if_exists unique_index(:assets, [:name, :space_id],
- name: "assets_unique_name_per_space_index"
- )
-
- drop constraint(:assets, "assets_space_id_fkey")
-
- alter table(:assets) do
- remove :space_id
- remove :name
- end
-
drop_if_exists index(:bookings, [:user_id])
- drop constraint(:bookings, "bookings_asset_id_fkey")
-
- drop constraint(:bookings, "bookings_time_slot_template_id_fkey")
+ drop constraint(:bookings, "bookings_space_id_fkey")
drop constraint(:bookings, "bookings_user_id_fkey")
- alter table(:bookings) do
- modify :user_id, :uuid
- modify :time_slot_template_id, :uuid
- modify :asset_id, :uuid
- end
-
- drop table(:assets)
-
drop table(:bookings)
drop_if_exists unique_index(:spaces, [:slug], name: "spaces_unique_slug_index")
@@ -172,6 +125,7 @@ defmodule SpazioSolazzo.Repo.Migrations.SetupResources do
drop_if_exists unique_index(:spaces, [:name], name: "spaces_unique_name_index")
alter table(:spaces) do
+ remove :capacity
remove :slug
remove :description
remove :name
diff --git a/priv/repo/migrations/20260202155008_add_booking_indexes.exs b/priv/repo/migrations/20260202155008_add_booking_indexes.exs
new file mode 100644
index 0000000..2133001
--- /dev/null
+++ b/priv/repo/migrations/20260202155008_add_booking_indexes.exs
@@ -0,0 +1,29 @@
+defmodule SpazioSolazzo.Repo.Migrations.AddBookingIndexes 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
+ create index(:bookings, [:end_datetime])
+
+ create index(:bookings, [:start_datetime])
+
+ create index(:bookings, [:space_id, :state])
+
+ create index(:bookings, [:space_id, :start_datetime, :end_datetime])
+ end
+
+ def down do
+ drop_if_exists index(:bookings, [:space_id, :start_datetime, :end_datetime])
+
+ drop_if_exists index(:bookings, [:space_id, :state])
+
+ drop_if_exists index(:bookings, [:start_datetime])
+
+ drop_if_exists index(:bookings, [:end_datetime])
+ end
+end
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
index 1d3816f..7050d3f 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -17,45 +17,39 @@ case BookingSystem.Space |> Ash.read() do
:ok
end
-# Create Coworking Space
+# Create Coworking Space (capacity: 10)
coworking =
- BookingSystem.create_space!("Arcipelago", "coworking", "Flexible desk spaces for remote work")
+ BookingSystem.create_space!(
+ "Arcipelago",
+ "coworking",
+ "Flexible desk spaces for remote work",
+ 10
+ )
IO.puts("✓ Created Coworking space")
-# Create Meeting Room Space
+# Create Meeting Room Space (capacity: 1)
meeting =
BookingSystem.create_space!(
"Media room",
"meeting",
- "Private conference room for your meetings"
+ "Private conference room for your meetings",
+ 1
)
IO.puts("✓ Created Meeting Room space")
-# Create Music Studio Space
-music = BookingSystem.create_space!("Hall", "music", "Tailored for band rehearsals.")
+# Create Music Studio Space (capacity: 1)
+music =
+ BookingSystem.create_space!(
+ "Hall",
+ "music",
+ "Tailored for band rehearsals.",
+ 1
+ )
IO.puts("✓ Created Music Studio space")
-# Create Coworking Tables (Assets)
-tables =
- for i <- 1..5 do
- BookingSystem.create_asset!("Table #{i}", coworking.id)
- end
-
-IO.puts("✓ Created #{length(tables)} coworking tables")
-
-# Create Meeting Room Asset
-BookingSystem.create_asset!("Main Conference Room", meeting.id)
-
-IO.puts("✓ Created meeting room asset")
-
-# Create Music Studio Asset
-BookingSystem.create_asset!("Recording Studio", music.id)
-
-IO.puts("✓ Created music studio asset")
-
# Create Coworking Time Slot Templates for each weekday
coworking_slots = [
%{start_time: ~T[09:00:00], end_time: ~T[13:00:00]},
diff --git a/priv/resource_snapshots/repo/assets/20260109010257.json b/priv/resource_snapshots/repo/assets/20260109010257.json
deleted file mode 100644
index 0f57500..0000000
--- a/priv/resource_snapshots/repo/assets/20260109010257.json
+++ /dev/null
@@ -1,93 +0,0 @@
-{
- "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": "name",
- "type": "text"
- },
- {
- "allow_nil?": false,
- "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": "assets_space_id_fkey",
- "on_delete": null,
- "on_update": null,
- "primary_key?": true,
- "schema": "public",
- "table": "spaces"
- },
- "scale": null,
- "size": null,
- "source": "space_id",
- "type": "uuid"
- }
- ],
- "base_filter": null,
- "check_constraints": [],
- "custom_indexes": [],
- "custom_statements": [],
- "has_create_action": true,
- "hash": "9FAD08E854AEBC8A2C7C7466BBC5254CAFB16C2A860034688C3C1142E183052C",
- "identities": [
- {
- "all_tenants?": false,
- "base_filter": null,
- "index_name": "assets_unique_name_per_space_index",
- "keys": [
- {
- "type": "atom",
- "value": "name"
- },
- {
- "type": "atom",
- "value": "space_id"
- }
- ],
- "name": "unique_name_per_space",
- "nils_distinct?": true,
- "where": null
- }
- ],
- "multitenancy": {
- "attribute": null,
- "global": null,
- "strategy": null
- },
- "repo": "Elixir.SpazioSolazzo.Repo",
- "schema": null,
- "table": "assets"
-}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/bookings/20260109010257.json b/priv/resource_snapshots/repo/bookings/20260202013644.json
similarity index 81%
rename from priv/resource_snapshots/repo/bookings/20260109010257.json
rename to priv/resource_snapshots/repo/bookings/20260202013644.json
index 3f998c8..a42c8d6 100644
--- a/priv/resource_snapshots/repo/bookings/20260109010257.json
+++ b/priv/resource_snapshots/repo/bookings/20260202013644.json
@@ -12,6 +12,30 @@
"source": "id",
"type": "uuid"
},
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "start_datetime",
+ "type": "utc_datetime"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "end_datetime",
+ "type": "utc_datetime"
+ },
{
"allow_nil?": false,
"default": "nil",
@@ -73,7 +97,7 @@
"type": "time"
},
{
- "allow_nil?": false,
+ "allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
@@ -96,9 +120,33 @@
"source": "customer_comment",
"type": "text"
},
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "cancellation_reason",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "rejection_reason",
+ "type": "text"
+ },
{
"allow_nil?": false,
- "default": "\"reserved\"",
+ "default": "\"requested\"",
"generated?": false,
"precision": null,
"primary_key?": false,
@@ -133,7 +181,7 @@
"type": "utc_datetime_usec"
},
{
- "allow_nil?": true,
+ "allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
@@ -151,47 +199,16 @@
"global": null,
"strategy": null
},
- "name": "bookings_asset_id_fkey",
+ "name": "bookings_space_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
- "table": "assets"
+ "table": "spaces"
},
"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",
+ "source": "space_id",
"type": "uuid"
},
{
@@ -231,7 +248,7 @@
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
- "hash": "D4E1D8A61AAAA83530EF07DE6BB15175AE052ADE62D06E538C222576218F0289",
+ "hash": "CBA4F1C24A9B1AB8A2E4EF42917C1DE5F143C74D0D175D1A29156ECCE6FD5660",
"identities": [],
"multitenancy": {
"attribute": null,
diff --git a/priv/resource_snapshots/repo/bookings/20260115004442.json b/priv/resource_snapshots/repo/bookings/20260202155008.json
similarity index 61%
rename from priv/resource_snapshots/repo/bookings/20260115004442.json
rename to priv/resource_snapshots/repo/bookings/20260202155008.json
index df0e822..9468710 100644
--- a/priv/resource_snapshots/repo/bookings/20260115004442.json
+++ b/priv/resource_snapshots/repo/bookings/20260202155008.json
@@ -12,6 +12,30 @@
"source": "id",
"type": "uuid"
},
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "start_datetime",
+ "type": "utc_datetime"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "end_datetime",
+ "type": "utc_datetime"
+ },
{
"allow_nil?": false,
"default": "nil",
@@ -96,9 +120,33 @@
"source": "customer_comment",
"type": "text"
},
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "cancellation_reason",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "rejection_reason",
+ "type": "text"
+ },
{
"allow_nil?": false,
- "default": "\"reserved\"",
+ "default": "\"requested\"",
"generated?": false,
"precision": null,
"primary_key?": false,
@@ -133,7 +181,7 @@
"type": "utc_datetime_usec"
},
{
- "allow_nil?": true,
+ "allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
@@ -151,47 +199,16 @@
"global": null,
"strategy": null
},
- "name": "bookings_asset_id_fkey",
+ "name": "bookings_space_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
- "table": "assets"
+ "table": "spaces"
},
"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",
+ "source": "space_id",
"type": "uuid"
},
{
@@ -228,10 +245,114 @@
],
"base_filter": null,
"check_constraints": [],
- "custom_indexes": [],
+ "custom_indexes": [
+ {
+ "all_tenants?": false,
+ "concurrently": false,
+ "error_fields": [
+ "space_id",
+ "start_datetime",
+ "end_datetime"
+ ],
+ "fields": [
+ {
+ "type": "atom",
+ "value": "space_id"
+ },
+ {
+ "type": "atom",
+ "value": "start_datetime"
+ },
+ {
+ "type": "atom",
+ "value": "end_datetime"
+ }
+ ],
+ "include": null,
+ "message": null,
+ "name": null,
+ "nulls_distinct": true,
+ "prefix": null,
+ "table": null,
+ "unique": false,
+ "using": null,
+ "where": null
+ },
+ {
+ "all_tenants?": false,
+ "concurrently": false,
+ "error_fields": [
+ "space_id",
+ "state"
+ ],
+ "fields": [
+ {
+ "type": "atom",
+ "value": "space_id"
+ },
+ {
+ "type": "atom",
+ "value": "state"
+ }
+ ],
+ "include": null,
+ "message": null,
+ "name": null,
+ "nulls_distinct": true,
+ "prefix": null,
+ "table": null,
+ "unique": false,
+ "using": null,
+ "where": null
+ },
+ {
+ "all_tenants?": false,
+ "concurrently": false,
+ "error_fields": [
+ "start_datetime"
+ ],
+ "fields": [
+ {
+ "type": "atom",
+ "value": "start_datetime"
+ }
+ ],
+ "include": null,
+ "message": null,
+ "name": null,
+ "nulls_distinct": true,
+ "prefix": null,
+ "table": null,
+ "unique": false,
+ "using": null,
+ "where": null
+ },
+ {
+ "all_tenants?": false,
+ "concurrently": false,
+ "error_fields": [
+ "end_datetime"
+ ],
+ "fields": [
+ {
+ "type": "atom",
+ "value": "end_datetime"
+ }
+ ],
+ "include": null,
+ "message": null,
+ "name": null,
+ "nulls_distinct": true,
+ "prefix": null,
+ "table": null,
+ "unique": false,
+ "using": null,
+ "where": null
+ }
+ ],
"custom_statements": [],
"has_create_action": true,
- "hash": "1480C13D76AD8CE079362CC851CF250063914A40A6CA48182E3D3B5D83CD174A",
+ "hash": "5DB2211C98FA47BC6D744D8FD763CC8920F067DFD092EC120CBD2115E26810CF",
"identities": [],
"multitenancy": {
"attribute": null,
diff --git a/priv/resource_snapshots/repo/spaces/20260109010257.json b/priv/resource_snapshots/repo/spaces/20260202013644.json
similarity index 85%
rename from priv/resource_snapshots/repo/spaces/20260109010257.json
rename to priv/resource_snapshots/repo/spaces/20260202013644.json
index 91a6104..4e02965 100644
--- a/priv/resource_snapshots/repo/spaces/20260109010257.json
+++ b/priv/resource_snapshots/repo/spaces/20260202013644.json
@@ -47,6 +47,18 @@
"size": null,
"source": "slug",
"type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "capacity",
+ "type": "bigint"
}
],
"base_filter": null,
@@ -54,7 +66,7 @@
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
- "hash": "29965B10015A4BDE39A648F85CE4FDB39DDFC21E0CB18903C7F9677E11B11D21",
+ "hash": "900D00B10E6347AEAB477B96DE6C11FCC9AD623D34E5DB404EA57E89E8A77E5B",
"identities": [
{
"all_tenants?": false,
diff --git a/priv/resource_snapshots/repo/time_slot_templates/20260109010257.json b/priv/resource_snapshots/repo/time_slot_templates/20260202013644.json
similarity index 100%
rename from priv/resource_snapshots/repo/time_slot_templates/20260109010257.json
rename to priv/resource_snapshots/repo/time_slot_templates/20260202013644.json
diff --git a/priv/resource_snapshots/repo/tokens/20260109010257.json b/priv/resource_snapshots/repo/tokens/20260202013644.json
similarity index 100%
rename from priv/resource_snapshots/repo/tokens/20260109010257.json
rename to priv/resource_snapshots/repo/tokens/20260202013644.json
diff --git a/priv/resource_snapshots/repo/users/20260109010257.json b/priv/resource_snapshots/repo/users/20260109010257.json
deleted file mode 100644
index 0d34ebc..0000000
--- a/priv/resource_snapshots/repo/users/20260109010257.json
+++ /dev/null
@@ -1,82 +0,0 @@
-{
- "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?": false,
- "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": "57A4F6BB98AF78EE399E42DAC6AAD95429A43D3A110B792DD6A05735591A5C62",
- "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"
-}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/users/20260115004442.json b/priv/resource_snapshots/repo/users/20260115004442.json
deleted file mode 100644
index e36391e..0000000
--- a/priv/resource_snapshots/repo/users/20260115004442.json
+++ /dev/null
@@ -1,82 +0,0 @@
-{
- "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"
-}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/users/20260118203753.json b/priv/resource_snapshots/repo/users/20260202013644.json
similarity index 96%
rename from priv/resource_snapshots/repo/users/20260118203753.json
rename to priv/resource_snapshots/repo/users/20260202013644.json
index abb00bd..d3389b1 100644
--- a/priv/resource_snapshots/repo/users/20260118203753.json
+++ b/priv/resource_snapshots/repo/users/20260202013644.json
@@ -66,7 +66,7 @@
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
- "hash": "0B279BA9251EA91451BB80DF86AF81ECCA5395011864BE01BE1C369420AD9C18",
+ "hash": "8150DCC90B96652CEC9629DE7283AED0E40B0E9A860BE5B1F25BC7E6BF8C0570",
"identities": [
{
"all_tenants?": false,
diff --git a/test/spazio_solazzo/accounts/user_test.exs b/test/spazio_solazzo/accounts/user_test.exs
index 7e423be..770810d 100644
--- a/test/spazio_solazzo/accounts/user_test.exs
+++ b/test/spazio_solazzo/accounts/user_test.exs
@@ -43,14 +43,15 @@ 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 = register_user("delete@example.com")
- {_space, asset, time_slot} = create_booking_fixtures()
+ {space, _time_slot} = create_booking_fixtures()
{:ok, booking1} =
BookingSystem.create_booking(
- time_slot.id,
- asset.id,
+ space.id,
user.id,
Date.utc_today(),
+ ~T[09:00:00],
+ ~T[11:00:00],
"John Doe",
"john@example.com",
"+393627384027",
@@ -59,10 +60,11 @@ defmodule SpazioSolazzo.Accounts.UserTest do
{:ok, booking2} =
BookingSystem.create_booking(
- time_slot.id,
- asset.id,
+ space.id,
user.id,
Date.add(Date.utc_today(), 1),
+ ~T[09:00:00],
+ ~T[11:00:00],
"Jane Doe",
"jane@example.com",
"+393627384028",
@@ -88,60 +90,46 @@ defmodule SpazioSolazzo.Accounts.UserTest do
test "cancels future confirmed bookings before anonymizing" do
user = register_user("cancel@example.com")
- {_space, asset, time_slot} = create_booking_fixtures()
+ {space, _time_slot} = create_booking_fixtures()
future_date = Date.add(Date.utc_today(), 7)
{:ok, future_booking} =
BookingSystem.create_booking(
- time_slot.id,
- asset.id,
+ space.id,
user.id,
future_date,
+ ~T[09:00:00],
+ ~T[11:00:00],
"Future User",
"future@example.com",
"+393627384029",
"future booking"
)
- {:ok, past_booking} =
- BookingSystem.create_booking(
- time_slot.id,
- asset.id,
- user.id,
- Date.add(Date.utc_today(), -7),
- "Past User",
- "past@example.com",
- "+393627384030",
- "past booking"
- )
-
future_booking_id = future_booking.id
- past_booking_id = past_booking.id
:ok = Accounts.terminate_account(user, false, actor: user)
{:ok, cancelled_booking} = Ash.get(Booking, future_booking_id, authorize?: false)
- {:ok, preserved_past_booking} = Ash.get(Booking, past_booking_id, authorize?: false)
assert cancelled_booking.state == :cancelled
assert cancelled_booking.user_id == nil
-
- assert preserved_past_booking.user_id == nil
end
end
describe "terminate_account with delete_history: true (hard delete)" do
test "deletes user and all associated bookings permanently" do
user = register_user("harddelete@example.com")
- {_space, asset, time_slot} = create_booking_fixtures()
+ {space, _time_slot} = create_booking_fixtures()
{:ok, booking1} =
BookingSystem.create_booking(
- time_slot.id,
- asset.id,
+ space.id,
user.id,
Date.utc_today(),
+ ~T[09:00:00],
+ ~T[11:00:00],
"Delete Me",
"deleteme@example.com",
"+393627384031",
@@ -150,10 +138,11 @@ defmodule SpazioSolazzo.Accounts.UserTest do
{:ok, booking2} =
BookingSystem.create_booking(
- time_slot.id,
- asset.id,
+ space.id,
user.id,
Date.add(Date.utc_today(), 1),
+ ~T[09:00:00],
+ ~T[11:00:00],
"Delete Me Too",
"deletemetoo@example.com",
"+393627384032",
@@ -189,11 +178,10 @@ defmodule SpazioSolazzo.Accounts.UserTest do
BookingSystem.create_space(
"Test Space #{unique_id}",
"test-space-#{unique_id}",
- "Test description"
+ "Test description",
+ 10
)
- {:ok, asset} = BookingSystem.create_asset("Test Asset", space.id)
-
{:ok, time_slot} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
@@ -202,6 +190,6 @@ defmodule SpazioSolazzo.Accounts.UserTest do
space.id
)
- {space, asset, time_slot}
+ {space, time_slot}
end
end
diff --git a/test/spazio_solazzo/booking_system/asset_test.exs b/test/spazio_solazzo/booking_system/asset_test.exs
deleted file mode 100644
index 4c6fdc3..0000000
--- a/test/spazio_solazzo/booking_system/asset_test.exs
+++ /dev/null
@@ -1,51 +0,0 @@
-defmodule SpazioSolazzo.BookingSystem.AssetTest do
- use ExUnit.Case, async: true
- use SpazioSolazzo.DataCase
-
- alias SpazioSolazzo.BookingSystem
- alias SpazioSolazzo.BookingSystem.Asset
-
- setup do
- {:ok, space} = BookingSystem.create_space("AssetSpace", "assetspace", "desc")
- %{space: space}
- end
-
- test "prevents duplicate asset names within the system", %{space: space} do
- assert {:ok, _} = BookingSystem.create_asset("T1", space.id)
- assert {:error, error} = BookingSystem.create_asset("T1", space.id)
-
- message = Ash.Error.error_descriptions(error)
-
- assert String.contains?(message, "already been taken")
- end
-
- test "allows same asset name for different spaces", %{space: space} do
- assert {:ok, _} = BookingSystem.create_asset("T1", space.id)
-
- # create another space
- {:ok, other_space} = BookingSystem.create_space("OtherSpace", "otherspace", "desc")
-
- # same name in different space should succeed
- assert {:ok, _} = BookingSystem.create_asset("T1", other_space.id)
- end
-
- test "can get single asset by space id", %{space: space} do
- assert {:ok, expected_asset} = BookingSystem.create_asset("T1", space.id)
- assert {:ok, asset} = BookingSystem.get_asset_by_space_id(space.id)
- assert asset.id == expected_asset.id
- end
-
- test "can get multiple assets by space id", %{space: space} do
- assert {:ok, _} = BookingSystem.create_asset("T1", space.id)
- assert {:ok, _} = BookingSystem.create_asset("T2", space.id)
- assert {:ok, _} = BookingSystem.create_asset("T3", space.id)
-
- assert {:ok,
- [
- %Asset{name: "T1"},
- %Asset{name: "T2"},
- %Asset{name: "T3"}
- ]} =
- BookingSystem.get_space_assets(space.id)
- end
-end
diff --git a/test/spazio_solazzo/booking_system/booking/request_created_email_worker_test.exs b/test/spazio_solazzo/booking_system/booking/request_created_email_worker_test.exs
new file mode 100644
index 0000000..a6a3cb3
--- /dev/null
+++ b/test/spazio_solazzo/booking_system/booking/request_created_email_worker_test.exs
@@ -0,0 +1,133 @@
+defmodule SpazioSolazzo.BookingSystem.Booking.RequestCreatedEmailWorkerTest do
+ use SpazioSolazzo.DataCase, async: true
+
+ alias SpazioSolazzo.BookingSystem.Booking.RequestCreatedEmailWorker
+ alias Swoosh.Adapters.Local.Storage.Memory
+
+ describe "perform/1" do
+ test "sends confirmation email to customer" do
+ job_args = %{
+ "booking_id" => "test-booking-id",
+ "customer_name" => "John Doe",
+ "customer_email" => "john@example.com",
+ "customer_phone" => "+1234567890",
+ "customer_comment" => "Test comment",
+ "space_name" => "Coworking Space",
+ "start_datetime" => "2026-02-02T09:00:00Z",
+ "end_datetime" => "2026-02-02T13:00:00Z"
+ }
+
+ assert :ok = perform_job(RequestCreatedEmailWorker, job_args)
+
+ # Verify customer email was sent
+ emails = Memory.all()
+
+ assert Enum.any?(emails, fn email ->
+ email.to == [{"John Doe", "john@example.com"}]
+ end)
+ end
+
+ test "sends notification email to admin" do
+ job_args = %{
+ "booking_id" => "test-booking-id",
+ "customer_name" => "John Doe",
+ "customer_email" => "john@example.com",
+ "customer_phone" => "+1234567890",
+ "customer_comment" => "Test comment",
+ "space_name" => "Coworking Space",
+ "start_datetime" => "2026-02-02T09:00:00Z",
+ "end_datetime" => "2026-02-02T13:00:00Z"
+ }
+
+ admin_email = Application.get_env(:spazio_solazzo, :admin_email)
+
+ assert :ok = perform_job(RequestCreatedEmailWorker, job_args)
+
+ # Verify admin email was sent
+ emails = Memory.all()
+
+ assert Enum.any?(emails, fn email ->
+ email.to == [{"", admin_email}]
+ end)
+ end
+
+ test "sends both customer and admin emails in single job execution" do
+ job_args = %{
+ "booking_id" => "test-booking-id",
+ "customer_name" => "Jane Smith",
+ "customer_email" => "jane@example.com",
+ "customer_phone" => "+1234567890",
+ "customer_comment" => "Another test",
+ "space_name" => "Meeting Room",
+ "start_datetime" => "2026-02-03T14:00:00Z",
+ "end_datetime" => "2026-02-03T18:00:00Z"
+ }
+
+ admin_email = Application.get_env(:spazio_solazzo, :admin_email)
+
+ assert :ok = perform_job(RequestCreatedEmailWorker, job_args)
+
+ # Both emails should be sent
+ emails = Memory.all()
+ assert length(emails) == 2
+
+ email_recipients = Enum.map(emails, fn email -> email.to end)
+ assert [{"Jane Smith", "jane@example.com"}] in email_recipients
+ assert [{"", admin_email}] in email_recipients
+ end
+
+ test "customer email contains booking details" do
+ job_args = %{
+ "booking_id" => "test-booking-id",
+ "customer_name" => "Test User",
+ "customer_email" => "test@example.com",
+ "customer_phone" => "+1234567890",
+ "customer_comment" => "Test",
+ "space_name" => "Music Room",
+ "start_datetime" => "2026-02-04T10:00:00Z",
+ "end_datetime" => "2026-02-04T12:00:00Z"
+ }
+
+ assert :ok = perform_job(RequestCreatedEmailWorker, job_args)
+
+ emails = Memory.all()
+
+ customer_email =
+ Enum.find(emails, fn email ->
+ email.to == [{"Test User", "test@example.com"}]
+ end)
+
+ assert customer_email != nil
+ assert String.contains?(customer_email.html_body, "Music Room")
+ assert String.contains?(customer_email.html_body, "Wednesday, February 04")
+ end
+
+ test "admin email contains customer information" do
+ job_args = %{
+ "booking_id" => "test-booking-id",
+ "customer_name" => "Admin Test",
+ "customer_email" => "admin.test@example.com",
+ "customer_phone" => "+1234567890",
+ "customer_comment" => "Admin comment",
+ "space_name" => "Coworking Space",
+ "start_datetime" => "2026-02-05T09:00:00Z",
+ "end_datetime" => "2026-02-05T11:00:00Z"
+ }
+
+ admin_email = Application.get_env(:spazio_solazzo, :admin_email)
+
+ assert :ok = perform_job(RequestCreatedEmailWorker, job_args)
+
+ emails = Memory.all()
+
+ admin_notification =
+ Enum.find(emails, fn email ->
+ email.to == [{"", admin_email}]
+ end)
+
+ assert admin_notification != nil
+ assert String.contains?(admin_notification.html_body, "Admin Test")
+ assert String.contains?(admin_notification.html_body, "admin.test@example.com")
+ end
+ end
+end
diff --git a/test/spazio_solazzo/booking_system/booking_pagination_test.exs b/test/spazio_solazzo/booking_system/booking_pagination_test.exs
new file mode 100644
index 0000000..2875b7a
--- /dev/null
+++ b/test/spazio_solazzo/booking_system/booking_pagination_test.exs
@@ -0,0 +1,399 @@
+defmodule SpazioSolazzo.BookingSystem.BookingPaginationTest do
+ use ExUnit.Case, async: true
+ use SpazioSolazzo.DataCase
+
+ alias SpazioSolazzo.BookingSystem
+
+ describe "read_pending_bookings/3 pagination" do
+ setup do
+ {:ok, space} =
+ BookingSystem.create_space(
+ "Coworking",
+ "coworking-pagination-test",
+ "Test space for pagination",
+ 10
+ )
+
+ base_date = Date.add(Date.utc_today(), 1)
+
+ pending_bookings =
+ for i <- 1..15 do
+ {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ base_date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "Customer #{i}",
+ "customer#{i}@example.com",
+ nil,
+ nil
+ )
+
+ booking
+ end
+
+ %{space: space, pending_bookings: pending_bookings, tomorrow: base_date}
+ end
+
+ test "returns first page with default limit of 10" do
+ {:ok, page} =
+ BookingSystem.read_pending_bookings(
+ nil,
+ nil,
+ nil,
+ page: [limit: 10, offset: 0, count: true]
+ )
+
+ assert length(page.results) == 10
+ assert page.count == 15
+ assert page.limit == 10
+ assert page.offset == 0
+ assert page.more? == true
+ end
+
+ test "returns second page correctly" do
+ {:ok, page} =
+ BookingSystem.read_pending_bookings(
+ nil,
+ nil,
+ nil,
+ page: [limit: 10, offset: 10, count: true]
+ )
+
+ assert length(page.results) == 5
+ assert page.count == 15
+ assert page.more? == false
+ end
+
+ test "filters by space_id", %{space: space} do
+ {:ok, other_space} =
+ BookingSystem.create_space(
+ "Other Space",
+ "other-space-pagination",
+ "Another test space",
+ 5
+ )
+
+ tomorrow = Date.add(Date.utc_today(), 1)
+
+ {:ok, _} =
+ BookingSystem.create_booking(
+ other_space.id,
+ nil,
+ tomorrow,
+ ~T[10:00:00],
+ ~T[11:00:00],
+ "Other Customer",
+ "other@example.com",
+ nil,
+ nil
+ )
+
+ {:ok, page} =
+ BookingSystem.read_pending_bookings(
+ space.id,
+ nil,
+ nil,
+ page: [limit: 10, offset: 0, count: true]
+ )
+
+ assert page.count == 15
+ assert Enum.all?(page.results, fn b -> b.space_id == space.id end)
+ end
+
+ test "filters by email" do
+ {:ok, page} =
+ BookingSystem.read_pending_bookings(
+ nil,
+ "customer1@example.com",
+ nil,
+ page: [limit: 10, offset: 0, count: true]
+ )
+
+ assert page.count == 1
+ assert hd(page.results).customer_email == "customer1@example.com"
+ end
+
+ test "filters by date", %{tomorrow: tomorrow} do
+ {:ok, page} =
+ BookingSystem.read_pending_bookings(
+ nil,
+ nil,
+ tomorrow,
+ page: [limit: 20, offset: 0, count: true]
+ )
+
+ assert page.count == 15
+ assert Enum.all?(page.results, fn b -> DateTime.to_date(b.start_datetime) == tomorrow end)
+ end
+
+ test "sorts by inserted_at descending (newest first)" do
+ {:ok, page} =
+ BookingSystem.read_pending_bookings(
+ nil,
+ nil,
+ nil,
+ page: [limit: 2, offset: 0, count: true]
+ )
+
+ [first, second] = page.results
+ assert DateTime.compare(first.inserted_at, second.inserted_at) in [:gt, :eq]
+ end
+
+ test "returns empty results when no bookings match" do
+ {:ok, page} =
+ BookingSystem.read_pending_bookings(
+ nil,
+ "nonexistent@example.com",
+ nil,
+ page: [limit: 10, offset: 0, count: true]
+ )
+
+ assert page.results == []
+ assert page.count == 0
+ assert page.more? == false
+ end
+
+ test "only returns requested state bookings", %{pending_bookings: bookings} do
+ [first_booking | _] = bookings
+
+ {:ok, _} = BookingSystem.approve_booking(first_booking)
+
+ {:ok, page} =
+ BookingSystem.read_pending_bookings(
+ nil,
+ nil,
+ nil,
+ page: [limit: 20, offset: 0, count: true],
+ load: [:space]
+ )
+
+ assert page.count == 14
+ assert Enum.all?(page.results, fn b -> b.state == :requested end)
+ end
+
+ test "combined filters work together", %{space: space, tomorrow: tomorrow} do
+ {:ok, page} =
+ BookingSystem.read_pending_bookings(
+ space.id,
+ "customer5@example.com",
+ tomorrow,
+ page: [limit: 10, offset: 0, count: true]
+ )
+
+ assert page.count == 1
+ result = hd(page.results)
+ assert result.space_id == space.id
+ assert result.customer_email == "customer5@example.com"
+ assert DateTime.to_date(result.start_datetime) == tomorrow
+ end
+ end
+
+ describe "read_booking_history/3 pagination" do
+ setup do
+ {:ok, space} =
+ BookingSystem.create_space(
+ "Coworking History",
+ "coworking-history-test",
+ "Test space for history pagination",
+ 10
+ )
+
+ base_date = Date.add(Date.utc_today(), 1)
+
+ for i <- 1..30 do
+ {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ base_date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "Customer #{i}",
+ "customer#{i}@example.com",
+ nil,
+ nil
+ )
+
+ cond do
+ rem(i, 10) == 0 ->
+ BookingSystem.reject_booking(booking, "Test rejection")
+
+ rem(i, 5) == 0 ->
+ {:ok, approved} = BookingSystem.approve_booking(booking)
+ BookingSystem.cancel_booking(approved, "Test cancellation")
+
+ true ->
+ BookingSystem.approve_booking(booking)
+ end
+ end
+
+ %{space: space, tomorrow: base_date}
+ end
+
+ test "returns first page with default limit of 25" do
+ {:ok, page} =
+ BookingSystem.read_booking_history(
+ nil,
+ nil,
+ nil,
+ page: [limit: 25, offset: 0, count: true]
+ )
+
+ assert length(page.results) == 25
+ assert page.count == 30
+ assert page.more? == true
+ end
+
+ test "returns second page correctly" do
+ {:ok, page} =
+ BookingSystem.read_booking_history(
+ nil,
+ nil,
+ nil,
+ page: [limit: 25, offset: 25, count: true]
+ )
+
+ assert length(page.results) == 5
+ assert page.count == 30
+ assert page.more? == false
+ end
+
+ test "returns only accepted, rejected, and cancelled bookings", %{space: space} do
+ tomorrow = Date.add(Date.utc_today(), 2)
+
+ {:ok, _pending} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ tomorrow,
+ ~T[10:00:00],
+ ~T[11:00:00],
+ "Pending Customer",
+ "pending@example.com",
+ nil,
+ nil
+ )
+
+ {:ok, page} =
+ BookingSystem.read_booking_history(
+ nil,
+ nil,
+ nil,
+ page: [limit: 50, offset: 0, count: true]
+ )
+
+ assert page.count == 30
+ assert Enum.all?(page.results, fn b -> b.state in [:accepted, :rejected, :cancelled] end)
+ end
+
+ test "sorts by start_datetime descending (most recent first)" do
+ {:ok, page} =
+ BookingSystem.read_booking_history(
+ nil,
+ nil,
+ nil,
+ page: [limit: 2, offset: 0, count: true]
+ )
+
+ [first, second] = page.results
+ assert DateTime.compare(first.start_datetime, second.start_datetime) in [:gt, :eq]
+ end
+
+ test "filters by space_id", %{space: space} do
+ {:ok, other_space} =
+ BookingSystem.create_space(
+ "Other History Space",
+ "other-history-space",
+ "Another test space",
+ 5
+ )
+
+ tomorrow = Date.add(Date.utc_today(), 1)
+
+ {:ok, other_booking} =
+ BookingSystem.create_booking(
+ other_space.id,
+ nil,
+ tomorrow,
+ ~T[10:00:00],
+ ~T[11:00:00],
+ "Other Customer",
+ "other@example.com",
+ nil,
+ nil
+ )
+
+ BookingSystem.approve_booking(other_booking)
+
+ {:ok, page} =
+ BookingSystem.read_booking_history(
+ space.id,
+ nil,
+ nil,
+ page: [limit: 50, offset: 0, count: true]
+ )
+
+ assert page.count == 30
+ assert Enum.all?(page.results, fn b -> b.space_id == space.id end)
+ end
+
+ test "filters by email" do
+ {:ok, page} =
+ BookingSystem.read_booking_history(
+ nil,
+ "customer1@example.com",
+ nil,
+ page: [limit: 10, offset: 0, count: true]
+ )
+
+ assert page.count == 1
+ assert hd(page.results).customer_email == "customer1@example.com"
+ end
+
+ test "filters by date", %{tomorrow: tomorrow} do
+ {:ok, page} =
+ BookingSystem.read_booking_history(
+ nil,
+ nil,
+ tomorrow,
+ page: [limit: 50, offset: 0, count: true]
+ )
+
+ assert page.count == 30
+ assert Enum.all?(page.results, fn b -> DateTime.to_date(b.start_datetime) == tomorrow end)
+ end
+
+ test "combined filters work together", %{space: space, tomorrow: tomorrow} do
+ {:ok, page} =
+ BookingSystem.read_booking_history(
+ space.id,
+ "customer2@example.com",
+ tomorrow,
+ page: [limit: 10, offset: 0, count: true]
+ )
+
+ assert page.count == 1
+ result = hd(page.results)
+ assert result.space_id == space.id
+ assert result.customer_email == "customer2@example.com"
+ assert DateTime.to_date(result.start_datetime) == tomorrow
+ end
+
+ test "returns empty results when no bookings match" do
+ {:ok, page} =
+ BookingSystem.read_booking_history(
+ nil,
+ "nonexistent@example.com",
+ nil,
+ page: [limit: 25, offset: 0, count: true]
+ )
+
+ assert page.results == []
+ assert page.count == 0
+ assert page.more? == false
+ end
+ end
+end
diff --git a/test/spazio_solazzo/booking_system/booking_test.exs b/test/spazio_solazzo/booking_system/booking_test.exs
index 4816af2..acd1689 100644
--- a/test/spazio_solazzo/booking_system/booking_test.exs
+++ b/test/spazio_solazzo/booking_system/booking_test.exs
@@ -1,234 +1,738 @@
defmodule SpazioSolazzo.BookingSystem.BookingTest do
use ExUnit.Case, async: true
- use SpazioSolazzo.DataCase
+ use SpazioSolazzo.DataCase
alias SpazioSolazzo.BookingSystem
- alias SpazioSolazzo.BookingSystem.Booking
- alias SpazioSolazzo.BookingSystem.Booking.EmailWorker
setup do
- {:ok, space} = BookingSystem.create_space("Test", "test2", "desc")
- {:ok, asset} = BookingSystem.create_asset("Table 1", space.id)
-
- {:ok, time_slot} =
- BookingSystem.create_time_slot_template(
- ~T[09:00:00],
- ~T[18:00:00],
- :monday,
- space.id
+ {:ok, space} =
+ BookingSystem.create_space(
+ "Test Space",
+ "test-space",
+ "Test description",
+ 2
)
- user = register_user("test@example.com")
+ user = register_user("testuser@example.com", "Test User")
- %{space: space, asset: asset, time_slot: time_slot, user: user}
+ date = ~D[2026-02-10]
+
+ %{space: space, user: user, date: date}
end
- test "it can create a booking from a time slot template", %{
- asset: asset,
- time_slot: time_slot,
- user: user
- } do
- {:ok, booking} =
- BookingSystem.create_booking(
- time_slot.id,
- asset.id,
- user.id,
- Date.utc_today(),
- "John",
- "john@example.com",
- "+393627384027",
- "test"
- )
+ describe "create_booking" do
+ test "creates a booking request successfully", %{space: space, date: date} do
+ assert {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "John Doe",
+ "john@example.com",
+ "+39 1234567890",
+ "Test booking"
+ )
- assert booking.start_time == time_slot.start_time
- assert booking.end_time == time_slot.end_time
- assert booking.state == :reserved
- assert booking.user_id == user.id
+ assert booking.space_id == space.id
+ assert booking.user_id == nil
+ assert booking.date == date
+ assert booking.start_time == ~T[09:00:00]
+ assert booking.end_time == ~T[10:00:00]
+ assert booking.customer_name == "John Doe"
+ assert booking.customer_email == "john@example.com"
+ assert booking.customer_phone == "+39 1234567890"
+ assert booking.customer_comment == "Test booking"
+ assert booking.state == :requested
+ end
+
+ test "creates booking with authenticated user", %{space: space, user: user, date: date} do
+ assert {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ user.id,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "John Doe",
+ user.email,
+ "+39 1234567890",
+ ""
+ )
+
+ assert booking.user_id == user.id
+ assert to_string(booking.customer_email) == to_string(user.email)
+ end
+
+ test "rejects booking with end time before start time", %{space: space, date: date} do
+ assert {:error, error} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[10:00:00],
+ ~T[09:00:00],
+ "John Doe",
+ "john@example.com",
+ nil,
+ nil
+ )
+
+ error_messages = Ash.Error.error_descriptions(error)
+ assert String.contains?(error_messages, "must be after start_time")
+ end
+
+ test "rejects booking in the past", %{space: space} do
+ past_date = Date.add(Date.utc_today(), -1)
+
+ assert {:error, error} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ past_date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "John Doe",
+ "john@example.com",
+ nil,
+ nil
+ )
+
+ error_messages = Ash.Error.error_descriptions(error)
+ assert String.contains?(error_messages, "cannot be in the past")
+ end
+
+ test "allows booking for today", %{space: space} do
+ today = Date.utc_today()
+
+ assert {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ today,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "John Doe",
+ "john@example.com",
+ nil,
+ nil
+ )
+
+ assert booking.date == today
+ end
+
+ test "requires customer name and email", %{space: space, date: date} do
+ assert {:error, _error} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "",
+ "",
+ "",
+ ""
+ )
+ end
+
+ test "phone number is optional", %{space: space, date: date} do
+ assert {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "John Doe",
+ "john@example.com",
+ nil,
+ nil
+ )
+
+ assert booking.customer_phone == nil || booking.customer_phone == ""
+ end
end
- test "it sends a confirmation email after the booking is created", %{
- asset: asset,
- time_slot: time_slot,
- user: user
- } do
- {:ok, booking} =
- BookingSystem.create_booking(
- time_slot.id,
- asset.id,
- user.id,
- Date.utc_today(),
- "John",
- "john@example.com",
- "+393627384027",
- "test"
- )
+ describe "approve_booking/1" do
+ test "approves a pending booking", %{space: space, date: date} do
+ {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "John Doe",
+ "john@example.com",
+ nil,
+ nil
+ )
- formatted_date = Calendar.strftime(booking.date, "%A, %B %d")
+ assert booking.state == :requested
- assert_enqueued worker: EmailWorker,
- args: %{
- "booking_id" => booking.id,
- "customer_name" => booking.customer_name,
- "customer_email" => booking.customer_email,
- "date" => formatted_date,
- "start_time" => booking.start_time,
- "end_time" => booking.end_time
- }
+ {:ok, approved_booking} = BookingSystem.approve_booking(booking.id)
+
+ assert approved_booking.state == :accepted
+ assert approved_booking.id == booking.id
+ end
+
+ test "cannot approve already approved booking", %{space: space, date: date} do
+ {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "John Doe",
+ "john@example.com",
+ nil,
+ nil
+ )
+
+ {:ok, _} = BookingSystem.approve_booking(booking.id)
+
+ assert {:error, error} = BookingSystem.approve_booking(booking.id)
+ error_messages = Ash.Error.error_descriptions(error)
+ assert String.contains?(error_messages, "no matching transition")
+ end
+
+ test "cannot approve cancelled booking", %{space: space, date: date} do
+ {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "John Doe",
+ "john@example.com",
+ nil,
+ nil
+ )
+
+ {:ok, _} = BookingSystem.cancel_booking(booking.id, "Test cancellation")
+
+ assert {:error, error} = BookingSystem.approve_booking(booking.id)
+ error_messages = Ash.Error.error_descriptions(error)
+ assert String.contains?(error_messages, "no matching transition")
+ end
end
- test "it can confirm a booking was paid", %{asset: asset, time_slot: time_slot, user: user} do
- {:ok, booking} =
- BookingSystem.create_booking(
- time_slot.id,
- asset.id,
- user.id,
- Date.utc_today(),
- "John",
- "john@example.com",
- "+393627384027",
- "test"
- )
+ describe "cancel_booking/1" do
+ test "cancels a pending booking", %{space: space, date: date} do
+ {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "John Doe",
+ "john@example.com",
+ nil,
+ nil
+ )
- assert booking.state == :reserved
+ {:ok, cancelled_booking} = BookingSystem.cancel_booking(booking.id, "Test cancellation")
- assert {:ok, booking} = BookingSystem.confirm_booking(booking)
+ assert cancelled_booking.state == :cancelled
+ assert cancelled_booking.id == booking.id
+ end
- assert booking.state == :completed
+ test "cancels an approved booking", %{space: space, date: date} do
+ {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "John Doe",
+ "john@example.com",
+ nil,
+ nil
+ )
+
+ {:ok, _} = BookingSystem.approve_booking(booking.id)
+ {:ok, cancelled_booking} = BookingSystem.cancel_booking(booking.id, "Test cancellation")
+
+ assert cancelled_booking.state == :cancelled
+ end
+
+ test "cannot cancel already cancelled booking", %{space: space, date: date} do
+ {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "John Doe",
+ "john@example.com",
+ nil,
+ nil
+ )
+
+ {:ok, _} = BookingSystem.cancel_booking(booking.id, "Test cancellation")
+
+ assert {:error, error} = BookingSystem.cancel_booking(booking.id, "Test cancellation")
+ error_messages = Ash.Error.error_descriptions(error)
+ assert String.contains?(error_messages, "no matching transition")
+ end
end
- test "it can cancel a booking", %{asset: asset, time_slot: time_slot, user: user} do
- {:ok, booking} =
- BookingSystem.create_booking(
- time_slot.id,
- asset.id,
- user.id,
- Date.utc_today(),
- "John",
- "john@example.com",
- "+393627384027",
- "test"
- )
+ describe "search_bookings/5 for accepted bookings" do
+ test "returns only approved bookings for specific date", %{space: space, date: date} do
+ {:ok, approved1} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "User 1",
+ "user1@example.com",
+ "",
+ ""
+ )
- assert booking.state == :reserved
+ {:ok, _} = BookingSystem.approve_booking(approved1.id)
- assert {:ok, booking} = BookingSystem.cancel_booking(booking)
+ {:ok, approved2} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[10:00:00],
+ ~T[11:00:00],
+ "User 2",
+ "user2@example.com",
+ "",
+ ""
+ )
- assert booking.state == :cancelled
+ {:ok, _} = BookingSystem.approve_booking(approved2.id)
+
+ {:ok, _pending} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[11:00:00],
+ ~T[12:00:00],
+ "User 3",
+ "user3@example.com",
+ "",
+ ""
+ )
+
+ start_datetime = DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
+ end_datetime = DateTime.new!(Date.add(date, 1), ~T[00:00:00], "Etc/UTC")
+
+ {:ok, bookings} =
+ BookingSystem.search_bookings(
+ space.id,
+ start_datetime,
+ end_datetime,
+ [:accepted],
+ nil
+ )
+
+ assert length(bookings) == 2
+ assert Enum.all?(bookings, &(&1.state == :accepted))
+ end
+
+ test "does not return cancelled bookings", %{space: space, date: date} do
+ {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "User 1",
+ "user1@example.com",
+ "",
+ ""
+ )
+
+ {:ok, _} = BookingSystem.approve_booking(booking.id)
+ {:ok, _} = BookingSystem.cancel_booking(booking.id, "Test cancellation")
+
+ start_datetime = DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
+ end_datetime = DateTime.new!(Date.add(date, 1), ~T[00:00:00], "Etc/UTC")
+
+ {:ok, bookings} =
+ BookingSystem.search_bookings(
+ space.id,
+ start_datetime,
+ end_datetime,
+ [:accepted],
+ nil
+ )
+
+ assert bookings == []
+ end
+
+ test "only returns bookings for specified date", %{space: space, date: date} do
+ other_date = Date.add(date, 1)
+
+ {:ok, booking1} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "User 1",
+ "user1@example.com",
+ "",
+ ""
+ )
+
+ {:ok, _} = BookingSystem.approve_booking(booking1.id)
+
+ {:ok, booking2} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ other_date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "User 2",
+ "user2@example.com",
+ "",
+ ""
+ )
+
+ {:ok, _} = BookingSystem.approve_booking(booking2.id)
+
+ start_datetime = DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
+ end_datetime = DateTime.new!(Date.add(date, 1), ~T[00:00:00], "Etc/UTC")
+
+ {:ok, bookings} =
+ BookingSystem.search_bookings(
+ space.id,
+ start_datetime,
+ end_datetime,
+ [:accepted],
+ nil
+ )
+
+ assert length(bookings) == 1
+ assert hd(bookings).date == date
+ end
+
+ test "only returns bookings for specified space", %{space: space, date: date} do
+ {:ok, other_space} =
+ BookingSystem.create_space(
+ "Other Space",
+ "other-space",
+ "Other description",
+ 5
+ )
+
+ {:ok, booking} =
+ BookingSystem.create_booking(
+ other_space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "User 1",
+ "user1@example.com",
+ "",
+ ""
+ )
+
+ {:ok, _} = BookingSystem.approve_booking(booking.id)
+
+ start_datetime = DateTime.new!(date, ~T[00:00:00], "Etc/UTC")
+ end_datetime = DateTime.new!(Date.add(date, 1), ~T[00:00:00], "Etc/UTC")
+
+ {:ok, bookings} =
+ BookingSystem.search_bookings(
+ space.id,
+ start_datetime,
+ end_datetime,
+ [:accepted],
+ nil
+ )
+
+ assert bookings == []
+ end
end
- test "it can list asset bookings by date", %{
- asset: asset,
- space: space,
- time_slot: time_slot,
- user: user
- } do
- {:ok, asset2} = BookingSystem.create_asset("Table 2", space.id)
- {:ok, asset3} = BookingSystem.create_asset("Table 3", space.id)
- today_date = Date.utc_today()
+ describe "count pending requests" do
+ test "returns only pending bookings", %{space: space, date: date} do
+ {:ok, _pending1} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "User 1",
+ "user1@example.com",
+ "",
+ ""
+ )
- {:ok, time_slot2} =
- BookingSystem.create_time_slot_template(~T[13:00:00], ~T[18:00:00], :tuesday, space.id)
+ {:ok, approved} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[10:00:00],
+ ~T[11:00:00],
+ "User 2",
+ "user2@example.com",
+ "",
+ ""
+ )
- {:ok, time_slot3} =
- BookingSystem.create_time_slot_template(~T[09:00:00], ~T[13:00:00], :tuesday, space.id)
+ {:ok, _} = BookingSystem.approve_booking(approved.id)
- # Create the bookings we want to query
- assert {:ok, _} =
- BookingSystem.create_booking(
- time_slot2.id,
- asset.id,
- user.id,
- today_date,
- "John",
- "john@example.com",
- "+393627384027",
- "test"
- )
+ {:ok, cancelled} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[11:00:00],
+ ~T[12:00:00],
+ "User 3",
+ "user3@example.com",
+ "",
+ ""
+ )
- assert {:ok, _} =
- BookingSystem.create_booking(
- time_slot3.id,
- asset.id,
- user.id,
- today_date,
- "John",
- "john@example.com",
- "+393627384027",
- "test"
- )
+ {:ok, _} = BookingSystem.cancel_booking(cancelled.id, "Test cancellation")
- # Create bookings for asset but another date
- assert {:ok, _} =
- BookingSystem.create_booking(
- time_slot2.id,
- asset.id,
- user.id,
- Date.add(today_date, 1),
- "John",
- "john@example.com",
- "+393627384027",
- "test"
- )
+ {:ok, count} =
+ Ash.count(SpazioSolazzo.BookingSystem.Booking,
+ query: [filter: [state: :requested]]
+ )
- # Create bookings for other assets
- assert {:ok, _} =
- BookingSystem.create_booking(
- time_slot.id,
- asset2.id,
- user.id,
- today_date,
- "John",
- "john@example.com",
- "+393627384027",
- "test"
- )
+ assert count == 1
+ end
- assert {:ok, _} =
- BookingSystem.create_booking(
- time_slot.id,
- asset3.id,
- user.id,
- today_date,
- "John",
- "john@example.com",
- "+393627384027",
- "test"
- )
+ test "returns zero when no pending requests", %{space: space, date: date} do
+ {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "User 1",
+ "user1@example.com",
+ "",
+ ""
+ )
- assert {:ok, bookings} =
- BookingSystem.list_active_asset_bookings_by_date(asset.id, Date.utc_today())
+ {:ok, _} = BookingSystem.approve_booking(booking.id)
- asset_id = asset.id
+ {:ok, count} =
+ Ash.count(SpazioSolazzo.BookingSystem.Booking,
+ query: [filter: [state: :requested]]
+ )
- assert [
- %Booking{date: ^today_date, asset_id: ^asset_id},
- %Booking{date: ^today_date, asset_id: ^asset_id}
- ] = bookings
+ assert count == 0
+ end
+
+ test "counts pending requests across multiple spaces", %{space: space, date: date} do
+ {:ok, other_space} =
+ BookingSystem.create_space(
+ "Other Space",
+ "other-space-pending",
+ "Other description",
+ 5
+ )
+
+ {:ok, _pending1} =
+ BookingSystem.create_booking(
+ space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "User 1",
+ "user1@example.com",
+ "",
+ ""
+ )
+
+ {:ok, _pending2} =
+ BookingSystem.create_booking(
+ other_space.id,
+ nil,
+ date,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ "User 2",
+ "user2@example.com",
+ "",
+ ""
+ )
+
+ {:ok, count} =
+ Ash.count(SpazioSolazzo.BookingSystem.Booking,
+ query: [filter: [state: :requested]]
+ )
+
+ assert count == 2
+ end
end
- test "booking belongs to the user who created it", %{
- asset: asset,
- time_slot: time_slot,
- user: user
- } do
- {:ok, booking} =
- BookingSystem.create_booking(
- time_slot.id,
- asset.id,
- user.id,
- Date.utc_today(),
- user.name,
- user.email,
- user.phone_number,
- "test comment"
- )
+ describe "create_walk_in/7" do
+ test "creates a walk-in booking with accepted state", %{space: space} do
+ start_datetime = DateTime.utc_now() |> DateTime.add(1, :hour)
+ end_datetime = DateTime.add(start_datetime, 2, :hour)
- assert booking.user_id == user.id
+ assert {:ok, booking} =
+ BookingSystem.create_walk_in(
+ space.id,
+ start_datetime,
+ end_datetime,
+ "Walk-in Customer",
+ "walkin@example.com",
+ "+39 1234567890"
+ )
- # 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 booking_with_user.user.email == user.email
- assert booking_with_user.user.name == user.name
+ assert booking.space_id == space.id
+ assert booking.customer_name == "Walk-in Customer"
+ assert booking.customer_email == "walkin@example.com"
+ assert booking.customer_phone == "+39 1234567890"
+ assert booking.state == :accepted
+ assert booking.date == DateTime.to_date(start_datetime)
+ # Compare times ignoring microseconds
+ expected_start = DateTime.to_time(start_datetime)
+ expected_end = DateTime.to_time(end_datetime)
+
+ assert booking.start_time.hour == expected_start.hour
+ assert booking.start_time.minute == expected_start.minute
+ assert booking.start_time.second == expected_start.second
+
+ assert booking.end_time.hour == expected_end.hour
+ assert booking.end_time.minute == expected_end.minute
+ assert booking.end_time.second == expected_end.second
+ end
+
+ test "creates walk-in without optional fields", %{space: space} do
+ start_datetime = DateTime.utc_now() |> DateTime.add(1, :hour)
+ end_datetime = DateTime.add(start_datetime, 2, :hour)
+
+ assert {:ok, booking} =
+ BookingSystem.create_walk_in(
+ space.id,
+ start_datetime,
+ end_datetime,
+ "Walk-in Customer",
+ "walkin@example.com",
+ nil
+ )
+
+ assert booking.customer_phone == nil
+ assert booking.customer_comment == nil
+ assert booking.state == :accepted
+ end
+
+ test "rejects walk-in with end datetime before start datetime", %{space: space} do
+ start_datetime = DateTime.utc_now() |> DateTime.add(2, :hour)
+ end_datetime = DateTime.add(start_datetime, -1, :hour)
+
+ assert {:error, error} =
+ BookingSystem.create_walk_in(
+ space.id,
+ start_datetime,
+ end_datetime,
+ "Walk-in Customer",
+ "walkin@example.com",
+ nil
+ )
+
+ error_messages = Ash.Error.error_descriptions(error)
+ assert String.contains?(error_messages, "must be after start_datetime")
+ end
+
+ test "rejects walk-in with end time in the past", %{space: space} do
+ start_datetime = DateTime.utc_now() |> DateTime.add(-2, :hour)
+ end_datetime = DateTime.utc_now() |> DateTime.add(-1, :hour)
+
+ assert {:error, error} =
+ BookingSystem.create_walk_in(
+ space.id,
+ start_datetime,
+ end_datetime,
+ "Walk-in Customer",
+ "walkin@example.com",
+ nil
+ )
+
+ error_messages = Ash.Error.error_descriptions(error)
+ assert String.contains?(error_messages, "cannot be in the past")
+ end
+
+ test "allows walk-in with start time in the past but end time in the future", %{space: space} do
+ start_datetime = DateTime.utc_now() |> DateTime.add(-1, :hour)
+ end_datetime = DateTime.utc_now() |> DateTime.add(2, :hour)
+
+ assert {:ok, booking} =
+ BookingSystem.create_walk_in(
+ space.id,
+ start_datetime,
+ end_datetime,
+ "Walk-in Customer",
+ "walkin@example.com",
+ nil
+ )
+
+ assert booking.state == :accepted
+ assert booking.customer_name == "Walk-in Customer"
+ end
+
+ test "rejects walk-in with invalid email", %{space: space} do
+ start_datetime = DateTime.utc_now() |> DateTime.add(1, :hour)
+ end_datetime = DateTime.add(start_datetime, 2, :hour)
+
+ assert {:error, error} =
+ BookingSystem.create_walk_in(
+ space.id,
+ start_datetime,
+ end_datetime,
+ "Walk-in Customer",
+ "invalid-email",
+ nil
+ )
+
+ error_messages = Ash.Error.error_descriptions(error)
+ assert String.contains?(error_messages, "must be a valid email")
+ end
+
+ test "converts datetime to date and time correctly", %{space: space} do
+ date = Date.add(Date.utc_today(), 1)
+ start_time = ~T[14:30:00]
+ end_time = ~T[16:45:00]
+
+ start_datetime = DateTime.new!(date, start_time, "Etc/UTC")
+ end_datetime = DateTime.new!(date, end_time, "Etc/UTC")
+
+ assert {:ok, booking} =
+ BookingSystem.create_walk_in(
+ space.id,
+ start_datetime,
+ end_datetime,
+ "Walk-in Customer",
+ "walkin@example.com",
+ nil
+ )
+
+ assert booking.date == date
+ assert booking.start_time == start_time
+ assert booking.end_time == end_time
+ end
end
end
diff --git a/test/spazio_solazzo/booking_system/space_test.exs b/test/spazio_solazzo/booking_system/space_test.exs
index cb8018a..89d6b62 100644
--- a/test/spazio_solazzo/booking_system/space_test.exs
+++ b/test/spazio_solazzo/booking_system/space_test.exs
@@ -4,21 +4,85 @@ defmodule SpazioSolazzo.BookingSystem.SpaceTest do
alias SpazioSolazzo.BookingSystem
- test "can create a space" do
- assert {:ok, _} =
- BookingSystem.create_space("Space", "space", "test description")
+ describe "create_space/4" do
+ test "creates a space with all attributes" do
+ assert {:ok, space} =
+ BookingSystem.create_space(
+ "Test Space",
+ "test-space",
+ "test description",
+ 10
+ )
- assert {:ok, space} = BookingSystem.get_space_by_slug("space")
+ assert space.name == "Test Space"
+ assert space.slug == "test-space"
+ assert space.description == "test description"
+ assert space.capacity == 10
+ end
- assert space.slug == "space"
+ test "requires positive capacity values" do
+ assert {:error, error} =
+ BookingSystem.create_space(
+ "Zero Space",
+ "zero",
+ "description",
+ -1
+ )
+
+ error_messages = Ash.Error.error_descriptions(error)
+ assert String.contains?(error_messages, "must be greater than 0")
+ end
end
- test "can't create two spaces with same name and slug" do
- assert {:ok, _} = BookingSystem.create_space("Space", "space", "test description")
- assert {:error, error} = BookingSystem.create_space("Space", "space", "test description")
+ describe "get_space_by_slug/1" do
+ test "retrieves space by slug" do
+ {:ok, _} =
+ BookingSystem.create_space("Space", "test-slug", "test description", 5)
- error_messages = Ash.Error.error_descriptions(error)
+ assert {:ok, space} = BookingSystem.get_space_by_slug("test-slug")
- assert String.contains?(error_messages, "has already been")
+ assert space.slug == "test-slug"
+ assert space.name == "Space"
+ end
+
+ test "returns error when space not found" do
+ assert {:error, _} = BookingSystem.get_space_by_slug("nonexistent")
+ end
+ end
+
+ describe "space uniqueness" do
+ test "can't create two spaces with same slug" do
+ assert {:ok, _} =
+ BookingSystem.create_space("Space 1", "same-slug", "description 1", 5)
+
+ assert {:error, error} =
+ BookingSystem.create_space("Space 2", "same-slug", "description 2", 10)
+
+ error_messages = Ash.Error.error_descriptions(error)
+
+ assert String.contains?(error_messages, "has already been")
+ end
+
+ test "can't create two spaces with same name" do
+ assert {:ok, _} =
+ BookingSystem.create_space("Same Name", "slug-1", "description 1", 5)
+
+ assert {:error, error} =
+ BookingSystem.create_space("Same Name", "slug-2", "description 2", 10)
+
+ error_messages = Ash.Error.error_descriptions(error)
+
+ assert String.contains?(error_messages, "has already been")
+ end
+
+ test "can create spaces with different names and slugs" do
+ assert {:ok, space1} =
+ BookingSystem.create_space("Space 1", "slug-1", "description 1", 5)
+
+ assert {:ok, space2} =
+ BookingSystem.create_space("Space 2", "slug-2", "description 2", 10)
+
+ assert space1.id != space2.id
+ end
end
end
diff --git a/test/spazio_solazzo/booking_system/time_slot_template_test.exs b/test/spazio_solazzo/booking_system/time_slot_template_test.exs
index f6aa9d8..b162db8 100644
--- a/test/spazio_solazzo/booking_system/time_slot_template_test.exs
+++ b/test/spazio_solazzo/booking_system/time_slot_template_test.exs
@@ -5,65 +5,263 @@ defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplateTest do
alias SpazioSolazzo.BookingSystem
setup do
- {:ok, space} = BookingSystem.create_space("Test", "test", "description")
+ {:ok, space} =
+ BookingSystem.create_space(
+ "Test Space",
+ "test-space",
+ "Test description",
+ 10
+ )
+
%{space: space}
end
- test "prevents overlapping time slot templates for same space", %{space: space} do
- assert {:ok, _} =
- BookingSystem.create_time_slot_template(
- ~T[09:00:00],
- ~T[12:00:00],
- :monday,
- space.id
- )
+ describe "create_time_slot_template/4" do
+ test "creates a time slot template successfully", %{space: space} do
+ assert {:ok, slot} =
+ BookingSystem.create_time_slot_template(
+ ~T[09:00:00],
+ ~T[10:00:00],
+ :monday,
+ space.id
+ )
- assert {:error, changeset} =
- BookingSystem.create_time_slot_template(
- ~T[11:00:00],
- ~T[13:00:00],
- :monday,
- space.id
- )
+ assert slot.start_time == ~T[09:00:00]
+ assert slot.end_time == ~T[10:00:00]
+ assert slot.day_of_week == :monday
+ assert slot.space_id == space.id
+ end
- assert Ash.Error.error_descriptions(changeset.errors) =~ "overlaps"
+ test "creates templates for all days of the week", %{space: space} do
+ days = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
+
+ for day <- days do
+ assert {:ok, slot} =
+ BookingSystem.create_time_slot_template(
+ ~T[09:00:00],
+ ~T[10:00:00],
+ day,
+ space.id
+ )
+
+ assert slot.day_of_week == day
+ end
+ end
+
+ test "prevents overlapping time slots on same day", %{space: space} do
+ assert {:ok, _slot1} =
+ BookingSystem.create_time_slot_template(
+ ~T[09:00:00],
+ ~T[11:00:00],
+ :monday,
+ space.id
+ )
+
+ assert {:error, error} =
+ BookingSystem.create_time_slot_template(
+ ~T[10:00:00],
+ ~T[12:00:00],
+ :monday,
+ space.id
+ )
+
+ error_messages = Ash.Error.error_descriptions(error)
+
+ assert String.contains?(
+ error_messages,
+ "time slot overlaps with existing template for this space and day."
+ )
+ end
+
+ test "allows same time slot on different days", %{space: space} do
+ assert {:ok, _slot1} =
+ BookingSystem.create_time_slot_template(
+ ~T[09:00:00],
+ ~T[11:00:00],
+ :monday,
+ space.id
+ )
+
+ assert {:ok, slot2} =
+ BookingSystem.create_time_slot_template(
+ ~T[09:00:00],
+ ~T[11:00:00],
+ :tuesday,
+ space.id
+ )
+
+ assert slot2.day_of_week == :tuesday
+ end
+
+ test "allows adjacent time slots on same day", %{space: space} do
+ assert {:ok, _slot1} =
+ BookingSystem.create_time_slot_template(
+ ~T[09:00:00],
+ ~T[11:00:00],
+ :monday,
+ space.id
+ )
+
+ assert {:ok, slot2} =
+ BookingSystem.create_time_slot_template(
+ ~T[11:00:00],
+ ~T[13:00:00],
+ :monday,
+ space.id
+ )
+
+ assert slot2.start_time == ~T[11:00:00]
+ end
+
+ test "rejects invalid day of week", %{space: space} do
+ assert {:error, error} =
+ BookingSystem.create_time_slot_template(
+ ~T[09:00:00],
+ ~T[10:00:00],
+ :invalid_day,
+ space.id
+ )
+
+ error_messages = Ash.Error.error_descriptions(error)
+ assert String.contains?(error_messages, "atom must be one of")
+ end
+
+ test "rejects end time before start time", %{space: space} do
+ assert {:error, error} =
+ BookingSystem.create_time_slot_template(
+ ~T[10:00:00],
+ ~T[09:00:00],
+ :monday,
+ space.id
+ )
+
+ error_messages = Ash.Error.error_descriptions(error)
+ assert String.contains?(error_messages, "must be after start_time")
+ end
+
+ test "rejects equal start and end times", %{space: space} do
+ assert {:error, error} =
+ BookingSystem.create_time_slot_template(
+ ~T[10:00:00],
+ ~T[10:00:00],
+ :monday,
+ space.id
+ )
+
+ error_messages = Ash.Error.error_descriptions(error)
+ assert String.contains?(error_messages, "must be after start_time")
+ end
end
- test "allows non-overlapping time slot templates for same space on the same day", %{
- space: space
- } do
- assert {:ok, _} =
- BookingSystem.create_time_slot_template(
- ~T[09:00:00],
- ~T[12:00:00],
- :monday,
- space.id
- )
+ describe "get_space_time_slots_by_date/2" do
+ setup %{space: space} do
+ {:ok, monday_morning} =
+ BookingSystem.create_time_slot_template(
+ ~T[09:00:00],
+ ~T[13:00:00],
+ :monday,
+ space.id
+ )
- assert {:ok, _} =
- BookingSystem.create_time_slot_template(
- ~T[13:00:00],
- ~T[16:00:00],
- :monday,
- space.id
- )
- end
+ {:ok, monday_afternoon} =
+ BookingSystem.create_time_slot_template(
+ ~T[14:00:00],
+ ~T[18:00:00],
+ :monday,
+ space.id
+ )
- test "allows overlapping time slot templates for same space on different days", %{space: space} do
- assert {:ok, _} =
- BookingSystem.create_time_slot_template(
- ~T[09:00:00],
- ~T[12:00:00],
- :monday,
- space.id
- )
+ {:ok, tuesday_slot} =
+ BookingSystem.create_time_slot_template(
+ ~T[09:00:00],
+ ~T[13:00:00],
+ :tuesday,
+ space.id
+ )
- assert {:ok, _} =
- BookingSystem.create_time_slot_template(
- ~T[09:00:00],
- ~T[12:00:00],
- :tuesday,
- space.id
- )
+ %{
+ monday_morning: monday_morning,
+ monday_afternoon: monday_afternoon,
+ tuesday_slot: tuesday_slot
+ }
+ end
+
+ test "returns time slots for specific date's day of week", %{space: space} do
+ monday_date = ~D[2026-02-02]
+ assert Date.day_of_week(monday_date) == 1
+
+ {:ok, slots} = BookingSystem.get_space_time_slots_by_date(space.id, monday_date)
+
+ assert length(slots) == 2
+ assert Enum.any?(slots, &(&1.start_time == ~T[09:00:00]))
+ assert Enum.any?(slots, &(&1.start_time == ~T[14:00:00]))
+ end
+
+ test "returns different slots for different days", %{space: space} do
+ tuesday_date = ~D[2026-02-03]
+ assert Date.day_of_week(tuesday_date) == 2
+
+ {:ok, slots} = BookingSystem.get_space_time_slots_by_date(space.id, tuesday_date)
+
+ assert length(slots) == 1
+ assert hd(slots).start_time == ~T[09:00:00]
+ end
+
+ test "returns empty list when no slots for that day", %{space: space} do
+ wednesday_date = ~D[2026-02-04]
+ assert Date.day_of_week(wednesday_date) == 3
+
+ {:ok, slots} = BookingSystem.get_space_time_slots_by_date(space.id, wednesday_date)
+
+ assert slots == []
+ end
+
+ test "handles all days of the week correctly", %{space: space} do
+ days_and_dates = [
+ {:monday, ~D[2026-02-02]},
+ {:tuesday, ~D[2026-02-03]},
+ {:wednesday, ~D[2026-02-04]},
+ {:thursday, ~D[2026-02-05]},
+ {:friday, ~D[2026-02-06]},
+ {:saturday, ~D[2026-02-07]},
+ {:sunday, ~D[2026-02-01]}
+ ]
+
+ for {day_atom, date} <- days_and_dates do
+ BookingSystem.create_time_slot_template(
+ ~T[20:00:00],
+ ~T[21:00:00],
+ day_atom,
+ space.id
+ )
+
+ {:ok, slots} = BookingSystem.get_space_time_slots_by_date(space.id, date)
+ assert Enum.any?(slots, &(&1.start_time == ~T[20:00:00]))
+ end
+ end
+
+ test "only returns slots for specified space", %{space: space} do
+ {:ok, other_space} =
+ BookingSystem.create_space(
+ "Other Space",
+ "other-space",
+ "Other description",
+ 5
+ )
+
+ {:ok, _other_slot} =
+ BookingSystem.create_time_slot_template(
+ ~T[20:00:00],
+ ~T[22:00:00],
+ :monday,
+ other_space.id
+ )
+
+ monday_date = ~D[2026-02-02]
+ {:ok, slots} = BookingSystem.get_space_time_slots_by_date(space.id, monday_date)
+
+ assert Enum.all?(slots, &(&1.space_id == space.id))
+ refute Enum.any?(slots, &(&1.start_time == ~T[20:00:00]))
+ end
end
end
diff --git a/test/spazio_solazzo_web/controllers/booking_controller_test.exs b/test/spazio_solazzo_web/controllers/booking_controller_test.exs
deleted file mode 100644
index 45d5ef6..0000000
--- a/test/spazio_solazzo_web/controllers/booking_controller_test.exs
+++ /dev/null
@@ -1,99 +0,0 @@
-defmodule SpazioSolazzoWeb.BookingControllerTest do
- use SpazioSolazzoWeb.ConnCase, async: true
-
- alias SpazioSolazzo.BookingSystem
- alias SpazioSolazzo.BookingSystem.Booking.Token
-
- setup do
- unique_id = :erlang.unique_integer([:positive, :monotonic])
-
- {:ok, space} =
- BookingSystem.create_space("Test #{unique_id}", "test-space-#{unique_id}", "desc")
-
- {:ok, asset} = BookingSystem.create_asset("Table 1", space.id)
-
- {:ok, time_slot} =
- BookingSystem.create_time_slot_template(
- ~T[09:00:00],
- ~T[18:00:00],
- :monday,
- space.id
- )
-
- user = register_user("test@example.com", "Test User", "+1234567890")
-
- %{space: space, asset: asset, time_slot: time_slot, user: user}
- end
-
- describe "cancel/2" do
- test "first cancel shows success message, not error message", %{
- conn: conn,
- asset: asset,
- time_slot: time_slot,
- user: user
- } do
- {:ok, booking} =
- BookingSystem.create_booking(
- time_slot.id,
- asset.id,
- user.id,
- Date.utc_today(),
- "John",
- "john@example.com",
- "+393627384027",
- "test"
- )
-
- # Verify initial state
- assert booking.state == :reserved
-
- cancel_token = Token.generate_customer_cancel_token(booking.id)
- conn = get(conn, ~p"/bookings/cancel?token=#{cancel_token}")
-
- assert redirected_to(conn) == "/"
-
- # Should show success message
- assert Phoenix.Flash.get(conn.assigns.flash, :info) == "The booking has been cancelled."
-
- # Should NOT show error message
- assert Phoenix.Flash.get(conn.assigns.flash, :error) == nil
-
- # Verify booking is now cancelled in database
- cancelled_booking = Ash.get!(SpazioSolazzo.BookingSystem.Booking, booking.id)
- assert cancelled_booking.state == :cancelled
- end
-
- test "shows error message when booking is already cancelled", %{
- conn: conn,
- asset: asset,
- time_slot: time_slot,
- user: user
- } do
- {:ok, booking} =
- BookingSystem.create_booking(
- time_slot.id,
- asset.id,
- user.id,
- Date.utc_today(),
- "John",
- "john@example.com",
- "+393627384027",
- "test"
- )
-
- # Cancel the booking first time
- {:ok, _cancelled_booking} = BookingSystem.cancel_booking(booking)
-
- # Generate a cancel token for the already-cancelled booking
- cancel_token = Token.generate_customer_cancel_token(booking.id)
-
- # Try to cancel again
- conn = get(conn, ~p"/bookings/cancel?token=#{cancel_token}")
-
- assert redirected_to(conn) == "/"
-
- assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
- "Action could not be completed (e.g. already processed)."
- end
- end
-end
diff --git a/test/spazio_solazzo_web/live/admin/booking_management_pagination_test.exs b/test/spazio_solazzo_web/live/admin/booking_management_pagination_test.exs
new file mode 100644
index 0000000..ea81d03
--- /dev/null
+++ b/test/spazio_solazzo_web/live/admin/booking_management_pagination_test.exs
@@ -0,0 +1,573 @@
+defmodule SpazioSolazzoWeb.Admin.BookingManagementPaginationTest do
+ use SpazioSolazzoWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+ alias SpazioSolazzo.BookingSystem
+
+ setup do
+ {:ok, space} =
+ BookingSystem.create_space(
+ "Coworking",
+ "coworking-pagination",
+ "Coworking space",
+ 10
+ )
+
+ admin_user =
+ "admin@example.com"
+ |> register_user("Admin User")
+ |> SpazioSolazzo.Accounts.make_admin!(authorize?: false)
+
+ tomorrow = Date.add(Date.utc_today(), 1)
+
+ %{space: space, admin_user: admin_user, tomorrow: tomorrow}
+ end
+
+ describe "pagination - pending bookings" do
+ test "displays first page of pending bookings", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ tomorrow: tomorrow
+ } do
+ user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
+
+ for i <- 1..15 do
+ hour = rem(8 + i, 24)
+ start_time = Time.new!(hour, 0, 0)
+ end_time = Time.new!(hour, 30, 0)
+
+ BookingSystem.create_booking(
+ space.id,
+ user.id,
+ tomorrow,
+ start_time,
+ end_time,
+ "Customer #{i}",
+ "customer#{i}@example.com",
+ nil,
+ nil
+ )
+ end
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, html} = live(conn, "/admin/bookings")
+
+ assert html =~ "Showing 1-10 of 15"
+ assert has_element?(view, "button[phx-click='pending_page_change']")
+ end
+
+ test "navigates to second page of pending bookings", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ tomorrow: tomorrow
+ } do
+ user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
+
+ for i <- 1..15 do
+ hour = rem(8 + i, 24)
+ start_time = Time.new!(hour, 0, 0)
+ end_time = Time.new!(hour, 30, 0)
+
+ BookingSystem.create_booking(
+ space.id,
+ user.id,
+ tomorrow,
+ start_time,
+ end_time,
+ "Customer #{i}",
+ "customer#{i}@example.com",
+ nil,
+ nil
+ )
+ end
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ html =
+ view
+ |> element("button[phx-click='pending_page_change']", "2")
+ |> render_click()
+
+ assert html =~ "Showing 11-15 of 15"
+ end
+
+ test "pagination URL params are updated", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ tomorrow: tomorrow
+ } do
+ user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
+
+ for i <- 1..15 do
+ hour = rem(8 + i, 24)
+ start_time = Time.new!(hour, 0, 0)
+ end_time = Time.new!(hour, 30, 0)
+
+ BookingSystem.create_booking(
+ space.id,
+ user.id,
+ tomorrow,
+ start_time,
+ end_time,
+ "Customer #{i}",
+ "customer#{i}@example.com",
+ nil,
+ nil
+ )
+ end
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ view
+ |> element("button[phx-click='pending_page_change']", "2")
+ |> render_click()
+
+ assert_patch(view, "/admin/bookings?history_page=1&pending_page=2")
+ end
+
+ test "filters reset pagination to page 1", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ tomorrow: tomorrow
+ } do
+ user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
+
+ for i <- 1..15 do
+ hour = rem(8 + i, 24)
+ start_time = Time.new!(hour, 0, 0)
+ end_time = Time.new!(hour, 30, 0)
+
+ BookingSystem.create_booking(
+ space.id,
+ user.id,
+ tomorrow,
+ start_time,
+ end_time,
+ "Customer #{i}",
+ "customer#{i}@example.com",
+ nil,
+ nil
+ )
+ end
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings?history_page=1&pending_page=2")
+
+ view
+ |> form("form", %{"email" => "customer1@example.com"})
+ |> render_change()
+
+ path = assert_patch(view)
+ refute path =~ "pending_page=2"
+ end
+
+ test "previous button is disabled on first page", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ tomorrow: tomorrow
+ } do
+ user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
+
+ for i <- 1..15 do
+ hour = rem(8 + i, 24)
+ start_time = Time.new!(hour, 0, 0)
+ end_time = Time.new!(hour, 30, 0)
+
+ BookingSystem.create_booking(
+ space.id,
+ user.id,
+ tomorrow,
+ start_time,
+ end_time,
+ "Customer #{i}",
+ "customer#{i}@example.com",
+ nil,
+ nil
+ )
+ end
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, html} = live(conn, "/admin/bookings")
+
+ assert html =~ "disabled"
+ assert has_element?(view, "button[phx-click='pending_page_change'][disabled]")
+ end
+ end
+
+ describe "pagination - booking history" do
+ test "displays first page of booking history", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ tomorrow: tomorrow
+ } do
+ for i <- 1..30 do
+ start_datetime = DateTime.new!(tomorrow, Time.add(~T[09:00:00], i * 3600), "Etc/UTC")
+ end_datetime = DateTime.add(start_datetime, 3600, :second)
+
+ {:ok, booking} =
+ BookingSystem.create_walk_in(
+ space.id,
+ start_datetime,
+ end_datetime,
+ "Customer #{i}",
+ "customer#{i}@example.com",
+ nil
+ )
+
+ BookingSystem.approve_booking(booking)
+ end
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, html} = live(conn, "/admin/bookings")
+
+ assert html =~ "Showing 1-10 of 30"
+ assert has_element?(view, "button[phx-click='history_page_change'][phx-value-page='2']")
+ end
+
+ test "navigates to second page of booking history", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ tomorrow: tomorrow
+ } do
+ for i <- 1..30 do
+ start_datetime = DateTime.new!(tomorrow, Time.add(~T[09:00:00], i * 3600), "Etc/UTC")
+ end_datetime = DateTime.add(start_datetime, 3600, :second)
+
+ {:ok, booking} =
+ BookingSystem.create_walk_in(
+ space.id,
+ start_datetime,
+ end_datetime,
+ "Customer #{i}",
+ "customer#{i}@example.com",
+ nil
+ )
+
+ BookingSystem.approve_booking(booking)
+ end
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ html =
+ view
+ |> element("button[phx-click='history_page_change']", "2")
+ |> render_click()
+
+ assert html =~ "Showing 11-20 of 30"
+ end
+
+ test "history pagination URL params are updated", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ tomorrow: tomorrow
+ } do
+ for i <- 1..30 do
+ start_datetime = DateTime.new!(tomorrow, Time.add(~T[09:00:00], i * 3600), "Etc/UTC")
+ end_datetime = DateTime.add(start_datetime, 3600, :second)
+
+ {:ok, booking} =
+ BookingSystem.create_walk_in(
+ space.id,
+ start_datetime,
+ end_datetime,
+ "Customer #{i}",
+ "customer#{i}@example.com",
+ nil
+ )
+
+ BookingSystem.approve_booking(booking)
+ end
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ view
+ |> element("button[phx-click='history_page_change']", "2")
+ |> render_click()
+
+ assert_patch(view, "/admin/bookings?history_page=2&pending_page=1")
+ end
+ end
+
+ describe "pagination with booking management" do
+ test "approving booking refreshes current page", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ tomorrow: tomorrow
+ } do
+ user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
+
+ {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ user.id,
+ tomorrow,
+ ~T[10:00:00],
+ ~T[11:00:00],
+ "Test Customer",
+ "test@example.com",
+ nil,
+ nil
+ )
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ # Trigger a refresh
+ view |> form("form", %{"email" => ""}) |> render_change()
+
+ view
+ |> element("button[phx-click='approve_booking'][phx-value-booking_id='#{booking.id}']")
+ |> render_click()
+
+ # Just verify the view still works after approval
+ html = render(view)
+ assert html =~ "Manage Bookings"
+ end
+
+ test "rejecting booking refreshes current page", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ tomorrow: tomorrow
+ } do
+ user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
+
+ {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ user.id,
+ tomorrow,
+ ~T[14:00:00],
+ ~T[15:00:00],
+ "Test Customer",
+ "test2@example.com",
+ nil,
+ nil
+ )
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ # Trigger a refresh
+ view |> form("form", %{"email" => ""}) |> render_change()
+
+ view
+ |> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
+ |> render_click()
+
+ view
+ |> element("textarea[name='reason']")
+ |> render_change(%{"reason" => "Test rejection"})
+
+ view
+ |> element("form[phx-submit='confirm_reject']")
+ |> render_submit()
+
+ # Just verify the view still works after rejection
+ html = render(view)
+ assert html =~ "Manage Bookings"
+ end
+ end
+
+ describe "empty states with pagination" do
+ test "shows empty state when no bookings exist", %{
+ conn: conn,
+ admin_user: admin_user
+ } do
+ conn = log_in_user(conn, admin_user)
+ {:ok, _view, html} = live(conn, "/admin/bookings")
+
+ assert html =~ "No bookings found"
+ end
+
+ test "shows pending count as 0 when no pending bookings", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ tomorrow: tomorrow
+ } do
+ start_datetime = DateTime.new!(tomorrow, ~T[10:00:00], "Etc/UTC")
+ end_datetime = DateTime.add(start_datetime, 3600, :second)
+
+ {:ok, booking} =
+ BookingSystem.create_walk_in(
+ space.id,
+ start_datetime,
+ end_datetime,
+ "Customer",
+ "customer@example.com",
+ nil
+ )
+
+ BookingSystem.approve_booking(booking)
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, _view, html} = live(conn, "/admin/bookings")
+
+ assert html =~ "0 "
+ end
+ end
+
+ describe "pagination with filters" do
+ test "pagination works with space filter", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ tomorrow: tomorrow
+ } do
+ user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
+
+ for i <- 1..15 do
+ hour = rem(8 + i, 24)
+ start_time = Time.new!(hour, 0, 0)
+ end_time = Time.new!(hour, 30, 0)
+
+ BookingSystem.create_booking(
+ space.id,
+ user.id,
+ tomorrow,
+ start_time,
+ end_time,
+ "Customer #{i}",
+ "customer#{i}@example.com",
+ nil,
+ nil
+ )
+ end
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ view
+ |> form("form", %{"space" => space.slug})
+ |> render_change()
+
+ html = render(view)
+ assert html =~ "Showing 1-10 of 15"
+ end
+
+ test "pagination works with email filter", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ tomorrow: tomorrow
+ } do
+ user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
+
+ for i <- 1..15 do
+ hour = rem(8 + i, 24)
+ start_time = Time.new!(hour, 0, 0)
+ end_time = Time.new!(hour, 30, 0)
+
+ BookingSystem.create_booking(
+ space.id,
+ user.id,
+ tomorrow,
+ start_time,
+ end_time,
+ "Customer #{i}",
+ "customer#{i}@example.com",
+ nil,
+ nil
+ )
+ end
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ view
+ |> form("form", %{"email" => "customer1@example.com"})
+ |> render_change()
+
+ html = render(view)
+ assert html =~ "Showing 1-1 of 1"
+ end
+
+ test "pagination works with date filter", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ tomorrow: tomorrow
+ } do
+ user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
+
+ for i <- 1..15 do
+ hour = rem(8 + i, 24)
+ start_time = Time.new!(hour, 0, 0)
+ end_time = Time.new!(hour, 30, 0)
+
+ BookingSystem.create_booking(
+ space.id,
+ user.id,
+ tomorrow,
+ start_time,
+ end_time,
+ "Customer #{i}",
+ "customer#{i}@example.com",
+ nil,
+ nil
+ )
+ end
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ view
+ |> form("form", %{"date" => Date.to_iso8601(tomorrow)})
+ |> render_change()
+
+ html = render(view)
+ assert html =~ "Showing 1-10 of 15"
+ end
+
+ test "clear filters resets pagination", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ tomorrow: tomorrow
+ } do
+ user = register_user("user#{System.unique_integer([:positive])}@example.com", "Test User")
+
+ for i <- 1..15 do
+ hour = rem(8 + i, 24)
+ start_time = Time.new!(hour, 0, 0)
+ end_time = Time.new!(hour, 30, 0)
+
+ BookingSystem.create_booking(
+ space.id,
+ user.id,
+ tomorrow,
+ start_time,
+ end_time,
+ "Customer #{i}",
+ "customer#{i}@example.com",
+ nil,
+ nil
+ )
+ end
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings?pending_page=2")
+
+ view
+ |> element("button[phx-click='clear_filters']")
+ |> render_click()
+
+ path = assert_patch(view)
+ refute path =~ "pending_page=2"
+ end
+ end
+end
diff --git a/test/spazio_solazzo_web/live/admin/booking_management_rejection_test.exs b/test/spazio_solazzo_web/live/admin/booking_management_rejection_test.exs
new file mode 100644
index 0000000..0bd5cd5
--- /dev/null
+++ b/test/spazio_solazzo_web/live/admin/booking_management_rejection_test.exs
@@ -0,0 +1,279 @@
+defmodule SpazioSolazzoWeb.Admin.BookingManagementRejectionTest do
+ use SpazioSolazzoWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+ alias SpazioSolazzo.BookingSystem
+
+ defp create_booking(space, user) do
+ tomorrow = Date.add(Date.utc_today(), 1)
+ start_time = ~T[10:00:00]
+ end_time = ~T[12:00:00]
+
+ {:ok, booking} =
+ BookingSystem.create_booking(
+ space.id,
+ user.id,
+ tomorrow,
+ start_time,
+ end_time,
+ "Test User",
+ "test@example.com",
+ "+1234567890",
+ "Test booking comment"
+ )
+
+ booking
+ end
+
+ setup do
+ {:ok, space} =
+ BookingSystem.create_space(
+ "Coworking",
+ "coworking",
+ "Coworking space",
+ 5
+ )
+
+ admin_user =
+ "admin@example.com"
+ |> register_user("Admin User")
+ |> SpazioSolazzo.Accounts.make_admin!(authorize?: false)
+
+ regular_user = register_user("user@example.com", "Regular User")
+
+ %{space: space, admin_user: admin_user, regular_user: regular_user}
+ end
+
+ describe "booking rejection modal" do
+ test "shows reject modal when clicking reject button", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ regular_user: regular_user
+ } do
+ booking = create_booking(space, regular_user)
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ refute has_element?(view, "#success-modal")
+
+ html =
+ view
+ |> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
+ |> render_click()
+
+ assert html =~ "Reject Booking"
+ assert html =~ "Rejection Reason"
+ end
+
+ test "hides reject modal when clicking cancel", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ regular_user: regular_user
+ } do
+ booking = create_booking(space, regular_user)
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ view
+ |> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
+ |> render_click()
+
+ html =
+ view
+ |> element("button[phx-click='hide_reject_modal']")
+ |> render_click()
+
+ refute html =~ "Reject Booking"
+ end
+
+ test "shows error when rejection reason is empty", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ regular_user: regular_user
+ } do
+ booking = create_booking(space, regular_user)
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ view
+ |> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
+ |> render_click()
+
+ html =
+ view
+ |> element("form[phx-submit='confirm_reject']")
+ |> render_submit(%{"reason" => ""})
+
+ assert html =~ "Please provide a rejection reason"
+ end
+
+ test "successfully rejects booking with valid reason", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ regular_user: regular_user
+ } do
+ booking = create_booking(space, regular_user)
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ view
+ |> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
+ |> render_click()
+
+ view
+ |> element("textarea[name='reason']")
+ |> render_change(%{"reason" => "Space under maintenance"})
+
+ html =
+ view
+ |> element("form[phx-submit='confirm_reject']")
+ |> render_submit()
+
+ assert html =~ "Booking rejected"
+
+ {:ok, updated_booking} = Ash.get(SpazioSolazzo.BookingSystem.Booking, booking.id)
+ assert updated_booking.state == :rejected
+ assert updated_booking.rejection_reason == "Space under maintenance"
+ end
+
+ test "updates rejection reason as user types", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ regular_user: regular_user
+ } do
+ booking = create_booking(space, regular_user)
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ view
+ |> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
+ |> render_click()
+
+ html =
+ view
+ |> element("textarea[name='reason']")
+ |> render_change(%{"reason" => "Fully booked"})
+
+ assert html =~ "Fully booked"
+ end
+
+ test "closes modal after successful rejection", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ regular_user: regular_user
+ } do
+ booking = create_booking(space, regular_user)
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ view
+ |> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
+ |> render_click()
+
+ view
+ |> element("textarea[name='reason']")
+ |> render_change(%{"reason" => "Not available"})
+
+ html =
+ view
+ |> element("form[phx-submit='confirm_reject']")
+ |> render_submit()
+
+ refute html =~ "Reject Booking"
+ assert html =~ "Booking rejected"
+ end
+
+ test "rejected booking moves from pending to past bookings", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ regular_user: regular_user
+ } do
+ booking = create_booking(space, regular_user)
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, html} = live(conn, "/admin/bookings")
+
+ assert html =~ "Pending"
+
+ view
+ |> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking.id}']")
+ |> render_click()
+
+ view
+ |> element("textarea[name='reason']")
+ |> render_change(%{"reason" => "Not available"})
+
+ _html =
+ view
+ |> element("form[phx-submit='confirm_reject']")
+ |> render_submit()
+
+ {:ok, updated_booking} = Ash.get(SpazioSolazzo.BookingSystem.Booking, booking.id)
+ assert updated_booking.state == :rejected
+ assert updated_booking.rejection_reason == "Not available"
+ end
+
+ test "multiple bookings can be rejected independently", %{
+ conn: conn,
+ admin_user: admin_user,
+ space: space,
+ regular_user: regular_user
+ } do
+ tomorrow = Date.add(Date.utc_today(), 1)
+
+ {:ok, booking1} =
+ BookingSystem.create_booking(
+ space.id,
+ regular_user.id,
+ tomorrow,
+ ~T[09:00:00],
+ ~T[11:00:00],
+ "Test User",
+ "test@example.com",
+ "+1234567890",
+ "First booking"
+ )
+
+ {:ok, booking2} =
+ BookingSystem.create_booking(
+ space.id,
+ regular_user.id,
+ tomorrow,
+ ~T[14:00:00],
+ ~T[16:00:00],
+ "Test User",
+ "test@example.com",
+ "+1234567890",
+ "Second booking"
+ )
+
+ conn = log_in_user(conn, admin_user)
+ {:ok, view, _html} = live(conn, "/admin/bookings")
+
+ view
+ |> element("button[phx-click='show_reject_modal'][phx-value-booking_id='#{booking1.id}']")
+ |> render_click()
+
+ view
+ |> element("textarea[name='reason']")
+ |> render_change(%{"reason" => "Reason 1"})
+
+ view
+ |> element("form[phx-submit='confirm_reject']")
+ |> render_submit()
+
+ {:ok, updated_booking1} = Ash.get(SpazioSolazzo.BookingSystem.Booking, booking1.id)
+ {:ok, updated_booking2} = Ash.get(SpazioSolazzo.BookingSystem.Booking, booking2.id)
+
+ assert updated_booking1.state == :rejected
+ assert updated_booking1.rejection_reason == "Reason 1"
+ assert updated_booking2.state == :requested
+ end
+ end
+end
diff --git a/test/spazio_solazzo_web/live/admin/walk_in_live_simple_test.exs b/test/spazio_solazzo_web/live/admin/walk_in_live_simple_test.exs
new file mode 100644
index 0000000..7972aed
--- /dev/null
+++ b/test/spazio_solazzo_web/live/admin/walk_in_live_simple_test.exs
@@ -0,0 +1,64 @@
+defmodule SpazioSolazzoWeb.Admin.WalkInLiveSimpleTest do
+ use SpazioSolazzoWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+ alias SpazioSolazzo.BookingSystem
+
+ setup do
+ {:ok, space} =
+ BookingSystem.create_space(
+ "Coworking",
+ "coworking",
+ "Coworking space",
+ 5
+ )
+
+ user =
+ "admin@example.com"
+ |> register_user("Admin User")
+ |> SpazioSolazzo.Accounts.make_admin!(authorize?: false)
+
+ %{space: space, user: user}
+ end
+
+ describe "walk-in booking creation bug" do
+ test "can create booking by directly setting assigns", %{conn: conn, user: user, space: space} do
+ conn = log_in_user(conn, user)
+ {:ok, view, _html} = live(conn, "/admin/walk-in")
+
+ tomorrow = Date.add(Date.utc_today(), 1)
+
+ # Simulate the calendar component sending the date_selected message
+ send(view.pid, {:date_selected, tomorrow, tomorrow})
+
+ # Fill in customer details using the form
+ view
+ |> form("form[phx-change='validate_customer_details']", %{
+ "customer_name" => "John Doe",
+ "customer_email" => "john@example.com"
+ })
+ |> render_change()
+
+ # Try to create the booking
+ html =
+ view
+ |> element("form[phx-submit='create_booking']")
+ |> render_submit()
+
+ assert html =~ "Walk-in booking created successfully"
+
+ # Verify booking was created
+ start_datetime = DateTime.new!(tomorrow, ~T[00:00:00], "Etc/UTC")
+ end_datetime = DateTime.new!(Date.add(tomorrow, 1), ~T[00:00:00], "Etc/UTC")
+
+ assert {:ok, [_booking]} =
+ BookingSystem.search_bookings(
+ space.id,
+ start_datetime,
+ end_datetime,
+ [:accepted],
+ nil
+ )
+ end
+ end
+end
diff --git a/test/spazio_solazzo_web/live/admin/walk_in_live_test.exs b/test/spazio_solazzo_web/live/admin/walk_in_live_test.exs
new file mode 100644
index 0000000..db03f96
--- /dev/null
+++ b/test/spazio_solazzo_web/live/admin/walk_in_live_test.exs
@@ -0,0 +1,279 @@
+defmodule SpazioSolazzoWeb.Admin.WalkInLiveTest do
+ use SpazioSolazzoWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+ alias SpazioSolazzo.BookingSystem
+
+ setup do
+ {:ok, space} =
+ BookingSystem.create_space(
+ "Coworking",
+ "coworking",
+ "Coworking space",
+ 5
+ )
+
+ user =
+ "admin@example.com"
+ |> register_user("Admin User")
+ |> SpazioSolazzo.Accounts.make_admin!(authorize?: false)
+
+ %{space: space, user: user}
+ end
+
+ describe "walk-in booking form" do
+ test "displays the form with calendar and customer details", %{conn: conn, user: user} do
+ conn = log_in_user(conn, user)
+ {:ok, view, _html} = live(conn, "/admin/walk-in")
+
+ assert has_element?(view, "form[phx-change='validate_customer_details']")
+ assert has_element?(view, "input[name='customer_name']")
+ assert has_element?(view, "input[name='customer_email']")
+ assert has_element?(view, "button[type='submit']")
+ end
+
+ test "creates single-day walk-in booking successfully", %{
+ conn: conn,
+ user: user,
+ space: space
+ } do
+ conn = log_in_user(conn, user)
+ {:ok, view, _html} = live(conn, "/admin/walk-in")
+
+ tomorrow = Date.add(Date.utc_today(), 1)
+
+ # Simulate date selection by sending message to LiveView
+ send(view.pid, {:date_selected, tomorrow, tomorrow})
+ :timer.sleep(50)
+
+ # Fill in customer details
+ view
+ |> form("form[phx-change='validate_customer_details']", %{
+ "customer_name" => "John Doe",
+ "customer_email" => "john@example.com"
+ })
+ |> render_change()
+
+ # Submit the form
+ html =
+ view
+ |> element("form[phx-submit='create_booking']")
+ |> render_submit()
+
+ assert html =~ "Walk-in booking created successfully"
+
+ # Verify booking was created
+ start_datetime = DateTime.new!(tomorrow, ~T[00:00:00], "Etc/UTC")
+ end_datetime = DateTime.new!(Date.add(tomorrow, 1), ~T[00:00:00], "Etc/UTC")
+
+ {:ok, bookings} =
+ BookingSystem.search_bookings(
+ space.id,
+ start_datetime,
+ end_datetime,
+ [:accepted],
+ nil
+ )
+
+ assert length(bookings) == 1
+ booking = hd(bookings)
+ assert booking.customer_name == "John Doe"
+ assert booking.customer_email == "john@example.com"
+ assert booking.state == :accepted
+ end
+
+ test "shows error when no date is selected", %{conn: conn, user: user} do
+ conn = log_in_user(conn, user)
+ {:ok, view, _html} = live(conn, "/admin/walk-in")
+
+ # Fill in customer details without selecting a date
+ view
+ |> form("form[phx-change='validate_customer_details']", %{
+ "customer_name" => "John Doe",
+ "customer_email" => "john@example.com"
+ })
+ |> render_change()
+
+ # Try to submit
+ html =
+ view
+ |> element("form[phx-submit='create_booking']")
+ |> render_submit()
+
+ assert html =~ "Please fill in all required fields and select a date"
+ end
+
+ test "shows error when customer name is missing", %{conn: conn, user: user} do
+ conn = log_in_user(conn, user)
+ {:ok, view, _html} = live(conn, "/admin/walk-in")
+
+ tomorrow = Date.add(Date.utc_today(), 1)
+ send(view.pid, {:date_selected, tomorrow, tomorrow})
+ :timer.sleep(50)
+
+ view
+ |> form("form[phx-change='validate_customer_details']", %{
+ "customer_email" => "john@example.com"
+ })
+ |> render_change()
+
+ html =
+ view
+ |> element("form[phx-submit='create_booking']")
+ |> render_submit()
+
+ assert html =~ "Please fill in all required fields and select a date"
+ end
+
+ test "shows error when customer email is missing", %{conn: conn, user: user} do
+ conn = log_in_user(conn, user)
+ {:ok, view, _html} = live(conn, "/admin/walk-in")
+
+ tomorrow = Date.add(Date.utc_today(), 1)
+ send(view.pid, {:date_selected, tomorrow, tomorrow})
+ :timer.sleep(50)
+
+ view
+ |> form("form[phx-change='validate_customer_details']", %{
+ "customer_name" => "John Doe"
+ })
+ |> render_change()
+
+ html =
+ view
+ |> element("form[phx-submit='create_booking']")
+ |> render_submit()
+
+ assert html =~ "Please fill in all required fields and select a date"
+ end
+
+ test "creates multi-day walk-in booking", %{conn: conn, user: user, space: space} do
+ conn = log_in_user(conn, user)
+ {:ok, view, _html} = live(conn, "/admin/walk-in")
+
+ # Select date range (3 days)
+ start_date = Date.add(Date.utc_today(), 1)
+ end_date = Date.add(Date.utc_today(), 3)
+
+ send(view.pid, {:date_selected, start_date, end_date})
+ :timer.sleep(50)
+
+ # Fill in customer details
+ view
+ |> form("form[phx-change='validate_customer_details']", %{
+ "customer_name" => "Jane Smith",
+ "customer_email" => "jane@example.com"
+ })
+ |> render_change()
+
+ # Submit the form
+ html =
+ view
+ |> element("form[phx-submit='create_booking']")
+ |> render_submit()
+
+ assert html =~ "Walk-in booking created successfully"
+
+ # Verify booking was created and spans multiple days
+ start_datetime = DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC")
+ end_datetime_search = DateTime.new!(Date.add(start_date, 1), ~T[00:00:00], "Etc/UTC")
+
+ {:ok, bookings} =
+ BookingSystem.search_bookings(
+ space.id,
+ start_datetime,
+ end_datetime_search,
+ [:accepted],
+ nil
+ )
+
+ assert length(bookings) == 1
+ booking = hd(bookings)
+ assert booking.customer_name == "Jane Smith"
+
+ # Verify booking appears on all days in the range
+ day2_start = DateTime.new!(Date.add(start_date, 1), ~T[00:00:00], "Etc/UTC")
+ day2_end = DateTime.new!(Date.add(start_date, 2), ~T[00:00:00], "Etc/UTC")
+
+ {:ok, day2_bookings} =
+ BookingSystem.search_bookings(space.id, day2_start, day2_end, [:accepted], nil)
+
+ assert length(day2_bookings) == 1
+
+ day3_start = DateTime.new!(end_date, ~T[00:00:00], "Etc/UTC")
+ day3_end = DateTime.new!(Date.add(end_date, 1), ~T[00:00:00], "Etc/UTC")
+
+ {:ok, day3_bookings} =
+ BookingSystem.search_bookings(space.id, day3_start, day3_end, [:accepted], nil)
+
+ assert length(day3_bookings) == 1
+ end
+
+ test "includes optional phone", %{conn: conn, user: user, space: space} do
+ conn = log_in_user(conn, user)
+ {:ok, view, _html} = live(conn, "/admin/walk-in")
+
+ tomorrow = Date.add(Date.utc_today(), 1)
+ send(view.pid, {:date_selected, tomorrow, tomorrow})
+ :timer.sleep(50)
+
+ view
+ |> form("form[phx-change='validate_customer_details']", %{
+ "customer_name" => "John Doe",
+ "customer_email" => "john@example.com",
+ "customer_phone" => "+39 1234567890"
+ })
+ |> render_change()
+
+ html =
+ view
+ |> element("form[phx-submit='create_booking']")
+ |> render_submit()
+
+ assert html =~ "Walk-in booking created successfully"
+
+ start_datetime = DateTime.new!(tomorrow, ~T[00:00:00], "Etc/UTC")
+ end_datetime = DateTime.new!(Date.add(tomorrow, 1), ~T[00:00:00], "Etc/UTC")
+
+ {:ok, bookings} =
+ BookingSystem.search_bookings(
+ space.id,
+ start_datetime,
+ end_datetime,
+ [:accepted],
+ nil
+ )
+
+ booking = hd(bookings)
+ assert booking.customer_phone == "+39 1234567890"
+ end
+
+ test "clears form after successful booking", %{conn: conn, user: user} do
+ conn = log_in_user(conn, user)
+ {:ok, view, _html} = live(conn, "/admin/walk-in")
+
+ tomorrow = Date.add(Date.utc_today(), 1)
+ send(view.pid, {:date_selected, tomorrow, tomorrow})
+ :timer.sleep(50)
+
+ view
+ |> form("form[phx-change='validate_customer_details']", %{
+ "customer_name" => "John Doe",
+ "customer_email" => "john@example.com"
+ })
+ |> render_change()
+
+ html =
+ view
+ |> element("form[phx-submit='create_booking']")
+ |> render_submit()
+
+ assert html =~ "Walk-in booking created successfully"
+
+ # Check that form inputs are cleared by verifying empty values
+ html = render(view)
+ assert html =~ "Not selected"
+ assert html =~ ~s(value="")
+ end
+ end
+end
diff --git a/test/spazio_solazzo_web/live/booking_live/asset_booking_test.exs b/test/spazio_solazzo_web/live/booking_live/asset_booking_test.exs
deleted file mode 100644
index 2e5dd89..0000000
--- a/test/spazio_solazzo_web/live/booking_live/asset_booking_test.exs
+++ /dev/null
@@ -1,323 +0,0 @@
-defmodule SpazioSolazzoWeb.BookingLive.AssetBookingTest do
- use SpazioSolazzoWeb.ConnCase
- import Phoenix.LiveViewTest
-
- alias SpazioSolazzo.BookingSystem
-
- setup %{conn: conn} do
- {:ok, space} = BookingSystem.create_space("TestSpace", "test-space", "Test description")
- {:ok, asset} = BookingSystem.create_asset("Test Asset", space.id)
-
- today = Date.utc_today()
- day_of_week = SpazioSolazzo.DateExt.day_of_week_atom(today)
-
- {:ok, slot} =
- BookingSystem.create_time_slot_template(
- ~T[09:00:00],
- ~T[10:00:00],
- day_of_week,
- space.id
- )
-
- user = register_user("test@example.com", "Test User", "+1234567890")
- conn = log_in_user(conn, user)
-
- %{space: space, asset: asset, slot: slot, conn: conn}
- end
-
- describe "AssetBooking mount" do
- test "renders asset booking page with available time slots", %{
- conn: conn,
- space: space,
- asset: asset
- } do
- {:ok, view, html} = live(conn, ~p"/book/asset/#{asset.id}")
-
- assert html =~ space.name
- assert html =~ asset.name
- assert html =~ "Available Time Slots"
- assert has_element?(view, "button[phx-click='select_slot']")
- assert has_element?(view, "#booking-calendar")
- end
-
- test "displays calendar with current month", %{conn: conn, asset: asset} do
- {:ok, view, html} = live(conn, ~p"/book/asset/#{asset.id}")
-
- today = Date.utc_today()
- month_name = Calendar.strftime(today, "%B %Y")
-
- assert html =~ month_name
- assert has_element?(view, ".calendar-container")
- end
-
- test "displays back button to space landing page", %{conn: conn, asset: asset, space: space} do
- {:ok, _view, html} = live(conn, ~p"/book/asset/#{asset.id}")
-
- assert html =~ "Back to #{space.name}"
- assert html =~ "/#{space.slug}"
- end
-
- test "redirects when asset not found", %{conn: conn} do
- assert {:error, {:live_redirect, %{to: "/", flash: %{"error" => "Asset not found"}}}} =
- live(conn, ~p"/book/asset/00000000-0000-0000-0000-000000000000")
- end
- end
-
- describe "AssetBooking time slot selection" do
- test "opens booking modal when clicking a time slot", %{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, "textarea[name='customer_comment']")
- end
- end
-
- describe "AssetBooking full booking flow" do
- test "completes full booking flow", %{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")
-
- view
- |> element("#booking-form")
- |> render_submit(%{
- "customer_name" => "Test User",
- "customer_phone" => "+1234567890",
- "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 == "test@example.com"
- assert booking.customer_name == "Test User"
- assert booking.customer_phone == "+1234567890"
- assert booking.customer_comment == "test comment"
- end
- end
-
- describe "AssetBooking cancellation" do
- test "cancels booking flow", %{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")
-
- view
- |> element("button", "Cancel")
- |> render_click()
-
- refute has_element?(view, "#booking-form")
- end
- end
-
- describe "AssetBooking date selection" do
- test "updates available time slots when selecting date from calendar", %{
- conn: conn,
- asset: asset,
- space: space
- } do
- {:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
-
- tomorrow = Date.add(Date.utc_today(), 1)
- tomorrow_day_of_week = SpazioSolazzo.DateExt.day_of_week_atom(tomorrow)
-
- {:ok, _slot} =
- BookingSystem.create_time_slot_template(
- ~T[14:00:00],
- ~T[15:00:00],
- tomorrow_day_of_week,
- space.id
- )
-
- # Click on a date in the calendar
- view
- |> element(
- "#booking-calendar button[phx-click='select-date'][phx-value-date='#{Date.to_iso8601(tomorrow)}']"
- )
- |> render_click()
-
- assert has_element?(view, "button[phx-click='select_slot']")
- end
-
- test "prevents selection of past dates", %{conn: conn, asset: asset} do
- {:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
-
- yesterday = Date.add(Date.utc_today(), -1)
-
- # Past dates should be disabled
- assert has_element?(
- view,
- "#booking-calendar button[disabled][phx-value-date='#{Date.to_iso8601(yesterday)}']"
- )
- end
-
- test "displays selected date in the time slots section", %{conn: conn, asset: asset} do
- {:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
-
- today = Date.utc_today()
- formatted_date = SpazioSolazzo.CalendarExt.format_date(today)
-
- assert has_element?(view, ".time-slots-wrapper", formatted_date)
- end
- end
-
- describe "AssetBooking calendar navigation" do
- test "navigates to next month", %{conn: conn, asset: asset} do
- {:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
-
- current_month = Date.utc_today() |> Date.beginning_of_month()
- next_month = Date.shift(current_month, month: 1)
- next_month_name = Calendar.strftime(next_month, "%B %Y")
-
- view
- |> element("#booking-calendar button[phx-click='next-month']")
- |> render_click()
-
- assert has_element?(view, ".calendar-container", next_month_name)
- end
-
- test "navigates to previous month", %{conn: conn, asset: asset} do
- {:ok, view, _html} = live(conn, ~p"/book/asset/#{asset.id}")
-
- current_month = Date.utc_today() |> Date.beginning_of_month()
- prev_month = Date.shift(current_month, month: -1)
- prev_month_name = Calendar.strftime(prev_month, "%B %Y")
-
- view
- |> element("#booking-calendar button[phx-click='prev-month']")
- |> render_click()
-
- assert has_element?(view, ".calendar-container", prev_month_name)
- end
-
- test "only displays days from current viewing month", %{conn: conn, asset: asset} do
- {:ok, _view, html} = live(conn, ~p"/book/asset/#{asset.id}")
-
- # Calendar should have empty divs for days not in current month
- assert html =~ ~s(
)
- 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
diff --git a/test/spazio_solazzo_web/live/booking_live/space_booking_test.exs b/test/spazio_solazzo_web/live/booking_live/space_booking_test.exs
new file mode 100644
index 0000000..799c5d7
--- /dev/null
+++ b/test/spazio_solazzo_web/live/booking_live/space_booking_test.exs
@@ -0,0 +1,680 @@
+defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
+ use SpazioSolazzoWeb.ConnCase
+ import Phoenix.LiveViewTest
+ alias SpazioSolazzo.BookingSystem
+
+ # Helper to convert old map-based call to new signature
+ defp request_booking(space_id, user_id, date, start_time, end_time, customer_details) do
+ BookingSystem.create_booking(
+ space_id,
+ user_id,
+ date,
+ start_time,
+ end_time,
+ customer_details.name,
+ customer_details.email,
+ customer_details[:phone],
+ customer_details[:comment]
+ )
+ end
+
+ setup %{conn: conn} do
+ {:ok, space} =
+ BookingSystem.create_space(
+ "Test Space",
+ "test-space",
+ "Test description",
+ 2
+ )
+
+ today = Date.utc_today()
+ day_of_week = day_of_week_atom(today)
+
+ {:ok, slot1} =
+ BookingSystem.create_time_slot_template(
+ ~T[09:00:00],
+ ~T[13:00:00],
+ day_of_week,
+ space.id
+ )
+
+ {:ok, slot2} =
+ BookingSystem.create_time_slot_template(
+ ~T[14:00:00],
+ ~T[18:00:00],
+ day_of_week,
+ space.id
+ )
+
+ user = register_user("testuser@example.com", "Test User", "+39 1234567890")
+ unauth_conn = conn
+ conn = log_in_user(conn, user)
+
+ %{
+ conn: conn,
+ unauth_conn: unauth_conn,
+ space: space,
+ slot1: slot1,
+ slot2: slot2,
+ today: today,
+ user: user
+ }
+ end
+
+ defp day_of_week_atom(date) do
+ case Date.day_of_week(date) do
+ 1 -> :monday
+ 2 -> :tuesday
+ 3 -> :wednesday
+ 4 -> :thursday
+ 5 -> :friday
+ 6 -> :saturday
+ 7 -> :sunday
+ end
+ end
+
+ describe "SpaceBooking mount" do
+ test "renders space booking page with available time slots", %{
+ conn: conn,
+ space: space
+ } do
+ {:ok, view, html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ assert html =~ space.name
+ assert html =~ space.description
+ assert html =~ "Available Time Slots"
+ assert has_element?(view, "button[phx-click='select_slot']")
+ assert has_element?(view, "#booking-calendar")
+ end
+
+ test "displays calendar with current month", %{conn: conn, space: space} do
+ {:ok, view, html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ today = Date.utc_today()
+ month_name = Calendar.strftime(today, "%B %Y")
+
+ assert html =~ month_name
+ assert has_element?(view, ".calendar-container")
+ end
+
+ test "displays back button to home page", %{conn: conn, space: space} do
+ {:ok, _view, html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ assert html =~ "Back to #{space.name}"
+ assert html =~ "/#{space.slug}"
+ end
+
+ test "redirects when space not found", %{conn: conn} do
+ assert {:error, {:live_redirect, %{to: "/", flash: %{"error" => "Space not found"}}}} =
+ live(conn, ~p"/book/space/nonexistent-space")
+ end
+
+ test "shows message when no time slots available", %{conn: conn} do
+ {:ok, empty_space} =
+ BookingSystem.create_space(
+ "Empty Space",
+ "empty-space",
+ "No slots",
+ 5
+ )
+
+ {:ok, _view, html} = live(conn, ~p"/book/space/#{empty_space.slug}")
+
+ assert html =~ "No time slots available for this date"
+ end
+ end
+
+ describe "SpaceBooking calendar navigation" do
+ test "navigates to previous month", %{conn: conn, space: space} do
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ today = Date.utc_today()
+ current_month = Calendar.strftime(today, "%B %Y")
+
+ prev_month =
+ today
+ |> Date.shift(month: -1)
+ |> Calendar.strftime("%B %Y")
+
+ html = view |> element("button[phx-click='prev-month']") |> render_click()
+
+ assert html =~ prev_month
+ refute html =~ current_month
+ end
+
+ test "navigates to next month", %{conn: conn, space: space} do
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ today = Date.utc_today()
+ current_month = Calendar.strftime(today, "%B %Y")
+
+ next_month =
+ today
+ |> Date.shift(month: 1)
+ |> Calendar.strftime("%B %Y")
+
+ html = view |> element("button[phx-click='next-month']") |> render_click()
+
+ assert html =~ next_month
+ refute html =~ current_month
+ end
+ end
+
+ describe "SpaceBooking date selection" do
+ test "updates time slots when selecting a different date", %{conn: conn, space: space} do
+ # Find next Monday from today
+ today = Date.utc_today()
+ days_until_monday = rem(8 - Date.day_of_week(today), 7)
+ days_until_monday = if days_until_monday == 0, do: 7, else: days_until_monday
+ monday_date = Date.add(today, days_until_monday)
+
+ {:ok, _monday_slot} =
+ BookingSystem.create_time_slot_template(
+ ~T[20:00:00],
+ ~T[22:00:00],
+ :monday,
+ space.id
+ )
+
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ # Click on a date button in the calendar
+ view
+ |> element("button[phx-value-date='#{Date.to_iso8601(monday_date)}']")
+ |> render_click()
+
+ html = render(view)
+
+ assert html =~ "20:00"
+ assert html =~ "22:00"
+ end
+
+ test "shows selected date in formatted string", %{conn: conn, space: space} do
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ today = Date.utc_today()
+ formatted_date = Calendar.strftime(today, "%A, %B %d, %Y")
+
+ html = render(view)
+
+ assert html =~ formatted_date
+ end
+
+ test "does not allow selecting past dates", %{conn: conn, space: space} do
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ # Navigate to previous month (January 2026), where all dates are in the past
+ view
+ |> element("button[phx-click='prev-month']")
+ |> render_click()
+
+ # Check that there's at least one disabled button (past date)
+ html = render(view)
+ assert html =~ "disabled"
+
+ # Verify that clicking a past date doesn't change the selected date
+ # by checking we can't click on January 15th (a past date)
+ refute has_element?(view, "button[phx-click='select-date'][phx-value-date='2026-01-15']")
+ end
+ end
+
+ describe "SpaceBooking time slot display" do
+ test "displays available time slots with correct styling", %{conn: conn, space: space} do
+ {:ok, _view, html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ assert html =~ "09:00"
+ assert html =~ "13:00"
+ assert html =~ "14:00"
+ assert html =~ "18:00"
+ assert html =~ "Available - Request Booking"
+ end
+
+ test "shows high demand label for slots over public capacity", %{
+ conn: conn,
+ space: space,
+ today: today
+ } do
+ for i <- 1..2 do
+ {:ok, booking} =
+ request_booking(
+ space.id,
+ nil,
+ today,
+ ~T[09:00:00],
+ ~T[13:00:00],
+ %{name: "User #{i}", email: "user#{i}@example.com", phone: "", comment: ""}
+ )
+
+ {:ok, _} = BookingSystem.approve_booking(booking.id)
+ end
+
+ {:ok, _view, html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ assert html =~ "High Demand - Join Waitlist"
+ end
+
+ test "shows slots over capacity with high demand warning", %{
+ conn: conn,
+ space: space,
+ today: today
+ } do
+ for i <- 1..3 do
+ {:ok, booking} =
+ request_booking(
+ space.id,
+ nil,
+ today,
+ ~T[09:00:00],
+ ~T[13:00:00],
+ %{name: "User #{i}", email: "user#{i}@example.com", phone: "", comment: ""}
+ )
+
+ {:ok, _} = BookingSystem.approve_booking(booking.id)
+ end
+
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ html = render(view)
+
+ assert html =~ "14:00"
+ assert html =~ "09:00"
+ assert html =~ "High Demand"
+ end
+ end
+
+ describe "SpaceBooking modal interaction" do
+ test "opens booking modal when clicking a time slot", %{
+ conn: conn,
+ space: space,
+ slot1: slot1,
+ user: user
+ } do
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ view
+ |> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
+ |> render_click()
+
+ assert has_element?(view, "#booking-modal")
+ assert has_element?(view, "input[name='customer_name']")
+ # Authenticated users see their email displayed, not an input field
+ assert render(view) =~ to_string(user.email)
+ assert has_element?(view, "input[name='customer_phone']")
+ assert has_element?(view, "textarea[name='customer_comment']")
+ end
+
+ test "closes modal when clicking cancel", %{conn: conn, space: space, slot1: slot1} do
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ view
+ |> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
+ |> render_click()
+
+ assert has_element?(view, "#booking-modal")
+
+ view
+ |> element("button", "Cancel")
+ |> render_click()
+
+ refute has_element?(view, "#booking-modal")
+ end
+
+ test "shows high demand warning in modal for slots over public capacity", %{
+ conn: conn,
+ space: space,
+ today: today,
+ slot1: slot1
+ } do
+ for i <- 1..2 do
+ {:ok, booking} =
+ request_booking(
+ space.id,
+ nil,
+ today,
+ ~T[09:00:00],
+ ~T[13:00:00],
+ %{name: "User #{i}", email: "user#{i}@example.com", phone: "", comment: ""}
+ )
+
+ {:ok, _} = BookingSystem.approve_booking(booking.id)
+ end
+
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ html =
+ view
+ |> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
+ |> render_click()
+
+ assert html =~ "High Demand Time Slot"
+ assert html =~ "subject to admin approval"
+ end
+ end
+
+ describe "SpaceBooking submission" do
+ test "completes booking request successfully", %{conn: conn, space: space, slot1: slot1} do
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ view
+ |> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
+ |> render_click()
+
+ form_data = %{
+ "customer_name" => "Test User",
+ "customer_email" => "testuser@example.com",
+ "customer_phone" => "+39 1234567890",
+ "customer_comment" => "Test comment"
+ }
+
+ view
+ |> element("#booking-form")
+ |> render_submit(form_data)
+
+ assert has_element?(view, "#success-modal")
+ assert render(view) =~ "Request Submitted!"
+ assert render(view) =~ "pending approval"
+ end
+
+ test "creates booking in database", %{conn: conn, space: space, today: today, slot1: slot1} do
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ view
+ |> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
+ |> render_click()
+
+ form_data = %{
+ "customer_name" => "Test User",
+ "customer_email" => "testuser@example.com",
+ "customer_phone" => "+39 1234567890",
+ "customer_comment" => "Test comment"
+ }
+
+ view
+ |> element("#booking-form")
+ |> render_submit(form_data)
+
+ # Process the handle_info message that creates the booking
+ render(view)
+
+ day_start = DateTime.new!(today, ~T[00:00:00], "Etc/UTC")
+ day_end = DateTime.new!(today, ~T[23:59:59], "Etc/UTC")
+
+ {:ok, bookings} =
+ BookingSystem.search_bookings(
+ space.id,
+ day_start,
+ day_end,
+ [:requested],
+ [:customer_name, :customer_email, :customer_phone, :customer_comment, :state]
+ )
+
+ assert length(bookings) == 1
+ booking = hd(bookings)
+ assert booking.customer_name == "Test User"
+ assert booking.customer_email == "testuser@example.com"
+ assert booking.customer_phone == "+39 1234567890"
+ assert booking.customer_comment == "Test comment"
+ assert booking.state == :requested
+ end
+
+ test "validates required fields", %{conn: conn, space: space, slot1: slot1} do
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ view
+ |> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
+ |> render_click()
+
+ form_data = %{
+ "customer_name" => "",
+ "customer_email" => "",
+ "customer_phone" => "",
+ "customer_comment" => ""
+ }
+
+ view
+ |> element("#booking-form")
+ |> render_submit(form_data)
+
+ refute has_element?(view, "#success-modal")
+ end
+
+ test "closes success modal when clicking close", %{conn: conn, space: space, slot1: slot1} do
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ view
+ |> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
+ |> render_click()
+
+ form_data = %{
+ "customer_name" => "Test User",
+ "customer_email" => "testuser@example.com",
+ "customer_phone" => "",
+ "customer_comment" => ""
+ }
+
+ view
+ |> element("#booking-form")
+ |> render_submit(form_data)
+
+ assert has_element?(view, "#success-modal")
+
+ view
+ |> element("button[phx-click='close_success_modal']")
+ |> render_click()
+
+ refute has_element?(view, "#success-modal")
+ end
+ end
+
+ describe "SpaceBooking real-time updates" do
+ test "updates availability when booking is approved", %{
+ conn: conn,
+ space: space,
+ today: today
+ } do
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ initial_html = render(view)
+ assert initial_html =~ "Available - Request Booking"
+
+ {:ok, booking} =
+ request_booking(
+ space.id,
+ nil,
+ today,
+ ~T[09:00:00],
+ ~T[13:00:00],
+ %{name: "User 1", email: "user1@example.com", phone: "", comment: ""}
+ )
+
+ {:ok, _} = BookingSystem.approve_booking(booking.id)
+
+ html = render(view)
+ assert html =~ "Available - Request Booking"
+
+ {:ok, booking2} =
+ request_booking(
+ space.id,
+ nil,
+ today,
+ ~T[09:00:00],
+ ~T[13:00:00],
+ %{name: "User 2", email: "user2@example.com", phone: "", comment: ""}
+ )
+
+ {:ok, _} = BookingSystem.approve_booking(booking2.id)
+
+ html = render(view)
+ assert html =~ "High Demand - Join Waitlist"
+ end
+
+ test "updates when booking is cancelled", %{conn: conn, space: space, today: today} do
+ {:ok, booking1} =
+ request_booking(
+ space.id,
+ nil,
+ today,
+ ~T[09:00:00],
+ ~T[13:00:00],
+ %{name: "User 1", email: "user1@example.com", phone: "", comment: ""}
+ )
+
+ {:ok, _} = BookingSystem.approve_booking(booking1.id)
+
+ {:ok, booking2} =
+ request_booking(
+ space.id,
+ nil,
+ today,
+ ~T[09:00:00],
+ ~T[13:00:00],
+ %{name: "User 2", email: "user2@example.com", phone: "", comment: ""}
+ )
+
+ {:ok, _} = BookingSystem.approve_booking(booking2.id)
+
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ html = render(view)
+ assert html =~ "High Demand - Join Waitlist"
+
+ {:ok, _} = BookingSystem.cancel_booking(booking1.id, "Test cancellation")
+
+ html = render(view)
+ assert html =~ "Available - Request Booking"
+ end
+ end
+
+ describe "SpaceBooking authenticated user flow" do
+ test "pre-fills user data in booking form", %{
+ conn: conn,
+ space: space,
+ user: user,
+ slot1: slot1
+ } do
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ html =
+ view
+ |> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
+ |> render_click()
+
+ assert html =~ user.name
+ assert html =~ to_string(user.email)
+ assert html =~ user.phone_number
+ end
+
+ test "uses authenticated user email for booking", %{
+ slot1: slot1,
+ conn: conn,
+ space: space,
+ user: user,
+ today: today
+ } do
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ view
+ |> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
+ |> render_click()
+
+ form_data = %{
+ "customer_name" => "Test User",
+ "customer_phone" => "+39 1234567890",
+ "customer_comment" => "Test"
+ }
+
+ view
+ |> element("#booking-form")
+ |> render_submit(form_data)
+
+ # Process the handle_info message that creates the booking
+ render(view)
+
+ day_start = DateTime.new!(today, ~T[00:00:00], "Etc/UTC")
+ day_end = DateTime.new!(today, ~T[23:59:59], "Etc/UTC")
+
+ {:ok, bookings} =
+ BookingSystem.search_bookings(
+ space.id,
+ day_start,
+ day_end,
+ [:requested],
+ [:customer_email, :user_id]
+ )
+
+ assert length(bookings) == 1
+ booking = hd(bookings)
+ assert to_string(booking.customer_email) == to_string(user.email)
+ assert booking.user_id == user.id
+ end
+ end
+
+ describe "SpaceBooking edge cases" do
+ # Note: This test was removed because duplicate booking prevention now correctly
+ # blocks the same user from booking the same slot twice, which is the expected behavior.
+ # Duplicate booking prevention is thoroughly tested in duplicate_booking_prevention_test.exs
+
+ test "shows high demand when public capacity is reached", %{conn: conn} do
+ {:ok, small_space} =
+ BookingSystem.create_space(
+ "Small Space",
+ "small-space",
+ "Limited capacity",
+ 1
+ )
+
+ today = Date.utc_today()
+ day_of_week = day_of_week_atom(today)
+
+ {:ok, _slot} =
+ BookingSystem.create_time_slot_template(
+ ~T[09:00:00],
+ ~T[10:00:00],
+ day_of_week,
+ small_space.id
+ )
+
+ {:ok, booking} =
+ request_booking(
+ small_space.id,
+ nil,
+ today,
+ ~T[09:00:00],
+ ~T[10:00:00],
+ %{name: "User 1", email: "user1@example.com", phone: "", comment: ""}
+ )
+
+ {:ok, _} = BookingSystem.approve_booking(booking.id)
+
+ {:ok, _view, html} = live(conn, ~p"/book/space/#{small_space.slug}")
+
+ assert html =~ "High Demand - Join Waitlist"
+ end
+
+ test "handles rapid date changes", %{conn: conn, space: space} do
+ {:ok, view, _html} = live(conn, ~p"/book/space/#{space.slug}")
+
+ # Use future dates relative to today
+ today = Date.utc_today()
+
+ dates = [
+ Date.add(today, 1),
+ Date.add(today, 2),
+ Date.add(today, 3),
+ Date.add(today, 1)
+ ]
+
+ for date <- dates do
+ view
+ |> element("button[phx-click='select-date'][phx-value-date='#{Date.to_iso8601(date)}']")
+ |> render_click()
+ end
+
+ html = render(view)
+ # Verify the last selected date is shown (which is Date.add(today, 1))
+ final_date = Date.add(today, 1)
+ formatted_date = Calendar.strftime(final_date, "%A, %B %d, %Y")
+ assert html =~ formatted_date
+ end
+ end
+end
diff --git a/test/spazio_solazzo_web/live/coworking_live_test.exs b/test/spazio_solazzo_web/live/coworking_live_test.exs
index e5f6874..a869a38 100644
--- a/test/spazio_solazzo_web/live/coworking_live_test.exs
+++ b/test/spazio_solazzo_web/live/coworking_live_test.exs
@@ -5,11 +5,9 @@ defmodule SpazioSolazzoWeb.CoworkingLiveTest do
alias SpazioSolazzo.BookingSystem
setup do
- {:ok, space} = BookingSystem.create_space("CoworkingTest", "coworking", "desc")
- {:ok, asset1} = BookingSystem.create_asset("Table 1", space.id)
- {:ok, asset2} = BookingSystem.create_asset("Table 2", space.id)
+ {:ok, space} = BookingSystem.create_space("CoworkingTest", "coworking", "desc", 10)
- %{space: space, assets: [asset1, asset2]}
+ %{space: space}
end
describe "CoworkingLive landing page" do
@@ -17,21 +15,13 @@ defmodule SpazioSolazzoWeb.CoworkingLiveTest do
{:ok, _view, html} = live(conn, "/coworking")
assert html =~ space.name
- assert html =~ "Interactive Floor Plan"
assert html =~ "Fiber Internet"
end
- test "displays all available assets as selectable cards", %{
- conn: conn,
- assets: [asset1, asset2]
- } do
- {:ok, view, html} = live(conn, "/coworking")
+ test "has link to space booking page with correct space slug", %{conn: conn, space: space} do
+ {:ok, view, _html} = live(conn, "/coworking")
- assert html =~ asset1.name
- assert html =~ asset2.name
-
- assert has_element?(view, "a[href='/book/asset/#{asset1.id}']")
- assert has_element?(view, "a[href='/book/asset/#{asset2.id}']")
+ assert has_element?(view, "a[href='/book/space/#{space.slug}']")
end
end
end
diff --git a/test/spazio_solazzo_web/live/meeting_live_test.exs b/test/spazio_solazzo_web/live/meeting_live_test.exs
index 97e969b..56b846d 100644
--- a/test/spazio_solazzo_web/live/meeting_live_test.exs
+++ b/test/spazio_solazzo_web/live/meeting_live_test.exs
@@ -5,10 +5,9 @@ defmodule SpazioSolazzoWeb.MeetingLiveTest do
alias SpazioSolazzo.BookingSystem
setup do
- {:ok, space} = BookingSystem.create_space("MeetingTest", "meeting", "desc")
- {:ok, asset} = BookingSystem.create_asset("Main Room", space.id)
+ {:ok, space} = BookingSystem.create_space("MeetingTest", "meeting", "desc", 1)
- %{space: space, asset: asset}
+ %{space: space}
end
describe "MeetingLive landing page" do
@@ -16,17 +15,15 @@ defmodule SpazioSolazzoWeb.MeetingLiveTest do
conn: conn,
space: space
} do
- {:ok, view, html} = live(conn, "/meeting")
+ {:ok, _view, html} = live(conn, "/meeting")
assert html =~ space.name
- assert html =~ "Book This Room"
- assert has_element?(view, "h2", "Everything you need to succeed")
end
- test "has link to asset booking page with correct asset id", %{conn: conn, asset: asset} do
+ test "has link to space booking page with correct space slug", %{conn: conn, space: space} do
{:ok, view, _html} = live(conn, "/meeting")
- assert has_element?(view, "a[href='/book/asset/#{asset.id}']", "Book This Room")
+ assert has_element?(view, "a[href='/book/space/#{space.slug}']")
end
end
end
diff --git a/test/spazio_solazzo_web/live/music_live_test.exs b/test/spazio_solazzo_web/live/music_live_test.exs
index 00e2b08..3369888 100644
--- a/test/spazio_solazzo_web/live/music_live_test.exs
+++ b/test/spazio_solazzo_web/live/music_live_test.exs
@@ -5,10 +5,9 @@ defmodule SpazioSolazzoWeb.MusicLiveTest do
alias SpazioSolazzo.BookingSystem
setup do
- {:ok, space} = BookingSystem.create_space("MusicTest", "music", "desc")
- {:ok, asset} = BookingSystem.create_asset("Studio", space.id)
+ {:ok, space} = BookingSystem.create_space("MusicTest", "music", "desc", 1)
- %{space: space, asset: asset}
+ %{space: space}
end
describe "MusicLive landing page" do
@@ -16,17 +15,15 @@ defmodule SpazioSolazzoWeb.MusicLiveTest do
conn: conn,
space: space
} do
- {:ok, view, html} = live(conn, "/music")
+ {:ok, _view, html} = live(conn, "/music")
assert html =~ space.name
- assert html =~ "Book This Room"
- assert has_element?(view, "h2", "Jam, Practice, Create")
end
- test "has link to asset booking page with correct asset id", %{conn: conn, asset: asset} do
+ test "has link to space booking page with correct space slug", %{conn: conn, space: space} do
{:ok, view, _html} = live(conn, "/music")
- assert has_element?(view, "a[href='/book/asset/#{asset.id}']", "Book This Room")
+ assert has_element?(view, "a[href='/book/space/#{space.slug}']")
end
end
end
diff --git a/test/spazio_solazzo_web/live/page_live_test.exs b/test/spazio_solazzo_web/live/page_live_test.exs
index 0415ef2..126a2ec 100644
--- a/test/spazio_solazzo_web/live/page_live_test.exs
+++ b/test/spazio_solazzo_web/live/page_live_test.exs
@@ -6,7 +6,7 @@ defmodule SpazioSolazzoWeb.PageLiveTest do
setup do
for {name, slug} <- [{"Coworking", "coworking"}, {"Meeting", "meeting"}, {"Music", "music"}] do
- BookingSystem.create_space!(name, slug, "desc")
+ BookingSystem.create_space!(name, slug, "desc", 10)
end
:ok
diff --git a/test/spazio_solazzo_web/live/user/profile_live_test.exs b/test/spazio_solazzo_web/live/user/profile_live_test.exs
index 36ef751..6f3eebf 100644
--- a/test/spazio_solazzo_web/live/user/profile_live_test.exs
+++ b/test/spazio_solazzo_web/live/user/profile_live_test.exs
@@ -123,14 +123,15 @@ defmodule SpazioSolazzoWeb.ProfileLiveTest do
conn: conn,
user: user
} do
- {_space, asset, time_slot} = create_booking_fixtures()
+ {space, _time_slot} = create_booking_fixtures()
{:ok, booking} =
BookingSystem.create_booking(
- time_slot.id,
- asset.id,
+ space.id,
user.id,
Date.utc_today(),
+ ~T[09:00:00],
+ ~T[11:00:00],
"Test User",
"test@example.com",
"+1234567890",
@@ -158,14 +159,15 @@ defmodule SpazioSolazzoWeb.ProfileLiveTest do
conn: conn,
user: user
} do
- {_space, asset, time_slot} = create_booking_fixtures()
+ {space, _time_slot} = create_booking_fixtures()
{:ok, booking} =
BookingSystem.create_booking(
- time_slot.id,
- asset.id,
+ space.id,
user.id,
Date.utc_today(),
+ ~T[09:00:00],
+ ~T[11:00:00],
"Test User",
"test@example.com",
"+1234567890",
@@ -199,11 +201,10 @@ defmodule SpazioSolazzoWeb.ProfileLiveTest do
BookingSystem.create_space(
"Test Space #{unique_id}",
"test-space-#{unique_id}",
- "Test description"
+ "Test description",
+ 10
)
- {:ok, asset} = BookingSystem.create_asset("Test Asset", space.id)
-
{:ok, time_slot} =
BookingSystem.create_time_slot_template(
~T[09:00:00],
@@ -212,6 +213,6 @@ defmodule SpazioSolazzoWeb.ProfileLiveTest do
space.id
)
- {space, asset, time_slot}
+ {space, time_slot}
end
end