do not let users request twice the same slot

This commit is contained in:
JasterV 2026-02-02 02:19:58 +01:00
parent b48af6d3e6
commit 5afff793dc
16 changed files with 956 additions and 210 deletions

View file

@ -95,20 +95,6 @@ defmodule SpazioSolazzo.BookingSystem.Space do
end
end
attributes do
uuid_primary_key :id
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 :public_capacity, :integer, allow_nil?: false, public?: true
attribute :real_capacity, :integer, allow_nil?: false, public?: true
end
identities do
identity :unique_name, [:name]
identity :unique_slug, [:slug]
end
policies do
policy action(:check_availability) do
authorize_if always()
@ -122,4 +108,18 @@ defmodule SpazioSolazzo.BookingSystem.Space do
authorize_if always()
end
end
attributes do
uuid_primary_key :id
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 :public_capacity, :integer, allow_nil?: false, public?: true
attribute :real_capacity, :integer, allow_nil?: false, public?: true
end
identities do
identity :unique_name, [:name]
identity :unique_slug, [:slug]
end
end

View file

@ -223,8 +223,12 @@ defmodule SpazioSolazzoWeb.Admin.AdminCalendarComponent do
end
defp day_in_range?(_date, nil, nil, nil), do: false
defp day_in_range?(date, selected, nil, nil) when not is_nil(selected), do: Date.compare(date, selected) == :eq
defp day_in_range?(date, nil, start_date, nil) when not is_nil(start_date), do: Date.compare(date, start_date) == :eq
defp day_in_range?(date, selected, nil, nil) when not is_nil(selected),
do: Date.compare(date, selected) == :eq
defp day_in_range?(date, nil, start_date, nil) when not is_nil(start_date),
do: Date.compare(date, start_date) == :eq
defp day_in_range?(date, nil, start_date, end_date)
when not is_nil(start_date) and not is_nil(end_date) do
@ -254,28 +258,49 @@ defmodule SpazioSolazzoWeb.Admin.AdminCalendarComponent do
[base, "text-slate-400 dark:text-slate-600 cursor-not-allowed opacity-50"]
capacity == :over_real_capacity ->
[base, "bg-red-50 dark:bg-red-900/20 text-slate-400 dark:text-slate-500 border border-red-300 dark:border-red-800/30 cursor-not-allowed"]
[
base,
"bg-red-50 dark:bg-red-900/20 text-slate-400 dark:text-slate-500 border border-red-300 dark:border-red-800/30 cursor-not-allowed"
]
in_range && assigns.multi_day_mode && assigns.end_date != nil ->
cond do
is_start ->
[base, "rounded-l-lg bg-primary text-white shadow-lg shadow-primary/30 relative z-10 hover:scale-105"]
[
base,
"rounded-l-lg bg-primary text-white shadow-lg shadow-primary/30 relative z-10 hover:scale-105"
]
is_end ->
[base, "rounded-r-lg bg-primary text-white shadow-lg shadow-primary/30 relative z-10 hover:scale-105"]
[
base,
"rounded-r-lg bg-primary text-white shadow-lg shadow-primary/30 relative z-10 hover:scale-105"
]
true ->
[base, "bg-primary/20 dark:bg-primary/30 text-slate-900 dark:text-white border-y border-primary/20 dark:border-primary/50"]
[
base,
"bg-primary/20 dark:bg-primary/30 text-slate-900 dark:text-white border-y border-primary/20 dark:border-primary/50"
]
end
in_range ->
[base, "rounded-lg bg-primary text-white shadow-lg shadow-primary/30 relative z-10 hover:scale-105"]
[
base,
"rounded-lg bg-primary text-white shadow-lg shadow-primary/30 relative z-10 hover:scale-105"
]
capacity == :over_public_capacity ->
[base, "rounded-lg bg-orange-100 dark:bg-orange-900/20 hover:bg-orange-200 dark:hover:bg-orange-900/40 text-slate-700 dark:text-slate-200 border border-transparent hover:border-orange-500 dark:hover:border-orange-600"]
[
base,
"rounded-lg bg-orange-100 dark:bg-orange-900/20 hover:bg-orange-200 dark:hover:bg-orange-900/40 text-slate-700 dark:text-slate-200 border border-transparent hover:border-orange-500 dark:hover:border-orange-600"
]
true ->
[base, "rounded-lg bg-green-100 dark:bg-green-900/20 hover:bg-green-200 dark:hover:bg-green-900/40 text-slate-700 dark:text-slate-200 border border-transparent hover:border-green-500 dark:hover:border-green-600"]
[
base,
"rounded-lg bg-green-100 dark:bg-green-900/20 hover:bg-green-200 dark:hover:bg-green-900/40 text-slate-700 dark:text-slate-200 border border-transparent hover:border-green-500 dark:hover:border-green-600"
]
end
end

