spazio-solazzo/lib/spazio_solazzo/booking_system/booking.ex

433 lines
14 KiB
Elixir

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