refactor ash validations to be reusable

This commit is contained in:
JasterV 2026-02-07 18:25:00 +01:00
parent c01bd8e733
commit 9dd9feac27
9 changed files with 121 additions and 83 deletions

View file

@ -116,7 +116,7 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
end
# Apply shared admin filters preparation
prepare SpazioSolazzo.BookingSystem.Preparations.ApplyAdminFilters
prepare SpazioSolazzo.BookingSystem.Booking.Preparations.ApplyAdminFilters
prepare fn query, _ctx ->
Ash.Query.sort(query, inserted_at: :desc)
@ -142,7 +142,7 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
end
# Apply shared admin filters preparation
prepare SpazioSolazzo.BookingSystem.Preparations.ApplyAdminFilters
prepare SpazioSolazzo.BookingSystem.Booking.Preparations.ApplyAdminFilters
prepare fn query, _ctx ->
Ash.Query.sort(query, start_datetime: :desc)
@ -161,40 +161,11 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
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 fn changeset, _ctx ->
date = Ash.Changeset.get_argument(changeset, :date)
today = Date.utc_today()
if date && Date.compare(date, today) == :lt do
{:error, field: :date, message: "cannot be in the past"}
else
:ok
end
end
validate fn changeset, _ctx ->
start_time = Ash.Changeset.get_argument(changeset, :start_time)
end_time = Ash.Changeset.get_argument(changeset, :end_time)
if start_time && end_time && Time.compare(end_time, start_time) != :gt do
{:error, field: :end_time, message: "must be after start time"}
else
:ok
end
end
validate fn changeset, _ctx ->
email = Ash.Changeset.get_argument(changeset, :customer_email)
if email && !String.match?(email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/) do
{:error, field: :customer_email, message: "must be a valid email"}
else
:ok
end
end
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)
@ -258,38 +229,9 @@ defmodule SpazioSolazzo.BookingSystem.Booking do
change manage_relationship(:space_id, :space, type: :append_and_remove)
validate fn changeset, _ctx ->
end_datetime = Ash.Changeset.get_argument(changeset, :end_datetime)
now = DateTime.utc_now()
if end_datetime && DateTime.compare(end_datetime, now) == :lt do
{:error, field: :end_datetime, message: "cannot be in the past"}
else
:ok
end
end
validate fn changeset, _ctx ->
start_datetime = Ash.Changeset.get_argument(changeset, :start_datetime)
end_datetime = Ash.Changeset.get_argument(changeset, :end_datetime)
if start_datetime && end_datetime &&
DateTime.compare(end_datetime, start_datetime) != :gt do
{:error, field: :end_datetime, message: "must be after start datetime"}
else
:ok
end
end
validate fn changeset, _ctx ->
email = Ash.Changeset.get_argument(changeset, :customer_email)
if email && !String.match?(email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/) do
{:error, field: :customer_email, message: "must be a valid email"}
else
:ok
end
end
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)

View file

@ -1,4 +1,4 @@
defmodule SpazioSolazzo.BookingSystem.Preparations.ApplyAdminFilters do
defmodule SpazioSolazzo.BookingSystem.Booking.Preparations.ApplyAdminFilters do
@moduledoc """
Ash Preparation that applies common admin filters (space_id, email, date) to booking queries.
"""

View file

@ -21,16 +21,8 @@ defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplate do
create :create do
accept [:start_time, :end_time, :space_id, :day_of_week]
validate fn changeset, _ctx ->
start_time = Ash.Changeset.get_attribute(changeset, :start_time)
end_time = Ash.Changeset.get_attribute(changeset, :end_time)
if start_time && end_time && Time.compare(end_time, start_time) != :gt do
{:error, field: :end_time, message: "must be after start time"}
else
:ok
end
end
validate {SpazioSolazzo.BookingSystem.Validations.ChronologicalOrder,
start: :start_time, end: :end_time}
change {Changes.PreventCreationOverlap, []}
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -3,7 +3,7 @@ defmodule SpazioSolazzoWeb.BookingCalendarLiveComponent do
The calendar displayed in the space booking view.
It allows users to select a date in a beautifully-styled calendar grid.
"""
use SpazioSolazzoWeb, :live_component
alias SpazioSolazzo.CalendarExt
@ -54,7 +54,7 @@ defmodule SpazioSolazzoWeb.BookingCalendarLiveComponent do
def render(assigns) do
~H"""
<div class="calendar-container">
<div id={@id} class="calendar-container">
<%!-- Header --%>
<div class="flex items-center justify-between mb-4">
<button

View file

@ -107,7 +107,7 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "must be after start time")
assert String.contains?(error_messages, "must be after start_time")
end
test "rejects booking in the past", %{space: space} do
@ -682,7 +682,7 @@ defmodule SpazioSolazzo.BookingSystem.BookingTest do
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "must be after start datetime")
assert String.contains?(error_messages, "must be after start_datetime")
end
test "rejects walk-in with end time in the past", %{space: space} do

View file

@ -136,7 +136,7 @@ defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplateTest do
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "must be after start time")
assert String.contains?(error_messages, "must be after start_time")
end
test "rejects equal start and end times", %{space: space} do
@ -149,7 +149,7 @@ defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplateTest do
)
error_messages = Ash.Error.error_descriptions(error)
assert String.contains?(error_messages, "must be after start time")
assert String.contains?(error_messages, "must be after start_time")
end
end