From 94cb0870ff9bab6c5e19d566e77aa9068ca7641e Mon Sep 17 00:00:00 2001 From: Kevin Sivic Date: Mon, 1 Dec 2025 08:14:42 -0500 Subject: [PATCH] Add duplicate detection to business import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- config/test.exs | 27 ++- lib/localspot/businesses/import.ex | 29 ++- lib/localspot_web/live/admin_live/import.ex | 7 +- priv/repo/seeds.exs | 227 +++++--------------- test/localspot/businesses/import_test.exs | 44 +++- 5 files changed, 143 insertions(+), 191 deletions(-) diff --git a/config/test.exs b/config/test.exs index 021f34b..8f72410 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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, - username: "kevinsivic", - password: "", - hostname: "localhost", - database: "localspot_test#{System.get_env("MIX_TEST_PARTITION")}", - pool: Ecto.Adapters.SQL.Sandbox, - pool_size: System.schedulers_online() * 2 +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")}" + ] + 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. diff --git a/lib/localspot/businesses/import.ex b/lib/localspot/businesses/import.ex index 6415dd7..d28c5d9 100644 --- a/lib/localspot/businesses/import.ex +++ b/lib/localspot/businesses/import.ex @@ -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), diff --git a/lib/localspot_web/live/admin_live/import.ex b/lib/localspot_web/live/admin_live/import.ex index 8a03e73..63d53d6 100644 --- a/lib/localspot_web/live/admin_live/import.ex +++ b/lib/localspot_web/live/admin_live/import.ex @@ -144,12 +144,17 @@ defmodule LocalspotWeb.AdminLive.Import do
-
+
<.icon name="hero-check-circle" class="w-5 h-5" /> Successfully imported {@import_result.imported} business(es)
+
0} class="alert alert-info"> + <.icon name="hero-information-circle" class="w-5 h-5" /> + Skipped {@import_result.skipped} duplicate(s) +
+
0} class="alert alert-warning"> <.icon name="hero-exclamation-triangle" class="w-5 h-5" />
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index eca707a..25525bc 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -11,192 +11,75 @@ # 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 = - [ - %{ - name: "Restaurants", - slug: "restaurants", - description: "Local dining establishments", - icon: "hero-cake" - }, - %{ - name: "Coffee Shops", - slug: "coffee-shops", - description: "Cafes and coffee houses", - icon: "hero-cup-soda" - }, - %{name: "Retail", slug: "retail", description: "Shops and stores", icon: "hero-shopping-bag"}, - %{ - name: "Services", - slug: "services", - description: "Professional services", - icon: "hero-wrench-screwdriver" - }, - %{ - name: "Arts & Entertainment", - 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 = [ +# Create categories (only if they don't exist) +categories_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: "Restaurants", + slug: "restaurants", + description: "Local dining establishments", + icon: "hero-cake" }, %{ - 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: "Coffee Shops", + slug: "coffee-shops", + description: "Cafes and coffee houses", + icon: "hero-cup-soda" + }, + %{name: "Retail", slug: "retail", description: "Shops and stores", icon: "hero-shopping-bag"}, + %{ + name: "Services", + slug: "services", + description: "Professional services", + icon: "hero-wrench-screwdriver" }, %{ - 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: "Arts & Entertainment", + slug: "arts-entertainment", + description: "Galleries, theaters, and venues", + icon: "hero-paint-brush" }, %{ - 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: "Breweries", + slug: "breweries", + description: "Craft breweries and taprooms", + icon: "hero-beaker" }, %{ - 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: "Wineries", + slug: "wineries", + description: "Wineries and vineyards", + icon: "hero-sparkles" }, %{ - 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: "Outdoor Recreation", + slug: "outdoor-recreation", + description: "Outdoor gear, guides, and adventure", + icon: "hero-sun" + }, + %{ + 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) - |> Repo.insert!() +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)") diff --git a/test/localspot/businesses/import_test.exs b/test/localspot/businesses/import_test.exs index 9ba49e4..00b1f6c 100644 --- a/test/localspot/businesses/import_test.exs +++ b/test/localspot/businesses/import_test.exs @@ -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