From 2e3cf63ef82461b692941ffdd32c49e853d555e6 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 28 Jul 2026 14:01:53 -0500 Subject: [PATCH 1/4] feat(onboarding): seed the catalog when the first artist is added in setup /setup/artists added artists through useAddRosterArtist, which creates and enriches but never values. useAddSpotifyArtist -- already used by the /artists dialog -- also kicks POST /api/valuation when the account has no catalog yet, which is what makes the payoff reachable: without it an account onboarded through setup finished with no catalog, so chat#1895's reward could only ever render its 'Claim your catalog' fallback, and that CTA lands on 'No catalogs found' with nothing to click. Points AddArtistForm at that hook and deletes useAddRosterArtist, whose enrichment duplicated addSpotifyArtist (identical createRosterArtist then saveArtist with image + profileUrls, and it predates chat#1892). Carries chat#1892's non-fatal-enrichment guard into addSpotifyArtist first: it awaited saveArtist bare, so a failed PATCH reported failure over an artist POST /api/artists had already created and a retry duplicated it. Co-Authored-By: Claude Opus 5 (1M context) --- components/Onboarding/AddArtistForm.tsx | 15 +-- .../__tests__/AddArtistForm.test.tsx | 26 +++--- .../__tests__/useAddRosterArtist.test.tsx | 93 ------------------- hooks/onboarding/useAddRosterArtist.ts | 84 ----------------- hooks/useAddSpotifyArtist.ts | 5 +- .../__tests__/addSpotifyArtist.test.ts | 12 +++ lib/artists/addSpotifyArtist.ts | 41 ++++---- 7 files changed, 58 insertions(+), 218 deletions(-) delete mode 100644 hooks/onboarding/__tests__/useAddRosterArtist.test.tsx delete mode 100644 hooks/onboarding/useAddRosterArtist.ts diff --git a/components/Onboarding/AddArtistForm.tsx b/components/Onboarding/AddArtistForm.tsx index 67529459f..dc66575fa 100644 --- a/components/Onboarding/AddArtistForm.tsx +++ b/components/Onboarding/AddArtistForm.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import SpotifyArtistSearch from "@/components/Artists/SpotifyArtistSearch"; -import { useAddRosterArtist } from "@/hooks/onboarding/useAddRosterArtist"; +import { useAddSpotifyArtist } from "@/hooks/useAddSpotifyArtist"; import type { SpotifyArtistSearchResult } from "@/types/spotify"; /** @@ -15,16 +15,19 @@ import type { SpotifyArtistSearchResult } from "@/types/spotify"; * structurally unreachable for anyone who arrived without a funnel valuation * (chat#1889). Picking a real Spotify artist resolves the id, profile URL, and * avatar in one step, reusing the same typeahead as verify-socials (chat#1882). + * + * Adds through `useAddSpotifyArtist` — the same hook the `/artists` dialog uses + * — because it also kicks `POST /api/valuation` for the picked artist when the + * account has no catalog yet. Without that seeding an account onboarded here + * finished setup with nothing to value, so the reward on the completion panel + * could only ever render its "Claim your catalog" fallback (chat#1889 row 8). */ const AddArtistForm = () => { - const { addArtist, isAdding } = useAddRosterArtist(); + const { add, isAdding } = useAddSpotifyArtist(); const [isOpen, setIsOpen] = useState(false); const handleSelect = async (artist: SpotifyArtistSearchResult) => { - const added = await addArtist(artist.name, { - profileUrl: artist.external_urls.spotify, - image: artist.images?.[0]?.url, - }); + const added = await add(artist); if (added) setIsOpen(false); }; diff --git a/components/Onboarding/__tests__/AddArtistForm.test.tsx b/components/Onboarding/__tests__/AddArtistForm.test.tsx index 7e0b569c5..f961637b7 100644 --- a/components/Onboarding/__tests__/AddArtistForm.test.tsx +++ b/components/Onboarding/__tests__/AddArtistForm.test.tsx @@ -4,18 +4,20 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import AddArtistForm from "@/components/Onboarding/AddArtistForm"; -const addArtist = vi.fn(); +const add = vi.fn(); const SPOTIFY_RESULT = { id: "3TVXtAsR1Inumwj472S9r4", name: "Drake", - external_urls: { spotify: "https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4" }, + external_urls: { + spotify: "https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4", + }, images: [{ url: "https://i.scdn.co/image/drake.jpg" }], followers: { total: 92000000 }, }; -vi.mock("@/hooks/onboarding/useAddRosterArtist", () => ({ - useAddRosterArtist: () => ({ addArtist, isAdding: false }), +vi.mock("@/hooks/useAddSpotifyArtist", () => ({ + useAddSpotifyArtist: () => ({ add, isAdding: false }), })); vi.mock("@/hooks/useSpotifyArtistSearch", () => ({ @@ -27,8 +29,8 @@ vi.mock("@/hooks/useSpotifyArtistSearch", () => ({ describe("AddArtistForm", () => { beforeEach(() => { - addArtist.mockClear(); - addArtist.mockResolvedValue(true); + add.mockClear(); + add.mockResolvedValue(true); }); const openForm = () => { @@ -46,13 +48,15 @@ describe("AddArtistForm", () => { expect(screen.getByLabelText(/search spotify for an artist/i)).toBeDefined(); }); - it("resolves the picked artist to its Spotify profile URL and avatar", () => { + // useAddSpotifyArtist seeds the onboarding catalog by kicking POST + // /api/valuation for the picked artist when the account has none yet. + // useAddRosterArtist never did, so an account onboarded here finished setup + // with no catalog and the valuation reward could only show its fallback + // (chat#1889 row 8). + it("adds through the hook that seeds the catalog, passing the whole Spotify result", () => { openForm(); fireEvent.click(screen.getByRole("option", { name: /drake/i })); - expect(addArtist).toHaveBeenCalledWith("Drake", { - profileUrl: SPOTIFY_RESULT.external_urls.spotify, - image: SPOTIFY_RESULT.images[0].url, - }); + expect(add).toHaveBeenCalledWith(SPOTIFY_RESULT); }); }); diff --git a/hooks/onboarding/__tests__/useAddRosterArtist.test.tsx b/hooks/onboarding/__tests__/useAddRosterArtist.test.tsx deleted file mode 100644 index 4919802b4..000000000 --- a/hooks/onboarding/__tests__/useAddRosterArtist.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -// @vitest-environment jsdom -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useAddRosterArtist } from "@/hooks/onboarding/useAddRosterArtist"; - -const createRosterArtist = vi.fn(); -const saveArtist = vi.fn(); -const getArtists = vi.fn(); -const toastError = vi.fn(); - -vi.mock("@privy-io/react-auth", () => ({ - usePrivy: () => ({ getAccessToken: async () => "token" }), -})); - -vi.mock("@/lib/artists/createRosterArtist", () => ({ - createRosterArtist: (...args: unknown[]) => createRosterArtist(...args), -})); - -vi.mock("@/lib/saveArtist", () => ({ - default: (...args: unknown[]) => saveArtist(...args), -})); - -vi.mock("@/providers/ArtistProvider", () => ({ - useArtistProvider: () => ({ getArtists }), -})); - -vi.mock("@/providers/OrganizationProvider", () => ({ - useOrganization: () => ({ selectedOrgId: "org-1" }), -})); - -vi.mock("sonner", () => ({ - toast: { error: (...args: unknown[]) => toastError(...args) }, -})); - -const SPOTIFY = { - profileUrl: "https://open.spotify.com/artist/43qxAkuKFB6fMNSeS5dO7Z", - image: "https://i.scdn.co/image/ana.jpg", -}; - -describe("useAddRosterArtist", () => { - beforeEach(() => { - vi.clearAllMocks(); - createRosterArtist.mockResolvedValue({ artist: { account_id: "artist-1" }, created: true }); - saveArtist.mockResolvedValue(undefined); - }); - - it("enriches a created artist with its Spotify profile and avatar", async () => { - const { result } = renderHook(() => useAddRosterArtist()); - - let added: boolean | undefined; - await act(async () => { - added = await result.current.addArtist("Ana Bárbara", SPOTIFY); - }); - - expect(added).toBe(true); - expect(saveArtist).toHaveBeenCalledWith("token", "artist-1", { - profileUrls: { SPOTIFY: SPOTIFY.profileUrl }, - image: SPOTIFY.image, - }); - expect(getArtists).toHaveBeenCalled(); - }); - - // POST /api/artists has already succeeded by the time enrichment runs, so a - // failing PATCH must not report failure: the form would stay open over an - // artist that exists, and picking again creates a duplicate (chat#1889). - it("treats a failed enrichment as non-fatal", async () => { - saveArtist.mockRejectedValue(new Error("Forbidden")); - const { result } = renderHook(() => useAddRosterArtist()); - - let added: boolean | undefined; - await act(async () => { - added = await result.current.addArtist("Ana Bárbara", SPOTIFY); - }); - - expect(added).toBe(true); - expect(getArtists).toHaveBeenCalled(); - expect(toastError).not.toHaveBeenCalled(); - }); - - it("still reports failure when the artist itself cannot be created", async () => { - createRosterArtist.mockRejectedValue(new Error("nope")); - const { result } = renderHook(() => useAddRosterArtist()); - - let added: boolean | undefined; - await act(async () => { - added = await result.current.addArtist("Ana Bárbara", SPOTIFY); - }); - - expect(added).toBe(false); - expect(toastError).toHaveBeenCalled(); - expect(getArtists).not.toHaveBeenCalled(); - }); -}); diff --git a/hooks/onboarding/useAddRosterArtist.ts b/hooks/onboarding/useAddRosterArtist.ts deleted file mode 100644 index d92a18c89..000000000 --- a/hooks/onboarding/useAddRosterArtist.ts +++ /dev/null @@ -1,84 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { usePrivy } from "@privy-io/react-auth"; -import { toast } from "sonner"; -import { createRosterArtist } from "@/lib/artists/createRosterArtist"; -import saveArtist from "@/lib/saveArtist"; -import { buildSocialFixPayload } from "@/lib/onboarding/buildSocialFixPayload"; -import { useArtistProvider } from "@/providers/ArtistProvider"; -import { useOrganization } from "@/providers/OrganizationProvider"; - -export interface AddRosterArtistOptions { - /** Spotify profile URL from the typeahead, linked as the artist's social. */ - profileUrl?: string; - /** Spotify avatar, so the roster row isn't a blank initial. */ - image?: string; -} - -/** - * "Add an artist" for the onboarding roster step. Creates the artist through - * `POST /api/artists`, then — when the caller resolved a real Spotify artist — - * attaches the profile URL and avatar via the existing - * `PATCH /api/artists/{id}` path, and refreshes the shared roster. - * - * The second call is what makes the added artist usable: `POST /api/artists` - * accepts only a name, and an artist with no Spotify id can never get a catalog - * or a valuation (chat#1889). Enrichment failing is non-fatal — the artist is - * already on the roster and its socials can still be fixed in the next step. - */ -export function useAddRosterArtist() { - const { getAccessToken } = usePrivy(); - const { getArtists } = useArtistProvider(); - const { selectedOrgId } = useOrganization(); - const [isAdding, setIsAdding] = useState(false); - - const addArtist = async ( - name: string, - options: AddRosterArtistOptions = {}, - ): Promise => { - const trimmed = name.trim(); - if (!trimmed) return false; - setIsAdding(true); - try { - const accessToken = await getAccessToken(); - if (!accessToken) { - throw new Error("Please sign in to add an artist"); - } - const { artist } = await createRosterArtist( - accessToken, - trimmed, - selectedOrgId, - ); - - const payload = options.profileUrl - ? buildSocialFixPayload(options.profileUrl) - : null; - if (artist?.account_id && (payload || options.image)) { - try { - await saveArtist(accessToken, artist.account_id, { - ...(payload ? { profileUrls: payload.profileUrls } : {}), - ...(options.image ? { image: options.image } : {}), - }); - } catch { - // Swallowed on purpose. POST /api/artists already succeeded, so - // surfacing this would leave the form open over an artist that - // exists — and picking again would create a duplicate. The socials - // are still fixable in the next step. - } - } - - await getArtists(); - return true; - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to add artist", - ); - return false; - } finally { - setIsAdding(false); - } - }; - - return { addArtist, isAdding }; -} diff --git a/hooks/useAddSpotifyArtist.ts b/hooks/useAddSpotifyArtist.ts index dd7be7974..0631f2d2a 100644 --- a/hooks/useAddSpotifyArtist.ts +++ b/hooks/useAddSpotifyArtist.ts @@ -13,8 +13,9 @@ import type { SpotifyArtistSearchResult } from "@/types/spotify"; /** * Adds a Spotify-searched artist to the roster and refreshes + selects it. - * Wraps `addSpotifyArtist` with auth + the shared roster (ArtistProvider), - * mirroring `useAddRosterArtist`. + * Wraps `addSpotifyArtist` with auth + the shared roster (ArtistProvider). + * The onboarding roster step adds through this hook too, so a first artist + * added during setup seeds the catalog the same way (chat#1889 row 8). * * On the **first** artist add (the account has no catalog yet) it also * fire-and-forget kicks `POST /api/valuation` for that artist's Spotify id — diff --git a/lib/artists/__tests__/addSpotifyArtist.test.ts b/lib/artists/__tests__/addSpotifyArtist.test.ts index 09f4e6f7a..9af40d96a 100644 --- a/lib/artists/__tests__/addSpotifyArtist.test.ts +++ b/lib/artists/__tests__/addSpotifyArtist.test.ts @@ -71,4 +71,16 @@ describe("addSpotifyArtist", () => { expect(saveArtist).not.toHaveBeenCalled(); expect(result.account_id).toBe("canonical-1"); }); + + // POST /api/artists has already succeeded by the time enrichment runs, so a + // failing PATCH must not fail the whole add: the caller would report failure + // over an artist that exists, and picking again creates a duplicate + // (chat#1889, same defect fixed in useAddRosterArtist). + it("returns the created artist when enrichment fails", async () => { + vi.mocked(saveArtist).mockRejectedValue(new Error("Forbidden")); + + const result = await addSpotifyArtist(token, spotifyArtist, "org-1"); + + expect(result.account_id).toBe("acc-1"); + }); }); diff --git a/lib/artists/addSpotifyArtist.ts b/lib/artists/addSpotifyArtist.ts index 57ac87a5e..00fd29640 100644 --- a/lib/artists/addSpotifyArtist.ts +++ b/lib/artists/addSpotifyArtist.ts @@ -4,38 +4,35 @@ import { createRosterArtist } from "@/lib/artists/createRosterArtist"; import saveArtist from "@/lib/saveArtist"; /** - * Adds a Spotify-searched artist to the roster. The API resolves-or-creates - * the canonical artist for the Spotify id and attaches its SPOTIFY social - * server-side (chat#1889 row 8) — the old client-side profileUrls PATCH is - * gone, because create-then-attach here is what minted a duplicate artist row - * per signup. + * Adds a Spotify-searched artist to the roster with real data: creates the + * artist by name (`POST /api/artists`, which only accepts a name), then links + * its Spotify profile URL and avatar image (`PATCH /api/artists/{id}`) so the + * roster card and reports have the correct image + a Spotify social to enrich. * - * The avatar is written only when the artist was CREATED (201): a reused - * canonical is shared state, and writing our image onto it would overwrite - * metadata every other rostering account sees (chat#1866). The image PATCH is - * non-fatal — the artist is already on the roster (chat#1892). + * Enrichment is non-fatal. The create has already succeeded by then, so + * throwing would report failure over an artist that exists and a retry would + * create a duplicate (chat#1889); the socials are still fixable in the + * verify-socials step. */ export async function addSpotifyArtist( accessToken: string, artist: SpotifyArtistSearchResult, orgId?: string | null, ): Promise { - const { artist: rosterArtist, created } = await createRosterArtist( - accessToken, - artist.name, - orgId, - artist.id, - ); + const created = await createRosterArtist(accessToken, artist.name, orgId); const imageUrl = artist.images?.[0]?.url; - if (!created || !imageUrl) return rosterArtist; - try { - const { artist: updated } = await saveArtist(accessToken, rosterArtist.account_id, { - image: imageUrl, - }); - return updated ?? rosterArtist; + const { artist: updated } = await saveArtist( + accessToken, + created.account_id, + { + ...(imageUrl ? { image: imageUrl } : {}), + profileUrls: { SPOTIFY: artist.external_urls.spotify }, + }, + ); + return updated ?? created; } catch { - return rosterArtist; + return created; } } From 22c2e19021f84220d9ea4a382f9baa1d67572e5c Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 28 Jul 2026 15:34:44 -0500 Subject: [PATCH 2/4] fix(onboarding): give /setup/valuation a terminal state while measuring Seeding creates the catalog seconds after the first artist is added, but its measurements land later. In that window /setup/valuation could not redirect (a catalog exists) and could not render the hero (nothing measured), so it sat on a skeleton with no exit -- reproduced live on preview right after the seeded valuation returned. Flagged as a theoretical risk when chat#1895 shipped; row 8 makes it routine, so it is fixed here rather than left. Co-Authored-By: Claude Opus 5 (1M context) --- components/Onboarding/SetupValuation.tsx | 27 ++++++++++++++++++- .../__tests__/SetupValuation.test.tsx | 19 +++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/components/Onboarding/SetupValuation.tsx b/components/Onboarding/SetupValuation.tsx index ba6626019..23380d80e 100644 --- a/components/Onboarding/SetupValuation.tsx +++ b/components/Onboarding/SetupValuation.tsx @@ -36,7 +36,7 @@ const SetupValuation = () => { if (!hasCatalog || isError) router.replace("/catalogs"); }, [isPending, hasCatalog, isError, router]); - if (isPending || !valuation.show) { + if (isPending) { return (
@@ -45,6 +45,31 @@ const SetupValuation = () => { ); } + // A catalog exists but has no valuation yet. Seeding (chat#1889 row 8) + // creates the catalog seconds after the first artist is added and its + // measurements land later, so this window is routine. The redirect above + // cannot fire (there IS a catalog) and the hero cannot render, so without a + // terminal state here the route is a skeleton with no exit. + if (!valuation.show) { + return ( +
+