View file

@ -12,7 +12,10 @@
class="size-4 rounded border-slate-300 dark:border-slate-600 text-primary focus:ring-primary dark:bg-slate-700 pointer-events-none"
readonly
/>
<label for={"multi-day-#{@id}"} class="text-sm font-semibold text-slate-700 dark:text-slate-300 select-none flex-1">
<label
for={"multi-day-#{@id}"}
class="text-sm font-semibold text-slate-700 dark:text-slate-300 select-none flex-1"
>
Enable Multi-Day Selection
</label>
</div>

View file

@ -119,6 +119,10 @@ defmodule SpazioSolazzoWeb.Admin.BookingManagementLive do
)}
end
def handle_event("stop_propagation", _, socket) do
{:noreply, socket}
end
def handle_event("update_rejection_reason", %{"reason" => reason}, socket) do
{:noreply, assign(socket, rejection_reason: reason)}
end

View file

@ -174,7 +174,9 @@
</td>
<td class="px-6 py-4 whitespace-nowrap">
<p class="text-sm text-slate-900 dark:text-slate-200">
{SpazioSolazzo.CalendarExt.format_datetime_range_start(booking.start_datetime)}
{SpazioSolazzo.CalendarExt.format_datetime_range_start(
booking.start_datetime
)}
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap">
@ -324,7 +326,9 @@
</td>
<td class="px-6 py-4 whitespace-nowrap">
<p class="text-sm text-slate-900 dark:text-slate-200">
{SpazioSolazzo.CalendarExt.format_datetime_range_start(booking.start_datetime)}
{SpazioSolazzo.CalendarExt.format_datetime_range_start(
booking.start_datetime
)}
</p>
</td>
<td class="px-6 py-4 whitespace-nowrap">
@ -431,21 +435,21 @@
<%!-- Reject Modal --%>
<%= if @show_reject_modal do %>
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
class="fixed inset-0 bg-slate-900/20 backdrop-blur-sm flex items-center justify-center p-4 z-50"
phx-click="hide_reject_modal"
>
<div
class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-md w-full p-8"
class="bg-white dark:bg-slate-800 rounded-3xl border border-slate-200 dark:border-slate-700 shadow-xl shadow-slate-200/50 dark:shadow-none max-w-md w-full p-6"
phx-click="stop_propagation"
>
<h3 class="text-2xl font-bold text-slate-900 dark:text-white mb-4">Reject Booking</h3>
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-3">Reject Booking</h3>
<p class="text-slate-600 dark:text-slate-400 mb-6">
Please provide a reason for rejecting this booking request. The customer will receive this reason in their email.
<p class="text-slate-600 dark:text-slate-400 mb-4 text-sm">
Provide a reason for rejecting this booking. The customer will receive this in their email.
</p>
<form phx-submit="confirm_reject">
<div class="mb-6">
<div class="mb-4">
<label class="block text-sm font-semibold text-slate-900 dark:text-white mb-2">
Rejection Reason <span class="text-red-500">*</span>
</label>
@ -455,23 +459,23 @@
rows="4"
required
placeholder="e.g., Space under maintenance, Fully booked, etc."
class="w-full px-4 py-3 border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white rounded-xl focus:outline-none focus:ring-2 focus:ring-primary"
class="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white rounded-xl focus:outline-none focus:ring-2 focus:ring-primary"
>{@rejection_reason}</textarea>
</div>
<div class="flex gap-3">
<div class="flex gap-2">
<button
type="button"
phx-click="hide_reject_modal"
class="flex-1 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-200 px-6 py-3 rounded-xl font-semibold hover:bg-slate-300 dark:hover:bg-slate-600 transition-colors"
class="flex-1 bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-200 px-4 py-2 rounded-lg font-medium hover:bg-slate-300 dark:hover:bg-slate-600 transition-colors text-sm"
>
Cancel
</button>
<button
type="submit"
class="flex-1 bg-red-500 text-white px-6 py-3 rounded-xl font-semibold hover:bg-red-600 transition-colors"
class="flex-1 bg-red-500 text-white px-4 py-2 rounded-lg font-medium hover:bg-red-600 transition-colors text-sm"
>
Confirm Rejection
Reject
</button>
</div>
</form>

