diff --git a/app/api/artist/route.ts b/app/api/artist/route.ts new file mode 100644 index 00000000..7764bd00 --- /dev/null +++ b/app/api/artist/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createArtistPostHandler } from "@/lib/artists/createArtistPostHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/artist + * + * Creates a new artist account and associates it with an owner account. + * + * JSON body: + * - name (required): The name of the artist to create + * - account_id (required): The ID of the owner account (UUID) + * + * @param request - The request object containing JSON body + * @returns A NextResponse with the created artist data + */ +export async function POST(request: NextRequest) { + return createArtistPostHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; 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/__tests__/createArtistPostHandler.test.ts b/lib/artists/__tests__/createArtistPostHandler.test.ts new file mode 100644 index 00000000..eb2dc6e5 --- /dev/null +++ b/lib/artists/__tests__/createArtistPostHandler.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockCreateArtistInDb = vi.fn(); +const mockValidateCreateArtistBody = vi.fn(); + +vi.mock("@/lib/artists/createArtistInDb", () => ({ + createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args), +})); + +vi.mock("@/lib/artists/validateCreateArtistBody", () => ({ + validateCreateArtistBody: (...args: unknown[]) => + mockValidateCreateArtistBody(...args), +})); + +import { createArtistPostHandler } from "../createArtistPostHandler"; + +describe("createArtistPostHandler", () => { + const mockArtist = { + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + created_at: "2026-01-15T00:00:00Z", + updated_at: "2026-01-15T00:00:00Z", + 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, + account_info: [], + account_socials: [], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 201 with artist data on successful creation", async () => { + const validatedBody = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistBody.mockReturnValue(validatedBody); + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: JSON.stringify(validatedBody), + headers: { "Content-Type": "application/json" }, + }); + + const response = await createArtistPostHandler(request); + const data = await response.json(); + + expect(response.status).toBe(201); + expect(data.artist).toEqual(mockArtist); + expect(mockCreateArtistInDb).toHaveBeenCalledWith("Test Artist", "owner-456"); + }); + + it("parses JSON body from request", async () => { + const validatedBody = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistBody.mockReturnValue(validatedBody); + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: JSON.stringify(validatedBody), + headers: { "Content-Type": "application/json" }, + }); + + await createArtistPostHandler(request); + + expect(mockValidateCreateArtistBody).toHaveBeenCalledWith(validatedBody); + }); + + it("returns validation error response when validation fails", async () => { + const { NextResponse } = await import("next/server"); + const errorResponse = NextResponse.json( + { status: "error", error: "name is required" }, + { status: 400 }, + ); + mockValidateCreateArtistBody.mockReturnValue(errorResponse); + + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: JSON.stringify({ account_id: "owner-456" }), + headers: { "Content-Type": "application/json" }, + }); + + const response = await createArtistPostHandler(request); + + expect(response.status).toBe(400); + expect(mockCreateArtistInDb).not.toHaveBeenCalled(); + }); + + it("returns 500 when createArtistInDb returns null", async () => { + const validatedBody = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistBody.mockReturnValue(validatedBody); + mockCreateArtistInDb.mockResolvedValue(null); + + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: JSON.stringify(validatedBody), + headers: { "Content-Type": "application/json" }, + }); + + const response = await createArtistPostHandler(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.message).toBe("Failed to create artist"); + }); + + it("returns 400 with error message when createArtistInDb throws", async () => { + const validatedBody = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistBody.mockReturnValue(validatedBody); + mockCreateArtistInDb.mockRejectedValue(new Error("Database error")); + + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: JSON.stringify(validatedBody), + headers: { "Content-Type": "application/json" }, + }); + + const response = await createArtistPostHandler(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.message).toBe("Database error"); + }); + + it("returns 400 when request body is not valid JSON", async () => { + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: "not-json", + headers: { "Content-Type": "application/json" }, + }); + + const response = await createArtistPostHandler(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.message).toBe("Invalid JSON body"); + }); + + it("includes CORS headers in successful response", async () => { + const validatedBody = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistBody.mockReturnValue(validatedBody); + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: JSON.stringify(validatedBody), + headers: { "Content-Type": "application/json" }, + }); + + const response = await createArtistPostHandler(request); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + it("includes CORS headers in error response", async () => { + const validatedBody = { + name: "Test Artist", + account_id: "owner-456", + }; + mockValidateCreateArtistBody.mockReturnValue(validatedBody); + mockCreateArtistInDb.mockResolvedValue(null); + + const request = new NextRequest("http://localhost/api/artist", { + method: "POST", + body: JSON.stringify(validatedBody), + headers: { "Content-Type": "application/json" }, + }); + + const response = await createArtistPostHandler(request); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); +}); diff --git a/lib/artists/__tests__/validateCreateArtistBody.test.ts b/lib/artists/__tests__/validateCreateArtistBody.test.ts new file mode 100644 index 00000000..44a7a0ef --- /dev/null +++ b/lib/artists/__tests__/validateCreateArtistBody.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from "vitest"; +import { + validateCreateArtistBody, + createArtistBodySchema, +} from "../validateCreateArtistBody"; + +describe("createArtistBodySchema", () => { + it("accepts valid body with name and account_id", () => { + const body = { + name: "Test Artist", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }; + + const result = createArtistBodySchema.safeParse(body); + expect(result.success).toBe(true); + expect(result.data).toEqual(body); + }); + + it("rejects body without name", () => { + const body = { + account_id: "550e8400-e29b-41d4-a716-446655440000", + }; + + const result = createArtistBodySchema.safeParse(body); + expect(result.success).toBe(false); + }); + + it("rejects body without account_id", () => { + const body = { + name: "Test Artist", + }; + + const result = createArtistBodySchema.safeParse(body); + expect(result.success).toBe(false); + }); + + it("rejects body with invalid UUID for account_id", () => { + const body = { + name: "Test Artist", + account_id: "not-a-uuid", + }; + + const result = createArtistBodySchema.safeParse(body); + expect(result.success).toBe(false); + }); + + it("rejects body with empty name", () => { + const body = { + name: "", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }; + + const result = createArtistBodySchema.safeParse(body); + expect(result.success).toBe(false); + }); +}); + +describe("validateCreateArtistBody", () => { + it("returns validated data for valid body", () => { + const body = { + name: "Test Artist", + account_id: "550e8400-e29b-41d4-a716-446655440000", + }; + + const result = validateCreateArtistBody(body); + expect(result).toEqual(body); + }); + + it("returns NextResponse with 400 for missing name", () => { + const body = { + account_id: "550e8400-e29b-41d4-a716-446655440000", + }; + + const result = validateCreateArtistBody(body); + expect(result.status).toBe(400); + }); + + it("returns NextResponse with error message for missing name", async () => { + const body = { + account_id: "550e8400-e29b-41d4-a716-446655440000", + }; + + const result = validateCreateArtistBody(body); + const data = await result.json(); + expect(data.status).toBe("error"); + expect(data.error).toContain("name"); + }); + + it("returns NextResponse with 400 for invalid UUID", async () => { + const body = { + name: "Test Artist", + account_id: "invalid-uuid", + }; + + const result = validateCreateArtistBody(body); + expect(result.status).toBe(400); + const data = await result.json(); + expect(data.error).toContain("UUID"); + }); + + it("includes CORS headers in error response", async () => { + const body = { + name: "Test Artist", + account_id: "invalid-uuid", + }; + + const result = validateCreateArtistBody(body); + expect(result.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); +}); diff --git a/lib/artists/createArtistInDb.ts b/lib/artists/createArtistInDb.ts new file mode 100644 index 00000000..d68e7484 --- /dev/null +++ b/lib/artists/createArtistInDb.ts @@ -0,0 +1,88 @@ +import { insertAccount } from "@/lib/supabase/accounts/insertAccount"; +import { insertAccountInfo } from "@/lib/supabase/account_info/insertAccountInfo"; +import { selectAccountWithSocials } 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 interface CreateArtistResult { + id: string; + account_id: string; + name: string; + created_at: string | null; + updated_at: string | null; + image: string | null; + instruction: string | null; + knowledges: string[] | null; + label: string | null; + organization: string | null; + company_name: string | null; + job_title: string | null; + role_type: string | null; + onboarding_status: string | null; + onboarding_data: unknown; + account_info: unknown[]; + account_socials: unknown[]; +} + +/** + * 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); + } + + // Build return object by explicitly picking fields + const info = artist.account_info?.[0]; + + return { + id: artist.id, + account_id: artist.id, + name: artist.name, + created_at: artist.created_at, + updated_at: artist.updated_at, + image: info?.image ?? null, + instruction: info?.instruction ?? null, + knowledges: info?.knowledges ?? null, + label: info?.label ?? null, + organization: info?.organization ?? null, + company_name: info?.company_name ?? null, + job_title: info?.job_title ?? null, + role_type: info?.role_type ?? null, + onboarding_status: info?.onboarding_status ?? null, + onboarding_data: info?.onboarding_data ?? null, + account_info: artist.account_info, + account_socials: artist.account_socials, + }; + } catch (error) { + return null; + } +} diff --git a/lib/artists/createArtistPostHandler.ts b/lib/artists/createArtistPostHandler.ts new file mode 100644 index 00000000..82538f55 --- /dev/null +++ b/lib/artists/createArtistPostHandler.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateCreateArtistBody } from "@/lib/artists/validateCreateArtistBody"; +import { createArtistInDb } from "@/lib/artists/createArtistInDb"; + +/** + * Handler for creating a new artist via POST request. + * + * JSON body: + * - name (required): The name of the artist to create + * - account_id (required): The ID of the owner account (UUID) + * + * @param request - The request object containing JSON body + * @returns A NextResponse with artist data or error + */ +export async function createArtistPostHandler( + request: NextRequest, +): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { message: "Invalid JSON body" }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const validatedBody = validateCreateArtistBody(body); + if (validatedBody instanceof NextResponse) { + return validatedBody; + } + + try { + const artist = await createArtistInDb( + validatedBody.name, + validatedBody.account_id, + ); + + if (!artist) { + return NextResponse.json( + { message: "Failed to create artist" }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } + + return NextResponse.json( + { artist }, + { + status: 201, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "failed"; + return NextResponse.json( + { message }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } +} diff --git a/lib/artists/validateCreateArtistBody.ts b/lib/artists/validateCreateArtistBody.ts new file mode 100644 index 00000000..5000bcad --- /dev/null +++ b/lib/artists/validateCreateArtistBody.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const createArtistBodySchema = z.object({ + name: z + .string({ message: "name is required" }) + .min(1, "name cannot be empty"), + account_id: z + .string({ message: "account_id is required" }) + .uuid("account_id must be a valid UUID"), +}); + +export type CreateArtistBody = z.infer; + +/** + * Validates JSON body for POST /api/artist. + * + * @param body - The parsed JSON body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateCreateArtistBody( + body: unknown, +): NextResponse | CreateArtistBody { + const result = createArtistBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} 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..ce6c94a1 --- /dev/null +++ b/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts @@ -0,0 +1,148 @@ +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", + image: null, + }; + 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", + image: null, + }; + 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", + image: null, + }; + 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..89ddd76a --- /dev/null +++ b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts @@ -0,0 +1,102 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { createArtistInDb } 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() + .describe("The account ID of the human with admin access to the new artist account"), + 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 interface CreateNewArtistResult { + artist?: { + account_id: string; + name: string; + 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. " + + "Requires the artist name and the account ID of the user with admin access to the new artist account. " + + "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) => { + try { + const { name, account_id, active_conversation_id, organization_id } = args; + + // Create the artist account (with optional org linking) + const artist = await createArtistInDb( + name, + account_id, + 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.image, + }, + 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..518f8b2a --- /dev/null +++ b/lib/supabase/accounts/__tests__/selectAccountWithSocials.test.ts @@ -0,0 +1,62 @@ +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", + account_socials: [{ id: "social-1", platform: "spotify" }], + account_info: [{ id: "info-1", image: "https://example.com/image.jpg" }], + }; + 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); + }); + + 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..88bbd309 --- /dev/null +++ b/lib/supabase/accounts/selectAccountWithSocials.ts @@ -0,0 +1,32 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Account with account_socials and account_info relations. + */ +export type AccountWithSocials = Tables<"accounts"> & { + account_socials: Tables<"account_socials">[]; + account_info: Tables<"account_info">[]; +}; + +/** + * 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; + } + + return data as AccountWithSocials; +}