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>
This commit is contained in:
Kevin Sivic 2025-12-01 08:14:42 -05:00
parent f62ddffb80
commit 94cb0870ff
5 changed files with 143 additions and 191 deletions

View file

@ -5,13 +5,26 @@ import Config
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :localspot, Localspot.Repo,
database_url = System.get_env("DATABASE_URL")
db_config =
if database_url do
[url: database_url]
else
[
username: "kevinsivic",
password: "",
hostname: "localhost",
database: "localspot_test#{System.get_env("MIX_TEST_PARTITION")}",
database: "localspot_test#{System.get_env("MIX_TEST_PARTITION")}"
]
end
config :localspot,
Localspot.Repo,
Keyword.merge(db_config,
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2
)
# We don't run a server during test. If one is required,
# you can enable the server option below.

View file

@ -41,7 +41,9 @@ defmodule Localspot.Businesses.Import do
require Logger
@type import_result :: {:ok, %{imported: non_neg_integer(), errors: list()}} | {:error, term()}
@type import_result ::
{:ok, %{imported: non_neg_integer(), skipped: non_neg_integer(), errors: list()}}
| {:error, term()}
@doc """
Imports businesses from a JSON file path.
@ -73,18 +75,20 @@ defmodule Localspot.Businesses.Import do
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, 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, errors: errors}}
{:ok, %{imported: imported, skipped: skipped, errors: errors}}
end
defp load_categories do
@ -96,7 +100,24 @@ defmodule Localspot.Businesses.Import do
end)
end
defp import_single_business(data, categories, index) do
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),

View file

@ -144,12 +144,17 @@ defmodule LocalspotWeb.AdminLive.Import do
</.form>
<div :if={@import_result} class="mt-6">
<div :if={match?(%{imported: _, errors: _}, @import_result)} class="space-y-4">
<div :if={match?(%{imported: _, skipped: _, errors: _}, @import_result)} class="space-y-4">
<div class="alert alert-success">
<.icon name="hero-check-circle" class="w-5 h-5" />
<span>Successfully imported {@import_result.imported} business(es)</span>
</div>
<div :if={@import_result.skipped > 0} class="alert alert-info">
<.icon name="hero-information-circle" class="w-5 h-5" />
<span>Skipped {@import_result.skipped} duplicate(s)</span>
</div>
<div :if={length(@import_result.errors) > 0} class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<div>

View file

@ -11,17 +11,10 @@
# and so on) as they will fail if something goes wrong.
alias Localspot.Repo
alias Localspot.Businesses.{Category, Business, BusinessHour, BusinessPhoto}
alias Localspot.Businesses.Category
# Clear existing data
Repo.delete_all(BusinessPhoto)
Repo.delete_all(BusinessHour)
Repo.delete_all(Business)
Repo.delete_all(Category)
# Create categories
categories =
[
# Create categories (only if they don't exist)
categories_data = [
%{
name: "Restaurants",
slug: "restaurants",
@ -46,157 +39,47 @@ categories =
slug: "arts-entertainment",
description: "Galleries, theaters, and venues",
icon: "hero-paint-brush"
}
]
|> Enum.map(fn attrs ->
%Category{}
|> Category.changeset(attrs)
|> Repo.insert!()
end)
[restaurants, coffee, retail, services, arts] = categories
# Sample businesses - using Columbus, OH area coordinates
businesses_data = [
%{
name: "The Cozy Bean",
slug: "the-cozy-bean",
description:
"A family-owned coffee shop serving locally roasted beans and homemade pastries since 1998.",
street_address: "123 High Street",
city: "Columbus",
state: "OH",
zip_code: "43215",
latitude: Decimal.new("39.9612"),
longitude: Decimal.new("-82.9988"),
phone: "6145551234",
email: "hello@cozybean.example",
website: "https://cozybean.example",
locally_owned: true,
category_id: coffee.id
},
%{
name: "Mama Rosa's Kitchen",
slug: "mama-rosas-kitchen",
description:
"Authentic Italian cuisine made with recipes passed down through four generations.",
street_address: "456 Main Street",
city: "Columbus",
state: "OH",
zip_code: "43215",
latitude: Decimal.new("39.9650"),
longitude: Decimal.new("-83.0020"),
phone: "6145555678",
email: "reservations@mamarosas.example",
website: "https://mamarosas.example",
locally_owned: true,
category_id: restaurants.id
name: "Breweries",
slug: "breweries",
description: "Craft breweries and taprooms",
icon: "hero-beaker"
},
%{
name: "Buckeye Books",
slug: "buckeye-books",
description: "Independent bookstore specializing in local authors and rare finds.",
street_address: "789 Oak Avenue",
city: "Columbus",
state: "OH",
zip_code: "43215",
latitude: Decimal.new("39.9580"),
longitude: Decimal.new("-82.9950"),
phone: "6145559012",
locally_owned: true,
category_id: retail.id
name: "Wineries",
slug: "wineries",
description: "Wineries and vineyards",
icon: "hero-sparkles"
},
%{
name: "Short North Gallery",
slug: "short-north-gallery",
description: "Contemporary art gallery featuring works by Ohio artists.",
street_address: "321 Short North Ave",
city: "Columbus",
state: "OH",
zip_code: "43201",
latitude: Decimal.new("39.9750"),
longitude: Decimal.new("-83.0030"),
locally_owned: true,
category_id: arts.id
name: "Outdoor Recreation",
slug: "outdoor-recreation",
description: "Outdoor gear, guides, and adventure",
icon: "hero-sun"
},
%{
name: "Fix-It Fred's",
slug: "fix-it-freds",
description:
"Family-owned repair shop for electronics, appliances, and more. If it's broken, Fred can fix it!",
street_address: "555 Repair Lane",
city: "Columbus",
state: "OH",
zip_code: "43215",
latitude: Decimal.new("39.9520"),
longitude: Decimal.new("-83.0100"),
phone: "6145553456",
email: "fred@fixitfreds.example",
locally_owned: true,
category_id: services.id
},
%{
name: "German Village Bakery",
slug: "german-village-bakery",
description: "Traditional German pastries and breads baked fresh daily.",
street_address: "888 Schiller Park",
city: "Columbus",
state: "OH",
zip_code: "43206",
latitude: Decimal.new("39.9430"),
longitude: Decimal.new("-82.9920"),
phone: "6145557890",
locally_owned: true,
category_id: restaurants.id
name: "Farm Markets",
slug: "farm-markets",
description: "Farm stands, orchards, and local produce",
icon: "hero-shopping-cart"
}
]
businesses =
businesses_data
|> Enum.map(fn attrs ->
%Business{}
|> Business.changeset(attrs)
created_count =
categories_data
|> Enum.reduce(0, fn attrs, count ->
case Repo.get_by(Category, slug: attrs.slug) do
nil ->
%Category{}
|> Category.changeset(attrs)
|> Repo.insert!()
count + 1
_existing ->
count
end
end)
# Add hours for each business (most open 9-5 or similar)
for business <- businesses do
# Monday through Friday: 9 AM - 5 PM (or restaurant hours)
is_restaurant = business.category_id == restaurants.id
for day <- 1..5 do
%BusinessHour{}
|> BusinessHour.changeset(%{
business_id: business.id,
day_of_week: day,
opens_at: if(is_restaurant, do: ~T[11:00:00], else: ~T[09:00:00]),
closes_at: if(is_restaurant, do: ~T[21:00:00], else: ~T[17:00:00]),
closed: false
})
|> Repo.insert!()
end
# Saturday: shorter hours
%BusinessHour{}
|> BusinessHour.changeset(%{
business_id: business.id,
day_of_week: 6,
opens_at: ~T[10:00:00],
closes_at: ~T[15:00:00],
closed: false
})
|> Repo.insert!()
# Sunday: closed (except restaurants)
%BusinessHour{}
|> BusinessHour.changeset(%{
business_id: business.id,
day_of_week: 0,
opens_at: if(is_restaurant, do: ~T[12:00:00], else: nil),
closes_at: if(is_restaurant, do: ~T[20:00:00], else: nil),
closed: !is_restaurant
})
|> Repo.insert!()
end
IO.puts("Seeded #{length(categories)} categories and #{length(businesses)} businesses")
IO.puts("Seeded #{created_count} new categories (#{length(categories_data)} total defined)")

View file

@ -37,13 +37,40 @@ defmodule Localspot.Businesses.ImportTest do
}
"""
assert {:ok, %{imported: 1, errors: []}} = Import.from_json(json)
assert {:ok, %{imported: 1, skipped: 0, errors: []}} = Import.from_json(json)
business = Businesses.get_business_by_slug("test-business")
assert business.name == "Test Business"
assert business.category_id == category.id
end
test "skips duplicate businesses", %{category: _category} do
json = """
{
"businesses": [
{
"name": "Duplicate Test",
"category": "test-category",
"street_address": "123 Test St",
"city": "Columbus",
"state": "OH",
"zip_code": "43215"
}
]
}
"""
# First import should succeed
assert {:ok, %{imported: 1, skipped: 0, errors: []}} = Import.from_json(json)
# Second import should skip the duplicate
assert {:ok, %{imported: 0, skipped: 1, errors: []}} = Import.from_json(json)
# Should still only have one business
businesses = Businesses.list_businesses(%{query: "Duplicate Test"})
assert length(businesses) == 1
end
test "imports business with hours", %{category: _category} do
json = """
{
@ -64,7 +91,7 @@ defmodule Localspot.Businesses.ImportTest do
}
"""
assert {:ok, %{imported: 1, errors: []}} = Import.from_json(json)
assert {:ok, %{imported: 1, skipped: 0, errors: []}} = Import.from_json(json)
business = Businesses.get_business_by_slug("business-with-hours")
assert length(business.hours) == 2
@ -96,7 +123,7 @@ defmodule Localspot.Businesses.ImportTest do
}
"""
assert {:ok, %{imported: 1, errors: []}} = Import.from_json(json)
assert {:ok, %{imported: 1, skipped: 0, errors: []}} = Import.from_json(json)
business = Businesses.get_business_by_slug("business-with-photos")
assert length(business.photos) == 1
@ -128,7 +155,7 @@ defmodule Localspot.Businesses.ImportTest do
}
"""
assert {:ok, %{imported: 2, errors: []}} = Import.from_json(json)
assert {:ok, %{imported: 2, skipped: 0, errors: []}} = Import.from_json(json)
end
test "reports errors for invalid businesses", %{category: _category} do
@ -150,7 +177,7 @@ defmodule Localspot.Businesses.ImportTest do
}
"""
assert {:ok, %{imported: 1, errors: errors}} = Import.from_json(json)
assert {:ok, %{imported: 1, skipped: 0, errors: errors}} = Import.from_json(json)
assert length(errors) == 1
end
@ -179,8 +206,11 @@ defmodule Localspot.Businesses.ImportTest do
"""
assert {:ok,
%{imported: 0, errors: [{:error, 1, {:unknown_category, "nonexistent-category"}}]}} =
Import.from_json(json)
%{
imported: 0,
skipped: 0,
errors: [{:error, 1, {:unknown_category, "nonexistent-category"}}]
}} = Import.from_json(json)
end
end