View file

@ -117,7 +117,8 @@ defmodule SpazioSolazzoWeb.Admin.WalkInLive do
end
else
_ ->
{:noreply, put_flash(socket, :error, "Please fill in all required fields and select a date")}
{:noreply,
put_flash(socket, :error, "Please fill in all required fields and select a date")}
end
end
@ -163,7 +164,9 @@ defmodule SpazioSolazzoWeb.Admin.WalkInLive do
assign(socket, time_slot_warning: "This time slot is currently full.")
{:ok, :over_public_capacity} ->
assign(socket, time_slot_warning: "This time slot has high demand but space is still available.")
assign(socket,
time_slot_warning: "This time slot has high demand but space is still available."
)
_ ->
assign(socket, time_slot_warning: nil)

View file

@ -34,10 +34,12 @@ defmodule SpazioSolazzoWeb.SpaceBookingLive do
time_slots: time_slots,
current_scope: nil,
slot_availability: %{},
slot_booking_counts: %{}
slot_booking_counts: %{},
user_booked_slots: %{}
)
|> compute_slot_availability()
|> compute_slot_booking_counts()}
|> compute_slot_booking_counts()
|> compute_user_booked_slots()}
{:error, _error} ->
{:ok,
@ -50,11 +52,16 @@ defmodule SpazioSolazzoWeb.SpaceBookingLive do
def handle_event("select_slot", %{"time_slot_id" => time_slot_id}, socket) do
time_slot = Enum.find(socket.assigns.time_slots, &(&1.id == time_slot_id))
{:noreply,
assign(socket,
selected_time_slot: time_slot,
show_booking_modal: true
)}
# Prevent opening modal if user already has a booking for this slot
if socket.assigns.user_booked_slots[time_slot_id] do
{:noreply, socket}
else
{:noreply,
assign(socket,
selected_time_slot: time_slot,
show_booking_modal: true
)}
end
end
def handle_event("cancel_booking", _params, socket) do
@ -90,6 +97,21 @@ defmodule SpazioSolazzoWeb.SpaceBookingLive do
show_success_modal: true
)}
{:error, %Ash.Error.Invalid{errors: errors}} ->
error_message =
errors
|> Enum.map(fn
%{field: :date, message: msg} -> msg
%{message: msg} -> msg
_error -> "Invalid booking request"
end)
|> Enum.join(", ")
{:noreply,
socket
|> assign(show_booking_modal: false)
|> put_flash(:error, error_message)}
{:error, _error} ->
{:noreply,
socket
@ -113,7 +135,8 @@ defmodule SpazioSolazzoWeb.SpaceBookingLive do
bookings: bookings
)
|> compute_slot_availability()
|> compute_slot_booking_counts()}
|> compute_slot_booking_counts()
|> compute_user_booked_slots()}
end
def handle_info(
@ -126,7 +149,8 @@ defmodule SpazioSolazzoWeb.SpaceBookingLive do
socket
|> assign(bookings: bookings)
|> compute_slot_availability()
|> compute_slot_booking_counts()}
|> compute_slot_booking_counts()
|> compute_user_booked_slots()}
end
def handle_info(_msg, socket) do
@ -170,4 +194,38 @@ defmodule SpazioSolazzoWeb.SpaceBookingLive do
assign(socket, slot_booking_counts: slot_booking_counts)
end
defp compute_user_booked_slots(socket) do
current_user = socket.assigns.current_user
user_booked_slots =
if current_user do
socket.assigns.time_slots
|> Enum.map(fn time_slot ->
start_datetime =
DateTime.new!(socket.assigns.selected_date, time_slot.start_time, "Etc/UTC")
end_datetime =
DateTime.new!(socket.assigns.selected_date, time_slot.end_time, "Etc/UTC")
existing_bookings =
SpazioSolazzo.BookingSystem.Booking
|> Ash.Query.filter(
user_id == ^current_user.id and
space_id == ^socket.assigns.space.id and
(state == :requested or state == :accepted) and
start_datetime < ^end_datetime and
end_datetime > ^start_datetime
)
|> Ash.read!()
{time_slot.id, existing_bookings != []}
end)
|> Map.new()
else
%{}
end
assign(socket, user_booked_slots: user_booked_slots)
end
end

