diff --git a/app/api/artists/[id]/segments/route.ts b/app/api/artists/[id]/segments/route.ts new file mode 100644 index 000000000..29c49f1c0 --- /dev/null +++ b/app/api/artists/[id]/segments/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postArtistSegmentsHandler } from "@/lib/artists/segments/postArtistSegmentsHandler"; + +/** + * 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/artists/{id}/segments + * + * Manually creates segments for the specified artist by delegating to the shared + * `createSegments` handler (also exposed via the MCP `create_segments` tool). + * + * @param request - The incoming request object + * @param options - Route options containing params + * @param options.params - Route params containing the artist account ID + * @returns A NextResponse with the segment creation envelope + */ +export async function POST(request: NextRequest, options: { params: Promise<{ id: string }> }) { + return postArtistSegmentsHandler(request, options.params); +} diff --git a/lib/artists/segments/__tests__/postArtistSegmentsHandler.test.ts b/lib/artists/segments/__tests__/postArtistSegmentsHandler.test.ts new file mode 100644 index 000000000..b4091b311 --- /dev/null +++ b/lib/artists/segments/__tests__/postArtistSegmentsHandler.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { postArtistSegmentsHandler } from "../postArtistSegmentsHandler"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +const mockValidateAuthContext = vi.fn(); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), +})); + +const mockSelectAccounts = vi.fn(); +vi.mock("@/lib/supabase/accounts/selectAccounts", () => ({ + selectAccounts: (...args: unknown[]) => mockSelectAccounts(...args), +})); + +const mockCheckAccountArtistAccess = vi.fn(); +vi.mock("@/lib/artists/checkAccountArtistAccess", () => ({ + checkAccountArtistAccess: (...args: unknown[]) => mockCheckAccountArtistAccess(...args), +})); + +const mockCreateSegments = vi.fn(); +vi.mock("@/lib/segments/createSegments", () => ({ + createSegments: (...args: unknown[]) => mockCreateSegments(...args), +})); + +const ARTIST_ID = "550e8400-e29b-41d4-a716-446655440000"; +const REQUESTER_ACCOUNT_ID = "660e8400-e29b-41d4-a716-446655440000"; + +function createRequest(body: unknown, headers: Record = {}): NextRequest { + const defaultHeaders: Record = { + "Content-Type": "application/json", + "x-api-key": "test-api-key", + }; + return new NextRequest(`http://localhost/api/artists/${ARTIST_ID}/segments`, { + method: "POST", + headers: { ...defaultHeaders, ...headers }, + body: JSON.stringify(body), + }); +} + +describe("postArtistSegmentsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidateAuthContext.mockResolvedValue({ + accountId: REQUESTER_ACCOUNT_ID, + orgId: null, + authToken: "test-api-key", + }); + mockSelectAccounts.mockResolvedValue([{ id: ARTIST_ID, name: "Test Artist" }]); + mockCheckAccountArtistAccess.mockResolvedValue(true); + }); + + it("returns 200 with success envelope when segments are created", async () => { + mockCreateSegments.mockResolvedValue({ + success: true, + status: "success", + message: "Successfully created 5 segments for artist", + data: { + supabase_segments: [], + supabase_artist_segments: [], + supabase_fan_segments: [], + segments: [], + }, + count: 5, + }); + + const request = createRequest({ prompt: "Segment my fans" }); + const response = await postArtistSegmentsHandler(request, Promise.resolve({ id: ARTIST_ID })); + const body = await response.json(); + + expect(response.status).toBe(201); + expect(body).toEqual({ + status: "success", + segments_created: 5, + message: "Segments generated successfully.", + }); + expect(mockCreateSegments).toHaveBeenCalledWith({ + artist_account_id: ARTIST_ID, + prompt: "Segment my fans", + }); + }); + + it("returns 400 when prompt is missing", async () => { + const request = createRequest({}); + const response = await postArtistSegmentsHandler(request, Promise.resolve({ id: ARTIST_ID })); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.status).toBe("error"); + expect(body.error).toBe("prompt is required"); + expect(mockCreateSegments).not.toHaveBeenCalled(); + }); + + it("returns 400 when prompt is empty", async () => { + const request = createRequest({ prompt: "" }); + const response = await postArtistSegmentsHandler(request, Promise.resolve({ id: ARTIST_ID })); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toBe("prompt cannot be empty"); + expect(mockCreateSegments).not.toHaveBeenCalled(); + }); + + it("returns 400 when path id is not a valid UUID", async () => { + const request = createRequest({ prompt: "Segment my fans" }); + const response = await postArtistSegmentsHandler( + request, + Promise.resolve({ id: "not-a-uuid" }), + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.status).toBe("error"); + expect(mockCreateSegments).not.toHaveBeenCalled(); + }); + + it("returns 401 when auth context fails", async () => { + mockValidateAuthContext.mockResolvedValue( + NextResponse.json( + { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, + { status: 401 }, + ), + ); + + const request = new NextRequest(`http://localhost/api/artists/${ARTIST_ID}/segments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: "Segment my fans" }), + }); + + const response = await postArtistSegmentsHandler(request, Promise.resolve({ id: ARTIST_ID })); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body.error).toBe("Exactly one of x-api-key or Authorization must be provided"); + expect(mockCreateSegments).not.toHaveBeenCalled(); + }); + + it("returns 403 when caller lacks access to artist", async () => { + mockCheckAccountArtistAccess.mockResolvedValue(false); + + const request = createRequest({ prompt: "Segment my fans" }); + const response = await postArtistSegmentsHandler(request, Promise.resolve({ id: ARTIST_ID })); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body.status).toBe("error"); + expect(body.error).toBe("Unauthorized segment creation attempt"); + expect(mockCreateSegments).not.toHaveBeenCalled(); + }); + + it("returns 404 when artist does not exist", async () => { + mockSelectAccounts.mockResolvedValue([]); + + const request = createRequest({ prompt: "Segment my fans" }); + const response = await postArtistSegmentsHandler(request, Promise.resolve({ id: ARTIST_ID })); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body.error).toBe("Artist not found"); + expect(mockCheckAccountArtistAccess).not.toHaveBeenCalled(); + expect(mockCreateSegments).not.toHaveBeenCalled(); + }); + + it("returns 409 when createSegments reports no social account with feedback", async () => { + const feedback = "No Instagram accounts found for Test Artist. Follow these steps..."; + mockCreateSegments.mockResolvedValue({ + success: false, + status: "error", + message: "No social account found for this artist", + data: [], + count: 0, + feedback, + }); + + const request = createRequest({ prompt: "Segment my fans" }); + const response = await postArtistSegmentsHandler(request, Promise.resolve({ id: ARTIST_ID })); + const body = await response.json(); + + expect(response.status).toBe(409); + expect(body).toEqual({ + status: "error", + error: "No social account found for this artist", + feedback, + }); + }); + + it("returns 409 when createSegments reports no fans with feedback", async () => { + const feedback = "No social_fans records found for Test Artist. Follow these steps..."; + mockCreateSegments.mockResolvedValue({ + success: false, + status: "error", + message: "No fans found for this artist", + data: [], + count: 0, + feedback, + }); + + const request = createRequest({ prompt: "Segment my fans" }); + const response = await postArtistSegmentsHandler(request, Promise.resolve({ id: ARTIST_ID })); + const body = await response.json(); + + expect(response.status).toBe(409); + expect(body).toEqual({ + status: "error", + error: "No fans found for this artist", + feedback, + }); + }); + + it("returns 500 when createSegments reports a generic failure", async () => { + mockCreateSegments.mockResolvedValue({ + success: false, + status: "error", + message: "Failed to generate segment names", + data: [], + count: 0, + }); + + const request = createRequest({ prompt: "Segment my fans" }); + const response = await postArtistSegmentsHandler(request, Promise.resolve({ id: ARTIST_ID })); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ + status: "error", + error: "Failed to generate segment names", + }); + }); + + it("returns 500 when createSegments throws", async () => { + mockCreateSegments.mockRejectedValue(new Error("Database exploded")); + + const request = createRequest({ prompt: "Segment my fans" }); + const response = await postArtistSegmentsHandler(request, Promise.resolve({ id: ARTIST_ID })); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.error).toBe("Database exploded"); + }); +}); diff --git a/lib/artists/segments/postArtistSegmentsHandler.ts b/lib/artists/segments/postArtistSegmentsHandler.ts new file mode 100644 index 000000000..2113b7e0a --- /dev/null +++ b/lib/artists/segments/postArtistSegmentsHandler.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createSegments } from "@/lib/segments/createSegments"; +import { validatePostArtistSegmentsRequest } from "@/lib/artists/segments/validatePostArtistSegmentsRequest"; + +const NO_RESOURCE_ERROR_MESSAGES = new Set([ + "No social account found for this artist", + "No fans found for this artist", +]); + +/** + * Handler for POST /api/artists/{id}/segments. + * + * Validates the request then delegates to the shared `createSegments` handler + * that also powers the MCP `create_segments` tool. + * + * @param request - The incoming request object. + * @param params - The route params containing the artist account ID. + * @returns A NextResponse with the segment generation result envelope. + */ +export async function postArtistSegmentsHandler( + request: NextRequest, + params: Promise<{ id: string }>, +): Promise { + try { + const { id } = await params; + + const validated = await validatePostArtistSegmentsRequest(request, id); + if (validated instanceof NextResponse) { + return validated; + } + + const result = await createSegments({ + artist_account_id: validated.artistId, + prompt: validated.body.prompt, + }); + + if (result.success) { + return NextResponse.json( + { + status: "success", + segments_created: result.count, + message: "Segments generated successfully.", + }, + { + status: 201, + headers: getCorsHeaders(), + }, + ); + } + + const message = result.message ?? "Failed to create segments"; + const feedback = "feedback" in result ? result.feedback : undefined; + const isNoResourceError = NO_RESOURCE_ERROR_MESSAGES.has(message); + const statusCode = isNoResourceError ? 409 : 500; + + return NextResponse.json( + { + status: "error", + error: message, + ...(feedback ? { feedback } : {}), + }, + { + status: statusCode, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + console.error("[ERROR] postArtistSegmentsHandler:", error); + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Internal server error", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} diff --git a/lib/artists/segments/validatePostArtistSegmentsRequest.ts b/lib/artists/segments/validatePostArtistSegmentsRequest.ts new file mode 100644 index 000000000..134b55452 --- /dev/null +++ b/lib/artists/segments/validatePostArtistSegmentsRequest.ts @@ -0,0 +1,84 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { validateAccountParams } from "@/lib/accounts/validateAccountParams"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; +import { + validatePostSegmentsBody, + type ValidatedPostSegmentsBody, +} from "@/lib/artists/segments/validatePostSegmentsBody"; + +export interface ValidatedPostArtistSegmentsRequest { + artistId: string; + requesterAccountId: string; + body: ValidatedPostSegmentsBody; +} + +/** + * Validates POST /api/artists/{id}/segments: path id, authentication, body, + * artist existence, and per-artist access. + * + * @param request - The incoming request + * @param id - The artist account ID from the route + * @returns The validated request context, or a NextResponse error + */ +export async function validatePostArtistSegmentsRequest( + request: NextRequest, + id: string, +): Promise { + const validatedParams = validateAccountParams(id); + if (validatedParams instanceof NextResponse) { + return validatedParams; + } + + const rawBody = await safeParseJson(request); + const validatedBody = validatePostSegmentsBody(rawBody); + if (validatedBody instanceof NextResponse) { + return validatedBody; + } + + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const artistId = validatedParams.id; + const requesterAccountId = authResult.accountId; + + const existingArtist = await selectAccounts(artistId); + if (!existingArtist.length) { + return NextResponse.json( + { + status: "error", + error: "Artist not found", + }, + { + status: 404, + headers: getCorsHeaders(), + }, + ); + } + + const hasAccess = await checkAccountArtistAccess(requesterAccountId, artistId); + if (!hasAccess) { + return NextResponse.json( + { + status: "error", + error: "Unauthorized segment creation attempt", + }, + { + status: 403, + headers: getCorsHeaders(), + }, + ); + } + + return { + artistId, + requesterAccountId, + body: validatedBody, + }; +} diff --git a/lib/artists/segments/validatePostSegmentsBody.ts b/lib/artists/segments/validatePostSegmentsBody.ts new file mode 100644 index 000000000..14bdf5601 --- /dev/null +++ b/lib/artists/segments/validatePostSegmentsBody.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +export const postSegmentsBodySchema = z.object({ + prompt: z.string({ message: "prompt is required" }).min(1, "prompt cannot be empty"), +}); + +export type ValidatedPostSegmentsBody = z.infer; + +/** + * Validates the request body for POST /api/artists/{id}/segments. + * + * @param body - The parsed request body to validate. + * @returns A NextResponse with an error when validation fails, or the validated body when it passes. + */ +export function validatePostSegmentsBody(body: unknown): NextResponse | ValidatedPostSegmentsBody { + const result = postSegmentsBodySchema.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; +}