- Skip businesses that already exist (by slug) - Track skipped count in import results - Display skipped count in admin import UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
215 lines
6.1 KiB
Elixir
215 lines
6.1 KiB
Elixir
defmodule Localspot.Businesses.Import do
|
|
@moduledoc """
|
|
Handles importing businesses from JSON files.
|
|
|
|
## JSON Format
|
|
|
|
The import expects a JSON file with the following structure:
|
|
|
|
{
|
|
"businesses": [
|
|
{
|
|
"name": "Business Name",
|
|
"description": "Optional description",
|
|
"category": "restaurants",
|
|
"street_address": "123 Main St",
|
|
"city": "Columbus",
|
|
"state": "OH",
|
|
"zip_code": "43215",
|
|
"latitude": 39.9612,
|
|
"longitude": -82.9988,
|
|
"phone": "6145551234",
|
|
"email": "contact@example.com",
|
|
"website": "https://example.com",
|
|
"locally_owned": true,
|
|
"hours": [
|
|
{"day": 0, "closed": true},
|
|
{"day": 1, "opens_at": "09:00", "closes_at": "17:00"},
|
|
...
|
|
],
|
|
"photos": [
|
|
{"url": "https://example.com/photo.jpg", "alt_text": "Front of store", "primary": true}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
Days are: 0=Sunday, 1=Monday, ..., 6=Saturday
|
|
"""
|
|
|
|
alias Localspot.Businesses
|
|
|
|
require Logger
|
|
|
|
@type import_result ::
|
|
{:ok, %{imported: non_neg_integer(), skipped: non_neg_integer(), errors: list()}}
|
|
| {:error, term()}
|
|
|
|
@doc """
|
|
Imports businesses from a JSON file path.
|
|
"""
|
|
@spec from_file(String.t()) :: import_result()
|
|
def from_file(path) do
|
|
case File.read(path) do
|
|
{:ok, content} -> from_json(content)
|
|
{:error, reason} -> {:error, {:file_error, reason}}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Imports businesses from a JSON string.
|
|
"""
|
|
@spec from_json(String.t()) :: import_result()
|
|
def from_json(json_string) do
|
|
case Jason.decode(json_string) do
|
|
{:ok, %{"businesses" => businesses}} when is_list(businesses) ->
|
|
import_businesses(businesses)
|
|
|
|
{:ok, _} ->
|
|
{:error, {:invalid_format, "JSON must contain a 'businesses' array"}}
|
|
|
|
{:error, reason} ->
|
|
{:error, {:json_error, reason}}
|
|
end
|
|
end
|
|
|
|
defp import_businesses(businesses) do
|
|
categories = load_categories()
|
|
existing_slugs = load_existing_slugs()
|
|
|
|
results =
|
|
businesses
|
|
|> Enum.with_index(1)
|
|
|> Enum.map(fn {business_data, index} ->
|
|
import_single_business(business_data, categories, existing_slugs, index)
|
|
end)
|
|
|
|
imported = Enum.count(results, &match?({:ok, _}, &1))
|
|
skipped = Enum.count(results, &match?({:skipped, _, _}, &1))
|
|
errors = Enum.filter(results, &match?({:error, _, _}, &1))
|
|
|
|
{:ok, %{imported: imported, skipped: skipped, errors: errors}}
|
|
end
|
|
|
|
defp load_categories do
|
|
Businesses.list_categories()
|
|
|> Enum.reduce(%{}, fn cat, acc ->
|
|
acc
|
|
|> Map.put(cat.slug, cat.id)
|
|
|> Map.put(String.downcase(cat.name), cat.id)
|
|
end)
|
|
end
|
|
|
|
defp load_existing_slugs do
|
|
Businesses.list_all_businesses()
|
|
|> Enum.map(& &1.slug)
|
|
|> MapSet.new()
|
|
end
|
|
|
|
defp import_single_business(data, categories, existing_slugs, index) do
|
|
slug = Businesses.generate_slug(data["name"] || "")
|
|
|
|
if MapSet.member?(existing_slugs, slug) do
|
|
Logger.info("Skipping duplicate business: #{data["name"]} (slug: #{slug})")
|
|
{:skipped, index, slug}
|
|
else
|
|
do_import_business(data, categories, index)
|
|
end
|
|
end
|
|
|
|
defp do_import_business(data, categories, index) do
|
|
with {:ok, business_attrs} <- build_business_attrs(data, categories),
|
|
{:ok, hours_attrs} <- build_hours_attrs(data),
|
|
{:ok, photos_attrs} <- build_photos_attrs(data),
|
|
{:ok, business} <-
|
|
Businesses.create_business_with_associations(business_attrs, hours_attrs, photos_attrs) do
|
|
Logger.info("Imported business: #{business.name}")
|
|
{:ok, business}
|
|
else
|
|
{:error, changeset} when is_struct(changeset, Ecto.Changeset) ->
|
|
errors = format_changeset_errors(changeset)
|
|
Logger.warning("Failed to import business at index #{index}: #{inspect(errors)}")
|
|
{:error, index, errors}
|
|
|
|
{:error, reason} ->
|
|
Logger.warning("Failed to import business at index #{index}: #{inspect(reason)}")
|
|
{:error, index, reason}
|
|
end
|
|
end
|
|
|
|
defp build_business_attrs(data, categories) do
|
|
category_key = data["category"] || ""
|
|
category_id = categories[String.downcase(category_key)]
|
|
|
|
if is_nil(category_id) and category_key != "" do
|
|
{:error, {:unknown_category, category_key}}
|
|
else
|
|
attrs = %{
|
|
"name" => data["name"],
|
|
"slug" => Businesses.generate_slug(data["name"] || ""),
|
|
"description" => data["description"],
|
|
"category_id" => category_id,
|
|
"street_address" => data["street_address"],
|
|
"city" => data["city"],
|
|
"state" => data["state"],
|
|
"zip_code" => data["zip_code"],
|
|
"latitude" => data["latitude"],
|
|
"longitude" => data["longitude"],
|
|
"phone" => data["phone"],
|
|
"email" => data["email"],
|
|
"website" => data["website"],
|
|
"locally_owned" => data["locally_owned"] || true
|
|
}
|
|
|
|
{:ok, attrs}
|
|
end
|
|
end
|
|
|
|
defp build_hours_attrs(data) do
|
|
hours = data["hours"] || []
|
|
|
|
attrs =
|
|
Enum.map(hours, fn hour ->
|
|
%{
|
|
day_of_week: hour["day"],
|
|
closed: hour["closed"] || false,
|
|
opens_at: parse_time(hour["opens_at"]),
|
|
closes_at: parse_time(hour["closes_at"])
|
|
}
|
|
end)
|
|
|
|
{:ok, attrs}
|
|
end
|
|
|
|
defp parse_time(nil), do: nil
|
|
|
|
defp parse_time(time_string) when is_binary(time_string) do
|
|
case Time.from_iso8601(time_string <> ":00") do
|
|
{:ok, time} -> time
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp build_photos_attrs(data) do
|
|
photos = data["photos"] || []
|
|
|
|
attrs =
|
|
Enum.map(photos, fn photo ->
|
|
%{
|
|
url: photo["url"],
|
|
alt_text: photo["alt_text"],
|
|
primary: photo["primary"] || false
|
|
}
|
|
end)
|
|
|
|
{:ok, attrs}
|
|
end
|
|
|
|
defp format_changeset_errors(changeset) do
|
|
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
|
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
|
|
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
|
end)
|
|
end)
|
|
end
|
|
end
|