View file

@ -46,18 +46,26 @@
<% else %>
<%= for time_slot <- @time_slots do %>
<% availability = Map.get(@slot_availability, time_slot.id, :available) %>
<% counts = Map.get(@slot_booking_counts, time_slot.id, %{pending: 0, approved: 0}) %>
<% counts =
Map.get(@slot_booking_counts, time_slot.id, %{pending: 0, approved: 0}) %>
<% user_has_booking = Map.get(@user_booked_slots, time_slot.id, false) %>
<%= if availability != :over_real_capacity do %>
<button
phx-click="select_slot"
phx-click={if user_has_booking, do: nil, else: "select_slot"}
phx-value-time_slot_id={time_slot.id}
disabled={user_has_booking}
class={[
"w-full p-4 rounded-xl border-2 transition-all duration-200 text-left",
if(availability == :available,
if(user_has_booking,
do:
"border-green-200 bg-green-50 hover:border-green-500 hover:shadow-lg cursor-pointer dark:bg-green-900/20 dark:border-green-800 dark:hover:border-green-600",
"border-slate-200 bg-slate-100 cursor-not-allowed opacity-60 dark:bg-slate-700/50 dark:border-slate-600",
else:
"border-yellow-200 bg-yellow-50 hover:border-yellow-500 hover:shadow-lg cursor-pointer dark:bg-yellow-900/20 dark:border-yellow-800 dark:hover:border-yellow-600"
if(availability == :available,
do:
"border-green-200 bg-green-50 hover:border-green-500 hover:shadow-lg cursor-pointer dark:bg-green-900/20 dark:border-green-800 dark:hover:border-green-600",
else:
"border-yellow-200 bg-yellow-50 hover:border-yellow-500 hover:shadow-lg cursor-pointer dark:bg-yellow-900/20 dark:border-yellow-800 dark:hover:border-yellow-600"
)
)
]}
>
@ -69,14 +77,20 @@
"%H:%M"
)}
</div>
<%= if availability == :available do %>
<div class="text-sm text-green-600 dark:text-green-400 font-medium mt-1">
Available - Request Booking
<%= if user_has_booking do %>
<div class="text-sm text-slate-500 dark:text-slate-400 font-medium mt-1">
Already Requested
</div>
<% else %>
<div class="text-sm text-yellow-600 dark:text-yellow-400 font-medium mt-1">
High Demand - Join Waitlist
</div>
<%= if availability == :available do %>
<div class="text-sm text-green-600 dark:text-green-400 font-medium mt-1">
Available - Request Booking
</div>
<% else %>
<div class="text-sm text-yellow-600 dark:text-yellow-400 font-medium mt-1">
High Demand - Join Waitlist
</div>
<% end %>
<% end %>
<div class="flex gap-3 mt-2 text-xs text-slate-600 dark:text-slate-400">
<%= if counts.pending > 0 do %>
@ -94,12 +108,16 @@
</div>
</div>
<.icon
name="hero-arrow-right"
name={if user_has_booking, do: "hero-check", else: "hero-arrow-right"}
class={[
"w-5 h-5",
if(availability == :available,
do: "text-green-500 dark:text-green-400",
else: "text-yellow-500 dark:text-yellow-400"
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"
)
)
]}
/>
@ -141,14 +159,14 @@
<%= if @show_success_modal do %>
<div
id="success-modal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
class="fixed inset-0 bg-slate-900/20 backdrop-blur-sm flex items-center justify-center p-4 z-50"
phx-click="close_success_modal"
>
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-md w-full p-8 text-center">
<div class="mb-6">
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-green-100 dark:bg-green-900/20 mb-4">
<div class="bg-white dark:bg-slate-800 rounded-3xl border border-slate-200 dark:border-slate-700 shadow-xl shadow-slate-200/50 dark:shadow-none max-w-md w-full p-6 text-center">
<div class="mb-4">
<div class="mx-auto flex items-center justify-center h-14 w-14 rounded-full bg-green-100 dark:bg-green-900/20 mb-3">
<svg
class="h-10 w-10 text-green-600 dark:text-green-400"
class="h-8 w-8 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -161,17 +179,17 @@
/>
</svg>
</div>
<h3 class="text-2xl font-bold text-slate-900 dark:text-white mb-2">
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-2">
Request Submitted!
</h3>
<p class="text-slate-600 dark:text-slate-400">
Your booking request has been received and is pending approval. You will receive an email confirmation shortly.
<p class="text-slate-600 dark:text-slate-400 text-sm">
Your booking request is pending approval. You'll receive an email confirmation shortly.
</p>
</div>
<button
phx-click="close_success_modal"
class="w-full bg-green-500 text-white px-6 py-3 rounded-xl font-semibold hover:bg-green-600 transition-colors"
class="w-full bg-green-500 text-white px-4 py-2 rounded-lg font-medium hover:bg-green-600 transition-colors text-sm"
>
Close
</button>

