From 69f992f8f6aba6b779ff7ba88a73d31a94dfe3ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= <49537445+JasterV@users.noreply.github.com> Date: Sat, 7 Feb 2026 19:08:39 +0100 Subject: [PATCH] feat: new booking system + admin dashboard (#12) feat: implement a new booking system and admin dashboard --- .formatter.exs | 1 - lib/spazio_solazzo/accounts.ex | 1 + lib/spazio_solazzo/accounts/user.ex | 9 + .../handle_bookings_on_account_deletion.ex | 8 +- ...fields.ex => parse_registration_fields.ex} | 0 lib/spazio_solazzo/booking_system.ex | 48 +- lib/spazio_solazzo/booking_system/asset.ex | 43 - lib/spazio_solazzo/booking_system/booking.ex | 368 ++++++-- .../booking/admin_action_email_worker.ex | 76 ++ .../booking_system/booking/email.ex | 117 ++- .../booking_system/booking/email_worker.ex | 49 - .../preparations/apply_admin_filters.ex | 40 + .../booking/request_created_email_worker.ex | 57 ++ .../booking/user_cancellation_email_worker.ex | 48 + lib/spazio_solazzo/booking_system/space.ex | 30 +- .../booking_system/time_slot_template.ex | 17 + .../calculations/slot_booking_stats.ex | 66 ++ .../changes/prevent_overlap.ex | 42 +- .../validations/chronological_order.ex | 38 + .../booking_system/validations/email.ex | 31 + .../booking_system/validations/future_date.ex | 35 + lib/spazio_solazzo/data/calendar_ext.ex | 128 +++ .../admin/booking_management_components.ex | 233 +++++ .../components/admin/dashboard_components.ex | 65 ++ .../components/admin_components.ex | 38 - .../components/booking_components.ex | 165 ++-- .../components/core_components.ex | 119 ++- .../controllers/booking_controller.ex | 77 -- ... admin_incoming_booking_request.html.heex} | 19 +- .../booking_cancelled.html.heex | 31 + ...eex => booking_request_approved.html.heex} | 16 +- .../booking_request_rejected.html.heex | 37 + ...ser_booking_request_confirmation.html.heex | 46 + .../live/admin/admin_calendar_component.ex | 248 +++++ .../live/admin/booking_management_live.ex | 251 +++++ .../admin/booking_management_live.html.heex | 196 ++++ .../live/admin/dashboard_live.ex | 14 +- .../live/admin/dashboard_live.html.heex | 42 +- .../live/admin/walk_in_live.ex | 180 ++++ .../live/admin/walk_in_live.html.heex | 245 +++++ .../live/booking/asset_booking_live.html.heex | 78 -- .../live/booking/booking_cancellation_live.ex | 63 ++ .../booking_cancellation_live.html.heex | 97 ++ .../booking/booking_form_live_component.ex | 34 +- .../live/booking/calendar_live_component.ex | 107 +-- ..._booking_live.ex => space_booking_live.ex} | 122 +-- .../live/booking/space_booking_live.html.heex | 117 +++ .../carousel_live_component.ex | 0 .../live/landing/coworking_live.ex | 16 +- .../live/landing/coworking_live.html.heex | 49 +- .../live/landing/meeting_live.ex | 16 +- .../live/landing/meeting_live.html.heex | 59 +- .../live/landing/music_live.ex | 17 +- .../live/landing/music_live.html.heex | 63 +- .../live/user/profile_live.html.heex | 10 +- lib/spazio_solazzo_web/router.ex | 17 +- mix.exs | 1 - mix.lock | 1 - ...115004442_set_phone_number_as_nullable.exs | 29 - .../20260118203753_add_role_to_users.exs | 21 - ...d_oban.exs => 20260202013632_add_oban.exs} | 0 ...42_create_base_resources_extensions_1.exs} | 2 +- ... 20260202013643_create_base_resources.exs} | 92 +- .../20260202155008_add_booking_indexes.exs | 29 + priv/repo/seeds.exs | 42 +- .../repo/assets/20260109010257.json | 93 -- ...0260109010257.json => 20260202013644.json} | 93 +- ...0260115004442.json => 20260202155008.json} | 197 +++- ...0260109010257.json => 20260202013644.json} | 14 +- ...0260109010257.json => 20260202013644.json} | 0 ...0260109010257.json => 20260202013644.json} | 0 .../repo/users/20260109010257.json | 82 -- .../repo/users/20260115004442.json | 82 -- ...0260118203753.json => 20260202013644.json} | 2 +- test/spazio_solazzo/accounts/user_test.exs | 54 +- .../booking_system/asset_test.exs | 51 - .../request_created_email_worker_test.exs | 133 +++ .../booking_pagination_test.exs | 399 ++++++++ .../booking_system/booking_test.exs | 884 ++++++++++++++---- .../booking_system/space_test.exs | 84 +- .../time_slot_template_test.exs | 298 +++++- .../controllers/booking_controller_test.exs | 99 -- .../booking_management_pagination_test.exs | 573 ++++++++++++ .../booking_management_rejection_test.exs | 279 ++++++ .../live/admin/walk_in_live_simple_test.exs | 64 ++ .../live/admin/walk_in_live_test.exs | 279 ++++++ .../live/booking_live/asset_booking_test.exs | 323 ------- .../live/booking_live/space_booking_test.exs | 680 ++++++++++++++ .../live/coworking_live_test.exs | 20 +- .../live/meeting_live_test.exs | 13 +- .../live/music_live_test.exs | 13 +- .../live/page_live_test.exs | 2 +- .../live/user/profile_live_test.exs | 21 +- 93 files changed, 7067 insertions(+), 2121 deletions(-) rename lib/spazio_solazzo/accounts/user/changes/{validate_registration_fields.ex => parse_registration_fields.ex} (100%) delete mode 100644 lib/spazio_solazzo/booking_system/asset.ex create mode 100644 lib/spazio_solazzo/booking_system/booking/admin_action_email_worker.ex delete mode 100644 lib/spazio_solazzo/booking_system/booking/email_worker.ex create mode 100644 lib/spazio_solazzo/booking_system/booking/preparations/apply_admin_filters.ex create mode 100644 lib/spazio_solazzo/booking_system/booking/request_created_email_worker.ex create mode 100644 lib/spazio_solazzo/booking_system/booking/user_cancellation_email_worker.ex create mode 100644 lib/spazio_solazzo/booking_system/time_slot_template/calculations/slot_booking_stats.ex create mode 100644 lib/spazio_solazzo/booking_system/validations/chronological_order.ex create mode 100644 lib/spazio_solazzo/booking_system/validations/email.ex create mode 100644 lib/spazio_solazzo/booking_system/validations/future_date.ex create mode 100644 lib/spazio_solazzo_web/components/admin/booking_management_components.ex create mode 100644 lib/spazio_solazzo_web/components/admin/dashboard_components.ex delete mode 100644 lib/spazio_solazzo_web/components/admin_components.ex delete mode 100644 lib/spazio_solazzo_web/controllers/booking_controller.ex rename lib/spazio_solazzo_web/emails/email_templates/{admin_notification.html.heex => admin_incoming_booking_request.html.heex} (72%) create mode 100644 lib/spazio_solazzo_web/emails/email_templates/booking_cancelled.html.heex rename lib/spazio_solazzo_web/emails/email_templates/{customer_confirmation.html.heex => booking_request_approved.html.heex} (69%) create mode 100644 lib/spazio_solazzo_web/emails/email_templates/booking_request_rejected.html.heex create mode 100644 lib/spazio_solazzo_web/emails/email_templates/user_booking_request_confirmation.html.heex create mode 100644 lib/spazio_solazzo_web/live/admin/admin_calendar_component.ex create mode 100644 lib/spazio_solazzo_web/live/admin/booking_management_live.ex create mode 100644 lib/spazio_solazzo_web/live/admin/booking_management_live.html.heex create mode 100644 lib/spazio_solazzo_web/live/admin/walk_in_live.ex create mode 100644 lib/spazio_solazzo_web/live/admin/walk_in_live.html.heex delete mode 100644 lib/spazio_solazzo_web/live/booking/asset_booking_live.html.heex create mode 100644 lib/spazio_solazzo_web/live/booking/booking_cancellation_live.ex create mode 100644 lib/spazio_solazzo_web/live/booking/booking_cancellation_live.html.heex rename lib/spazio_solazzo_web/live/booking/{asset_booking_live.ex => space_booking_live.ex} (50%) create mode 100644 lib/spazio_solazzo_web/live/booking/space_booking_live.html.heex rename lib/spazio_solazzo_web/live/{ => components}/carousel_live_component.ex (100%) delete mode 100644 priv/repo/migrations/20260115004442_set_phone_number_as_nullable.exs delete mode 100644 priv/repo/migrations/20260118203753_add_role_to_users.exs rename priv/repo/migrations/{20260109010248_add_oban.exs => 20260202013632_add_oban.exs} (100%) rename priv/repo/migrations/{20260109010256_setup_resources_extensions_1.exs => 20260202013642_create_base_resources_extensions_1.exs} (98%) rename priv/repo/migrations/{20260109010257_setup_resources.exs => 20260202013643_create_base_resources.exs} (65%) create mode 100644 priv/repo/migrations/20260202155008_add_booking_indexes.exs delete mode 100644 priv/resource_snapshots/repo/assets/20260109010257.json rename priv/resource_snapshots/repo/bookings/{20260109010257.json => 20260202013644.json} (81%) rename priv/resource_snapshots/repo/bookings/{20260115004442.json => 20260202155008.json} (61%) rename priv/resource_snapshots/repo/spaces/{20260109010257.json => 20260202013644.json} (85%) rename priv/resource_snapshots/repo/time_slot_templates/{20260109010257.json => 20260202013644.json} (100%) rename priv/resource_snapshots/repo/tokens/{20260109010257.json => 20260202013644.json} (100%) delete mode 100644 priv/resource_snapshots/repo/users/20260109010257.json delete mode 100644 priv/resource_snapshots/repo/users/20260115004442.json rename priv/resource_snapshots/repo/users/{20260118203753.json => 20260202013644.json} (96%) delete mode 100644 test/spazio_solazzo/booking_system/asset_test.exs create mode 100644 test/spazio_solazzo/booking_system/booking/request_created_email_worker_test.exs create mode 100644 test/spazio_solazzo/booking_system/booking_pagination_test.exs delete mode 100644 test/spazio_solazzo_web/controllers/booking_controller_test.exs create mode 100644 test/spazio_solazzo_web/live/admin/booking_management_pagination_test.exs create mode 100644 test/spazio_solazzo_web/live/admin/booking_management_rejection_test.exs create mode 100644 test/spazio_solazzo_web/live/admin/walk_in_live_simple_test.exs create mode 100644 test/spazio_solazzo_web/live/admin/walk_in_live_test.exs delete mode 100644 test/spazio_solazzo_web/live/booking_live/asset_booking_test.exs create mode 100644 test/spazio_solazzo_web/live/booking_live/space_booking_test.exs 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}

+
+
+ + + + + + + + + + <%= if @show_actions do %> + + <% end %> + + + + <%= for booking <- @bookings do %> + <% is_expanded = MapSet.member?(@expanded_booking_ids, booking.id) %> + + + + + + + + <%= if @show_actions do %> + + <% end %> + + <%= if is_expanded do %> + + + + + <% end %> + <% end %> + +
+ + Space + + Start + + End + + Customer + + Status + + Actions +
+ + +
+
+ <.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)} + + +
+ + +
+
+
+

+ + 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 %> +
+
+
+ <.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""" -