Skip to content
4 changes: 1 addition & 3 deletions src/api/artists/useCreateArtist.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -27,12 +26,11 @@ async function createArtist(
},
): Promise<Artist> {
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(),
})
Expand Down
3 changes: 1 addition & 2 deletions src/api/sets/useCreateSet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand All @@ -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,
};

Expand Down
64 changes: 64 additions & 0 deletions supabase/functions/commit-schedule/commit-schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
30 changes: 30 additions & 0 deletions supabase/migrations/20260804090308_add_sets_slug_unique.sql
Original file line number Diff line number Diff line change
@@ -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$$;
Original file line number Diff line number Diff line change
@@ -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;
$$;
87 changes: 87 additions & 0 deletions supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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);