defmodule SpazioSolazzo.BookingSystem.Booking do @moduledoc """ Represents a customer booking with state management for reservation lifecycle. """ use Ash.Resource, otp_app: :spazio_solazzo, domain: SpazioSolazzo.BookingSystem, data_layer: AshPostgres.DataLayer, notifiers: [Ash.Notifier.PubSub], authorizers: [Ash.Policy.Authorizer], extensions: [AshStateMachine] require Ash.Query alias SpazioSolazzo.BookingSystem.Booking.{ AdminActionEmailWorker, RequestCreatedEmailWorker, UserCancellationEmailWorker } postgres do table "bookings" repo SpazioSolazzo.Repo 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([:requested]) default_initial_state(:requested) transitions do 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 :search do description "Fetch bookings within a date/time range with optional filters" 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 :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(: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 -> 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) start_datetime = DateTime.new!(date, start_time, "Etc/UTC") end_datetime = DateTime.new!(date, end_time, "Etc/UTC") 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, space_name: booking.space.name, space_slug: booking.space.slug, start_datetime: booking.start_datetime, end_datetime: booking.end_datetime } |> RequestCreatedEmailWorker.new() |> Oban.insert!() {:ok, booking} end) end 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 [] 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, space_slug: booking.space.slug, 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, space_slug: booking.space.slug, 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 [: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 description "Delete a booking record" primary? true end end policies do policy action([:cancel, :approve, :reject]) do authorize_if always() end policy action_type(:destroy) do authorize_if expr(:user_id == ^actor(:id)) end policy action_type(:read) do authorize_if always() end policy action_type(:create) do authorize_if always() end end pub_sub do module SpazioSolazzoWeb.Endpoint 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 attribute :start_time, :time, allow_nil?: false 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 :requested public? true constraints one_of: [:requested, :accepted, :rejected, :cancelled] end create_timestamp :inserted_at update_timestamp :updated_at end relationships do belongs_to :space, SpazioSolazzo.BookingSystem.Space do allow_nil? false public? true end belongs_to :user, SpazioSolazzo.Accounts.User do allow_nil? true end end end