+ Measuring your catalog +

+

+ We are pulling play counts for every track. This usually takes a + minute. Your baseline value appears here as soon as it is ready. +

+ + Set up your weekly report + +
+ ); + } + return (
{ expect(getByText("hero")).toBeDefined(); expect(replace).not.toHaveBeenCalled(); }); + // Seeding (chat#1889 row 8) creates the catalog seconds after the first + // artist is added, but its measurements land later — so this window is now + // routine, not theoretical. Without a terminal state the route renders a + // skeleton forever: the redirect cannot fire (there IS a catalog) and the + // hero cannot render (nothing measured yet). + it("shows a measuring state, not an endless skeleton, when the catalog has no valuation yet", () => { + catalogsResult = { + data: { catalogs: [{ id: "cat-1" }] }, + isLoading: false, + isPending: false, + isError: false, + }; + valuationResult = { show: false }; + + const { getByText } = render(); + + expect(getByText(/measuring your catalog/i)).toBeDefined(); + expect(replace).not.toHaveBeenCalled(); + }); }); From 9b2b71291a4582e3be773ac96a97b526a2f1badd Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 28 Jul 2026 17:32:41 -0500 Subject: [PATCH 3/4] refactor(onboarding): extract MeasuringCatalogPanel to its own file (OCP) Review feedback on chat#1900: the measuring state was net-new component code defined inline in an existing component file. It now lives in components/Onboarding/MeasuringCatalogPanel.tsx, so SetupValuation is extended by composition rather than modified for each new state. Co-Authored-By: Claude Opus 5 (1M context) --- .../Onboarding/MeasuringCatalogPanel.tsx | 31 +++++++++++++++++++ components/Onboarding/SetupValuation.tsx | 28 +++-------------- 2 files changed, 35 insertions(+), 24 deletions(-) create mode 100644 components/Onboarding/MeasuringCatalogPanel.tsx diff --git a/components/Onboarding/MeasuringCatalogPanel.tsx b/components/Onboarding/MeasuringCatalogPanel.tsx new file mode 100644 index 000000000..40f2325aa --- /dev/null +++ b/components/Onboarding/MeasuringCatalogPanel.tsx @@ -0,0 +1,31 @@ +"use client"; + +import Link from "next/link"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +/** + * Terminal state for `/setup/valuation` while a catalog exists but has no + * valuation yet. + * + * Seeding (chat#1889 row 8) creates the catalog seconds after the first artist + * is added and its measurements land later, so this window is routine. Without + * a state of its own the route could neither redirect (there IS a catalog) nor + * render the hero, and sat on a skeleton with no exit. + */ +const MeasuringCatalogPanel = () => ( +
+