View file

@ -21,9 +21,10 @@ defmodule SpazioSolazzo.BookingSystem.Booking.RequestCreatedEmailWorkerTest do
# Verify customer email was sent
emails = Memory.all()
assert Enum.any?(emails, fn email ->
email.to == [{"John Doe", "john@example.com"}]
end)
email.to == [{"John Doe", "john@example.com"}]
end)
end
test "sends notification email to admin" do
@ -44,9 +45,10 @@ defmodule SpazioSolazzo.BookingSystem.Booking.RequestCreatedEmailWorkerTest do
# Verify admin email was sent
emails = Memory.all()
assert Enum.any?(emails, fn email ->
email.to == [{"", admin_email}]
end)
email.to == [{"", admin_email}]
end)
end
test "sends both customer and admin emails in single job execution" do
@ -89,9 +91,11 @@ defmodule SpazioSolazzo.BookingSystem.Booking.RequestCreatedEmailWorkerTest do
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)
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")
@ -115,9 +119,11 @@ defmodule SpazioSolazzo.BookingSystem.Booking.RequestCreatedEmailWorkerTest do
assert :ok = perform_job(RequestCreatedEmailWorker, job_args)
emails = Memory.all()
admin_notification = Enum.find(emails, fn email ->
email.to == [{"", admin_email}]
end)
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")

View file

@ -63,7 +63,6 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
"Test booking"
)
assert booking.space_id == space.id
assert booking.user_id == nil
assert booking.date == date
@ -108,7 +107,6 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
nil
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "must be after start time")
end
@ -129,7 +127,6 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
nil
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "cannot be in the past")
end
@ -150,7 +147,6 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
nil
)
assert booking.date == today
end
@ -183,7 +179,6 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
nil
)
assert booking.customer_phone == nil || booking.customer_phone == ""
end
end
@ -192,17 +187,16 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
test "approves a pending booking", %{space: space, date: date} do
{:ok, booking} =
request_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
assert booking.state == :requested
@ -215,17 +209,16 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
test "cannot approve already approved booking", %{space: space, date: date} do
{:ok, booking} =
request_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
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)
@ -237,17 +230,16 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
test "cannot approve cancelled booking", %{space: space, date: date} do
{:ok, booking} =
request_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
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")
@ -261,17 +253,16 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
test "cancels a pending booking", %{space: space, date: date} do
{:ok, booking} =
request_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
{:ok, cancelled_booking} = BookingSystem.cancel_booking(booking.id, "Test cancellation")
@ -282,17 +273,16 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
test "cancels an approved booking", %{space: space, date: date} do
{:ok, booking} =
request_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
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")
@ -303,17 +293,16 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
test "cannot cancel already cancelled booking", %{space: space, date: date} do
{:ok, booking} =
request_booking(
space.id,
nil,
date,
~T[09:00:00],
~T[10:00:00],
"John Doe",
"john@example.com",
nil,
nil
)
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")

View file

