diff --git a/src/api/artists/useCreateArtist.ts b/src/api/artists/useCreateArtist.ts index 16d9ced5..ac9e74b6 100644 --- a/src/api/artists/useCreateArtist.ts +++ b/src/api/artists/useCreateArtist.ts @@ -1,7 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; import { supabase } from "@/integrations/supabase/client"; -import { generateSlug } from "@/lib/slug"; import type { Artist } from "./types"; import { artistsKeys } from "./types"; @@ -27,12 +26,11 @@ async function createArtist( }, ): Promise { const { genre_ids, ...artist } = artistData; - // First, create the artist without slug const { data, error } = await supabase .from("artists") .insert({ ...artist, - slug: generateSlug(artist.name), + slug: "", // auto-generated by the artists_dedupe_slug DB trigger created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) diff --git a/src/api/sets/useCreateSet.ts b/src/api/sets/useCreateSet.ts index 2e9611ae..9cb41501 100644 --- a/src/api/sets/useCreateSet.ts +++ b/src/api/sets/useCreateSet.ts @@ -2,7 +2,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; import { supabase } from "@/integrations/supabase/client"; import type { Database } from "@/integrations/supabase/types"; -import { generateSlug } from "@/lib/slug"; import { FestivalSet, setsKeys } from "./types"; type SetInsert = Database["public"]["Tables"]["sets"]["Insert"]; @@ -29,7 +28,7 @@ async function createSet( time_start: setData.time_start, time_end: setData.time_end, created_by: setData.created_by, - slug: generateSlug(setData.name), + slug: "", // auto-generated by the sets_dedupe_slug DB trigger archived: false, }; diff --git a/supabase/functions/commit-schedule/commit-schedule.test.ts b/supabase/functions/commit-schedule/commit-schedule.test.ts index cd179206..dc60ec85 100644 --- a/supabase/functions/commit-schedule/commit-schedule.test.ts +++ b/supabase/functions/commit-schedule/commit-schedule.test.ts @@ -186,6 +186,70 @@ Deno.test("commit_schedule: archives orphaned sets", async () => { await db.from("sets").delete().eq("id", set!.id); }); +Deno.test( + "commit_schedule: two sets with the same name get distinct slugs", + async () => { + const db = adminClient(); + const editionId = await getTestEditionId(db); + const userId = await getTestUserId(db); + const setName = `Dup Name Set ${Date.now()}`; + const artistSlug = `test-dup-set-artist-${Date.now()}`; + + await db + .from("artists") + .insert({ name: "Dup Set Artist", slug: artistSlug, added_by: userId }); + + const { data, error } = await db.rpc("commit_schedule", { + p_festival_edition_id: editionId, + p_user_id: userId, + p_artists_to_create: [], + p_stages_to_create: [], + p_sets_to_create: [ + { + name: setName, + description: null, + stageName: null, + timeStart: null, + timeEnd: null, + artistSlugs: [artistSlug], + }, + { + name: setName, + description: null, + stageName: null, + timeStart: null, + timeEnd: null, + artistSlugs: [artistSlug], + }, + ], + p_sets_to_update: [], + p_set_ids_to_archive: [], + }); + + assertEquals(error, null); + assertEquals(data.setsCreated, 2); + + const { data: sets } = await db + .from("sets") + .select("id, slug") + .eq("festival_edition_id", editionId) + .eq("name", setName); + + assertEquals(sets?.length, 2); + assertExists(sets![0].slug); + assertExists(sets![1].slug); + assertEquals(sets![0].slug === sets![1].slug, false); + + // Cleanup + await db + .from("sets") + .delete() + .eq("festival_edition_id", editionId) + .eq("name", setName); + await db.from("artists").delete().eq("slug", artistSlug); + }, +); + Deno.test( "commit_schedule: midnight-crossing times stored correctly", async () => { diff --git a/supabase/migrations/20260804090308_add_sets_slug_unique.sql b/supabase/migrations/20260804090308_add_sets_slug_unique.sql new file mode 100644 index 00000000..f2459270 --- /dev/null +++ b/supabase/migrations/20260804090308_add_sets_slug_unique.sql @@ -0,0 +1,30 @@ +-- Add unique constraint on sets(festival_edition_id, slug). +-- Dedupe first: append the full id (guaranteed unique) to any slug that +-- collides with another set in the same edition, keeping the oldest row on +-- its original slug so existing slug-based links don't break. +UPDATE public.sets s +SET slug = s.slug || '-' || s.id::text +WHERE s.id IN ( + SELECT id + FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY festival_edition_id, slug ORDER BY created_at ASC, id + ) AS rn + FROM public.sets + ) ranked + WHERE rn > 1 +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'sets_festival_edition_id_slug_unique' + AND conrelid = 'public.sets'::regclass + ) THEN + ALTER TABLE public.sets + ADD CONSTRAINT sets_festival_edition_id_slug_unique + UNIQUE (festival_edition_id, slug); + END IF; +END$$; diff --git a/supabase/migrations/20260804101135_reactivate_archived_artists_on_upsert.sql b/supabase/migrations/20260804101135_reactivate_archived_artists_on_upsert.sql new file mode 100644 index 00000000..6080d8b7 --- /dev/null +++ b/supabase/migrations/20260804101135_reactivate_archived_artists_on_upsert.sql @@ -0,0 +1,29 @@ +-- p_artists_to_create: JSONB array of {name, slug}. A slug collision here can +-- only be against an archived artist (diff-schedule only proposes creates for +-- slugs it didn't find among active ones), so match+reactivate explicitly +-- instead of relying on ON CONFLICT. +CREATE OR REPLACE FUNCTION public.commit_schedule__upsert_artists( + p_artists_to_create JSONB, + p_user_id UUID +) +RETURNS VOID +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_elem JSONB; +BEGIN + FOR v_elem IN + SELECT value FROM jsonb_array_elements(COALESCE(p_artists_to_create, '[]'::jsonb)) + LOOP + UPDATE artists + SET name = v_elem->>'name', archived = false + WHERE slug = v_elem->>'slug' AND archived = true; + + IF NOT FOUND THEN + INSERT INTO artists (name, slug, added_by) + VALUES (v_elem->>'name', v_elem->>'slug', p_user_id); + END IF; + END LOOP; +END; +$$; diff --git a/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql b/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql new file mode 100644 index 00000000..1c907da8 --- /dev/null +++ b/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql @@ -0,0 +1,87 @@ +-- Mirrors src/lib/slug.ts generateSlug; keep them in sync. +CREATE OR REPLACE FUNCTION public.slugify(p_name TEXT) +RETURNS TEXT +LANGUAGE sql +IMMUTABLE +SET search_path = public +AS $$ + SELECT TRIM( + BOTH '-' FROM + REGEXP_REPLACE( + REGEXP_REPLACE(LOWER(TRIM(p_name)), '[^a-z0-9]+', '-', 'g'), + '-+', '-', 'g' + ) + ); +$$; + +CREATE OR REPLACE FUNCTION public.sets_dedupe_slug() +RETURNS TRIGGER +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + -- Trust a caller-supplied slug as-is (commit_schedule passes its own + -- precomputed slug and looks the row back up by it); otherwise derive one. + v_base TEXT := COALESCE(NULLIF(TRIM(NEW.slug), ''), public.slugify(NEW.name)); + v_candidate TEXT := v_base; + v_attempt INT := 1; +BEGIN + -- Serializes concurrent inserts for the same base slug so two can't both + -- see it as free; the unique constraint is the backstop either way. + PERFORM pg_advisory_xact_lock(hashtextextended( + 'sets:' || NEW.festival_edition_id::text || ':' || v_base, 0 + )); + + WHILE EXISTS ( + SELECT 1 FROM public.sets + WHERE festival_edition_id = NEW.festival_edition_id + AND slug = v_candidate + AND id IS DISTINCT FROM NEW.id + ) LOOP + v_attempt := v_attempt + 1; + v_candidate := v_base || '-' || v_attempt; + END LOOP; + + NEW.slug := v_candidate; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS sets_dedupe_slug_trigger ON public.sets; +CREATE TRIGGER sets_dedupe_slug_trigger + BEFORE INSERT ON public.sets + FOR EACH ROW + EXECUTE FUNCTION public.sets_dedupe_slug(); + +CREATE OR REPLACE FUNCTION public.artists_dedupe_slug() +RETURNS TRIGGER +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_base TEXT := COALESCE(NULLIF(TRIM(NEW.slug), ''), public.slugify(NEW.name)); + v_candidate TEXT := v_base; + v_attempt INT := 1; +BEGIN + -- Serializes concurrent inserts for the same base slug; see sets_dedupe_slug. + PERFORM pg_advisory_xact_lock(hashtextextended('artists:' || v_base, 0)); + + WHILE EXISTS ( + SELECT 1 FROM public.artists + WHERE slug = v_candidate + AND id IS DISTINCT FROM NEW.id + ) LOOP + v_attempt := v_attempt + 1; + v_candidate := v_base || '-' || v_attempt; + END LOOP; + + NEW.slug := v_candidate; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS artists_dedupe_slug_trigger ON public.artists; +CREATE TRIGGER artists_dedupe_slug_trigger + BEFORE INSERT ON public.artists + FOR EACH ROW + EXECUTE FUNCTION public.artists_dedupe_slug(); diff --git a/supabase/migrations/20260804103506_commit_schedule_slugify_delegates.sql b/supabase/migrations/20260804103506_commit_schedule_slugify_delegates.sql new file mode 100644 index 00000000..1ca3fd6c --- /dev/null +++ b/supabase/migrations/20260804103506_commit_schedule_slugify_delegates.sql @@ -0,0 +1,75 @@ +-- Call public.slugify() directly instead of going through +-- commit_schedule__slugify -- that wrapper only duplicated the same regex +-- public.slugify() now has, so there's no reason to keep the indirection. +CREATE OR REPLACE FUNCTION public.commit_schedule__upsert_stages( + p_festival_edition_id UUID, + p_stages_to_create JSONB +) +RETURNS VOID +LANGUAGE sql +SET search_path = public +AS $$ + INSERT INTO stages (festival_edition_id, name, slug) + SELECT + p_festival_edition_id, + elem->>'name', + public.slugify(elem->>'name') + FROM jsonb_array_elements(COALESCE(p_stages_to_create, '[]'::jsonb)) AS elem + ON CONFLICT (festival_edition_id, name) DO UPDATE + SET archived = false; +$$; + +CREATE OR REPLACE FUNCTION public.commit_schedule__create_sets( + p_festival_edition_id UUID, + p_user_id UUID, + p_sets_to_create JSONB +) +RETURNS INT +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_set_elem JSONB; + v_new_set_id UUID; + v_created INT := 0; +BEGIN + FOR v_set_elem IN + SELECT value FROM jsonb_array_elements(COALESCE(p_sets_to_create, '[]'::jsonb)) + LOOP + INSERT INTO sets ( + festival_edition_id, name, slug, description, stage_id, + time_start, time_end, created_by + ) + VALUES ( + p_festival_edition_id, + v_set_elem->>'name', + public.slugify(v_set_elem->>'name'), + NULLIF(v_set_elem->>'description', ''), + commit_schedule__resolve_stage_id( + p_festival_edition_id, v_set_elem->>'stageName' + ), + commit_schedule__parse_ts(v_set_elem->>'timeStart'), + commit_schedule__parse_ts(v_set_elem->>'timeEnd'), + p_user_id + ) + RETURNING id INTO v_new_set_id; + + -- Always suffix the slug with a short id chunk so two sets with the same + -- name (common when an artist plays multiple days) don't collide on the + -- (edition, slug) lookup used by the set detail pages. + UPDATE sets + SET slug = slug || '-' || SUBSTRING(v_new_set_id::text, 1, 8) + WHERE id = v_new_set_id; + + v_created := v_created + 1; + + PERFORM commit_schedule__sync_set_artists( + v_new_set_id, p_festival_edition_id, v_set_elem->'artistSlugs' + ); + END LOOP; + + RETURN v_created; +END; +$$; + +DROP FUNCTION IF EXISTS public.commit_schedule__slugify(TEXT);