+ Measuring your catalog +

+

+ We are pulling play counts for every track. This usually takes a minute. + Your baseline value appears here as soon as it is ready. +

+ + Set up your weekly report + +
+); + +export default MeasuringCatalogPanel; diff --git a/components/Onboarding/SetupValuation.tsx b/components/Onboarding/SetupValuation.tsx index 23380d80e..ba982fb2a 100644 --- a/components/Onboarding/SetupValuation.tsx +++ b/components/Onboarding/SetupValuation.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"; import { buttonVariants } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import ValuationHero from "@/components/Home/ValuationHero"; +import MeasuringCatalogPanel from "@/components/Onboarding/MeasuringCatalogPanel"; import useCatalogs from "@/hooks/useCatalogs"; import useHomeValuation from "@/hooks/useHomeValuation"; import { cn } from "@/lib/utils"; @@ -45,30 +46,9 @@ const SetupValuation = () => { ); } - // A catalog exists but has no valuation yet. Seeding (chat#1889 row 8) - // creates the catalog seconds after the first artist is added and its - // measurements land later, so this window is routine. The redirect above - // cannot fire (there IS a catalog) and the hero cannot render, so without a - // terminal state here the route is a skeleton with no exit. - if (!valuation.show) { - return ( -
-

- Measuring your catalog -