@ -0,0 +1,344 @@
defmodule SpazioSolazzo.BookingSystem.DuplicateBookingPreventionTest do
use SpazioSolazzo.DataCase, async: true
import SpazioSolazzo.AuthHelpers
alias SpazioSolazzo.BookingSystem
setup do
{:ok, space} =
BookingSystem.create_space(
"Coworking",
"coworking",
"Coworking space",
5,
10
)
user = register_user("user@example.com", "Test User")
%{space: space, user: user}
end
describe "duplicate booking prevention" do
test "prevents user from requesting duplicate booking when they have a pending request", %{
space: space,
user: user
} do
tomorrow = Date.add(Date.utc_today(), 1)
start_time = ~T[10:00:00]
end_time = ~T[12:00:00]
{:ok, _first_booking} =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Test User",
"user@example.com",
nil,
nil
)
result =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Test User",
"user@example.com",
nil,
nil
)
assert {:error, %Ash.Error.Invalid{}} = result
assert_error_contains(
result,
"You already have a pending or confirmed booking for this time slot"
)
end
test "prevents user from requesting duplicate booking when they have an accepted booking", %{
space: space,
user: user
} do
tomorrow = Date.add(Date.utc_today(), 1)
start_time = ~T[10:00:00]
end_time = ~T[12:00:00]
{:ok, first_booking} =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Test User",
"user@example.com",
nil,
nil
)
{:ok, _approved} = BookingSystem.approve_booking(first_booking)
result =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Test User",
"user@example.com",
nil,
nil
)
assert {:error, %Ash.Error.Invalid{}} = result
assert_error_contains(
result,
"You already have a pending or confirmed booking for this time slot"
)
end
test "allows user to request booking after previous booking was rejected", %{
space: space,
user: user
} do
tomorrow = Date.add(Date.utc_today(), 1)
start_time = ~T[10:00:00]
end_time = ~T[12:00:00]
{:ok, first_booking} =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Test User",
"user@example.com",
nil,
nil
)
{:ok, _rejected} = BookingSystem.reject_booking(first_booking, "Sorry, fully booked")
{:ok, second_booking} =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Test User",
"user@example.com",
nil,
nil
)
assert second_booking.state == :requested
assert second_booking.user_id == user.id
end
test "allows user to request booking after previous booking was cancelled", %{
space: space,
user: user
} do
tomorrow = Date.add(Date.utc_today(), 1)
start_time = ~T[10:00:00]
end_time = ~T[12:00:00]
{:ok, first_booking} =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Test User",
"user@example.com",
nil,
nil
)
{:ok, _cancelled} = BookingSystem.cancel_booking(first_booking, "Changed plans")
{:ok, second_booking} =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Test User",
"user@example.com",
nil,
nil
)
assert second_booking.state == :requested
assert second_booking.user_id == user.id
end
test "prevents overlapping bookings for same user", %{space: space, user: user} do
tomorrow = Date.add(Date.utc_today(), 1)
{:ok, _first_booking} =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
~T[10:00:00],
~T[12:00:00],
"Test User",
"user@example.com",
nil,
nil
)
result =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
~T[11:00:00],
~T[13:00:00],
"Test User",
"user@example.com",
nil,
nil
)
assert {:error, %Ash.Error.Invalid{}} = result
assert_error_contains(
result,
"You already have a pending or confirmed booking for this time slot"
)
end
test "allows different users to book the same time slot", %{space: space, user: user} do
another_user = register_user("another@example.com", "Another User")
tomorrow = Date.add(Date.utc_today(), 1)
start_time = ~T[10:00:00]
end_time = ~T[12:00:00]
{:ok, _first_booking} =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
start_time,
end_time,
"Test User",
"user@example.com",
nil,
nil
)
{:ok, second_booking} =
BookingSystem.create_booking(
space.id,
another_user.id,
tomorrow,
start_time,
end_time,
"Another User",
"another@example.com",
nil,
nil
)
assert second_booking.state == :requested
assert second_booking.user_id == another_user.id
end
test "allows user to book different time slots on same day", %{space: space, user: user} do
tomorrow = Date.add(Date.utc_today(), 1)
{:ok, _morning_booking} =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
~T[09:00:00],
~T[11:00:00],
"Test User",
"user@example.com",
nil,
nil
)
{:ok, afternoon_booking} =
BookingSystem.create_booking(
space.id,
user.id,
tomorrow,
~T[14:00:00],
~T[16:00:00],
"Test User",
"user@example.com",
nil,
nil
)
assert afternoon_booking.state == :requested
assert afternoon_booking.user_id == user.id
end
test "allows guest bookings without user_id validation", %{space: space} do
tomorrow = Date.add(Date.utc_today(), 1)
start_time = ~T[10:00:00]
end_time = ~T[12:00:00]
{:ok, _first_booking} =
BookingSystem.create_booking(
space.id,
nil,
tomorrow,
start_time,
end_time,
"Guest User",
"guest@example.com",
nil,
nil
)
{:ok, second_booking} =
BookingSystem.create_booking(
space.id,
nil,
tomorrow,
start_time,
end_time,
"Another Guest",
"another.guest@example.com",
nil,
nil
)
assert second_booking.state == :requested
assert second_booking.user_id == nil
end
end
defp assert_error_contains({:error, %Ash.Error.Invalid{errors: errors}}, expected_message) do
error_messages =
Enum.map(errors, fn error ->
case error do
%{message: message} -> message
_ -> inspect(error)
end
end)
assert Enum.any?(error_messages, &String.contains?(&1, expected_message)),
"Expected error message to contain '#{expected_message}', but got: #{inspect(error_messages)}"
end
end

