localspot/lib/localspot/businesses/import.ex
Kevin Sivic 94cb0870ff Add duplicate detection to business import
- 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>
2025-12-01 08:14:42 -05:00

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