-

- We are pulling play counts for every track. This usually takes a - minute. Your baseline value appears here as soon as it is ready. -

- - Set up your weekly report - -
- ); - } + // A catalog exists but has no valuation yet: routine while a seeded + // valuation is still measuring (chat#1889 row 8). + if (!valuation.show) return ; return (
From e86f3e74f9fe8132b2968af45a7c4f8993b9e174 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 29 Jul 2026 12:53:05 -0500 Subject: [PATCH 4/4] fix: restore main's addSpotifyArtist after inverted rebase resolution --theirs in a rebase is the incoming commit, not main; the resolution reverted chat#1901's resolve-or-create rework. Both files back to main verbatim -- this PR's remaining scope is the onboarding form swap, useAddRosterArtist deletion, and the SetupValuation measuring state. --- .../__tests__/addSpotifyArtist.test.ts | 12 ------ lib/artists/addSpotifyArtist.ts | 41 ++++++++++--------- 2 files changed, 22 insertions(+), 31 deletions(-) diff --git a/lib/artists/__tests__/addSpotifyArtist.test.ts b/lib/artists/__tests__/addSpotifyArtist.test.ts index 9af40d96a..09f4e6f7a 100644 --- a/lib/artists/__tests__/addSpotifyArtist.test.ts +++ b/lib/artists/__tests__/addSpotifyArtist.test.ts @@ -71,16 +71,4 @@ describe("addSpotifyArtist", () => { expect(saveArtist).not.toHaveBeenCalled(); expect(result.account_id).toBe("canonical-1"); }); - - // POST /api/artists has already succeeded by the time enrichment runs, so a - // failing PATCH must not fail the whole add: the caller would report failure - // over an artist that exists, and picking again creates a duplicate - // (chat#1889, same defect fixed in useAddRosterArtist). - it("returns the created artist when enrichment fails", async () => { - vi.mocked(saveArtist).mockRejectedValue(new Error("Forbidden")); - - const result = await addSpotifyArtist(token, spotifyArtist, "org-1"); - - expect(result.account_id).toBe("acc-1"); - }); }); diff --git a/lib/artists/addSpotifyArtist.ts b/lib/artists/addSpotifyArtist.ts index 00fd29640..57ac87a5e 100644 --- a/lib/artists/addSpotifyArtist.ts +++ b/lib/artists/addSpotifyArtist.ts @@ -4,35 +4,38 @@ import { createRosterArtist } from "@/lib/artists/createRosterArtist"; import saveArtist from "@/lib/saveArtist"; /** - * Adds a Spotify-searched artist to the roster with real data: creates the - * artist by name (`POST /api/artists`, which only accepts a name), then links - * its Spotify profile URL and avatar image (`PATCH /api/artists/{id}`) so the - * roster card and reports have the correct image + a Spotify social to enrich. + * Adds a Spotify-searched artist to the roster. The API resolves-or-creates + * the canonical artist for the Spotify id and attaches its SPOTIFY social + * server-side (chat#1889 row 8) — the old client-side profileUrls PATCH is + * gone, because create-then-attach here is what minted a duplicate artist row + * per signup. * - * Enrichment is non-fatal. The create has already succeeded by then, so - * throwing would report failure over an artist that exists and a retry would - * create a duplicate (chat#1889); the socials are still fixable in the - * verify-socials step. + * The avatar is written only when the artist was CREATED (201): a reused + * canonical is shared state, and writing our image onto it would overwrite + * metadata every other rostering account sees (chat#1866). The image PATCH is + * non-fatal — the artist is already on the roster (chat#1892). */ export async function addSpotifyArtist( accessToken: string, artist: SpotifyArtistSearchResult, orgId?: string | null, ): Promise { - const created = await createRosterArtist(accessToken, artist.name, orgId); + const { artist: rosterArtist, created } = await createRosterArtist( + accessToken, + artist.name, + orgId, + artist.id, + ); const imageUrl = artist.images?.[0]?.url; + if (!created || !imageUrl) return rosterArtist; + try { - const { artist: updated } = await saveArtist( - accessToken, - created.account_id, - { - ...(imageUrl ? { image: imageUrl } : {}), - profileUrls: { SPOTIFY: artist.external_urls.spotify }, - }, - ); - return updated ?? created; + const { artist: updated } = await saveArtist(accessToken, rosterArtist.account_id, { + image: imageUrl, + }); + return updated ?? rosterArtist; } catch { - return created; + return rosterArtist; } }