View file

@ -66,21 +66,31 @@ defmodule SpazioSolazzo.BookingSystem.MultiDayBookingTest do
)
# Should appear on start date
{:ok, day1_bookings} = BookingSystem.list_accepted_space_bookings_by_date(space.id, start_date)
{:ok, day1_bookings} =
BookingSystem.list_accepted_space_bookings_by_date(space.id, start_date)
assert length(day1_bookings) == 1
# Should appear on middle date
middle_date = Date.add(start_date, 1)
{:ok, day2_bookings} = BookingSystem.list_accepted_space_bookings_by_date(space.id, middle_date)
{:ok, day2_bookings} =
BookingSystem.list_accepted_space_bookings_by_date(space.id, middle_date)
assert length(day2_bookings) == 1
# Should appear on end date
{:ok, day4_bookings} = BookingSystem.list_accepted_space_bookings_by_date(space.id, end_date)
{:ok, day4_bookings} =
BookingSystem.list_accepted_space_bookings_by_date(space.id, end_date)
assert length(day4_bookings) == 1
# Should not appear on day after end date
day_after = Date.add(end_date, 1)
{:ok, day_after_bookings} = BookingSystem.list_accepted_space_bookings_by_date(space.id, day_after)
{:ok, day_after_bookings} =
BookingSystem.list_accepted_space_bookings_by_date(space.id, day_after)
assert length(day_after_bookings) == 0
end
@ -291,11 +301,16 @@ defmodule SpazioSolazzo.BookingSystem.MultiDayBookingTest do
# Should not appear on day before start
day_before = Date.add(start_date, -1)
{:ok, bookings_before} = BookingSystem.list_accepted_space_bookings_by_date(space.id, day_before)
{:ok, bookings_before} =
BookingSystem.list_accepted_space_bookings_by_date(space.id, day_before)
assert length(bookings_before) == 0
# Should appear on start date
{:ok, bookings_start} = BookingSystem.list_accepted_space_bookings_by_date(space.id, start_date)
{:ok, bookings_start} =
BookingSystem.list_accepted_space_bookings_by_date(space.id, start_date)
assert length(bookings_start) == 1
# Should appear on end date
@ -304,7 +319,10 @@ defmodule SpazioSolazzo.BookingSystem.MultiDayBookingTest do
# Should not appear on day after end
day_after = Date.add(end_date, 1)
{:ok, bookings_after} = BookingSystem.list_accepted_space_bookings_by_date(space.id, day_after)
{:ok, bookings_after} =
BookingSystem.list_accepted_space_bookings_by_date(space.id, day_after)
assert length(bookings_after) == 0
end
@ -338,7 +356,10 @@ defmodule SpazioSolazzo.BookingSystem.MultiDayBookingTest do
# Verify it doesn't appear the day after
day_after = Date.add(end_date, 1)
{:ok, bookings_after} = BookingSystem.list_accepted_space_bookings_by_date(space.id, day_after)
{:ok, bookings_after} =
BookingSystem.list_accepted_space_bookings_by_date(space.id, day_after)
assert length(bookings_after) == 0
end
end

