Recipe URL deduplication — plan implementacji
Przegląd
Zapobiec re-ekstrakcji tego samego URL przepisu gdy user udostępnia go ponownie (Web Share Target lub formularz manualny). Oszczędza koszty Inngest/Firecrawl/OpenAI, eliminuje duplikaty w liście przepisów, i robi UX bardziej przewidywalny (redirect do istniejącego przepisu zamiast tworzenia kopii).
Ta zmiana obejmuje też powiązany bug fix: slug collision gdy dwie różne strony mają przepis o tym samym tytule (np. “Tortilla z patelni” na mecooks.pl i innym blogu) — obecnie crashuje INSERT na unique(user_id, slug).
Analiza stanu obecnego
- Schema
recipes:unique(user_id, slug), kolumnasource_urlnullable, brak ograniczenia unikalności na URL —supabase/migrations/20260607000000_recipe_schema.sql:49 - Schema
recipe_shares:shared_url not null, status enumpending/completed/failed, brak normalizacji - Dwa entry points uruchamiające ekstrakcję, oba bez dedup check:
src/app/share/actions.ts(Web Share Target POST)src/app/(authenticated)/recipes/add-recipe-action.ts(manualny formularz)
- Slug generator:
src/lib/slugify.ts— slugifikuje tytuł, brak retry przy collision - Inngest function
src/inngest/functions.ts:102-118— wykonuje.insert(...).single()bez obsługi unique violation - RLS na
recipesirecipe_sharesegzekwuje per-user isolation; dedup musi być per-user
Pożądany stan końcowy
Po wdrożeniu:
- User udostępnia URL którego ekstrakcja była completed → redirect na
/recipes/<slug>?duplicate=1, toast „Ten przepis już masz”. - User udostępnia URL którego share jest pending → redirect na
/recipes?duplicate=pending, toast „Już przetwarzam ten przepis — wróć za chwilę”. - User udostępnia URL którego ekstrakcja failed → normalna ścieżka (nowa próba ekstrakcji).
- Dwie strony z różnymi przepisami ale tym samym tytułem → unikalne slugi (
tortilla-z-patelni,tortilla-z-patelni-2). - DB egzekwuje
unique(user_id, source_url)narecipes— race conditions zabezpieczone na poziomie schematu.
Kluczowe odkrycia
recipes.source_urljest nullable — unique constraint w PostgreSQL nie konfliktuje na NULL, więc istniejący 1 przepis (z source_url ustawionym) potrzebuje backfillu, ale starsze NULL wiersze (gdyby były) by działały.current_user_id()SECURITY DEFINER wrapper jest dostępny do użycia w policies —supabase/migrations/20260529093516_init_auth_helpers.sql(referencjonowany w RLS policies).- Inngest function używa
getSuabaseServiceRoleKey()— service role bypassuje RLS, więc dedup check w Server Action (cookie-aware) i slug retry w Inngest (service role) muszą obie filtrować pouser_idjawnie.
Czego NIE robimy
- Bulk re-extract istniejących failed shares (osobny feature, wymaga UI lub admin command)
- Cross-user dedup (nie ma sensu — RLS, prywatność)
- Migration istniejących duplikatów (jest 1 przepis, brak duplikatów do scalenia)
- Telemetria dedup hit rate (POC, nie potrzebne)
- Wsparcie dla URL shorteners (bit.ly, t.co — wymaga unfolding, osobny scope)
- Aggressive query param stripping (utm_*, fbclid) — minimal normalization wystarczy na MVP
Podejście do implementacji
Trzy warstwy obrony przeciwko duplikatom, każda na innej warstwie:
- DB (Phase 1): unique constraint
(user_id, source_url)narecipesblokuje finalny INSERT przy race. - Application (Phase 2): pre-check w Server Action zatrzymuje request zanim dotrze do Inngest — oszczędza koszt API i daje natychmiastowy redirect.
- Slug retry (Phase 3): w Inngest function, gdy slug ma collision z innym istniejącym przepisem (różnym URL ale tym samym tytułem), retry z licznikiem.
URL normalization jest minimalna: lowercase hostname, strip trailing slash, strip fragment. Reszta URL (path, query) zachowana — query params często niosą informację o konkretnym przepisie.
source_url w recipes i shared_url w recipe_shares przechowują znormalizowaną formę (source_url jest tym, którego używamy do dedup). Migration backfilluje istniejące wiersze.
Faza 1: Schema + URL normalizer
Przegląd
Migracja DB: znormalizuj istniejące URL-e, dodaj unique constraint. Dodaj pure-function url-normalize.ts używaną później w Phase 2 i 3.
Wymagane zmiany
1. URL normalizer utility
Plik: src/lib/url-normalize.ts (nowy)
Cel: Wystawić normalizeUrl(raw: string): string jako pure function — używana przez Server Actions (Phase 2), Inngest function (przy zapisie source_url) i przez migrację (przy backfill). Brak zewnętrznych zależności — czysta logika oparta o new URL().
Kontrakt: normalizeUrl('HTTPS://Mecooks.PL/lunch/tortilla/#tutaj') === 'https://mecooks.pl/lunch/tortilla'. Transformacje: lowercase URL.hostname (zachowuje case w path/query), strip trailing / z pathname (chyba że pathname to sam /), zignoruj hash. Throw TypeError przy malformed URL — caller decyduje co zrobić.
2. Unit testy normalizera
Plik: src/lib/url-normalize.test.ts (nowy — jeśli projekt ma test runner; jeśli nie, pomiń)
Cel: Pokryć transformacje + edge cases: case insensitivity hosta, trailing slash, fragment, query param preservation, malformed URL throw.
Kontrakt: Testy dla każdej reguły transformacji + minimum 3 negative cases.
3. Migration: backfill + unique constraint
Plik: supabase/migrations/<TIMESTAMP>_recipe_url_dedup.sql (nowy — użyj date -u +%Y%m%d%H%M%S)
Cel: Znormalizować istniejące recipes.source_url i recipe_shares.shared_url, potem dodać unique constraint (user_id, source_url) na recipes (partial gdzie source_url IS NOT NULL — żeby przyszłe NULL-e nie kolidowały).
Kontrakt:
- Pure SQL bez wywoływania TS code — backfill wykonany inline przez SQL functions (
lower(),regexp_replace()dla trailing slash,split_part(..., '#', 1)dla fragmentu). - Constraint:
create unique index recipes_user_source_url_uniq on public.recipes(user_id, source_url) where source_url is not null; - Migration musi być idempotentna w sensie „bezpieczna do uruchomienia w środowisku gdzie 1 wiersz już istnieje” — backfill UPDATE z
WHERE source_url IS NOT NULL. - Pułapka: SQL normalizer i TS normalizer muszą produkować identyczny wynik — udokumentować to w komentarzu migracji, wskazując
src/lib/url-normalize.tsjako źródło prawdy semantycznej.
Kryteria sukcesu
Weryfikacja automatyczna
- TypeScript przechodzi:
pnpm exec tsc --noEmit(nowe pliki bez błędów) - Unit testy normalizera przechodzą (jeśli dodane)
- Migration aplikuje się czysto na lokalnej kopii:
supabase db resetlubsupabase migration up - Brak nowych ESLint warnings:
pnpm lint(jeśli skonfigurowany)
Weryfikacja ręczna
- Po migracji, istniejący wiersz „Tortilla z patelni” w
recipesmasource_urlw formie znormalizowanej (lowercase host, bez trailing slash, bez fragmentu) - Sprawdź w Supabase SQL editor:
select source_url from recipespowinno pokazaćhttps://www.mecooks.pl/lunch/tortilla-z-patelni(bez końcowego/) - Próba INSERT do
recipesz duplikatem(user_id, source_url)rzuca unique violation
Uwaga implementacyjna: Po zakończeniu fazy i pomyślnym przejściu weryfikacji, zatrzymaj się na ręczne potwierdzenie.
Faza 2: Dedup helper + Server Actions integration
Przegląd
Helper recipe-dedup.ts enkapsuluje query do recipes (completed) i recipe_shares (pending). Oba Server Actions wywołują helper przed insertem nowego share/event do Inngest.
Wymagane zmiany
1. Dedup check helper
Plik: src/lib/recipe-dedup.ts (nowy)
Cel: Centralizować logikę „czy ten URL już istnieje dla tego usera?” jako pojedyncza funkcja używana przez oba Server Actions. Zgodne z duchem lesson o centralizacji w src/lib/env.ts — nowa odpowiedzialność = nowy moduł, nie inline w 2 plikach.
Kontrakt:
type DedupResult = | { status: 'new' } | { status: 'completed'; slug: string } | { status: 'pending' }
async function findExistingRecipeForUrl( supabase: SupabaseClient, userId: string, rawUrl: string,): Promise<DedupResult>- Normalizuje URL przez
normalizeUrl() - Query 1:
select slug from recipes where user_id = ? and source_url = ?→ jeśli znalezione, zwróćcompleted - Query 2: jeśli nie completed,
select 1 from recipe_shares where user_id = ? and shared_url = ? and status = 'pending'→ jeśli znalezione, zwróćpending - Inaczej zwróć
new - Pułapka: oba query muszą porównywać znormalizowane URL, więc
recipe_shares.shared_urlteż musi być przechowywany znormalizowany przy nowych insertach (patrz #2 i #3). Migration z Phase 1 załatwił istniejące wiersze.
2. Wpięcie w add-recipe-action.ts
Plik: src/app/(authenticated)/recipes/add-recipe-action.ts
Cel: Po walidacji URL i auth check, ale przed insertem do recipe_shares i inngest.send, wywołaj findExistingRecipeForUrl. Na completed → redirect('/recipes/<slug>?duplicate=1'). Na pending → redirect('/recipes?duplicate=pending'). Na new → kontynuuj obecny flow, ale używając normalizeUrl(url) przy zapisie do recipe_shares.shared_url i w payload Inngest.
Kontrakt: Dodatkowy import findExistingRecipeForUrl i normalizeUrl. Dwa nowe redirect() przed obecnym insert. Modyfikacja insert żeby używał znormalizowanego URL. Brak zmian w sygnaturze samego Server Action.
3. Wpięcie w share/actions.ts
Plik: src/app/share/actions.ts
Cel: Te same trzy ścieżki co w add-recipe-action.ts — Web Share Target z systemu powinien zachowywać się identycznie jak manualny formularz przy duplikacie.
Kontrakt: Importuje findExistingRecipeForUrl i normalizeUrl. Funkcja triggerRecipeExtraction zwraca obecnie { shareId, message } — przy duplikacie completed/pending zwraca { duplicate: 'completed', slug } lub { duplicate: 'pending' }. Caller (route handler /share) interpretuje to do odpowiedniego redirectu.
Pułapka: /share/route.ts (lub gdzie indziej w przepływie share) musi obsłużyć nowy wariant zwracany przez triggerRecipeExtraction. Wyłapać podczas implementacji.
4. UX: toast/komunikat na podstawie query params
Plik: src/app/(authenticated)/recipes/recipes-content.tsx i ewentualnie recipes/[slug]/page.tsx
Cel: Pokazać tekstowy komunikat gdy URL ma ?duplicate=1 (na detail page) lub ?duplicate=pending (na liście). Może być prostym banerem nad kontentem — nie wymaga toast library.
Kontrakt: Dodaj prop duplicate?: 'completed' | 'pending' do komponentu kontentu. Renderuj banner gdy ustawione. Tłumaczenia PL: „Ten przepis już masz” / „Już przetwarzam ten przepis — wróć za chwilę”. Nie wymaga state — odczyt z searchParams w page.tsx (Promise — pamiętaj await).
Kryteria sukcesu
Weryfikacja automatyczna
- TypeScript przechodzi:
pnpm exec tsc --noEmit - ESLint przechodzi:
pnpm lint - Brak regression w testach Playwright dla add-recipe-form:
pnpm exec playwright test add-recipe
Weryfikacja ręczna
- Udostępnij URL istniejącego przepisu (tortilla z patelni) → redirect na
/recipes/tortilla-z-patelni?duplicate=1, widoczny komunikat „Ten przepis już masz” - Udostępnij URL który ma pending share w bazie (manualnie ustawiony przez SQL dla testu) → redirect na
/recipes?duplicate=pending, komunikat „Już przetwarzam…” - Udostępnij nowy URL → normalny flow, ekstrakcja przebiega
- Udostępnij URL który był failed → normalny flow (nie blokuje), nowa próba
Uwaga implementacyjna: Po zakończeniu fazy i pomyślnym przejściu weryfikacji, zatrzymaj się na ręczne potwierdzenie.
Faza 3: Slug collision retry w Inngest function
Przegląd
Zabezpieczyć Inngest function extract-recipe przed crashem gdy (user_id, slug) koliduje z innym przepisem (różny URL, ten sam tytuł). Retry z licznikiem: tortilla-z-patelni-2, tortilla-z-patelni-3, itd.
Wymagane zmiany
1. Slug collision retry
Plik: src/inngest/functions.ts
Cel: Po pierwszej próbie insert(...).select().single() jeśli error jest unique violation na (user_id, slug), retry z baseSlug-2, -3, …, maksymalnie 10 prób. Po 10 nieudanych — throw (Inngest zarejestruje failure).
Kontrakt:
- Bazowy slug z
slugify(recipeJSON.title) - Loop:
for (let attempt = 1; attempt <= 10; attempt++)—attempt=1używa bazowego slug,attempt>1używa${baseSlug}-${attempt} - Po INSERT: jeśli
error.code === '23505'(PostgreSQL unique_violation) i komunikat zawiera nazwę indeksurecipes_user_id_slug_key(lub podobnej — sprawdź faktyczną nazwę w schema), kontynuuj loop. Inaczej rzuć normalnie. - Jeśli URL collision (
recipes_user_source_url_uniq) — to znak że Phase 2 puścił coś przez sito (race przed insertem). W takim wypadku też retry? Nie — to znaczy że już istnieje przepis dla tego URL, więc po prostu zaktualizujrecipe_sharesz istniejącymrecipe_idzamiast tworzyć duplikat. Sprawdźselect id from recipes where user_id=? and source_url=?żeby znaleźć istniejący wiersz i powiązać go zshare_id. - Maks 10 prób = przepisy 1-10 z tym samym tytułem; po tym throw — to wskazuje na coś nietypowego (spam? bug?), niech Inngest pokaże w dashboardzie.
Pułapka: nazwa constraintu w PostgreSQL przy unique(user_id, slug) to autogenerowane recipes_user_id_slug_key — potwierdź dokładną nazwę przed sprawdzeniem error.message. Alternatywnie: parsuj error.details które Supabase zwraca z konkretną informacją która kolumna kolidowała.
Kryteria sukcesu
Weryfikacja automatyczna
- TypeScript przechodzi:
pnpm exec tsc --noEmit
Weryfikacja ręczna
- Manualnie wstaw drugi przepis o tym samym tytule „Tortilla z patelni” przez Supabase SQL editor (różny
source_url), żeby zasymulować collision - Udostępnij URL trzeciego przepisu też zatytułowanego „Tortilla z patelni” z innego bloga
- W Inngest dashboard: run powinien zakończyć się sukcesem
- W tabeli
recipespowinny być 3 przepisy: slugitortilla-z-patelni,tortilla-z-patelni-2,tortilla-z-patelni-3 - Detail page każdego z 3 przepisów dostępny pod jego slugiem
Uwaga implementacyjna: Po zakończeniu fazy i pomyślnym przejściu weryfikacji, zatrzymaj się na ręczne potwierdzenie.
Strategia testowania
Testy jednostkowe
url-normalize.ts: case insensitivity, trailing slash, fragment strip, query preservation, malformed throwrecipe-dedup.ts: jeśli setup mockowy istnieje (Supabase client mock) — zwraca completed/pending/new dla różnych scenariuszy DB
Testy integracyjne
- Brak nowych E2E testów wymaganych — istniejący Playwright
add-recipe.spec.tspowinien dalej zielony
Kroki testowania ręcznego
- Udostępnij
https://www.mecooks.pl/lunch/tortilla-z-patelni/ponownie (z trailing slash) — sprawdź redirect z toast - Udostępnij
HTTPS://WWW.MECOOKS.PL/lunch/tortilla-z-patelni#sekcja— sprawdź że normalization wyłapuje to jako duplikat - Udostępnij URL który nie istnieje w bazie — normalny flow
- Zasymuluj pending share przez SQL (
update recipe_shares set status='pending' where id=...) — udostępnij ten sam URL, sprawdź blokadę - Po Phase 3: udostępnij dwa różne URL-e ze stron różnych autorów ale z tytułem „Tortilla z patelni” — drugi powinien dostać slug
tortilla-z-patelni-2
Uwagi dotyczące wydajności
Dedup check dodaje 1-2 query do Supabase przed insertem (~50-100ms łącznie na region EU). Akceptowalne dla MVP — Server Action już ma round-trip do auth.getUser() i insert do recipe_shares, dodatkowy SELECT jest pomijalny.
Uwagi dotyczące migracji
- Idempotentność: backfill UPDATE używa
WHERE source_url IS NOT NULL— bezpieczne do re-runu - Rollback: jeśli migration trzeba cofnąć, drop unique index + ewentualnie revert source_url do oryginalnej formy (ale obecnie tracimy oryginalną wartość — to akceptowalne dla 1 przepisu testowego)
Referencje
- Lesson „Centralize env reads in lib/env.ts”:
context/foundation/lessons.md:64-72— wzorzec dlasrc/lib/recipe-dedup.ts - Schema:
supabase/migrations/20260607000000_recipe_schema.sql:49,67 - Slug helper:
src/lib/slugify.ts - Entry points:
src/app/share/actions.ts,src/app/(authenticated)/recipes/add-recipe-action.ts - Inngest function:
src/inngest/functions.ts
Postęp
Konwencja:
- [ ]oczekujące,- [x]wykonane. Dodaj— <commit sha>przy realizacji.
Faza 1: Schema + URL normalizer
Automatyczne
- 1.1 TypeScript przechodzi:
pnpm exec tsc --noEmit— 42e7401 - 1.2 Unit testy normalizera przechodzą (jeśli dodane)
- 1.3 Migration aplikuje się czysto:
supabase db resetlubsupabase migration up— 42e7401 - 1.4 Brak nowych ESLint warnings:
pnpm lint— 42e7401
Ręczne
- 1.5 Istniejący wiersz „Tortilla z patelni” ma znormalizowany
source_url— 42e7401 - 1.6 Duplikat INSERT do
recipesz tym samym(user_id, source_url)rzuca unique violation — 42e7401
Faza 2: Dedup helper + Server Actions integration
Automatyczne
- 2.1 TypeScript przechodzi:
pnpm exec tsc --noEmit— 51e581e - 2.2 ESLint przechodzi:
pnpm lint— 51e581e - 2.3 Playwright testy add-recipe przechodzą:
pnpm exec playwright test add-recipe
Ręczne
- 2.4 Share istniejącego URL → redirect z toast „Ten przepis już masz”
- 2.5 Share URL z pending share → redirect z toast „Już przetwarzam…”
- 2.6 Share nowego URL → normalny flow ekstrakcji
- 2.7 Share URL po failed → normalny flow (nie blokuje)
Faza 3: Slug collision retry w Inngest function
Automatyczne
- 3.1 TypeScript przechodzi:
pnpm exec tsc --noEmit— f0563f4
Ręczne
- 3.2 3 przepisy o tym samym tytule mają slugi
<base>,<base>-2,<base>-3 - 3.3 Każdy z 3 przepisów dostępny pod swoim slugiem na detail page
- 3.4 Inngest run dla 3-ciej próby zakończony sukcesem (nie failed)