diff --git a/lib/artists/__tests__/createArtistInDb.test.ts b/lib/artists/__tests__/createArtistInDb.test.ts new file mode 100644 index 00000000..e979fbb5 --- /dev/null +++ b/lib/artists/__tests__/createArtistInDb.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockInsertAccount = vi.fn(); +const mockInsertAccountInfo = vi.fn(); +const mockSelectAccountWithSocials = vi.fn(); +const mockInsertAccountArtistId = vi.fn(); +const mockAddArtistToOrganization = vi.fn(); + +vi.mock("@/lib/supabase/accounts/insertAccount", () => ({ + insertAccount: (...args: unknown[]) => mockInsertAccount(...args), +})); + +vi.mock("@/lib/supabase/account_info/insertAccountInfo", () => ({ + insertAccountInfo: (...args: unknown[]) => mockInsertAccountInfo(...args), +})); + +vi.mock("@/lib/supabase/accounts/selectAccountWithSocials", () => ({ + selectAccountWithSocials: (...args: unknown[]) => mockSelectAccountWithSocials(...args), +})); + +vi.mock("@/lib/supabase/account_artist_ids/insertAccountArtistId", () => ({ + insertAccountArtistId: (...args: unknown[]) => mockInsertAccountArtistId(...args), +})); + +vi.mock("@/lib/supabase/artist_organization_ids/addArtistToOrganization", () => ({ + addArtistToOrganization: (...args: unknown[]) => mockAddArtistToOrganization(...args), +})); + +import { createArtistInDb } from "../createArtistInDb"; + +describe("createArtistInDb", () => { + const mockAccount = { + id: "artist-123", + name: "Test Artist", + created_at: "2026-01-15T00:00:00Z", + updated_at: "2026-01-15T00:00:00Z", + }; + + const mockAccountInfo = { + id: "info-123", + account_id: "artist-123", + image: null, + instruction: null, + knowledges: null, + label: null, + organization: null, + company_name: null, + job_title: null, + role_type: null, + onboarding_status: null, + onboarding_data: null, + }; + + const mockFullAccount = { + ...mockAccount, + account_socials: [], + account_info: [mockAccountInfo], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates an artist account with all required steps", async () => { + mockInsertAccount.mockResolvedValue(mockAccount); + mockInsertAccountInfo.mockResolvedValue(mockAccountInfo); + mockSelectAccountWithSocials.mockResolvedValue(mockFullAccount); + mockInsertAccountArtistId.mockResolvedValue({ id: "rel-123" }); + + const result = await createArtistInDb("Test Artist", "owner-456"); + + expect(mockInsertAccount).toHaveBeenCalledWith({ name: "Test Artist" }); + expect(mockInsertAccountInfo).toHaveBeenCalledWith({ account_id: "artist-123" }); + expect(mockSelectAccountWithSocials).toHaveBeenCalledWith("artist-123"); + expect(mockInsertAccountArtistId).toHaveBeenCalledWith("owner-456", "artist-123"); + expect(result).toMatchObject({ + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + }); + }); + + it("links artist to organization when organizationId is provided", async () => { + mockInsertAccount.mockResolvedValue(mockAccount); + mockInsertAccountInfo.mockResolvedValue(mockAccountInfo); + mockSelectAccountWithSocials.mockResolvedValue(mockFullAccount); + mockInsertAccountArtistId.mockResolvedValue({ id: "rel-123" }); + mockAddArtistToOrganization.mockResolvedValue("org-rel-123"); + + const result = await createArtistInDb("Test Artist", "owner-456", "org-789"); + + expect(mockAddArtistToOrganization).toHaveBeenCalledWith("artist-123", "org-789"); + expect(result).not.toBeNull(); + }); + + it("returns null when account creation fails", async () => { + mockInsertAccount.mockRejectedValue(new Error("Insert failed")); + + const result = await createArtistInDb("Test Artist", "owner-456"); + + expect(result).toBeNull(); + expect(mockInsertAccountInfo).not.toHaveBeenCalled(); + }); + + it("returns null when account info creation fails", async () => { + mockInsertAccount.mockResolvedValue(mockAccount); + mockInsertAccountInfo.mockResolvedValue(null); + + const result = await createArtistInDb("Test Artist", "owner-456"); + + expect(result).toBeNull(); + expect(mockSelectAccountWithSocials).not.toHaveBeenCalled(); + }); + + it("returns null when fetching full account data fails", async () => { + mockInsertAccount.mockResolvedValue(mockAccount); + mockInsertAccountInfo.mockResolvedValue(mockAccountInfo); + mockSelectAccountWithSocials.mockResolvedValue(null); + + const result = await createArtistInDb("Test Artist", "owner-456"); + + expect(result).toBeNull(); + expect(mockInsertAccountArtistId).not.toHaveBeenCalled(); + }); + + it("returns null when associating artist with owner fails", async () => { + mockInsertAccount.mockResolvedValue(mockAccount); + mockInsertAccountInfo.mockResolvedValue(mockAccountInfo); + mockSelectAccountWithSocials.mockResolvedValue(mockFullAccount); + mockInsertAccountArtistId.mockRejectedValue(new Error("Association failed")); + + const result = await createArtistInDb("Test Artist", "owner-456"); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/artists/createArtistInDb.ts b/lib/artists/createArtistInDb.ts new file mode 100644 index 00000000..e1eeceb9 --- /dev/null +++ b/lib/artists/createArtistInDb.ts @@ -0,0 +1,57 @@ +import { insertAccount } from "@/lib/supabase/accounts/insertAccount"; +import { insertAccountInfo } from "@/lib/supabase/account_info/insertAccountInfo"; +import { + selectAccountWithSocials, + type AccountWithSocials, +} from "@/lib/supabase/accounts/selectAccountWithSocials"; +import { insertAccountArtistId } from "@/lib/supabase/account_artist_ids/insertAccountArtistId"; +import { addArtistToOrganization } from "@/lib/supabase/artist_organization_ids/addArtistToOrganization"; + +/** + * Result of creating an artist in the database. + */ +export type CreateArtistResult = AccountWithSocials & { + account_id: string; +}; + +/** + * Create a new artist account in the database and associate it with an owner account. + * + * @param name - Name of the artist to create + * @param accountId - ID of the owner account that will have access to this artist + * @param organizationId - Optional organization ID to link the new artist to + * @returns Created artist object or null if creation failed + */ +export async function createArtistInDb( + name: string, + accountId: string, + organizationId?: string, +): Promise { + try { + // Step 1: Create the account + const account = await insertAccount({ name }); + + // Step 2: Create account info for the account + const accountInfo = await insertAccountInfo({ account_id: account.id }); + if (!accountInfo) return null; + + // Step 3: Get the full account data with socials and info + const artist = await selectAccountWithSocials(account.id); + if (!artist) return null; + + // Step 4: Associate the artist with the owner via account_artist_ids + await insertAccountArtistId(accountId, account.id); + + // Step 5: Link to organization if provided + if (organizationId) { + await addArtistToOrganization(account.id, organizationId); + } + + return { + ...artist, + account_id: artist.id, + }; + } catch (error) { + return null; + } +} diff --git a/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts b/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts new file mode 100644 index 00000000..1d8ce004 --- /dev/null +++ b/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +const mockCreateArtistInDb = vi.fn(); +const mockCopyRoom = vi.fn(); + +vi.mock("@/lib/artists/createArtistInDb", () => ({ + createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args), +})); + +vi.mock("@/lib/rooms/copyRoom", () => ({ + copyRoom: (...args: unknown[]) => mockCopyRoom(...args), +})); + +import { registerCreateNewArtistTool } from "../registerCreateNewArtistTool"; + +describe("registerCreateNewArtistTool", () => { + let mockServer: McpServer; + let registeredHandler: (args: unknown) => Promise; + + beforeEach(() => { + vi.clearAllMocks(); + + mockServer = { + registerTool: vi.fn((name, config, handler) => { + registeredHandler = handler; + }), + } as unknown as McpServer; + + registerCreateNewArtistTool(mockServer); + }); + + it("registers the create_new_artist tool", () => { + expect(mockServer.registerTool).toHaveBeenCalledWith( + "create_new_artist", + expect.objectContaining({ + description: expect.stringContaining("Create a new artist account"), + }), + expect.any(Function), + ); + }); + + it("creates an artist and returns success", async () => { + const mockArtist = { + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + account_info: [{ image: null }], + account_socials: [], + }; + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const result = await registeredHandler({ + name: "Test Artist", + account_id: "owner-456", + }); + + expect(mockCreateArtistInDb).toHaveBeenCalledWith("Test Artist", "owner-456", undefined); + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Successfully created artist"), + }, + ], + }); + }); + + it("copies room when active_conversation_id is provided", async () => { + const mockArtist = { + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + account_info: [{ image: null }], + account_socials: [], + }; + mockCreateArtistInDb.mockResolvedValue(mockArtist); + mockCopyRoom.mockResolvedValue("new-room-789"); + + const result = await registeredHandler({ + name: "Test Artist", + account_id: "owner-456", + active_conversation_id: "source-room-111", + }); + + expect(mockCopyRoom).toHaveBeenCalledWith("source-room-111", "artist-123"); + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("new-room-789"), + }, + ], + }); + }); + + it("passes organization_id to createArtistInDb", async () => { + const mockArtist = { + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + account_info: [{ image: null }], + account_socials: [], + }; + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + await registeredHandler({ + name: "Test Artist", + account_id: "owner-456", + organization_id: "org-999", + }); + + expect(mockCreateArtistInDb).toHaveBeenCalledWith("Test Artist", "owner-456", "org-999"); + }); + + it("returns error when artist creation fails", async () => { + mockCreateArtistInDb.mockResolvedValue(null); + + const result = await registeredHandler({ + name: "Test Artist", + account_id: "owner-456", + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Failed to create artist"), + }, + ], + }); + }); + + it("returns error with message when exception is thrown", async () => { + mockCreateArtistInDb.mockRejectedValue(new Error("Database connection failed")); + + const result = await registeredHandler({ + name: "Test Artist", + account_id: "owner-456", + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Database connection failed"), + }, + ], + }); + }); +}); diff --git a/lib/mcp/tools/artists/index.ts b/lib/mcp/tools/artists/index.ts new file mode 100644 index 00000000..20c5a867 --- /dev/null +++ b/lib/mcp/tools/artists/index.ts @@ -0,0 +1,11 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerCreateNewArtistTool } from "./registerCreateNewArtistTool"; + +/** + * Registers all artist-related MCP tools on the server. + * + * @param server - The MCP server instance to register tools on. + */ +export const registerAllArtistTools = (server: McpServer): void => { + registerCreateNewArtistTool(server); +}; diff --git a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts new file mode 100644 index 00000000..cfa662a9 --- /dev/null +++ b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts @@ -0,0 +1,114 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + createArtistInDb, + type CreateArtistResult, +} from "@/lib/artists/createArtistInDb"; +import { copyRoom } from "@/lib/rooms/copyRoom"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; + +const createNewArtistSchema = z.object({ + name: z.string().describe("The name of the artist to be created"), + account_id: z + .string() + .optional() + .describe( + "The account ID to create the artist for. Only required for organization API keys creating artists on behalf of other accounts.", + ), + active_conversation_id: z + .string() + .optional() + .describe( + "The ID of the room/conversation to copy for this artist's first conversation. " + + "If not provided, use the active_conversation_id from the system prompt.", + ), + organization_id: z + .string() + .optional() + .nullable() + .describe( + "The organization ID to link the new artist to. " + + "Use the organization_id from the system prompt context. Pass null or omit for personal artists.", + ), +}); + +export type CreateNewArtistArgs = z.infer; + +export type CreateNewArtistResult = { + artist?: Pick & { + image?: string | null; + }; + artistAccountId?: string; + message: string; + error?: string; + newRoomId?: string | null; +}; + +/** + * Registers the "create_new_artist" tool on the MCP server. + * Creates a new artist account in the system. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerCreateNewArtistTool(server: McpServer): void { + server.registerTool( + "create_new_artist", + { + description: + "Create a new artist account in the system. " + + "The account_id parameter is optional — only provide it when using an organization API key to create artists on behalf of other accounts. " + + "The active_conversation_id parameter is optional — when omitted, use the active_conversation_id from the system prompt " + + "to copy the conversation. Never ask the user to provide a room ID. " + + "The organization_id parameter is optional — use the organization_id from the system prompt context to link the artist to the user's selected organization.", + inputSchema: createNewArtistSchema, + }, + async (args: CreateNewArtistArgs, extra) => { + try { + const { name, account_id, active_conversation_id, organization_id } = args; + + // Get account_id from args or from API key context + const accountId = account_id ?? extra?.accountId; + if (!accountId) { + return getToolResultError( + "account_id is required. Provide it from the system prompt context.", + ); + } + + // Create the artist account (with optional org linking) + const artist = await createArtistInDb( + name, + accountId, + organization_id ?? undefined, + ); + + if (!artist) { + return getToolResultError("Failed to create artist"); + } + + // Copy the conversation to the new artist if requested + let newRoomId: string | null = null; + if (active_conversation_id) { + newRoomId = await copyRoom(active_conversation_id, artist.account_id); + } + + const result: CreateNewArtistResult = { + artist: { + account_id: artist.account_id, + name: artist.name, + image: artist.account_info[0]?.image ?? null, + }, + artistAccountId: artist.account_id, + message: `Successfully created artist "${name}". Now searching Spotify for this artist to connect their profile...`, + newRoomId, + }; + + return getToolResultSuccess(result); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to create artist for unknown reason"; + return getToolResultError(errorMessage); + } + }, + ); +} diff --git a/lib/mcp/tools/index.ts b/lib/mcp/tools/index.ts index 78db215b..984ba928 100644 --- a/lib/mcp/tools/index.ts +++ b/lib/mcp/tools/index.ts @@ -16,6 +16,7 @@ import { registerCreateSegmentsTool } from "./registerCreateSegmentsTool"; import { registerAllYouTubeTools } from "./youtube"; import { registerTranscribeTools } from "./transcribe"; import { registerSendEmailTool } from "./registerSendEmailTool"; +import { registerAllArtistTools } from "./artists"; /** * Registers all MCP tools on the server. @@ -24,6 +25,7 @@ import { registerSendEmailTool } from "./registerSendEmailTool"; * @param server - The MCP server instance to register tools on. */ export const registerAllTools = (server: McpServer): void => { + registerAllArtistTools(server); registerAllArtistSocialsTools(server); registerAllCatalogTools(server); registerAllFileTools(server); diff --git a/lib/rooms/__tests__/copyRoom.test.ts b/lib/rooms/__tests__/copyRoom.test.ts new file mode 100644 index 00000000..f49326e7 --- /dev/null +++ b/lib/rooms/__tests__/copyRoom.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockSelectRoom = vi.fn(); +const mockInsertRoom = vi.fn(); + +vi.mock("@/lib/supabase/rooms/selectRoom", () => ({ + default: (...args: unknown[]) => mockSelectRoom(...args), +})); + +vi.mock("@/lib/supabase/rooms/insertRoom", () => ({ + insertRoom: (...args: unknown[]) => mockInsertRoom(...args), +})); + +vi.mock("@/lib/uuid/generateUUID", () => ({ + default: () => "generated-uuid-123", +})); + +import { copyRoom } from "../copyRoom"; + +describe("copyRoom", () => { + const mockSourceRoom = { + id: "source-room-123", + account_id: "account-456", + artist_id: "old-artist-789", + topic: "Original Conversation", + }; + + const mockNewRoom = { + id: "generated-uuid-123", + account_id: "account-456", + artist_id: "new-artist-999", + topic: "Original Conversation", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("copies a room to a new artist", async () => { + mockSelectRoom.mockResolvedValue(mockSourceRoom); + mockInsertRoom.mockResolvedValue(mockNewRoom); + + const result = await copyRoom("source-room-123", "new-artist-999"); + + expect(mockSelectRoom).toHaveBeenCalledWith("source-room-123"); + expect(mockInsertRoom).toHaveBeenCalledWith({ + id: "generated-uuid-123", + account_id: "account-456", + artist_id: "new-artist-999", + topic: "Original Conversation", + }); + expect(result).toBe("generated-uuid-123"); + }); + + it("uses default topic when source room has no topic", async () => { + mockSelectRoom.mockResolvedValue({ ...mockSourceRoom, topic: null }); + mockInsertRoom.mockResolvedValue(mockNewRoom); + + await copyRoom("source-room-123", "new-artist-999"); + + expect(mockInsertRoom).toHaveBeenCalledWith( + expect.objectContaining({ + topic: "New conversation", + }), + ); + }); + + it("returns null when source room is not found", async () => { + mockSelectRoom.mockResolvedValue(null); + + const result = await copyRoom("nonexistent-room", "new-artist-999"); + + expect(result).toBeNull(); + expect(mockInsertRoom).not.toHaveBeenCalled(); + }); + + it("returns null when room insertion fails", async () => { + mockSelectRoom.mockResolvedValue(mockSourceRoom); + mockInsertRoom.mockRejectedValue(new Error("Insert failed")); + + const result = await copyRoom("source-room-123", "new-artist-999"); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/rooms/copyRoom.ts b/lib/rooms/copyRoom.ts new file mode 100644 index 00000000..f6f11be9 --- /dev/null +++ b/lib/rooms/copyRoom.ts @@ -0,0 +1,40 @@ +import selectRoom from "@/lib/supabase/rooms/selectRoom"; +import { insertRoom } from "@/lib/supabase/rooms/insertRoom"; +import generateUUID from "@/lib/uuid/generateUUID"; + +/** + * Create a new room based on an existing room's data. + * Does not copy messages - only creates a new room with the same topic. + * + * @param sourceRoomId - The ID of the source room to use as a template + * @param artistId - The ID of the artist for the new room + * @returns The ID of the new room or null if operation failed + */ +export async function copyRoom( + sourceRoomId: string, + artistId: string, +): Promise { + try { + // Get the source room data + const sourceRoom = await selectRoom(sourceRoomId); + + if (!sourceRoom) { + return null; + } + + // Generate new room ID + const newRoomId = generateUUID(); + + // Create new room with same account but new artist + await insertRoom({ + id: newRoomId, + account_id: sourceRoom.account_id, + artist_id: artistId, + topic: sourceRoom.topic || "New conversation", + }); + + return newRoomId; + } catch { + return null; + } +} diff --git a/lib/supabase/account_artist_ids/__tests__/insertAccountArtistId.test.ts b/lib/supabase/account_artist_ids/__tests__/insertAccountArtistId.test.ts new file mode 100644 index 00000000..2087aeda --- /dev/null +++ b/lib/supabase/account_artist_ids/__tests__/insertAccountArtistId.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockFrom = vi.fn(); +const mockInsert = vi.fn(); +const mockSelect = vi.fn(); +const mockSingle = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +import { insertAccountArtistId } from "../insertAccountArtistId"; + +describe("insertAccountArtistId", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ insert: mockInsert }); + mockInsert.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ single: mockSingle }); + }); + + it("inserts an account-artist relationship and returns the data", async () => { + const mockData = { + id: "rel-123", + account_id: "account-456", + artist_id: "artist-789", + }; + mockSingle.mockResolvedValue({ data: mockData, error: null }); + + const result = await insertAccountArtistId("account-456", "artist-789"); + + expect(mockFrom).toHaveBeenCalledWith("account_artist_ids"); + expect(mockInsert).toHaveBeenCalledWith({ + account_id: "account-456", + artist_id: "artist-789", + }); + expect(result).toEqual(mockData); + }); + + it("throws an error when insert fails", async () => { + mockSingle.mockResolvedValue({ + data: null, + error: { message: "Insert failed" }, + }); + + await expect(insertAccountArtistId("account-456", "artist-789")).rejects.toThrow( + "Failed to insert account-artist relationship: Insert failed", + ); + }); + + it("throws an error when no data is returned", async () => { + mockSingle.mockResolvedValue({ data: null, error: null }); + + await expect(insertAccountArtistId("account-456", "artist-789")).rejects.toThrow( + "Failed to insert account-artist relationship: No data returned", + ); + }); +}); diff --git a/lib/supabase/account_artist_ids/insertAccountArtistId.ts b/lib/supabase/account_artist_ids/insertAccountArtistId.ts new file mode 100644 index 00000000..1e65d41d --- /dev/null +++ b/lib/supabase/account_artist_ids/insertAccountArtistId.ts @@ -0,0 +1,37 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +type AccountArtistId = Tables<"account_artist_ids">; + +/** + * Inserts an account-artist relationship into the account_artist_ids table. + * This associates an artist account with a user/owner account. + * + * @param accountId - The account ID of the user/owner + * @param artistId - The account ID of the artist + * @returns The inserted relationship record + * @throws Error if the insert fails + */ +export async function insertAccountArtistId( + accountId: string, + artistId: string, +): Promise { + const { data, error } = await supabase + .from("account_artist_ids") + .insert({ + account_id: accountId, + artist_id: artistId, + }) + .select() + .single(); + + if (error) { + throw new Error(`Failed to insert account-artist relationship: ${error.message}`); + } + + if (!data) { + throw new Error("Failed to insert account-artist relationship: No data returned"); + } + + return data; +} diff --git a/lib/supabase/accounts/__tests__/selectAccountWithSocials.test.ts b/lib/supabase/accounts/__tests__/selectAccountWithSocials.test.ts new file mode 100644 index 00000000..966c4688 --- /dev/null +++ b/lib/supabase/accounts/__tests__/selectAccountWithSocials.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockFrom = vi.fn(); +const mockSelect = vi.fn(); +const mockEq = vi.fn(); +const mockSingle = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +import { selectAccountWithSocials } from "../selectAccountWithSocials"; + +describe("selectAccountWithSocials", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFrom.mockReturnValue({ select: mockSelect }); + mockSelect.mockReturnValue({ eq: mockEq }); + mockEq.mockReturnValue({ single: mockSingle }); + }); + + it("returns account with socials and info when found", async () => { + const mockData = { + id: "account-123", + name: "Test Artist", + timestamp: 1704067200000, + account_socials: [{ id: "social-1", platform: "spotify" }], + account_info: [{ id: "info-1", image: "https://example.com/image.jpg", updated_at: "2024-01-01T12:00:00Z" }], + }; + mockSingle.mockResolvedValue({ data: mockData, error: null }); + + const result = await selectAccountWithSocials("account-123"); + + expect(mockFrom).toHaveBeenCalledWith("accounts"); + expect(mockSelect).toHaveBeenCalledWith("*, account_socials(*), account_info(*)"); + expect(mockEq).toHaveBeenCalledWith("id", "account-123"); + expect(result).toEqual({ + ...mockData, + created_at: new Date(mockData.timestamp).toISOString(), + updated_at: "2024-01-01T12:00:00Z", + }); + }); + + it("returns null when account is not found", async () => { + mockSingle.mockResolvedValue({ + data: null, + error: { code: "PGRST116", message: "Row not found" }, + }); + + const result = await selectAccountWithSocials("nonexistent-id"); + + expect(result).toBeNull(); + }); + + it("returns null when query fails", async () => { + mockSingle.mockResolvedValue({ + data: null, + error: { message: "Database error" }, + }); + + const result = await selectAccountWithSocials("account-123"); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/supabase/accounts/selectAccountWithSocials.ts b/lib/supabase/accounts/selectAccountWithSocials.ts new file mode 100644 index 00000000..cc9e276a --- /dev/null +++ b/lib/supabase/accounts/selectAccountWithSocials.ts @@ -0,0 +1,42 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Account with account_socials and account_info relations. + * Includes created_at and updated_at for compatibility with artist creation flow. + */ +export type AccountWithSocials = Tables<"accounts"> & { + account_socials: Tables<"account_socials">[]; + account_info: Tables<"account_info">[]; + created_at: string | null; + updated_at: string | null; +}; + +/** + * Retrieves an account with its related socials and info. + * + * @param accountId - The account's ID (UUID) + * @returns Account with socials and info arrays, or null if not found/error + */ +export async function selectAccountWithSocials( + accountId: string, +): Promise { + const { data, error } = await supabase + .from("accounts") + .select("*, account_socials(*), account_info(*)") + .eq("id", accountId) + .single(); + + if (error || !data) { + return null; + } + + // Add created_at and updated_at for compatibility + // updated_at comes from account_info, created_at derived from timestamp + const accountInfo = data.account_info?.[0]; + return { + ...data, + created_at: data.timestamp ? new Date(data.timestamp).toISOString() : null, + updated_at: accountInfo?.updated_at ?? null, + } as AccountWithSocials; +}