Skip to content

Recipe URL deduplication — krótki plan

Pełny plan: context/changes/recipe-url-dedup/plan.md

Co i dlaczego

Zapobiec re-ekstrakcji tego samego URL przepisu gdy user udostępnia go ponownie. Oszczędza koszty Firecrawl/OpenAI/Inngest, eliminuje duplikaty w liście. Bonusowo: napraw bug (user_id, slug) collision gdy dwa przepisy z różnych źródeł mają ten sam tytuł.

Punkt wyjścia

Schema recipes ma unique(user_id, slug) ale brak ograniczenia na source_url. Oba Server Actions (Web Share Target i manualny formularz) wstawiają nowy recipe_shares i wywołują Inngest bez sprawdzania czy ten URL już był przetwarzany. Slug generuje się z tytułu, więc dwa różne przepisy o tym samym tytule crashują INSERT.

Pożądany stan końcowy

Share URL completed → redirect na detail z toast „Ten przepis już masz”. Share URL pending → redirect na listę z toast „Już przetwarzam…”. Share URL failed → normalna nowa próba. Dwa przepisy o tym samym tytule → slugi tortilla-z-patelni i tortilla-z-patelni-2. DB egzekwuje unikalność URL per user — race protection na poziomie schematu.

Kluczowe podjęte decyzje

DecyzjaWybórDlaczegoŹródło
Reakcja na duplikat completedRedirect do /recipes/<slug>?duplicate=1Zerowy koszt API, user widzi swój przepis; zgodne z archive-firstPlan
Reakcja na duplikat pendingBlokuj z toast „Już przetwarzam”Race protection, oszczędność Firecrawl quotaPlan
Reakcja na poprzedni failedPozwól re-extract (no dedup)Po naprawieniu pipeline można odzyskać failed przez ponowny sharePlan
URL normalizationMinimalna: lowercase host + strip trailing / + strip #Wyłapuje 80%+ duplikatów; query params często niosą info o przepisiePlan
Strategia slug collisionRetry z licznikiem -2, -3, …Pierwszy URL pięknie się prezentuje; suffix tylko gdy konfliktPlan
DB enforcement URL uniquenessUnique partial index (user_id, source_url) WHERE NOT NULLRace protection bez wymuszania source_url=not nullPlan
Migracja istniejącego przepisuBackfill source_url do normalized formSpójność danych od deployu; dedup działa dla obecnego przepisuPlan
Code placement dedup logikiNowy src/lib/recipe-dedup.ts shared helperDRY — używane przez 2 Server Actions; centralizacja jak w lessons.mdPlan
Slug collision: w tej zmianie czy osobno?W tej zmianie (jedna „recipe identity”)Spójny temat, jeden PR, mniej overhead na koordynacjęPlan

Zakres

W zakresie:

  • Migration: backfill normalized URL + unique partial index na recipes(user_id, source_url)
  • src/lib/url-normalize.ts (pure function) + unit testy
  • src/lib/recipe-dedup.ts (Supabase query helper)
  • Wpięcie w share/actions.ts i add-recipe-action.ts
  • UX: query params ?duplicate=1 / ?duplicate=pending + toast/banner
  • Slug retry w Inngest function extract-recipe

Poza zakresem:

  • Bulk re-extract istniejących failed shares
  • Cross-user dedup
  • Aggressive normalization (?utm_*, ?fbclid, www. strip)
  • URL shorteners unfolding
  • Telemetria dedup hit rate
  • Migration istniejących duplikatów (brak ich obecnie)

Architektura / Podejście

Trzy warstwy obrony:

  1. DB: unique constraint recipes(user_id, source_url) blokuje finalny INSERT przy race.
  2. Application: pre-check w Server Action zatrzymuje request przed Inngest (oszczędza API koszty + natychmiastowy redirect).
  3. Slug retry: Inngest function retry z licznikiem przy (user_id, slug) collision.

URL normalization minimalna: new URL() → lowercase hostname, strip trailing /, strip fragment. source_url i shared_url przechowywane w znormalizowanej formie.

Fazy w skrócie

FazaCo dostarczaKluczowe ryzyko
1. Schema + URL normalizerMigration z backfill + unique index; url-normalize.ts + testySQL i TS normalizer muszą zwracać identyczny wynik — synchronizacja
2. Dedup helper + Server Actionsrecipe-dedup.ts; wpięcie w 2 Server Actions; UX bannertriggerRecipeExtraction zwraca nowy wariant — caller musi obsłużyć
3. Slug collision retry w InngestLoop retry z licznikiem przy unique violationIdentyfikacja konkretnej nazwy constraintu w error.details

Wymagania wstępne: Brak — masterowa baza i działający Inngest pipeline (PR #54 zmergowany). Szacowany nakład pracy: ~1 sesja na fazę, 3 PR-y (każdy mergowany niezależnie). Faza 1 wymaga lokalnego dostępu do Supabase CLI do testu migracji.

Otwarte ryzyka i założenia

  • Brak istniejących duplikatów do scalenia w danych produkcyjnych (założenie zweryfikowane: 1 przepis testowy).
  • recipe_shares.shared_url przy nowych insertach musi być przechowywany znormalizowany — implementator musi pamiętać żeby normalizować w obu Server Actions (nie tylko w recipes.source_url).
  • Maks 10 prób retry dla slug to arbitralne — jeśli user ma 10+ przepisów o tym samym tytule, throw. Akceptowalne.

Kryteria sukcesu (podsumowanie)

  • Powtórny share completed URL → redirect na detail page z komunikatem (zero kosztu API)
  • Powtórny share pending URL → blokada z komunikatem (race protection)
  • Dwa przepisy o tym samym tytule z różnych źródeł — oba zapisane, drugi ma slug z suffix