View file

@ -0,0 +1,289 @@
defmodule SpazioSolazzoWeb.Admin.BookingManagementRejectionTest do
use SpazioSolazzoWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import SpazioSolazzo.AuthHelpers
import Ecto.Query
alias SpazioSolazzo.BookingSystem
defp create_admin_user do
user = register_user("admin@example.com", "Admin User")
{:ok, uuid} = Ecto.UUID.dump(user.id)
from(u in "users", where: u.id == ^uuid)
|> SpazioSolazzo.Repo.update_all(set: [role: "admin"])
user
end
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,
10
)
admin_user = create_admin_user()
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

View file

@ -11,8 +11,10 @@ defmodule SpazioSolazzoWeb.Admin.WalkInLiveSimpleTest do
user = register_user("admin@example.com", "Admin User")
# Directly update role to admin using Ecto
{:ok, uuid} = Ecto.UUID.dump(user.id)
from(u in "users", where: u.id == ^uuid)
|> SpazioSolazzo.Repo.update_all(set: [role: "admin"])
user
end

View file

@ -10,8 +10,10 @@ defmodule SpazioSolazzoWeb.Admin.WalkInLiveTest do
defp create_admin_user do
user = register_user("admin@example.com", "Admin User")
{:ok, uuid} = Ecto.UUID.dump(user.id)
from(u in "users", where: u.id == ^uuid)
|> SpazioSolazzo.Repo.update_all(set: [role: "admin"])
user
end
@ -41,7 +43,11 @@ defmodule SpazioSolazzoWeb.Admin.WalkInLiveTest do
assert has_element?(view, "button[phx-click='create_booking']")
end
test "creates single-day walk-in booking successfully", %{conn: conn, user: user, space: space} do
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")
@ -180,7 +186,9 @@ defmodule SpazioSolazzoWeb.Admin.WalkInLiveTest do
assert length(day2_bookings) == 1
{:ok, day3_bookings} = BookingSystem.list_accepted_space_bookings_by_date(space.id, end_date)
{:ok, day3_bookings} =
BookingSystem.list_accepted_space_bookings_by_date(space.id, end_date)
assert length(day3_bookings) == 1
end

View file

@ -50,9 +50,18 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
)
user = register_user("testuser@example.com", "Test User", "+39 1234567890")
unauth_conn = conn
conn = log_in_user(conn, user)
%{conn: conn, space: space, slot1: slot1, slot2: slot2, today: today, user: 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
@ -379,6 +388,9 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
|> element("#booking-form")
|> render_submit(form_data)
# Process the handle_info message that creates the booking
render(view)
{:ok, bookings} = BookingSystem.list_booking_requests(space.id, nil, today)
assert length(bookings) == 1
@ -560,6 +572,9 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
|> element("#booking-form")
|> render_submit(form_data)
# Process the handle_info message that creates the booking
render(view)
{:ok, bookings} = BookingSystem.list_booking_requests(space.id, nil, today)
assert length(bookings) == 1
@ -570,52 +585,9 @@ defmodule SpazioSolazzoWeb.BookingLive.SpaceBookingTest do
end
describe "SpaceBooking edge cases" do
test "handles multiple concurrent users booking same slot", %{
slot1: slot1,
conn: conn,
space: space,
today: today
} do
{:ok, view1, _html} = live(conn, ~p"/book/space/#{space.slug}")
{:ok, view2, _html} = live(conn, ~p"/book/space/#{space.slug}")
view1
|> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
|> render_click()
view2
|> element("button[phx-click='select_slot'][phx-value-time_slot_id='#{slot1.id}']")
|> render_click()
form_data1 = %{
"customer_name" => "User 1",
"customer_email" => "user1@example.com",
"customer_phone" => "",
"customer_comment" => ""
}
form_data2 = %{
"customer_name" => "User 2",
"customer_email" => "user2@example.com",
"customer_phone" => "",
"customer_comment" => ""
}
view1
|> element("#booking-form")
|> render_submit(form_data1)
view2
|> element("#booking-form")
|> render_submit(form_data2)
# Wait for async booking creation to complete
Process.sleep(100)
{:ok, bookings} = BookingSystem.list_booking_requests(space.id, nil, today)
assert length(bookings) == 2
end
# 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} =