Add Businesses context with A-Frame architecture
Phase 1 - Database foundation: - Categories, businesses, hours, photos migrations - Ecto schemas with changesets and validations Phase 2 - Business logic: - Logic module with pure functions (Haversine distance, slug generation, etc.) - Queries module for database operations with filtering - Businesses context coordinating Logic and Queries layers - 43 tests covering logic and queries 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
9cbbb045b9
commit
e8fd635ddb
15 changed files with 1133 additions and 0 deletions
11
.claude/settings.local.json
Normal file
11
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(mix compile:*)",
|
||||
"Bash(mix test:*)",
|
||||
"Bash(mix format:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
155
lib/localspot/businesses.ex
Normal file
155
lib/localspot/businesses.ex
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
defmodule Localspot.Businesses do
|
||||
@moduledoc """
|
||||
Application layer - the Businesses context.
|
||||
Coordinates Logic and Infrastructure layers.
|
||||
"""
|
||||
|
||||
alias Localspot.Businesses.{Logic, Queries}
|
||||
alias Localspot.Businesses.{Business, BusinessHour, BusinessPhoto}
|
||||
alias Localspot.Repo
|
||||
|
||||
# --- Categories ---
|
||||
|
||||
@doc """
|
||||
List all categories.
|
||||
"""
|
||||
defdelegate list_categories, to: Queries
|
||||
|
||||
@doc """
|
||||
Get a category by ID.
|
||||
"""
|
||||
defdelegate get_category(id), to: Queries
|
||||
|
||||
@doc """
|
||||
Get a category by slug.
|
||||
"""
|
||||
defdelegate get_category_by_slug(slug), to: Queries
|
||||
|
||||
@doc """
|
||||
Create a new category.
|
||||
"""
|
||||
defdelegate create_category(attrs), to: Queries
|
||||
|
||||
# --- Businesses ---
|
||||
|
||||
@doc """
|
||||
List businesses with optional filters.
|
||||
Filters can include: query, category_id, latitude, longitude, radius_miles, locally_owned_only
|
||||
"""
|
||||
def list_businesses(params \\ %{}) do
|
||||
filters = Logic.build_search_filters(params)
|
||||
Queries.list_businesses(filters)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get a business by ID.
|
||||
"""
|
||||
defdelegate get_business(id), to: Queries
|
||||
|
||||
@doc """
|
||||
Get a business by slug.
|
||||
"""
|
||||
defdelegate get_business_by_slug(slug), to: Queries
|
||||
|
||||
@doc """
|
||||
Create a new business.
|
||||
"""
|
||||
defdelegate create_business(attrs), to: Queries
|
||||
|
||||
@doc """
|
||||
Get business counts per category.
|
||||
"""
|
||||
defdelegate count_businesses_by_category, to: Queries
|
||||
|
||||
# --- Business with associations ---
|
||||
|
||||
@doc """
|
||||
Create a business with hours and photos in a single transaction.
|
||||
"""
|
||||
def create_business_with_associations(attrs, hours_attrs \\ [], photos_attrs \\ []) do
|
||||
Repo.transaction(fn ->
|
||||
with {:ok, business} <- create_business(attrs),
|
||||
:ok <- create_hours(business, hours_attrs),
|
||||
:ok <- create_photos(business, photos_attrs) do
|
||||
# Reload with associations
|
||||
Queries.get_business(business.id)
|
||||
else
|
||||
{:error, changeset} -> Repo.rollback(changeset)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp create_hours(_business, []), do: :ok
|
||||
|
||||
defp create_hours(business, hours_attrs) do
|
||||
results =
|
||||
Enum.map(hours_attrs, fn attrs ->
|
||||
%BusinessHour{}
|
||||
|> BusinessHour.changeset(Map.put(attrs, :business_id, business.id))
|
||||
|> Repo.insert()
|
||||
end)
|
||||
|
||||
if Enum.all?(results, &match?({:ok, _}, &1)) do
|
||||
:ok
|
||||
else
|
||||
error = Enum.find(results, &match?({:error, _}, &1))
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp create_photos(_business, []), do: :ok
|
||||
|
||||
defp create_photos(business, photos_attrs) do
|
||||
results =
|
||||
Enum.map(photos_attrs, fn attrs ->
|
||||
%BusinessPhoto{}
|
||||
|> BusinessPhoto.changeset(Map.put(attrs, :business_id, business.id))
|
||||
|> Repo.insert()
|
||||
end)
|
||||
|
||||
if Enum.all?(results, &match?({:ok, _}, &1)) do
|
||||
:ok
|
||||
else
|
||||
error = Enum.find(results, &match?({:error, _}, &1))
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
# --- Logic helpers ---
|
||||
|
||||
@doc """
|
||||
Check if a business is currently open.
|
||||
"""
|
||||
defdelegate currently_open?(hours, now \\ DateTime.utc_now()), to: Logic
|
||||
|
||||
@doc """
|
||||
Format a phone number for display.
|
||||
"""
|
||||
defdelegate format_phone(phone), to: Logic
|
||||
|
||||
@doc """
|
||||
Get the name of a day from its integer representation.
|
||||
"""
|
||||
defdelegate day_name(day_of_week), to: Logic
|
||||
|
||||
@doc """
|
||||
Generate a URL-friendly slug from a name.
|
||||
"""
|
||||
defdelegate generate_slug(name), to: Logic
|
||||
|
||||
# --- Changesets for forms ---
|
||||
|
||||
@doc """
|
||||
Returns a changeset for tracking business changes.
|
||||
"""
|
||||
def change_business(%Business{} = business, attrs \\ %{}) do
|
||||
Business.changeset(business, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a new business struct for forms.
|
||||
"""
|
||||
def new_business do
|
||||
%Business{}
|
||||
end
|
||||
end
|
||||
60
lib/localspot/businesses/business.ex
Normal file
60
lib/localspot/businesses/business.ex
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
defmodule Localspot.Businesses.Business do
|
||||
@moduledoc """
|
||||
Schema for businesses in the directory.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "businesses" do
|
||||
field :name, :string
|
||||
field :slug, :string
|
||||
field :description, :string
|
||||
field :phone, :string
|
||||
field :email, :string
|
||||
field :website, :string
|
||||
field :street_address, :string
|
||||
field :city, :string
|
||||
field :state, :string
|
||||
field :zip_code, :string
|
||||
field :latitude, :decimal
|
||||
field :longitude, :decimal
|
||||
field :locally_owned, :boolean, default: true
|
||||
field :active, :boolean, default: true
|
||||
|
||||
# Virtual field for distance calculations
|
||||
field :distance_miles, :float, virtual: true
|
||||
|
||||
belongs_to :category, Localspot.Businesses.Category
|
||||
has_many :hours, Localspot.Businesses.BusinessHour
|
||||
has_many :photos, Localspot.Businesses.BusinessPhoto
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(business, attrs) do
|
||||
business
|
||||
|> cast(attrs, [
|
||||
:name,
|
||||
:slug,
|
||||
:description,
|
||||
:phone,
|
||||
:email,
|
||||
:website,
|
||||
:street_address,
|
||||
:city,
|
||||
:state,
|
||||
:zip_code,
|
||||
:latitude,
|
||||
:longitude,
|
||||
:locally_owned,
|
||||
:active,
|
||||
:category_id
|
||||
])
|
||||
|> validate_required([:name, :slug, :street_address, :city, :state, :zip_code, :category_id])
|
||||
|> validate_format(:email, ~r/@/, message: "must be a valid email")
|
||||
|> validate_number(:latitude, greater_than_or_equal_to: -90, less_than_or_equal_to: 90)
|
||||
|> validate_number(:longitude, greater_than_or_equal_to: -180, less_than_or_equal_to: 180)
|
||||
|> unique_constraint(:slug)
|
||||
|> foreign_key_constraint(:category_id)
|
||||
end
|
||||
end
|
||||
46
lib/localspot/businesses/business_hour.ex
Normal file
46
lib/localspot/businesses/business_hour.ex
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
defmodule Localspot.Businesses.BusinessHour do
|
||||
@moduledoc """
|
||||
Schema for business operating hours.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@days_of_week 0..6
|
||||
|
||||
schema "business_hours" do
|
||||
field :day_of_week, :integer
|
||||
field :opens_at, :time
|
||||
field :closes_at, :time
|
||||
field :closed, :boolean, default: false
|
||||
|
||||
belongs_to :business, Localspot.Businesses.Business
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(hour, attrs) do
|
||||
hour
|
||||
|> cast(attrs, [:day_of_week, :opens_at, :closes_at, :closed, :business_id])
|
||||
|> validate_required([:day_of_week, :business_id])
|
||||
|> validate_inclusion(:day_of_week, @days_of_week)
|
||||
|> validate_hours_logic()
|
||||
|> unique_constraint([:business_id, :day_of_week])
|
||||
end
|
||||
|
||||
defp validate_hours_logic(changeset) do
|
||||
closed = get_field(changeset, :closed)
|
||||
opens_at = get_field(changeset, :opens_at)
|
||||
closes_at = get_field(changeset, :closes_at)
|
||||
|
||||
cond do
|
||||
closed ->
|
||||
changeset
|
||||
|
||||
is_nil(opens_at) or is_nil(closes_at) ->
|
||||
add_error(changeset, :opens_at, "opening and closing times required when not closed")
|
||||
|
||||
true ->
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
25
lib/localspot/businesses/business_photo.ex
Normal file
25
lib/localspot/businesses/business_photo.ex
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
defmodule Localspot.Businesses.BusinessPhoto do
|
||||
@moduledoc """
|
||||
Schema for business photos (URL references).
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "business_photos" do
|
||||
field :url, :string
|
||||
field :alt_text, :string
|
||||
field :position, :integer, default: 0
|
||||
field :primary, :boolean, default: false
|
||||
|
||||
belongs_to :business, Localspot.Businesses.Business
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(photo, attrs) do
|
||||
photo
|
||||
|> cast(attrs, [:url, :alt_text, :position, :primary, :business_id])
|
||||
|> validate_required([:url, :business_id])
|
||||
|> validate_format(:url, ~r/^https?:\/\//, message: "must be a valid URL")
|
||||
end
|
||||
end
|
||||
25
lib/localspot/businesses/category.ex
Normal file
25
lib/localspot/businesses/category.ex
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
defmodule Localspot.Businesses.Category do
|
||||
@moduledoc """
|
||||
Schema for business categories.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "categories" do
|
||||
field :name, :string
|
||||
field :slug, :string
|
||||
field :description, :string
|
||||
field :icon, :string
|
||||
|
||||
has_many :businesses, Localspot.Businesses.Business
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(category, attrs) do
|
||||
category
|
||||
|> cast(attrs, [:name, :slug, :description, :icon])
|
||||
|> validate_required([:name, :slug])
|
||||
|> unique_constraint(:slug)
|
||||
end
|
||||
end
|
||||
132
lib/localspot/businesses/logic.ex
Normal file
132
lib/localspot/businesses/logic.ex
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
defmodule Localspot.Businesses.Logic do
|
||||
@moduledoc """
|
||||
Pure business logic for the Businesses context.
|
||||
No external dependencies - all functions are pure.
|
||||
"""
|
||||
|
||||
@earth_radius_miles 3958.8
|
||||
|
||||
@doc """
|
||||
Calculate distance between two points using Haversine formula.
|
||||
Returns distance in miles.
|
||||
"""
|
||||
def haversine_distance(lat1, lng1, lat2, lng2) do
|
||||
lat1_rad = degrees_to_radians(lat1)
|
||||
lat2_rad = degrees_to_radians(lat2)
|
||||
delta_lat = degrees_to_radians(lat2 - lat1)
|
||||
delta_lng = degrees_to_radians(lng2 - lng1)
|
||||
|
||||
a =
|
||||
:math.sin(delta_lat / 2) * :math.sin(delta_lat / 2) +
|
||||
:math.cos(lat1_rad) * :math.cos(lat2_rad) *
|
||||
:math.sin(delta_lng / 2) * :math.sin(delta_lng / 2)
|
||||
|
||||
c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))
|
||||
|
||||
@earth_radius_miles * c
|
||||
end
|
||||
|
||||
defp degrees_to_radians(degrees), do: degrees * :math.pi() / 180
|
||||
|
||||
@doc """
|
||||
Generate a URL-friendly slug from a business name.
|
||||
"""
|
||||
def generate_slug(name) when is_binary(name) do
|
||||
name
|
||||
|> String.downcase()
|
||||
|> String.replace(~r/[^a-z0-9\s-]/, "")
|
||||
|> String.replace(~r/\s+/, "-")
|
||||
|> String.trim("-")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Format phone number for display.
|
||||
"""
|
||||
def format_phone(nil), do: nil
|
||||
|
||||
def format_phone(phone) do
|
||||
digits = String.replace(phone, ~r/\D/, "")
|
||||
|
||||
case String.length(digits) do
|
||||
10 ->
|
||||
"#{String.slice(digits, 0, 3)}-#{String.slice(digits, 3, 3)}-#{String.slice(digits, 6, 4)}"
|
||||
|
||||
11 ->
|
||||
"+1 #{String.slice(digits, 1, 3)}-#{String.slice(digits, 4, 3)}-#{String.slice(digits, 7, 4)}"
|
||||
|
||||
_ ->
|
||||
phone
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get the day name from day_of_week integer.
|
||||
0 = Sunday, 6 = Saturday
|
||||
"""
|
||||
def day_name(0), do: "Sunday"
|
||||
def day_name(1), do: "Monday"
|
||||
def day_name(2), do: "Tuesday"
|
||||
def day_name(3), do: "Wednesday"
|
||||
def day_name(4), do: "Thursday"
|
||||
def day_name(5), do: "Friday"
|
||||
def day_name(6), do: "Saturday"
|
||||
|
||||
@doc """
|
||||
Check if a business is currently open based on hours.
|
||||
"""
|
||||
def currently_open?(hours, now \\ DateTime.utc_now()) do
|
||||
# Convert to 0-indexed day of week (0 = Sunday, 6 = Saturday)
|
||||
# Date.day_of_week returns 1=Monday..7=Sunday, we need 0=Sunday..6=Saturday
|
||||
elixir_day = Date.day_of_week(DateTime.to_date(now))
|
||||
day_of_week = rem(elixir_day, 7)
|
||||
current_time = DateTime.to_time(now)
|
||||
|
||||
hours
|
||||
|> Enum.find(fn h -> h.day_of_week == day_of_week end)
|
||||
|> case do
|
||||
nil ->
|
||||
false
|
||||
|
||||
%{closed: true} ->
|
||||
false
|
||||
|
||||
%{opens_at: opens, closes_at: closes} ->
|
||||
Time.compare(current_time, opens) in [:gt, :eq] and
|
||||
Time.compare(current_time, closes) == :lt
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Build search filter criteria from params.
|
||||
"""
|
||||
def build_search_filters(params) do
|
||||
%{
|
||||
query: Map.get(params, "query", "") |> String.trim(),
|
||||
category_id: parse_integer(Map.get(params, "category_id")),
|
||||
latitude: parse_float(Map.get(params, "latitude")),
|
||||
longitude: parse_float(Map.get(params, "longitude")),
|
||||
radius_miles: parse_float(Map.get(params, "radius", "10")),
|
||||
locally_owned_only: Map.get(params, "locally_owned") == "true"
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_integer(nil), do: nil
|
||||
defp parse_integer(""), do: nil
|
||||
|
||||
defp parse_integer(val) when is_binary(val) do
|
||||
case Integer.parse(val) do
|
||||
{int, _} -> int
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_float(nil), do: nil
|
||||
defp parse_float(""), do: nil
|
||||
|
||||
defp parse_float(val) when is_binary(val) do
|
||||
case Float.parse(val) do
|
||||
{float, _} -> float
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
126
lib/localspot/businesses/queries.ex
Normal file
126
lib/localspot/businesses/queries.ex
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
defmodule Localspot.Businesses.Queries do
|
||||
@moduledoc """
|
||||
Infrastructure layer - database queries for the Businesses context.
|
||||
Wraps Ecto.Repo operations with clean interfaces.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
alias Localspot.Repo
|
||||
alias Localspot.Businesses.{Business, Category}
|
||||
alias Localspot.Businesses.Logic
|
||||
|
||||
# --- Categories ---
|
||||
|
||||
def list_categories do
|
||||
Category
|
||||
|> order_by(:name)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def get_category(id), do: Repo.get(Category, id)
|
||||
|
||||
def get_category_by_slug(slug), do: Repo.get_by(Category, slug: slug)
|
||||
|
||||
def create_category(attrs) do
|
||||
%Category{}
|
||||
|> Category.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
# --- Businesses ---
|
||||
|
||||
def list_businesses(filters \\ %{}) do
|
||||
Business
|
||||
|> where([b], b.active == true)
|
||||
|> apply_filters(filters)
|
||||
|> preload([:category, :photos])
|
||||
|> order_by(:name)
|
||||
|> Repo.all()
|
||||
|> maybe_filter_by_distance(filters)
|
||||
end
|
||||
|
||||
def get_business(id) do
|
||||
Business
|
||||
|> where([b], b.id == ^id and b.active == true)
|
||||
|> preload([:category, :hours, :photos])
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
def get_business_by_slug(slug) do
|
||||
Business
|
||||
|> where([b], b.slug == ^slug and b.active == true)
|
||||
|> preload([:category, :hours, :photos])
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
def create_business(attrs) do
|
||||
%Business{}
|
||||
|> Business.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def count_businesses_by_category do
|
||||
Business
|
||||
|> where([b], b.active == true)
|
||||
|> group_by([b], b.category_id)
|
||||
|> select([b], {b.category_id, count(b.id)})
|
||||
|> Repo.all()
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
# --- Private helpers ---
|
||||
|
||||
defp apply_filters(query, filters) do
|
||||
query
|
||||
|> filter_by_query(Map.get(filters, :query))
|
||||
|> filter_by_category(Map.get(filters, :category_id))
|
||||
|> filter_by_locally_owned(Map.get(filters, :locally_owned_only))
|
||||
end
|
||||
|
||||
defp filter_by_query(query, nil), do: query
|
||||
defp filter_by_query(query, ""), do: query
|
||||
|
||||
defp filter_by_query(query, search_term) do
|
||||
term = "%#{search_term}%"
|
||||
where(query, [b], ilike(b.name, ^term) or ilike(b.description, ^term))
|
||||
end
|
||||
|
||||
defp filter_by_category(query, nil), do: query
|
||||
|
||||
defp filter_by_category(query, category_id) do
|
||||
where(query, [b], b.category_id == ^category_id)
|
||||
end
|
||||
|
||||
defp filter_by_locally_owned(query, false), do: query
|
||||
defp filter_by_locally_owned(query, nil), do: query
|
||||
|
||||
defp filter_by_locally_owned(query, true) do
|
||||
where(query, [b], b.locally_owned == true)
|
||||
end
|
||||
|
||||
# Post-query filtering for distance (Haversine)
|
||||
defp maybe_filter_by_distance(businesses, filters) do
|
||||
lat = Map.get(filters, :latitude)
|
||||
lng = Map.get(filters, :longitude)
|
||||
radius = Map.get(filters, :radius_miles)
|
||||
|
||||
if lat && lng && radius do
|
||||
businesses
|
||||
|> Enum.map(fn business ->
|
||||
distance =
|
||||
Logic.haversine_distance(
|
||||
lat,
|
||||
lng,
|
||||
Decimal.to_float(business.latitude),
|
||||
Decimal.to_float(business.longitude)
|
||||
)
|
||||
|
||||
%{business | distance_miles: Float.round(distance, 1)}
|
||||
end)
|
||||
|> Enum.filter(fn business -> business.distance_miles <= radius end)
|
||||
|> Enum.sort_by(fn business -> business.distance_miles end)
|
||||
else
|
||||
businesses
|
||||
end
|
||||
end
|
||||
end
|
||||
16
priv/repo/migrations/20251201042317_create_categories.exs
Normal file
16
priv/repo/migrations/20251201042317_create_categories.exs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
defmodule Localspot.Repo.Migrations.CreateCategories do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:categories) do
|
||||
add :name, :string, null: false
|
||||
add :slug, :string, null: false
|
||||
add :description, :text
|
||||
add :icon, :string
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:categories, [:slug])
|
||||
end
|
||||
end
|
||||
40
priv/repo/migrations/20251201042510_create_businesses.exs
Normal file
40
priv/repo/migrations/20251201042510_create_businesses.exs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
defmodule Localspot.Repo.Migrations.CreateBusinesses do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:businesses) do
|
||||
add :name, :string, null: false
|
||||
add :slug, :string, null: false
|
||||
add :description, :text
|
||||
add :phone, :string
|
||||
add :email, :string
|
||||
add :website, :string
|
||||
|
||||
# Address fields
|
||||
add :street_address, :string, null: false
|
||||
add :city, :string, null: false
|
||||
add :state, :string, null: false
|
||||
add :zip_code, :string, null: false
|
||||
|
||||
# Location for radius search (Haversine)
|
||||
add :latitude, :decimal, precision: 10, scale: 8
|
||||
add :longitude, :decimal, precision: 11, scale: 8
|
||||
|
||||
# Local ownership
|
||||
add :locally_owned, :boolean, default: true, null: false
|
||||
|
||||
# Status
|
||||
add :active, :boolean, default: true, null: false
|
||||
|
||||
add :category_id, references(:categories, on_delete: :restrict), null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:businesses, [:slug])
|
||||
create index(:businesses, [:category_id])
|
||||
create index(:businesses, [:latitude, :longitude])
|
||||
create index(:businesses, [:city, :state])
|
||||
create index(:businesses, [:active])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
defmodule Localspot.Repo.Migrations.CreateBusinessHours do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:business_hours) do
|
||||
add :day_of_week, :integer, null: false
|
||||
add :opens_at, :time
|
||||
add :closes_at, :time
|
||||
add :closed, :boolean, default: false, null: false
|
||||
|
||||
add :business_id, references(:businesses, on_delete: :delete_all), null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:business_hours, [:business_id])
|
||||
create unique_index(:business_hours, [:business_id, :day_of_week])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
defmodule Localspot.Repo.Migrations.CreateBusinessPhotos do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:business_photos) do
|
||||
add :url, :string, null: false
|
||||
add :alt_text, :string
|
||||
add :position, :integer, default: 0, null: false
|
||||
add :primary, :boolean, default: false, null: false
|
||||
|
||||
add :business_id, references(:businesses, on_delete: :delete_all), null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:business_photos, [:business_id])
|
||||
end
|
||||
end
|
||||
BIN
test/localspot/businesses/.logic_test.exs.swp
Normal file
BIN
test/localspot/businesses/.logic_test.exs.swp
Normal file
Binary file not shown.
208
test/localspot/businesses/logic_test.exs
Normal file
208
test/localspot/businesses/logic_test.exs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
defmodule Localspot.Businesses.LogicTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Localspot.Businesses.Logic
|
||||
|
||||
describe "haversine_distance/4" do
|
||||
test "calculates distance between two points in miles" do
|
||||
# New York to Los Angeles (~2,451 miles)
|
||||
ny_lat = 40.7128
|
||||
ny_lng = -74.0060
|
||||
la_lat = 34.0522
|
||||
la_lng = -118.2437
|
||||
|
||||
distance = Logic.haversine_distance(ny_lat, ny_lng, la_lat, la_lng)
|
||||
|
||||
assert_in_delta distance, 2451, 10
|
||||
end
|
||||
|
||||
test "returns 0 for same coordinates" do
|
||||
assert Logic.haversine_distance(40.0, -74.0, 40.0, -74.0) == 0.0
|
||||
end
|
||||
|
||||
test "calculates short distances accurately" do
|
||||
# Two points about 1 mile apart in NYC
|
||||
lat1 = 40.7580
|
||||
lng1 = -73.9855
|
||||
lat2 = 40.7484
|
||||
lng2 = -73.9857
|
||||
|
||||
distance = Logic.haversine_distance(lat1, lng1, lat2, lng2)
|
||||
|
||||
assert_in_delta distance, 0.66, 0.1
|
||||
end
|
||||
end
|
||||
|
||||
describe "generate_slug/1" do
|
||||
test "converts name to URL-friendly slug" do
|
||||
assert Logic.generate_slug("Joe's Coffee Shop") == "joes-coffee-shop"
|
||||
end
|
||||
|
||||
test "handles multiple spaces" do
|
||||
assert Logic.generate_slug(" Multiple Spaces ") == "multiple-spaces"
|
||||
end
|
||||
|
||||
test "removes special characters" do
|
||||
assert Logic.generate_slug("Special!@#$%^&*()Characters") == "specialcharacters"
|
||||
end
|
||||
|
||||
test "handles already lowercase names" do
|
||||
assert Logic.generate_slug("already lowercase") == "already-lowercase"
|
||||
end
|
||||
|
||||
test "handles numbers" do
|
||||
assert Logic.generate_slug("Cafe 123") == "cafe-123"
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_phone/1" do
|
||||
test "formats 10-digit phone numbers" do
|
||||
assert Logic.format_phone("5551234567") == "555-123-4567"
|
||||
end
|
||||
|
||||
test "formats phone with existing formatting" do
|
||||
assert Logic.format_phone("(555) 123-4567") == "555-123-4567"
|
||||
end
|
||||
|
||||
test "formats 11-digit phone with country code" do
|
||||
assert Logic.format_phone("15551234567") == "+1 555-123-4567"
|
||||
end
|
||||
|
||||
test "returns nil for nil input" do
|
||||
assert Logic.format_phone(nil) == nil
|
||||
end
|
||||
|
||||
test "returns original for non-standard formats" do
|
||||
assert Logic.format_phone("123") == "123"
|
||||
end
|
||||
end
|
||||
|
||||
describe "day_name/1" do
|
||||
test "returns correct day names" do
|
||||
assert Logic.day_name(0) == "Sunday"
|
||||
assert Logic.day_name(1) == "Monday"
|
||||
assert Logic.day_name(2) == "Tuesday"
|
||||
assert Logic.day_name(3) == "Wednesday"
|
||||
assert Logic.day_name(4) == "Thursday"
|
||||
assert Logic.day_name(5) == "Friday"
|
||||
assert Logic.day_name(6) == "Saturday"
|
||||
end
|
||||
end
|
||||
|
||||
describe "currently_open?/2" do
|
||||
test "returns true when business is open" do
|
||||
hours = [
|
||||
%{day_of_week: 1, opens_at: ~T[09:00:00], closes_at: ~T[17:00:00], closed: false}
|
||||
]
|
||||
|
||||
# Monday at noon UTC
|
||||
monday_noon = ~U[2024-01-08 12:00:00Z]
|
||||
|
||||
assert Logic.currently_open?(hours, monday_noon) == true
|
||||
end
|
||||
|
||||
test "returns false when business is closed for the day" do
|
||||
hours = [
|
||||
%{day_of_week: 1, opens_at: nil, closes_at: nil, closed: true}
|
||||
]
|
||||
|
||||
monday_noon = ~U[2024-01-08 12:00:00Z]
|
||||
|
||||
assert Logic.currently_open?(hours, monday_noon) == false
|
||||
end
|
||||
|
||||
test "returns false when outside business hours" do
|
||||
hours = [
|
||||
%{day_of_week: 1, opens_at: ~T[09:00:00], closes_at: ~T[17:00:00], closed: false}
|
||||
]
|
||||
|
||||
# Monday at 8pm UTC
|
||||
monday_evening = ~U[2024-01-08 20:00:00Z]
|
||||
|
||||
assert Logic.currently_open?(hours, monday_evening) == false
|
||||
end
|
||||
|
||||
test "returns false when no hours defined for day" do
|
||||
hours = [
|
||||
%{day_of_week: 2, opens_at: ~T[09:00:00], closes_at: ~T[17:00:00], closed: false}
|
||||
]
|
||||
|
||||
# Monday (day 1, but hours only defined for Tuesday day 2)
|
||||
monday_noon = ~U[2024-01-08 12:00:00Z]
|
||||
|
||||
assert Logic.currently_open?(hours, monday_noon) == false
|
||||
end
|
||||
|
||||
test "returns true at opening time" do
|
||||
hours = [
|
||||
%{day_of_week: 1, opens_at: ~T[09:00:00], closes_at: ~T[17:00:00], closed: false}
|
||||
]
|
||||
|
||||
monday_9am = ~U[2024-01-08 09:00:00Z]
|
||||
|
||||
assert Logic.currently_open?(hours, monday_9am) == true
|
||||
end
|
||||
|
||||
test "returns false at closing time" do
|
||||
hours = [
|
||||
%{day_of_week: 1, opens_at: ~T[09:00:00], closes_at: ~T[17:00:00], closed: false}
|
||||
]
|
||||
|
||||
monday_5pm = ~U[2024-01-08 17:00:00Z]
|
||||
|
||||
assert Logic.currently_open?(hours, monday_5pm) == false
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_search_filters/1" do
|
||||
test "parses all search params" do
|
||||
params = %{
|
||||
"query" => "coffee",
|
||||
"category_id" => "5",
|
||||
"latitude" => "40.7128",
|
||||
"longitude" => "-74.0060",
|
||||
"radius" => "25",
|
||||
"locally_owned" => "true"
|
||||
}
|
||||
|
||||
filters = Logic.build_search_filters(params)
|
||||
|
||||
assert filters.query == "coffee"
|
||||
assert filters.category_id == 5
|
||||
assert filters.latitude == 40.7128
|
||||
assert filters.longitude == -74.0060
|
||||
assert filters.radius_miles == 25.0
|
||||
assert filters.locally_owned_only == true
|
||||
end
|
||||
|
||||
test "handles missing params with defaults" do
|
||||
filters = Logic.build_search_filters(%{})
|
||||
|
||||
assert filters.query == ""
|
||||
assert filters.category_id == nil
|
||||
assert filters.latitude == nil
|
||||
assert filters.longitude == nil
|
||||
assert filters.radius_miles == 10.0
|
||||
assert filters.locally_owned_only == false
|
||||
end
|
||||
|
||||
test "trims query whitespace" do
|
||||
filters = Logic.build_search_filters(%{"query" => " coffee "})
|
||||
|
||||
assert filters.query == "coffee"
|
||||
end
|
||||
|
||||
test "handles empty string params" do
|
||||
filters =
|
||||
Logic.build_search_filters(%{
|
||||
"category_id" => "",
|
||||
"latitude" => "",
|
||||
"longitude" => ""
|
||||
})
|
||||
|
||||
assert filters.category_id == nil
|
||||
assert filters.latitude == nil
|
||||
assert filters.longitude == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
252
test/localspot/businesses/queries_test.exs
Normal file
252
test/localspot/businesses/queries_test.exs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
defmodule Localspot.Businesses.QueriesTest do
|
||||
use Localspot.DataCase, async: true
|
||||
|
||||
alias Localspot.Businesses.Queries
|
||||
alias Localspot.Businesses.{Category, Business}
|
||||
alias Localspot.Repo
|
||||
|
||||
setup do
|
||||
category = Repo.insert!(%Category{name: "Coffee", slug: "coffee"})
|
||||
|
||||
business =
|
||||
Repo.insert!(%Business{
|
||||
name: "Test Coffee Shop",
|
||||
slug: "test-coffee-shop",
|
||||
description: "A great coffee shop",
|
||||
street_address: "123 Main St",
|
||||
city: "Test City",
|
||||
state: "TC",
|
||||
zip_code: "12345",
|
||||
latitude: Decimal.new("40.7128"),
|
||||
longitude: Decimal.new("-74.0060"),
|
||||
locally_owned: true,
|
||||
active: true,
|
||||
category_id: category.id
|
||||
})
|
||||
|
||||
%{category: category, business: business}
|
||||
end
|
||||
|
||||
describe "list_categories/0" do
|
||||
test "returns all categories", %{category: category} do
|
||||
categories = Queries.list_categories()
|
||||
assert length(categories) >= 1
|
||||
assert Enum.any?(categories, &(&1.id == category.id))
|
||||
end
|
||||
|
||||
test "orders categories by name" do
|
||||
Repo.insert!(%Category{name: "Aardvark Services", slug: "aardvark"})
|
||||
Repo.insert!(%Category{name: "Zebra Goods", slug: "zebra"})
|
||||
|
||||
categories = Queries.list_categories()
|
||||
names = Enum.map(categories, & &1.name)
|
||||
|
||||
assert names == Enum.sort(names)
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_category/1" do
|
||||
test "returns category by ID", %{category: category} do
|
||||
found = Queries.get_category(category.id)
|
||||
assert found.id == category.id
|
||||
end
|
||||
|
||||
test "returns nil for unknown ID" do
|
||||
assert Queries.get_category(-1) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_category_by_slug/1" do
|
||||
test "returns category by slug", %{category: category} do
|
||||
found = Queries.get_category_by_slug(category.slug)
|
||||
assert found.id == category.id
|
||||
end
|
||||
|
||||
test "returns nil for unknown slug" do
|
||||
assert Queries.get_category_by_slug("unknown") == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_businesses/1" do
|
||||
test "returns active businesses", %{business: business} do
|
||||
businesses = Queries.list_businesses(%{})
|
||||
assert Enum.any?(businesses, &(&1.id == business.id))
|
||||
end
|
||||
|
||||
test "excludes inactive businesses", %{business: business, category: category} do
|
||||
inactive =
|
||||
Repo.insert!(%Business{
|
||||
name: "Inactive Shop",
|
||||
slug: "inactive-shop",
|
||||
street_address: "456 Oak Ave",
|
||||
city: "Test City",
|
||||
state: "TC",
|
||||
zip_code: "12345",
|
||||
active: false,
|
||||
category_id: category.id
|
||||
})
|
||||
|
||||
businesses = Queries.list_businesses(%{})
|
||||
assert Enum.any?(businesses, &(&1.id == business.id))
|
||||
refute Enum.any?(businesses, &(&1.id == inactive.id))
|
||||
end
|
||||
|
||||
test "filters by search query", %{business: business} do
|
||||
businesses = Queries.list_businesses(%{query: "Coffee"})
|
||||
assert Enum.any?(businesses, &(&1.id == business.id))
|
||||
|
||||
businesses = Queries.list_businesses(%{query: "nonexistent"})
|
||||
refute Enum.any?(businesses, &(&1.id == business.id))
|
||||
end
|
||||
|
||||
test "filters by category", %{category: category, business: business} do
|
||||
other_category = Repo.insert!(%Category{name: "Other", slug: "other"})
|
||||
|
||||
businesses = Queries.list_businesses(%{category_id: category.id})
|
||||
assert length(businesses) == 1
|
||||
assert hd(businesses).id == business.id
|
||||
|
||||
businesses = Queries.list_businesses(%{category_id: other_category.id})
|
||||
assert businesses == []
|
||||
end
|
||||
|
||||
test "filters by locally owned", %{business: business, category: category} do
|
||||
chain =
|
||||
Repo.insert!(%Business{
|
||||
name: "Chain Store",
|
||||
slug: "chain-store",
|
||||
street_address: "789 Corporate Blvd",
|
||||
city: "Test City",
|
||||
state: "TC",
|
||||
zip_code: "12345",
|
||||
locally_owned: false,
|
||||
active: true,
|
||||
category_id: category.id
|
||||
})
|
||||
|
||||
# All businesses
|
||||
businesses = Queries.list_businesses(%{locally_owned_only: false})
|
||||
assert length(businesses) == 2
|
||||
|
||||
# Only locally owned
|
||||
businesses = Queries.list_businesses(%{locally_owned_only: true})
|
||||
assert length(businesses) == 1
|
||||
assert hd(businesses).id == business.id
|
||||
refute Enum.any?(businesses, &(&1.id == chain.id))
|
||||
end
|
||||
|
||||
test "filters by distance", %{business: business, category: category} do
|
||||
# Business in LA (far from the test business in NYC area)
|
||||
far_business =
|
||||
Repo.insert!(%Business{
|
||||
name: "LA Coffee",
|
||||
slug: "la-coffee",
|
||||
street_address: "100 Hollywood Blvd",
|
||||
city: "Los Angeles",
|
||||
state: "CA",
|
||||
zip_code: "90028",
|
||||
latitude: Decimal.new("34.0522"),
|
||||
longitude: Decimal.new("-118.2437"),
|
||||
locally_owned: true,
|
||||
active: true,
|
||||
category_id: category.id
|
||||
})
|
||||
|
||||
# Search near NYC with small radius
|
||||
businesses =
|
||||
Queries.list_businesses(%{
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
radius_miles: 10.0
|
||||
})
|
||||
|
||||
assert Enum.any?(businesses, &(&1.id == business.id))
|
||||
refute Enum.any?(businesses, &(&1.id == far_business.id))
|
||||
|
||||
# Search near LA
|
||||
businesses =
|
||||
Queries.list_businesses(%{
|
||||
latitude: 34.0522,
|
||||
longitude: -118.2437,
|
||||
radius_miles: 10.0
|
||||
})
|
||||
|
||||
refute Enum.any?(businesses, &(&1.id == business.id))
|
||||
assert Enum.any?(businesses, &(&1.id == far_business.id))
|
||||
end
|
||||
|
||||
test "adds distance_miles to results when searching by location", %{business: business} do
|
||||
businesses =
|
||||
Queries.list_businesses(%{
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
radius_miles: 10.0
|
||||
})
|
||||
|
||||
found = Enum.find(businesses, &(&1.id == business.id))
|
||||
assert found.distance_miles != nil
|
||||
assert found.distance_miles < 1.0
|
||||
end
|
||||
|
||||
test "preloads category and photos", %{business: business} do
|
||||
businesses = Queries.list_businesses(%{})
|
||||
found = Enum.find(businesses, &(&1.id == business.id))
|
||||
|
||||
assert Ecto.assoc_loaded?(found.category)
|
||||
assert Ecto.assoc_loaded?(found.photos)
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_business_by_slug/1" do
|
||||
test "returns business with preloads", %{business: business} do
|
||||
found = Queries.get_business_by_slug(business.slug)
|
||||
assert found.id == business.id
|
||||
assert Ecto.assoc_loaded?(found.category)
|
||||
assert Ecto.assoc_loaded?(found.hours)
|
||||
assert Ecto.assoc_loaded?(found.photos)
|
||||
end
|
||||
|
||||
test "returns nil for unknown slug" do
|
||||
assert Queries.get_business_by_slug("unknown") == nil
|
||||
end
|
||||
|
||||
test "returns nil for inactive business slug", %{category: category} do
|
||||
inactive =
|
||||
Repo.insert!(%Business{
|
||||
name: "Inactive",
|
||||
slug: "inactive",
|
||||
street_address: "123 St",
|
||||
city: "City",
|
||||
state: "ST",
|
||||
zip_code: "12345",
|
||||
active: false,
|
||||
category_id: category.id
|
||||
})
|
||||
|
||||
assert Queries.get_business_by_slug(inactive.slug) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "count_businesses_by_category/0" do
|
||||
test "returns counts by category", %{category: category} do
|
||||
counts = Queries.count_businesses_by_category()
|
||||
assert Map.get(counts, category.id) == 1
|
||||
end
|
||||
|
||||
test "excludes inactive businesses", %{category: category} do
|
||||
Repo.insert!(%Business{
|
||||
name: "Inactive",
|
||||
slug: "inactive2",
|
||||
street_address: "123 St",
|
||||
city: "City",
|
||||
state: "ST",
|
||||
zip_code: "12345",
|
||||
active: false,
|
||||
category_id: category.id
|
||||
})
|
||||
|
||||
counts = Queries.count_businesses_by_category()
|
||||
assert Map.get(counts, category.id) == 1
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue