From 864ef7e540310e5e6bfd7fac7f1acf44a5809eb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:08:29 +0000 Subject: [PATCH 1/2] feat(link-wizard): persist soundcloud followers on candidate select Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018s7p4uu8gLGvgvJAHujagc --- .../mergeCandidateSelection.test.ts | 30 +++++++++++++++++++ .../artistSearch/mergeCandidateSelection.ts | 7 +++++ src/api/artists/useUpdateArtist.ts | 18 ++++++++++- .../festivals/LinkWizard/LinkWizardStep.tsx | 10 +++++++ ...060000_soundcloud_admin_write_policies.sql | 11 +++++++ 5 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/20261013060000_soundcloud_admin_write_policies.sql diff --git a/src/api/artistSearch/mergeCandidateSelection.test.ts b/src/api/artistSearch/mergeCandidateSelection.test.ts index fd66680dd..58ed3d712 100644 --- a/src/api/artistSearch/mergeCandidateSelection.test.ts +++ b/src/api/artistSearch/mergeCandidateSelection.test.ts @@ -136,6 +136,36 @@ describe("mergeCandidateSelection", () => { expect(update.providerUrl).toEqual({ spotify: candidate.url }); }); + + it("carries soundcloud followers when a soundcloud url is selected", () => { + const candidate = makeCandidate({ + url: "https://soundcloud.com/artist", + followers: 4200, + }); + + const update = mergeCandidateSelection(candidate, "soundcloud", ["url"]); + + expect(update.soundcloudFollowers).toBe(4200); + }); + + it("writes nothing for soundcloud candidates with null followers", () => { + const candidate = makeCandidate({ + url: "https://soundcloud.com/artist", + followers: null, + }); + + const update = mergeCandidateSelection(candidate, "soundcloud", ["url"]); + + expect(update).not.toHaveProperty("soundcloudFollowers"); + }); + + it("does not carry followers for spotify candidates", () => { + const candidate = makeCandidate({ followers: 1000 }); + + const update = mergeCandidateSelection(candidate, "spotify", ["url"]); + + expect(update).not.toHaveProperty("soundcloudFollowers"); + }); }); function makeCandidate(overrides: Partial = {}): Candidate { diff --git a/src/api/artistSearch/mergeCandidateSelection.ts b/src/api/artistSearch/mergeCandidateSelection.ts index 96b41b32f..b24a8dc81 100644 --- a/src/api/artistSearch/mergeCandidateSelection.ts +++ b/src/api/artistSearch/mergeCandidateSelection.ts @@ -4,6 +4,7 @@ export interface CandidateUpdate { providerUrl?: Partial>; image_url?: string | null; description?: string | null; + soundcloudFollowers?: number; } export type SelectableField = "url" | "image" | "description"; @@ -23,6 +24,12 @@ export function mergeCandidateSelection( if (fields.includes("url")) { updates.providerUrl = { [provider]: candidate.url }; + + // Spotify stopped returning followers for non-extended apps, so only + // SoundCloud has a real value to persist. + if (provider === "soundcloud" && candidate.followers !== null) { + updates.soundcloudFollowers = candidate.followers; + } } if (fields.includes("image") && candidate.imageUrl && !staged.image_url) { diff --git a/src/api/artists/useUpdateArtist.ts b/src/api/artists/useUpdateArtist.ts index 89778171d..5dfaa1454 100644 --- a/src/api/artists/useUpdateArtist.ts +++ b/src/api/artists/useUpdateArtist.ts @@ -21,7 +21,7 @@ export type UpdateArtistUpdates = Partial< | "time_end" | "archived" > -> & { genre_ids?: string[] }; +> & { genre_ids?: string[]; soundcloud_followers?: number }; // Mutation function async function updateArtist(variables: { @@ -76,6 +76,22 @@ async function updateArtist(variables: { } } + if (updates.soundcloud_followers !== undefined) { + // The next sync-artist-data run may overwrite this; it only seeds the + // count until then. + const { error } = await supabase + .from("soundcloud") + .upsert( + { artist_id: id, followers_count: updates.soundcloud_followers }, + { onConflict: "artist_id" }, + ); + + if (error) { + console.error("Error saving soundcloud followers:", error); + throw new Error("Failed to save SoundCloud followers"); + } + } + if (genre_ids !== undefined) { const { data: currentGenres, error: currentGenresError } = await supabase .from("artist_music_genres") diff --git a/src/pages/admin/festivals/LinkWizard/LinkWizardStep.tsx b/src/pages/admin/festivals/LinkWizard/LinkWizardStep.tsx index 70f1f57eb..5fe5c0695 100644 --- a/src/pages/admin/festivals/LinkWizard/LinkWizardStep.tsx +++ b/src/pages/admin/festivals/LinkWizard/LinkWizardStep.tsx @@ -58,6 +58,7 @@ const linkStepSchema = z.object({ }), image_url: z.string().nullable().optional(), description: z.string().nullable().optional(), + soundcloud_followers: z.number().optional(), }); export type LinkStepData = z.infer; @@ -214,6 +215,11 @@ export function LinkWizardStep({ shouldDirty: true, }); } + if (update.soundcloudFollowers !== undefined) { + form.setValue("soundcloud_followers", update.soundcloudFollowers, { + shouldDirty: true, + }); + } } // The picked types must persist before the step advances — the artist save @@ -247,6 +253,10 @@ export function LinkWizardStep({ updates.description = data.description; } + if (data.soundcloud_followers !== undefined) { + updates.soundcloud_followers = data.soundcloud_followers; + } + updateArtistMutation.mutate( { id: artist.id, updates }, { diff --git a/supabase/migrations/20261013060000_soundcloud_admin_write_policies.sql b/supabase/migrations/20261013060000_soundcloud_admin_write_policies.sql new file mode 100644 index 000000000..180c4df38 --- /dev/null +++ b/supabase/migrations/20261013060000_soundcloud_admin_write_policies.sql @@ -0,0 +1,11 @@ +-- Link Wizard persists the follower count of a selected SoundCloud candidate +-- from the admin client; previously only the sync function (service role) +-- could write this table. +CREATE POLICY "Admins can insert soundcloud data" ON public.soundcloud + FOR INSERT TO authenticated + WITH CHECK (public.can_edit_artists(auth.uid())); + +CREATE POLICY "Admins can update soundcloud data" ON public.soundcloud + FOR UPDATE TO authenticated + USING (public.can_edit_artists(auth.uid())) + WITH CHECK (public.can_edit_artists(auth.uid())); From e5280d0ebfded9deb8cfd810f8acbcdae05f8e5c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:13:08 +0000 Subject: [PATCH 2/2] fix(link-wizard): clear stale soundcloud followers and keep seed row syncable Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018s7p4uu8gLGvgvJAHujagc --- src/api/artists/useUpdateArtist.ts | 18 ++++++++++-------- .../festivals/LinkWizard/LinkWizardStep.tsx | 4 +++- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/api/artists/useUpdateArtist.ts b/src/api/artists/useUpdateArtist.ts index 5dfaa1454..6b092f3d4 100644 --- a/src/api/artists/useUpdateArtist.ts +++ b/src/api/artists/useUpdateArtist.ts @@ -77,14 +77,16 @@ async function updateArtist(variables: { } if (updates.soundcloud_followers !== undefined) { - // The next sync-artist-data run may overwrite this; it only seeds the - // count until then. - const { error } = await supabase - .from("soundcloud") - .upsert( - { artist_id: id, followers_count: updates.soundcloud_followers }, - { onConflict: "artist_id" }, - ); + // Only the count is seeded, so last_sync stays null to keep the row + // eligible for the next sync-artist-data run, which may overwrite it. + const { error } = await supabase.from("soundcloud").upsert( + { + artist_id: id, + followers_count: updates.soundcloud_followers, + last_sync: null, + }, + { onConflict: "artist_id" }, + ); if (error) { console.error("Error saving soundcloud followers:", error); diff --git a/src/pages/admin/festivals/LinkWizard/LinkWizardStep.tsx b/src/pages/admin/festivals/LinkWizard/LinkWizardStep.tsx index 5fe5c0695..e3a2f2422 100644 --- a/src/pages/admin/festivals/LinkWizard/LinkWizardStep.tsx +++ b/src/pages/admin/festivals/LinkWizard/LinkWizardStep.tsx @@ -215,7 +215,9 @@ export function LinkWizardStep({ shouldDirty: true, }); } - if (update.soundcloudFollowers !== undefined) { + // Reassigned (even to undefined) on every SoundCloud URL pick so a + // previous candidate's count never pairs with a new URL. + if (update.providerUrl?.soundcloud !== undefined) { form.setValue("soundcloud_followers", update.soundcloudFollowers, { shouldDirty: true, });