From 42ade7b0953c18add12f5f31c61d7cc420d0e6b8 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:56:21 -0500 Subject: [PATCH 01/23] feat(content): add create-content API flow and run video hydration --- app/api/content/create/route.ts | 32 +++ app/api/content/estimate/route.ts | 32 +++ app/api/content/templates/route.ts | 32 +++ app/api/content/validate/route.ts | 32 +++ lib/const.ts | 9 +- .../__tests__/createContentHandler.test.ts | 120 +++++++++++ .../getArtistContentReadiness.test.ts | 71 +++++++ .../getContentEstimateHandler.test.ts | 71 +++++++ .../getContentTemplatesHandler.test.ts | 46 +++++ .../getContentValidateHandler.test.ts | 85 ++++++++ .../persistCreateContentRunVideo.test.ts | 186 ++++++++++++++++++ .../validateCreateContentBody.test.ts | 117 +++++++++++ lib/content/contentTemplates.ts | 33 ++++ lib/content/createContentHandler.ts | 70 +++++++ lib/content/getArtistContentReadiness.ts | 131 ++++++++++++ lib/content/getContentEstimateHandler.ts | 40 ++++ lib/content/getContentTemplatesHandler.ts | 25 +++ lib/content/getContentValidateHandler.ts | 43 ++++ lib/content/persistCreateContentRunVideo.ts | 133 +++++++++++++ lib/content/validateCreateContentBody.ts | 80 ++++++++ .../validateGetContentEstimateQuery.ts | 42 ++++ .../validateGetContentValidateQuery.ts | 49 +++++ lib/supabase/files/selectFileByStorageKey.ts | 30 +++ .../storage/createSignedFileUrlByKey.ts | 27 +++ lib/tasks/__tests__/getTaskRunHandler.test.ts | 35 +++- lib/tasks/getTaskRunHandler.ts | 13 +- .../__tests__/triggerCreateContent.test.ts | 32 +++ lib/trigger/triggerCreateContent.ts | 19 ++ 28 files changed, 1621 insertions(+), 14 deletions(-) create mode 100644 app/api/content/create/route.ts create mode 100644 app/api/content/estimate/route.ts create mode 100644 app/api/content/templates/route.ts create mode 100644 app/api/content/validate/route.ts create mode 100644 lib/content/__tests__/createContentHandler.test.ts create mode 100644 lib/content/__tests__/getArtistContentReadiness.test.ts create mode 100644 lib/content/__tests__/getContentEstimateHandler.test.ts create mode 100644 lib/content/__tests__/getContentTemplatesHandler.test.ts create mode 100644 lib/content/__tests__/getContentValidateHandler.test.ts create mode 100644 lib/content/__tests__/persistCreateContentRunVideo.test.ts create mode 100644 lib/content/__tests__/validateCreateContentBody.test.ts create mode 100644 lib/content/contentTemplates.ts create mode 100644 lib/content/createContentHandler.ts create mode 100644 lib/content/getArtistContentReadiness.ts create mode 100644 lib/content/getContentEstimateHandler.ts create mode 100644 lib/content/getContentTemplatesHandler.ts create mode 100644 lib/content/getContentValidateHandler.ts create mode 100644 lib/content/persistCreateContentRunVideo.ts create mode 100644 lib/content/validateCreateContentBody.ts create mode 100644 lib/content/validateGetContentEstimateQuery.ts create mode 100644 lib/content/validateGetContentValidateQuery.ts create mode 100644 lib/supabase/files/selectFileByStorageKey.ts create mode 100644 lib/supabase/storage/createSignedFileUrlByKey.ts create mode 100644 lib/trigger/__tests__/triggerCreateContent.test.ts create mode 100644 lib/trigger/triggerCreateContent.ts diff --git a/app/api/content/create/route.ts b/app/api/content/create/route.ts new file mode 100644 index 00000000..9d1a5fd9 --- /dev/null +++ b/app/api/content/create/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createContentHandler } from "@/lib/content/createContentHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns Empty 204 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/content/create + * + * Triggers the background content-creation pipeline and returns a run ID. + * + * @param request - Incoming API request. + * @returns Trigger response for the created task run. + */ +export async function POST(request: NextRequest): Promise { + return createContentHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; + diff --git a/app/api/content/estimate/route.ts b/app/api/content/estimate/route.ts new file mode 100644 index 00000000..e6ad622f --- /dev/null +++ b/app/api/content/estimate/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getContentEstimateHandler } from "@/lib/content/getContentEstimateHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns Empty 204 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/content/estimate + * + * Returns estimated content-creation costs. + * + * @param request - Incoming API request. + * @returns Cost estimate response. + */ +export async function GET(request: NextRequest): Promise { + return getContentEstimateHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; + diff --git a/app/api/content/templates/route.ts b/app/api/content/templates/route.ts new file mode 100644 index 00000000..11572d48 --- /dev/null +++ b/app/api/content/templates/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getContentTemplatesHandler } from "@/lib/content/getContentTemplatesHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns Empty 204 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/content/templates + * + * Lists available templates for the content-creation pipeline. + * + * @param request - Incoming API request. + * @returns Template list response. + */ +export async function GET(request: NextRequest): Promise { + return getContentTemplatesHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; + diff --git a/app/api/content/validate/route.ts b/app/api/content/validate/route.ts new file mode 100644 index 00000000..9a205cdc --- /dev/null +++ b/app/api/content/validate/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getContentValidateHandler } from "@/lib/content/getContentValidateHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns Empty 204 response with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/content/validate + * + * Validates whether an artist is ready for content creation. + * + * @param request - Incoming API request. + * @returns Artist readiness response. + */ +export async function GET(request: NextRequest): Promise { + return getContentValidateHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; + diff --git a/lib/const.ts b/lib/const.ts index 545dbbeb..d5513561 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -22,6 +22,7 @@ export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com"; export const RECOUP_FROM_EMAIL = `Agent by Recoup `; export const SUPABASE_STORAGE_BUCKET = "user-files"; +export const CREATE_CONTENT_TASK_ID = "create-content"; /** * UUID of the Recoup admin organization. @@ -38,10 +39,4 @@ export const FLAMINGO_GENERATE_URL = // EVALS export const EVAL_ACCOUNT_ID = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; export const EVAL_ACCESS_TOKEN = process.env.EVAL_ACCESS_TOKEN || ""; -export const EVAL_ARTISTS = [ - "Gliiico", - "Mac Miller", - "Wiz Khalifa", - "Mod Sun", - "Julius Black", -]; +export const EVAL_ARTISTS = ["Gliiico", "Mac Miller", "Wiz Khalifa", "Mod Sun", "Julius Black"]; diff --git a/lib/content/__tests__/createContentHandler.test.ts b/lib/content/__tests__/createContentHandler.test.ts new file mode 100644 index 00000000..333baf18 --- /dev/null +++ b/lib/content/__tests__/createContentHandler.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { createContentHandler } from "@/lib/content/createContentHandler"; +import { validateCreateContentBody } from "@/lib/content/validateCreateContentBody"; +import { triggerCreateContent } from "@/lib/trigger/triggerCreateContent"; +import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/content/validateCreateContentBody", () => ({ + validateCreateContentBody: vi.fn(), +})); + +vi.mock("@/lib/trigger/triggerCreateContent", () => ({ + triggerCreateContent: vi.fn(), +})); + +vi.mock("@/lib/content/getArtistContentReadiness", () => ({ + getArtistContentReadiness: vi.fn(), +})); + +describe("createContentHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + artist_slug: "gatsby-grace", + ready: true, + missing: [], + warnings: [], + }); + }); + + it("returns validation/auth error when validation fails", async () => { + const errorResponse = NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401 }, + ); + vi.mocked(validateCreateContentBody).mockResolvedValue(errorResponse); + const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); + + const result = await createContentHandler(request); + + expect(result).toBe(errorResponse); + }); + + it("returns 202 with runId when trigger succeeds", async () => { + vi.mocked(validateCreateContentBody).mockResolvedValue({ + accountId: "acc_123", + artistSlug: "gatsby-grace", + template: "artist-caption-bedroom", + lipsync: false, + }); + vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run_abc123" } as never); + const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); + + const result = await createContentHandler(request); + const body = await result.json(); + + expect(result.status).toBe(202); + expect(body).toEqual({ + runId: "run_abc123", + status: "triggered", + artist: "gatsby-grace", + template: "artist-caption-bedroom", + lipsync: false, + }); + }); + + it("returns 500 when trigger fails", async () => { + vi.mocked(validateCreateContentBody).mockResolvedValue({ + accountId: "acc_123", + artistSlug: "gatsby-grace", + template: "artist-caption-bedroom", + lipsync: false, + }); + vi.mocked(triggerCreateContent).mockRejectedValue(new Error("Trigger unavailable")); + const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); + + const result = await createContentHandler(request); + const body = await result.json(); + + expect(result.status).toBe(500); + expect(body).toEqual({ + status: "error", + error: "Trigger unavailable", + }); + }); + + it("returns 400 when artist is not ready", async () => { + vi.mocked(validateCreateContentBody).mockResolvedValue({ + accountId: "acc_123", + artistSlug: "gatsby-grace", + template: "artist-caption-bedroom", + lipsync: false, + }); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + artist_slug: "gatsby-grace", + ready: false, + missing: [ + { + file: "context/images/face-guide.png", + severity: "required", + fix: "Generate a face guide image before creating content.", + }, + ], + warnings: [], + }); + const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); + + const result = await createContentHandler(request); + const body = await result.json(); + + expect(result.status).toBe(400); + expect(triggerCreateContent).not.toHaveBeenCalled(); + expect(body.ready).toBe(false); + expect(Array.isArray(body.missing)).toBe(true); + }); +}); diff --git a/lib/content/__tests__/getArtistContentReadiness.test.ts b/lib/content/__tests__/getArtistContentReadiness.test.ts new file mode 100644 index 00000000..3792d4ff --- /dev/null +++ b/lib/content/__tests__/getArtistContentReadiness.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { getRepoFileTree } from "@/lib/github/getRepoFileTree"; + +vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ + selectAccountSnapshots: vi.fn(), +})); + +vi.mock("@/lib/github/getRepoFileTree", () => ({ + getRepoFileTree: vi.fn(), +})); + +describe("getArtistContentReadiness", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { + github_repo: "https://github.com/test-org/test-repo", + }, + ] as never); + }); + + it("returns ready=true when required files exist", async () => { + vi.mocked(getRepoFileTree).mockResolvedValue([ + { path: "artists/gatsby-grace/context/images/face-guide.png", type: "blob", sha: "1" }, + { path: "artists/gatsby-grace/config/content-creation/config.json", type: "blob", sha: "2" }, + { path: "artists/gatsby-grace/songs/song-a.mp3", type: "blob", sha: "3" }, + { path: "artists/gatsby-grace/context/artist.md", type: "blob", sha: "4" }, + { path: "artists/gatsby-grace/context/audience.md", type: "blob", sha: "5" }, + { path: "artists/gatsby-grace/context/era.json", type: "blob", sha: "6" }, + ]); + + const result = await getArtistContentReadiness({ + accountId: "acc_123", + artistSlug: "gatsby-grace", + }); + + expect(result.ready).toBe(true); + expect(result.missing).toEqual([]); + }); + + it("returns ready=false with required issues when core files are missing", async () => { + vi.mocked(getRepoFileTree).mockResolvedValue([ + { path: "artists/gatsby-grace/context/artist.md", type: "blob", sha: "1" }, + ]); + + const result = await getArtistContentReadiness({ + accountId: "acc_123", + artistSlug: "gatsby-grace", + }); + + expect(result.ready).toBe(false); + expect(result.missing.some(item => item.file === "context/images/face-guide.png")).toBe(true); + expect(result.missing.some(item => item.file === "config/content-creation/config.json")).toBe( + true, + ); + expect(result.missing.some(item => item.file === "songs/*.mp3")).toBe(true); + }); + + it("throws when account has no github repo", async () => { + vi.mocked(selectAccountSnapshots).mockResolvedValue([] as never); + + await expect( + getArtistContentReadiness({ + accountId: "acc_123", + artistSlug: "gatsby-grace", + }), + ).rejects.toThrow("No GitHub repository found for this account"); + }); +}); diff --git a/lib/content/__tests__/getContentEstimateHandler.test.ts b/lib/content/__tests__/getContentEstimateHandler.test.ts new file mode 100644 index 00000000..926a299f --- /dev/null +++ b/lib/content/__tests__/getContentEstimateHandler.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getContentEstimateHandler } from "@/lib/content/getContentEstimateHandler"; +import { validateGetContentEstimateQuery } from "@/lib/content/validateGetContentEstimateQuery"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/content/validateGetContentEstimateQuery", () => ({ + validateGetContentEstimateQuery: vi.fn(), +})); + +describe("getContentEstimateHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns validation error when query validation fails", async () => { + const errorResponse = NextResponse.json( + { status: "error", error: "invalid query" }, + { status: 400 }, + ); + vi.mocked(validateGetContentEstimateQuery).mockResolvedValue(errorResponse); + const request = new NextRequest("http://localhost/api/content/estimate", { method: "GET" }); + + const result = await getContentEstimateHandler(request); + expect(result).toBe(errorResponse); + }); + + it("returns estimate payload", async () => { + vi.mocked(validateGetContentEstimateQuery).mockResolvedValue({ + lipsync: false, + batch: 2, + compare: false, + }); + const request = new NextRequest("http://localhost/api/content/estimate?batch=2", { + method: "GET", + }); + + const result = await getContentEstimateHandler(request); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.status).toBe("success"); + expect(body.per_video_estimate_usd).toBe(0.82); + expect(body.total_estimate_usd).toBe(1.64); + expect(body.profiles).toBeUndefined(); + }); + + it("returns compare profiles when compare=true", async () => { + vi.mocked(validateGetContentEstimateQuery).mockResolvedValue({ + lipsync: true, + batch: 1, + compare: true, + }); + const request = new NextRequest( + "http://localhost/api/content/estimate?compare=true&lipsync=true", + { + method: "GET", + }, + ); + + const result = await getContentEstimateHandler(request); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.per_video_estimate_usd).toBe(0.95); + expect(body.profiles.current).toBe(0.95); + }); +}); diff --git a/lib/content/__tests__/getContentTemplatesHandler.test.ts b/lib/content/__tests__/getContentTemplatesHandler.test.ts new file mode 100644 index 00000000..b7d45a16 --- /dev/null +++ b/lib/content/__tests__/getContentTemplatesHandler.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getContentTemplatesHandler } from "@/lib/content/getContentTemplatesHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +describe("getContentTemplatesHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns auth error when auth validation fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + const request = new NextRequest("http://localhost/api/content/templates", { method: "GET" }); + + const result = await getContentTemplatesHandler(request); + + expect(result.status).toBe(401); + }); + + it("returns templates when authenticated", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "test-key", + }); + const request = new NextRequest("http://localhost/api/content/templates", { method: "GET" }); + + const result = await getContentTemplatesHandler(request); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.status).toBe("success"); + expect(Array.isArray(body.templates)).toBe(true); + expect(body.templates.length).toBeGreaterThan(0); + }); +}); diff --git a/lib/content/__tests__/getContentValidateHandler.test.ts b/lib/content/__tests__/getContentValidateHandler.test.ts new file mode 100644 index 00000000..30ad289c --- /dev/null +++ b/lib/content/__tests__/getContentValidateHandler.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getContentValidateHandler } from "@/lib/content/getContentValidateHandler"; +import { validateGetContentValidateQuery } from "@/lib/content/validateGetContentValidateQuery"; +import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/content/validateGetContentValidateQuery", () => ({ + validateGetContentValidateQuery: vi.fn(), +})); + +vi.mock("@/lib/content/getArtistContentReadiness", () => ({ + getArtistContentReadiness: vi.fn(), +})); + +describe("getContentValidateHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + artist_slug: "gatsby-grace", + ready: true, + missing: [], + warnings: [], + }); + }); + + it("returns validation error when query validation fails", async () => { + const errorResponse = NextResponse.json( + { status: "error", error: "artist_slug is required" }, + { status: 400 }, + ); + vi.mocked(validateGetContentValidateQuery).mockResolvedValue(errorResponse); + const request = new NextRequest("http://localhost/api/content/validate", { method: "GET" }); + + const result = await getContentValidateHandler(request); + expect(result).toBe(errorResponse); + }); + + it("returns readiness payload when validation succeeds", async () => { + vi.mocked(validateGetContentValidateQuery).mockResolvedValue({ + accountId: "acc_123", + artistSlug: "gatsby-grace", + }); + const request = new NextRequest( + "http://localhost/api/content/validate?artist_slug=gatsby-grace", + { + method: "GET", + }, + ); + + const result = await getContentValidateHandler(request); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.status).toBe("success"); + expect(body.ready).toBe(true); + expect(body.artist_slug).toBe("gatsby-grace"); + expect(Array.isArray(body.missing)).toBe(true); + }); + + it("returns 500 when readiness check throws", async () => { + vi.mocked(validateGetContentValidateQuery).mockResolvedValue({ + accountId: "acc_123", + artistSlug: "gatsby-grace", + }); + vi.mocked(getArtistContentReadiness).mockRejectedValue( + new Error("Failed to retrieve repository file tree"), + ); + const request = new NextRequest( + "http://localhost/api/content/validate?artist_slug=gatsby-grace", + { + method: "GET", + }, + ); + + const result = await getContentValidateHandler(request); + const body = await result.json(); + + expect(result.status).toBe(500); + expect(body.error).toBe("Failed to retrieve repository file tree"); + }); +}); diff --git a/lib/content/__tests__/persistCreateContentRunVideo.test.ts b/lib/content/__tests__/persistCreateContentRunVideo.test.ts new file mode 100644 index 00000000..dbe4abec --- /dev/null +++ b/lib/content/__tests__/persistCreateContentRunVideo.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { persistCreateContentRunVideo } from "@/lib/content/persistCreateContentRunVideo"; +import { CREATE_CONTENT_TASK_ID } from "@/lib/const"; +import { selectFileByStorageKey } from "@/lib/supabase/files/selectFileByStorageKey"; +import { uploadFileByKey } from "@/lib/supabase/storage/uploadFileByKey"; +import { createFileRecord } from "@/lib/supabase/files/createFileRecord"; +import { createSignedFileUrlByKey } from "@/lib/supabase/storage/createSignedFileUrlByKey"; + +vi.mock("@/lib/supabase/files/selectFileByStorageKey", () => ({ + selectFileByStorageKey: vi.fn(), +})); + +vi.mock("@/lib/supabase/storage/uploadFileByKey", () => ({ + uploadFileByKey: vi.fn(), +})); + +vi.mock("@/lib/supabase/files/createFileRecord", () => ({ + createFileRecord: vi.fn(), +})); + +vi.mock("@/lib/supabase/storage/createSignedFileUrlByKey", () => ({ + createSignedFileUrlByKey: vi.fn(), +})); + +describe("persistCreateContentRunVideo", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createSignedFileUrlByKey).mockResolvedValue("https://example.com/signed.mp4"); + }); + + it("returns run unchanged when task is not create-content", async () => { + const run = { id: "run_1", taskIdentifier: "other-task", status: "COMPLETED", output: {} }; + const result = await persistCreateContentRunVideo(run); + expect(result).toEqual(run); + }); + + it("hydrates from existing file without uploading", async () => { + vi.mocked(selectFileByStorageKey).mockResolvedValue({ + id: "file_1", + owner_account_id: "acc_1", + artist_account_id: "acc_1", + storage_key: "content/acc_1/artist/run_1.mp4", + file_name: "artist-run_1.mp4", + mime_type: "video/mp4", + size_bytes: 100, + description: null, + tags: [], + }); + + const run = { + id: "run_1", + taskIdentifier: CREATE_CONTENT_TASK_ID, + status: "COMPLETED", + output: { + accountId: "acc_1", + artistSlug: "artist", + template: "artist-caption-bedroom", + lipsync: false, + videoSourceUrl: "https://example.com/video.mp4", + }, + }; + const result = await persistCreateContentRunVideo(run); + + expect(uploadFileByKey).not.toHaveBeenCalled(); + expect(createFileRecord).not.toHaveBeenCalled(); + expect((result.output as { video?: { fileId: string; signedUrl: string } }).video?.fileId).toBe( + "file_1", + ); + expect( + (result.output as { video?: { fileId: string; signedUrl: string } }).video?.signedUrl, + ).toBe("https://example.com/signed.mp4"); + }); + + it("downloads and persists video when file does not exist", async () => { + vi.mocked(selectFileByStorageKey).mockResolvedValue(null); + vi.mocked(createFileRecord).mockResolvedValue({ + id: "file_2", + owner_account_id: "acc_1", + artist_account_id: "acc_1", + storage_key: "content/acc_1/artist/artist-run_2.mp4", + file_name: "artist-run_2.mp4", + mime_type: "video/mp4", + size_bytes: 100, + description: null, + tags: [], + }); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "content-type": "video/mp4" }), + blob: async () => new Blob(["video-bytes"], { type: "video/mp4" }), + }), + ); + + const run = { + id: "run_2", + taskIdentifier: CREATE_CONTENT_TASK_ID, + status: "COMPLETED", + output: { + accountId: "acc_1", + artistSlug: "artist", + template: "artist-caption-bedroom", + lipsync: false, + videoSourceUrl: "https://example.com/video.mp4", + }, + }; + const result = await persistCreateContentRunVideo(run); + + expect(uploadFileByKey).toHaveBeenCalledOnce(); + expect(createFileRecord).toHaveBeenCalledOnce(); + expect((result.output as { video?: { fileId: string; signedUrl: string } }).video?.fileId).toBe( + "file_2", + ); + expect( + (result.output as { video?: { fileId: string; signedUrl: string } }).video?.signedUrl, + ).toBe("https://example.com/signed.mp4"); + }); + + it("throws when upload fails", async () => { + vi.mocked(selectFileByStorageKey).mockResolvedValue(null); + vi.mocked(uploadFileByKey).mockRejectedValue(new Error("upload failed")); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "content-type": "video/mp4" }), + blob: async () => new Blob(["video-bytes"], { type: "video/mp4" }), + }), + ); + + const run = { + id: "run_3", + taskIdentifier: CREATE_CONTENT_TASK_ID, + status: "COMPLETED", + output: { + accountId: "acc_1", + artistSlug: "artist", + template: "artist-caption-bedroom", + lipsync: false, + videoSourceUrl: "https://example.com/video.mp4", + }, + }; + + await expect(persistCreateContentRunVideo(run)).rejects.toThrow("upload failed"); + expect(createFileRecord).not.toHaveBeenCalled(); + }); + + it("throws when file record creation fails", async () => { + vi.mocked(selectFileByStorageKey).mockResolvedValue(null); + vi.mocked(uploadFileByKey).mockResolvedValue(undefined); + vi.mocked(createFileRecord).mockRejectedValue(new Error("create file record failed")); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ "content-type": "video/mp4" }), + blob: async () => new Blob(["video-bytes"], { type: "video/mp4" }), + }), + ); + + const run = { + id: "run_4", + taskIdentifier: CREATE_CONTENT_TASK_ID, + status: "COMPLETED", + output: { + accountId: "acc_1", + artistSlug: "artist", + template: "artist-caption-bedroom", + lipsync: false, + videoSourceUrl: "https://example.com/video.mp4", + }, + }; + + await expect(persistCreateContentRunVideo(run)).rejects.toThrow("create file record failed"); + }); +}); diff --git a/lib/content/__tests__/validateCreateContentBody.test.ts b/lib/content/__tests__/validateCreateContentBody.test.ts new file mode 100644 index 00000000..2112622f --- /dev/null +++ b/lib/content/__tests__/validateCreateContentBody.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateCreateContentBody } from "@/lib/content/validateCreateContentBody"; + +const mockValidateAuthContext = vi.fn(); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), +})); + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/networking/safeParseJson", () => ({ + safeParseJson: vi.fn(async (req: Request) => req.json()), +})); + +/** + * + * @param body + */ +function createRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/content/create", { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "test-key" }, + body: JSON.stringify(body), + }); +} + +describe("validateCreateContentBody", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidateAuthContext.mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "test-key", + }); + }); + + it("returns validated payload for a valid request", async () => { + const request = createRequest({ + artist_slug: "gatsby-grace", + template: "artist-caption-bedroom", + lipsync: true, + }); + + const result = await validateCreateContentBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result).toEqual({ + accountId: "acc_123", + artistSlug: "gatsby-grace", + template: "artist-caption-bedroom", + lipsync: true, + }); + } + }); + + it("applies defaults when optional fields are omitted", async () => { + const request = createRequest({ + artist_slug: "gatsby-grace", + }); + + const result = await validateCreateContentBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.template).toBe("artist-caption-bedroom"); + expect(result.lipsync).toBe(false); + } + }); + + it("returns 400 when artist_slug is missing", async () => { + const request = createRequest({ + template: "artist-caption-bedroom", + }); + + const result = await validateCreateContentBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + } + }); + + it("returns 400 when template is unsupported", async () => { + const request = createRequest({ + artist_slug: "gatsby-grace", + template: "not-a-real-template", + }); + + const result = await validateCreateContentBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = await result.json(); + expect(body.error).toContain("Unsupported template"); + } + }); + + it("returns auth error response when auth fails", async () => { + mockValidateAuthContext.mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + const request = createRequest({ artist_slug: "gatsby-grace" }); + + const result = await validateCreateContentBody(request); + + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(401); + } + }); +}); diff --git a/lib/content/contentTemplates.ts b/lib/content/contentTemplates.ts new file mode 100644 index 00000000..80fc2bc6 --- /dev/null +++ b/lib/content/contentTemplates.ts @@ -0,0 +1,33 @@ +export interface ContentTemplate { + name: string; + description: string; + defaultLipsync: boolean; +} + +export const DEFAULT_CONTENT_TEMPLATE = "artist-caption-bedroom"; + +export const CONTENT_TEMPLATES: ContentTemplate[] = [ + { + name: "artist-caption-bedroom", + description: "Moody purple bedroom setting", + defaultLipsync: false, + }, + { + name: "artist-caption-outside", + description: "Night street scene", + defaultLipsync: false, + }, + { + name: "artist-caption-stage", + description: "Small venue concert", + defaultLipsync: false, + }, +]; + +/** + * + * @param template + */ +export function isSupportedContentTemplate(template: string): boolean { + return CONTENT_TEMPLATES.some(item => item.name === template); +} diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts new file mode 100644 index 00000000..084019ba --- /dev/null +++ b/lib/content/createContentHandler.ts @@ -0,0 +1,70 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateCreateContentBody } from "@/lib/content/validateCreateContentBody"; +import { triggerCreateContent } from "@/lib/trigger/triggerCreateContent"; +import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; + +/** + * Handler for POST /api/content/create. + * Triggers a background content-creation run and returns a runId for polling. + * + * @param request + */ +export async function createContentHandler(request: NextRequest): Promise { + const validated = await validateCreateContentBody(request); + if (validated instanceof NextResponse) { + return validated; + } + + try { + const readiness = await getArtistContentReadiness({ + accountId: validated.accountId, + artistSlug: validated.artistSlug, + }); + + if (!readiness.ready) { + return NextResponse.json( + { + error: `Artist '${validated.artistSlug}' is not ready for content creation`, + ready: false, + missing: readiness.missing, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const handle = await triggerCreateContent({ + accountId: validated.accountId, + artistSlug: validated.artistSlug, + template: validated.template, + lipsync: validated.lipsync, + }); + + return NextResponse.json( + { + runId: handle.id, + status: "triggered", + artist: validated.artistSlug, + template: validated.template, + lipsync: validated.lipsync, + }, + { + status: 202, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to trigger content creation"; + return NextResponse.json( + { + status: "error", + error: message, + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/content/getArtistContentReadiness.ts b/lib/content/getArtistContentReadiness.ts new file mode 100644 index 00000000..3b1312e1 --- /dev/null +++ b/lib/content/getArtistContentReadiness.ts @@ -0,0 +1,131 @@ +import { getRepoFileTree } from "@/lib/github/getRepoFileTree"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; + +type MissingSeverity = "required" | "recommended"; + +export interface ContentReadinessIssue { + file: string; + severity: MissingSeverity; + fix: string; +} + +export interface ArtistContentReadiness { + artist_slug: string; + ready: boolean; + missing: ContentReadinessIssue[]; + warnings: ContentReadinessIssue[]; +} + +/** + * + * @param paths + * @param artistSlug + */ +function getArtistRootPrefix(paths: string[], artistSlug: string): string { + const preferredPrefix = `artists/${artistSlug}/`; + if (paths.some(path => path.startsWith(preferredPrefix))) { + return preferredPrefix; + } + + const directPrefix = `${artistSlug}/`; + if (paths.some(path => path.startsWith(directPrefix))) { + return directPrefix; + } + + return preferredPrefix; +} + +/** + * Checks whether an artist has the expected files for content creation. + * + * @param root0 + * @param root0.accountId + * @param root0.artistSlug + */ +export async function getArtistContentReadiness({ + accountId, + artistSlug, +}: { + accountId: string; + artistSlug: string; +}): Promise { + const snapshots = await selectAccountSnapshots(accountId); + const githubRepo = snapshots[0]?.github_repo ?? null; + if (!githubRepo) { + throw new Error("No GitHub repository found for this account"); + } + + const tree = await getRepoFileTree(githubRepo); + if (!tree) { + throw new Error("Failed to retrieve repository file tree"); + } + + const blobPaths = tree.filter(entry => entry.type === "blob").map(entry => entry.path); + const artistRootPrefix = getArtistRootPrefix(blobPaths, artistSlug); + + const hasFile = (relativePath: string): boolean => + blobPaths.some(path => path === `${artistRootPrefix}${relativePath}`); + const hasAnyMp3 = blobPaths.some( + path => path.startsWith(artistRootPrefix) && path.toLowerCase().endsWith(".mp3"), + ); + + const issues: ContentReadinessIssue[] = []; + + if (!hasFile("context/images/face-guide.png")) { + issues.push({ + file: "context/images/face-guide.png", + severity: "required", + fix: "Generate a face guide image before creating content.", + }); + } + + if (!hasFile("config/content-creation/config.json")) { + issues.push({ + file: "config/content-creation/config.json", + severity: "required", + fix: "Add the content creation config.json file.", + }); + } + + if (!hasAnyMp3) { + issues.push({ + file: "songs/*.mp3", + severity: "required", + fix: "Add at least one .mp3 file for audio selection.", + }); + } + + if (!hasFile("context/artist.md")) { + issues.push({ + file: "context/artist.md", + severity: "recommended", + fix: "Add artist context to improve caption quality.", + }); + } + + if (!hasFile("context/audience.md")) { + issues.push({ + file: "context/audience.md", + severity: "recommended", + fix: "Add audience context to improve targeting.", + }); + } + + if (!hasFile("context/era.json")) { + issues.push({ + file: "context/era.json", + severity: "recommended", + fix: "Add era metadata to improve song selection relevance.", + }); + } + + const requiredMissing = issues.filter(item => item.severity === "required"); + const warnings = issues.filter(item => item.severity === "recommended"); + + return { + artist_slug: artistSlug, + ready: requiredMissing.length === 0, + missing: issues, + warnings, + }; +} diff --git a/lib/content/getContentEstimateHandler.ts b/lib/content/getContentEstimateHandler.ts new file mode 100644 index 00000000..3d4d3a38 --- /dev/null +++ b/lib/content/getContentEstimateHandler.ts @@ -0,0 +1,40 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetContentEstimateQuery } from "@/lib/content/validateGetContentEstimateQuery"; + +const BASE_IMAGE_TO_VIDEO_COST = 0.82; +const BASE_AUDIO_TO_VIDEO_COST = 0.95; + +/** + * Handler for GET /api/content/estimate. + * + * @param request + */ +export async function getContentEstimateHandler(request: NextRequest): Promise { + const validated = await validateGetContentEstimateQuery(request); + if (validated instanceof NextResponse) { + return validated; + } + + const perVideoEstimate = validated.lipsync ? BASE_AUDIO_TO_VIDEO_COST : BASE_IMAGE_TO_VIDEO_COST; + const totalEstimate = Number((perVideoEstimate * validated.batch).toFixed(2)); + + const response: Record = { + status: "success", + lipsync: validated.lipsync, + batch: validated.batch, + per_video_estimate_usd: perVideoEstimate, + total_estimate_usd: totalEstimate, + }; + + if (validated.compare) { + response.profiles = { + budget: 0.18, + mid: 0.62, + current: validated.lipsync ? BASE_AUDIO_TO_VIDEO_COST : BASE_IMAGE_TO_VIDEO_COST, + }; + } + + return NextResponse.json(response, { status: 200, headers: getCorsHeaders() }); +} diff --git a/lib/content/getContentTemplatesHandler.ts b/lib/content/getContentTemplatesHandler.ts new file mode 100644 index 00000000..d1a65d80 --- /dev/null +++ b/lib/content/getContentTemplatesHandler.ts @@ -0,0 +1,25 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { CONTENT_TEMPLATES } from "@/lib/content/contentTemplates"; + +/** + * Handler for GET /api/content/templates. + * + * @param request + */ +export async function getContentTemplatesHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + return NextResponse.json( + { + status: "success", + templates: CONTENT_TEMPLATES, + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/content/getContentValidateHandler.ts b/lib/content/getContentValidateHandler.ts new file mode 100644 index 00000000..77c83738 --- /dev/null +++ b/lib/content/getContentValidateHandler.ts @@ -0,0 +1,43 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetContentValidateQuery } from "@/lib/content/validateGetContentValidateQuery"; +import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; + +/** + * Handler for GET /api/content/validate. + * NOTE: Phase 1 returns structural readiness scaffolding. Deep filesystem checks + * are performed in the background task before spend-heavy steps. + * + * @param request + */ +export async function getContentValidateHandler(request: NextRequest): Promise { + const validated = await validateGetContentValidateQuery(request); + if (validated instanceof NextResponse) { + return validated; + } + + try { + const readiness = await getArtistContentReadiness({ + accountId: validated.accountId, + artistSlug: validated.artistSlug, + }); + + return NextResponse.json( + { + status: "success", + ...readiness, + }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to validate content readiness"; + return NextResponse.json( + { + status: "error", + error: message, + }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/content/persistCreateContentRunVideo.ts b/lib/content/persistCreateContentRunVideo.ts new file mode 100644 index 00000000..bf778240 --- /dev/null +++ b/lib/content/persistCreateContentRunVideo.ts @@ -0,0 +1,133 @@ +import { CREATE_CONTENT_TASK_ID } from "@/lib/const"; +import { uploadFileByKey } from "@/lib/supabase/storage/uploadFileByKey"; +import { createFileRecord } from "@/lib/supabase/files/createFileRecord"; +import { selectFileByStorageKey } from "@/lib/supabase/files/selectFileByStorageKey"; +import { createSignedFileUrlByKey } from "@/lib/supabase/storage/createSignedFileUrlByKey"; + +type TriggerRunLike = { + id: string; + status?: string | null; + taskIdentifier?: string | null; + output?: unknown; +}; + +type CreateContentOutput = { + status?: string; + accountId?: string; + artistSlug?: string; + template?: string; + lipsync?: boolean; + videoSourceUrl?: string; + video?: { + fileId: string; + storageKey: string; + fileName: string; + mimeType: string | null; + sizeBytes: number | null; + signedUrl: string; + } | null; +}; + +/** + * + * @param run + */ +function isCompleted(run: TriggerRunLike): boolean { + return run.status === "COMPLETED"; +} + +/** + * Persists create-content task video output to Supabase storage + files table + * and returns the run with normalized output. + * + * This keeps Supabase writes in API only. + * + * @param run + */ +export async function persistCreateContentRunVideo(run: T): Promise { + if (run.taskIdentifier !== CREATE_CONTENT_TASK_ID || !isCompleted(run)) { + return run; + } + + const output = (run.output ?? {}) as CreateContentOutput; + if (!output.accountId || !output.artistSlug || !output.videoSourceUrl) { + return run; + } + + if (output.video?.storageKey) { + return run; + } + + const fileName = `${output.artistSlug}-${run.id}.mp4`; + const storageKey = `content/${output.accountId}/${output.artistSlug}/${fileName}`; + + const existingFile = await selectFileByStorageKey({ + ownerAccountId: output.accountId, + storageKey, + }); + + if (existingFile) { + const signedUrl = await createSignedFileUrlByKey({ + key: existingFile.storage_key, + }); + + return { + ...run, + output: { + ...output, + video: { + fileId: existingFile.id, + storageKey: existingFile.storage_key, + fileName: existingFile.file_name, + mimeType: existingFile.mime_type, + sizeBytes: existingFile.size_bytes, + signedUrl, + }, + }, + }; + } + + const response = await fetch(output.videoSourceUrl); + if (!response.ok) { + throw new Error(`Failed to download rendered video: ${response.status} ${response.statusText}`); + } + + const videoBlob = await response.blob(); + const mimeType = response.headers.get("content-type") || "video/mp4"; + + await uploadFileByKey(storageKey, videoBlob, { + contentType: mimeType, + upsert: true, + }); + + const createdFile = await createFileRecord({ + ownerAccountId: output.accountId, + // Phase 1: artist account mapping is not wired yet, so we scope to owner account. + artistAccountId: output.accountId, + storageKey, + fileName, + mimeType, + sizeBytes: videoBlob.size, + description: `Content pipeline output for ${output.artistSlug}`, + tags: ["content", "video", output.template ?? "unknown-template"], + }); + + const signedUrl = await createSignedFileUrlByKey({ + key: createdFile.storage_key, + }); + + return { + ...run, + output: { + ...output, + video: { + fileId: createdFile.id, + storageKey: createdFile.storage_key, + fileName: createdFile.file_name, + mimeType: createdFile.mime_type, + sizeBytes: createdFile.size_bytes, + signedUrl, + }, + }, + }; +} diff --git a/lib/content/validateCreateContentBody.ts b/lib/content/validateCreateContentBody.ts new file mode 100644 index 00000000..9c0862e5 --- /dev/null +++ b/lib/content/validateCreateContentBody.ts @@ -0,0 +1,80 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { + DEFAULT_CONTENT_TEMPLATE, + isSupportedContentTemplate, +} from "@/lib/content/contentTemplates"; + +export const createContentBodySchema = z.object({ + artist_slug: z + .string({ message: "artist_slug is required" }) + .min(1, "artist_slug cannot be empty"), + template: z + .string() + .min(1, "template cannot be empty") + .default(DEFAULT_CONTENT_TEMPLATE) + .optional(), + lipsync: z.boolean().default(false).optional(), + account_id: z.string().uuid("account_id must be a valid UUID").optional(), +}); + +export type ValidatedCreateContentBody = { + accountId: string; + artistSlug: string; + template: string; + lipsync: boolean; +}; + +/** + * Validates auth and request body for POST /api/content/create. + * + * @param request + */ +export async function validateCreateContentBody( + request: NextRequest, +): Promise { + const body = await safeParseJson(request); + const result = createContentBodySchema.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() }, + ); + } + + const authResult = await validateAuthContext(request, { + accountId: result.data.account_id, + }); + + if (authResult instanceof NextResponse) { + return authResult; + } + + const template = result.data.template ?? DEFAULT_CONTENT_TEMPLATE; + if (!isSupportedContentTemplate(template)) { + return NextResponse.json( + { + status: "error", + error: `Unsupported template: ${template}`, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return { + accountId: authResult.accountId, + artistSlug: result.data.artist_slug, + template, + lipsync: result.data.lipsync ?? false, + }; +} diff --git a/lib/content/validateGetContentEstimateQuery.ts b/lib/content/validateGetContentEstimateQuery.ts new file mode 100644 index 00000000..26590b07 --- /dev/null +++ b/lib/content/validateGetContentEstimateQuery.ts @@ -0,0 +1,42 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +const getContentEstimateQuerySchema = z.object({ + lipsync: z.coerce.boolean().default(false), + batch: z.coerce.number().int().min(1).max(100).default(1), + compare: z.coerce.boolean().default(false), +}); + +export type ValidatedGetContentEstimateQuery = z.infer; + +/** + * Validates auth and query params for GET /api/content/estimate. + * + * @param request + */ +export async function validateGetContentEstimateQuery( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const lipsync = request.nextUrl.searchParams.get("lipsync") ?? undefined; + const batch = request.nextUrl.searchParams.get("batch") ?? undefined; + const compare = request.nextUrl.searchParams.get("compare") ?? undefined; + const result = getContentEstimateQuerySchema.safeParse({ lipsync, batch, compare }); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { status: "error", error: firstError.message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return result.data; +} diff --git a/lib/content/validateGetContentValidateQuery.ts b/lib/content/validateGetContentValidateQuery.ts new file mode 100644 index 00000000..e268d0c4 --- /dev/null +++ b/lib/content/validateGetContentValidateQuery.ts @@ -0,0 +1,49 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +const getContentValidateQuerySchema = z.object({ + artist_slug: z + .string({ message: "artist_slug is required" }) + .min(1, "artist_slug cannot be empty"), +}); + +export type ValidatedGetContentValidateQuery = { + accountId: string; + artistSlug: string; +}; + +/** + * Validates auth and query params for GET /api/content/validate. + * + * @param request + */ +export async function validateGetContentValidateQuery( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const artistSlug = request.nextUrl.searchParams.get("artist_slug") ?? ""; + const result = getContentValidateQuerySchema.safeParse({ artist_slug: artistSlug }); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return { + accountId: authResult.accountId, + artistSlug: result.data.artist_slug, + }; +} diff --git a/lib/supabase/files/selectFileByStorageKey.ts b/lib/supabase/files/selectFileByStorageKey.ts new file mode 100644 index 00000000..78598c3c --- /dev/null +++ b/lib/supabase/files/selectFileByStorageKey.ts @@ -0,0 +1,30 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { FileRecord } from "@/lib/supabase/files/createFileRecord"; + +/** + * Select a file record by storage key for an owner account. + * + * @param root0 + * @param root0.ownerAccountId + * @param root0.storageKey + */ +export async function selectFileByStorageKey({ + ownerAccountId, + storageKey, +}: { + ownerAccountId: string; + storageKey: string; +}): Promise { + const { data, error } = await supabase + .from("files") + .select("*") + .eq("owner_account_id", ownerAccountId) + .eq("storage_key", storageKey) + .maybeSingle(); + + if (error) { + throw new Error(`Failed to select file by storage key: ${error.message}`); + } + + return data; +} diff --git a/lib/supabase/storage/createSignedFileUrlByKey.ts b/lib/supabase/storage/createSignedFileUrlByKey.ts new file mode 100644 index 00000000..30625b7a --- /dev/null +++ b/lib/supabase/storage/createSignedFileUrlByKey.ts @@ -0,0 +1,27 @@ +import supabase from "@/lib/supabase/serverClient"; +import { SUPABASE_STORAGE_BUCKET } from "@/lib/const"; + +/** + * Creates a signed URL for a file in Supabase storage. + * + * @param root0 + * @param root0.key + * @param root0.expiresInSeconds + */ +export async function createSignedFileUrlByKey({ + key, + expiresInSeconds = 60 * 60 * 24 * 7, +}: { + key: string; + expiresInSeconds?: number; +}): Promise { + const { data, error } = await supabase.storage + .from(SUPABASE_STORAGE_BUCKET) + .createSignedUrl(key, expiresInSeconds); + + if (error || !data?.signedUrl) { + throw new Error(`Failed to create signed URL: ${error?.message ?? "unknown error"}`); + } + + return data.signedUrl; +} diff --git a/lib/tasks/__tests__/getTaskRunHandler.test.ts b/lib/tasks/__tests__/getTaskRunHandler.test.ts index bce2ce57..54c5c397 100644 --- a/lib/tasks/__tests__/getTaskRunHandler.test.ts +++ b/lib/tasks/__tests__/getTaskRunHandler.test.ts @@ -6,6 +6,7 @@ import { getTaskRunHandler } from "../getTaskRunHandler"; import { validateGetTaskRunQuery } from "../validateGetTaskRunQuery"; import { retrieveTaskRun } from "@/lib/trigger/retrieveTaskRun"; import { listTaskRuns } from "@/lib/trigger/listTaskRuns"; +import { persistCreateContentRunVideo } from "@/lib/content/persistCreateContentRunVideo"; vi.mock("../validateGetTaskRunQuery", () => ({ validateGetTaskRunQuery: vi.fn(), @@ -19,10 +20,17 @@ vi.mock("@/lib/trigger/listTaskRuns", () => ({ listTaskRuns: vi.fn(), })); +vi.mock("@/lib/content/persistCreateContentRunVideo", () => ({ + persistCreateContentRunVideo: vi.fn(async run => run), +})); + vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); +/** + * + */ function createMockRequest(): NextRequest { return { url: "http://localhost:3000/api/tasks/runs", @@ -48,6 +56,7 @@ const mockRun = { describe("getTaskRunHandler", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(persistCreateContentRunVideo).mockImplementation(async run => run as never); }); it("returns error response when validation fails", async () => { @@ -71,6 +80,7 @@ describe("getTaskRunHandler", () => { expect(json.status).toBe("success"); expect(json.runs).toHaveLength(1); expect(json.runs[0].id).toBe("run_123"); + expect(persistCreateContentRunVideo).toHaveBeenCalledWith(mockRun); }); it("returns 404 when run is not found", async () => { @@ -94,7 +104,11 @@ describe("getTaskRunHandler", () => { describe("list mode", () => { it("returns empty runs array", async () => { - vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ mode: "list", accountId: "acc_123", limit: 20 }); + vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ + mode: "list", + accountId: "acc_123", + limit: 20, + }); vi.mocked(listTaskRuns).mockResolvedValue([]); const response = await getTaskRunHandler(createMockRequest()); @@ -105,7 +119,11 @@ describe("getTaskRunHandler", () => { }); it("returns populated runs array", async () => { - vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ mode: "list", accountId: "acc_123", limit: 20 }); + vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ + mode: "list", + accountId: "acc_123", + limit: 20, + }); vi.mocked(listTaskRuns).mockResolvedValue([mockRun]); const response = await getTaskRunHandler(createMockRequest()); @@ -113,10 +131,15 @@ describe("getTaskRunHandler", () => { expect(json.status).toBe("success"); expect(json.runs).toHaveLength(1); + expect(persistCreateContentRunVideo).toHaveBeenCalledWith(mockRun); }); it("calls listTaskRuns with accountId and limit", async () => { - vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ mode: "list", accountId: "acc_456", limit: 50 }); + vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ + mode: "list", + accountId: "acc_456", + limit: 50, + }); vi.mocked(listTaskRuns).mockResolvedValue([]); await getTaskRunHandler(createMockRequest()); @@ -125,7 +148,11 @@ describe("getTaskRunHandler", () => { }); it("returns 500 when listTaskRuns throws", async () => { - vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ mode: "list", accountId: "acc_123", limit: 20 }); + vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ + mode: "list", + accountId: "acc_123", + limit: 20, + }); vi.mocked(listTaskRuns).mockRejectedValue(new Error("API error")); const response = await getTaskRunHandler(createMockRequest()); diff --git a/lib/tasks/getTaskRunHandler.ts b/lib/tasks/getTaskRunHandler.ts index 70fb5041..386d2820 100644 --- a/lib/tasks/getTaskRunHandler.ts +++ b/lib/tasks/getTaskRunHandler.ts @@ -4,6 +4,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateGetTaskRunQuery } from "./validateGetTaskRunQuery"; import { retrieveTaskRun } from "@/lib/trigger/retrieveTaskRun"; import { listTaskRuns } from "@/lib/trigger/listTaskRuns"; +import { persistCreateContentRunVideo } from "@/lib/content/persistCreateContentRunVideo"; /** * Handles GET /api/tasks/runs requests. @@ -23,8 +24,9 @@ export async function getTaskRunHandler(request: NextRequest): Promise persistCreateContentRunVideo(run))); return NextResponse.json( - { status: "success", runs }, + { status: "success", runs: hydratedRuns }, { status: 200, headers: getCorsHeaders() }, ); } @@ -38,9 +40,14 @@ export async function getTaskRunHandler(request: NextRequest): Promise ({ + tasks: { + trigger: vi.fn(), + }, +})); + +describe("triggerCreateContent", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("triggers create-content task with the expected payload", async () => { + const mockHandle = { id: "run_abc123" }; + vi.mocked(tasks.trigger).mockResolvedValue(mockHandle as never); + + const payload = { + accountId: "acc_123", + artistSlug: "gatsby-grace", + template: "artist-caption-bedroom", + lipsync: true, + }; + const result = await triggerCreateContent(payload); + + expect(tasks.trigger).toHaveBeenCalledWith(CREATE_CONTENT_TASK_ID, payload); + expect(result).toEqual(mockHandle); + }); +}); diff --git a/lib/trigger/triggerCreateContent.ts b/lib/trigger/triggerCreateContent.ts new file mode 100644 index 00000000..e995379c --- /dev/null +++ b/lib/trigger/triggerCreateContent.ts @@ -0,0 +1,19 @@ +import { tasks } from "@trigger.dev/sdk"; +import { CREATE_CONTENT_TASK_ID } from "@/lib/const"; + +export interface TriggerCreateContentPayload { + accountId: string; + artistSlug: string; + template: string; + lipsync: boolean; +} + +/** + * Triggers the create-content task in Trigger.dev. + * + * @param payload + */ +export async function triggerCreateContent(payload: TriggerCreateContentPayload) { + const handle = await tasks.trigger(CREATE_CONTENT_TASK_ID, payload); + return handle; +} From cc04d75c6ff7261127e625bd19610bb49c880c98 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:38:03 -0500 Subject: [PATCH 02/23] fix: address code review feedback on content-creation endpoints - fix: z.coerce.boolean() treated 'false' as true in estimate query params - fix: remove account_id from create content body (derive from auth only) - fix: missing field now returns only required issues, not all issues - fix: make video hydration best-effort so GET /tasks/runs doesn't crash - refactor: derive DEFAULT_CONTENT_TEMPLATE from CONTENT_TEMPLATES array (DRY) - refactor: export getContentValidateQuerySchema for reuse --- lib/content/contentTemplates.ts | 10 +++--- lib/content/getArtistContentReadiness.ts | 16 +++------- lib/content/validateCreateContentBody.ts | 12 ++----- .../validateGetContentEstimateQuery.ts | 18 ++++++++--- .../validateGetContentValidateQuery.ts | 9 ++---- lib/tasks/getTaskRunHandler.ts | 32 +++++++++++++------ 6 files changed, 51 insertions(+), 46 deletions(-) diff --git a/lib/content/contentTemplates.ts b/lib/content/contentTemplates.ts index 80fc2bc6..6896c195 100644 --- a/lib/content/contentTemplates.ts +++ b/lib/content/contentTemplates.ts @@ -4,8 +4,6 @@ export interface ContentTemplate { defaultLipsync: boolean; } -export const DEFAULT_CONTENT_TEMPLATE = "artist-caption-bedroom"; - export const CONTENT_TEMPLATES: ContentTemplate[] = [ { name: "artist-caption-bedroom", @@ -24,10 +22,10 @@ export const CONTENT_TEMPLATES: ContentTemplate[] = [ }, ]; -/** - * - * @param template - */ +/** Derived from the first entry in CONTENT_TEMPLATES to avoid string duplication. */ +export const DEFAULT_CONTENT_TEMPLATE = CONTENT_TEMPLATES[0].name; + export function isSupportedContentTemplate(template: string): boolean { return CONTENT_TEMPLATES.some(item => item.name === template); } + diff --git a/lib/content/getArtistContentReadiness.ts b/lib/content/getArtistContentReadiness.ts index 3b1312e1..1df0036c 100644 --- a/lib/content/getArtistContentReadiness.ts +++ b/lib/content/getArtistContentReadiness.ts @@ -16,11 +16,6 @@ export interface ArtistContentReadiness { warnings: ContentReadinessIssue[]; } -/** - * - * @param paths - * @param artistSlug - */ function getArtistRootPrefix(paths: string[], artistSlug: string): string { const preferredPrefix = `artists/${artistSlug}/`; if (paths.some(path => path.startsWith(preferredPrefix))) { @@ -37,10 +32,6 @@ function getArtistRootPrefix(paths: string[], artistSlug: string): string { /** * Checks whether an artist has the expected files for content creation. - * - * @param root0 - * @param root0.accountId - * @param root0.artistSlug */ export async function getArtistContentReadiness({ accountId, @@ -66,7 +57,9 @@ export async function getArtistContentReadiness({ const hasFile = (relativePath: string): boolean => blobPaths.some(path => path === `${artistRootPrefix}${relativePath}`); const hasAnyMp3 = blobPaths.some( - path => path.startsWith(artistRootPrefix) && path.toLowerCase().endsWith(".mp3"), + path => + path.startsWith(artistRootPrefix) && + path.toLowerCase().endsWith(".mp3"), ); const issues: ContentReadinessIssue[] = []; @@ -125,7 +118,8 @@ export async function getArtistContentReadiness({ return { artist_slug: artistSlug, ready: requiredMissing.length === 0, - missing: issues, + missing: requiredMissing, warnings, }; } + diff --git a/lib/content/validateCreateContentBody.ts b/lib/content/validateCreateContentBody.ts index 9c0862e5..e4a02a19 100644 --- a/lib/content/validateCreateContentBody.ts +++ b/lib/content/validateCreateContentBody.ts @@ -10,16 +10,13 @@ import { } from "@/lib/content/contentTemplates"; export const createContentBodySchema = z.object({ - artist_slug: z - .string({ message: "artist_slug is required" }) - .min(1, "artist_slug cannot be empty"), + artist_slug: z.string({ message: "artist_slug is required" }).min(1, "artist_slug cannot be empty"), template: z .string() .min(1, "template cannot be empty") .default(DEFAULT_CONTENT_TEMPLATE) .optional(), lipsync: z.boolean().default(false).optional(), - account_id: z.string().uuid("account_id must be a valid UUID").optional(), }); export type ValidatedCreateContentBody = { @@ -31,8 +28,6 @@ export type ValidatedCreateContentBody = { /** * Validates auth and request body for POST /api/content/create. - * - * @param request */ export async function validateCreateContentBody( request: NextRequest, @@ -52,9 +47,7 @@ export async function validateCreateContentBody( ); } - const authResult = await validateAuthContext(request, { - accountId: result.data.account_id, - }); + const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) { return authResult; @@ -78,3 +71,4 @@ export async function validateCreateContentBody( lipsync: result.data.lipsync ?? false, }; } + diff --git a/lib/content/validateGetContentEstimateQuery.ts b/lib/content/validateGetContentEstimateQuery.ts index 26590b07..0ce06a0f 100644 --- a/lib/content/validateGetContentEstimateQuery.ts +++ b/lib/content/validateGetContentEstimateQuery.ts @@ -4,18 +4,25 @@ import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -const getContentEstimateQuerySchema = z.object({ - lipsync: z.coerce.boolean().default(false), +/** + * Parses a string query param as a boolean. Only "true" → true; everything else → false. + * z.coerce.boolean() would treat any non-empty string (including "false") as true. + */ +const booleanFromString = z + .enum(["true", "false"]) + .default("false") + .transform(v => v === "true"); + +export const getContentEstimateQuerySchema = z.object({ + lipsync: booleanFromString, batch: z.coerce.number().int().min(1).max(100).default(1), - compare: z.coerce.boolean().default(false), + compare: booleanFromString, }); export type ValidatedGetContentEstimateQuery = z.infer; /** * Validates auth and query params for GET /api/content/estimate. - * - * @param request */ export async function validateGetContentEstimateQuery( request: NextRequest, @@ -40,3 +47,4 @@ export async function validateGetContentEstimateQuery( return result.data; } + diff --git a/lib/content/validateGetContentValidateQuery.ts b/lib/content/validateGetContentValidateQuery.ts index e268d0c4..71a89795 100644 --- a/lib/content/validateGetContentValidateQuery.ts +++ b/lib/content/validateGetContentValidateQuery.ts @@ -4,10 +4,8 @@ import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -const getContentValidateQuerySchema = z.object({ - artist_slug: z - .string({ message: "artist_slug is required" }) - .min(1, "artist_slug cannot be empty"), +export const getContentValidateQuerySchema = z.object({ + artist_slug: z.string({ message: "artist_slug is required" }).min(1, "artist_slug cannot be empty"), }); export type ValidatedGetContentValidateQuery = { @@ -17,8 +15,6 @@ export type ValidatedGetContentValidateQuery = { /** * Validates auth and query params for GET /api/content/validate. - * - * @param request */ export async function validateGetContentValidateQuery( request: NextRequest, @@ -47,3 +43,4 @@ export async function validateGetContentValidateQuery( artistSlug: result.data.artist_slug, }; } + diff --git a/lib/tasks/getTaskRunHandler.ts b/lib/tasks/getTaskRunHandler.ts index 386d2820..ecf23e11 100644 --- a/lib/tasks/getTaskRunHandler.ts +++ b/lib/tasks/getTaskRunHandler.ts @@ -24,7 +24,17 @@ export async function getTaskRunHandler(request: NextRequest): Promise persistCreateContentRunVideo(run))); + // Best-effort hydration: if video persistence fails for a run, return the original run. + const hydratedRuns = await Promise.all( + runs.map(async run => { + try { + return await persistCreateContentRunVideo(run); + } catch (err) { + console.error("Video hydration failed for run", run.id, err); + return run; + } + }), + ); return NextResponse.json( { status: "success", runs: hydratedRuns }, { status: 200, headers: getCorsHeaders() }, @@ -40,15 +50,19 @@ export async function getTaskRunHandler(request: NextRequest): Promise Date: Thu, 5 Mar 2026 11:32:40 -0500 Subject: [PATCH 03/23] refactor: make config.json optional with sensible pipeline defaults - config/content-creation/config.json is now 'recommended' not 'required' - artists only need face-guide.png + at least one .mp3 to be ready - pipeline will use default model/resolution settings if no config exists - add test for config-optional readiness check --- .../getArtistContentReadiness.test.ts | 21 ++++++++++++++++--- lib/content/createContentHandler.ts | 3 +-- lib/content/getArtistContentReadiness.ts | 6 ++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/lib/content/__tests__/getArtistContentReadiness.test.ts b/lib/content/__tests__/getArtistContentReadiness.test.ts index 3792d4ff..107d87c7 100644 --- a/lib/content/__tests__/getArtistContentReadiness.test.ts +++ b/lib/content/__tests__/getArtistContentReadiness.test.ts @@ -52,12 +52,26 @@ describe("getArtistContentReadiness", () => { expect(result.ready).toBe(false); expect(result.missing.some(item => item.file === "context/images/face-guide.png")).toBe(true); - expect(result.missing.some(item => item.file === "config/content-creation/config.json")).toBe( - true, - ); expect(result.missing.some(item => item.file === "songs/*.mp3")).toBe(true); }); + it("returns ready=true when only face-guide and mp3 exist (config.json is optional)", async () => { + vi.mocked(getRepoFileTree).mockResolvedValue([ + { path: "artists/gatsby-grace/context/images/face-guide.png", type: "blob", sha: "1" }, + { path: "artists/gatsby-grace/songs/track.mp3", type: "blob", sha: "2" }, + ]); + + const result = await getArtistContentReadiness({ + accountId: "acc_123", + artistSlug: "gatsby-grace", + }); + + expect(result.ready).toBe(true); + expect(result.missing).toEqual([]); + // config.json appears as a warning, not a blocker + expect(result.warnings.some(item => item.file === "config/content-creation/config.json")).toBe(true); + }); + it("throws when account has no github repo", async () => { vi.mocked(selectAccountSnapshots).mockResolvedValue([] as never); @@ -69,3 +83,4 @@ describe("getArtistContentReadiness", () => { ).rejects.toThrow("No GitHub repository found for this account"); }); }); + diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index 084019ba..141f8711 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -8,8 +8,6 @@ import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadine /** * Handler for POST /api/content/create. * Triggers a background content-creation run and returns a runId for polling. - * - * @param request */ export async function createContentHandler(request: NextRequest): Promise { const validated = await validateCreateContentBody(request); @@ -68,3 +66,4 @@ export async function createContentHandler(request: NextRequest): Promise Date: Thu, 5 Mar 2026 11:39:28 -0500 Subject: [PATCH 04/23] feat: pass githubRepo to create-content task payload - extend TriggerCreateContentPayload with githubRepo field - getArtistContentReadiness now returns githubRepo - createContentHandler passes githubRepo when triggering task --- lib/content/createContentHandler.ts | 1 + lib/content/getArtistContentReadiness.ts | 3 +++ lib/trigger/triggerCreateContent.ts | 5 +++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index 141f8711..bbba54e9 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -40,6 +40,7 @@ export async function createContentHandler(request: NextRequest): Promise Date: Thu, 5 Mar 2026 12:59:38 -0500 Subject: [PATCH 05/23] feat: add caption_length param to content creation endpoint - add caption_length (short/medium/long) to request schema, defaults to short - pass through to task payload - restore proper validation flow in handler - update tests --- lib/content/__tests__/createContentHandler.test.ts | 5 +++++ lib/content/__tests__/validateCreateContentBody.test.ts | 6 ++---- lib/content/createContentHandler.ts | 2 +- lib/content/validateCreateContentBody.ts | 5 +++++ lib/trigger/triggerCreateContent.ts | 2 ++ 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/content/__tests__/createContentHandler.test.ts b/lib/content/__tests__/createContentHandler.test.ts index 333baf18..19c4c830 100644 --- a/lib/content/__tests__/createContentHandler.test.ts +++ b/lib/content/__tests__/createContentHandler.test.ts @@ -29,6 +29,7 @@ describe("createContentHandler", () => { ready: true, missing: [], warnings: [], + githubRepo: "https://github.com/test/repo", }); }); @@ -51,6 +52,7 @@ describe("createContentHandler", () => { artistSlug: "gatsby-grace", template: "artist-caption-bedroom", lipsync: false, + captionLength: "short", }); vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run_abc123" } as never); const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); @@ -74,6 +76,7 @@ describe("createContentHandler", () => { artistSlug: "gatsby-grace", template: "artist-caption-bedroom", lipsync: false, + captionLength: "short", }); vi.mocked(triggerCreateContent).mockRejectedValue(new Error("Trigger unavailable")); const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); @@ -94,6 +97,7 @@ describe("createContentHandler", () => { artistSlug: "gatsby-grace", template: "artist-caption-bedroom", lipsync: false, + captionLength: "short", }); vi.mocked(getArtistContentReadiness).mockResolvedValue({ artist_slug: "gatsby-grace", @@ -118,3 +122,4 @@ describe("createContentHandler", () => { expect(Array.isArray(body.missing)).toBe(true); }); }); + diff --git a/lib/content/__tests__/validateCreateContentBody.test.ts b/lib/content/__tests__/validateCreateContentBody.test.ts index 2112622f..8871c52e 100644 --- a/lib/content/__tests__/validateCreateContentBody.test.ts +++ b/lib/content/__tests__/validateCreateContentBody.test.ts @@ -16,10 +16,6 @@ vi.mock("@/lib/networking/safeParseJson", () => ({ safeParseJson: vi.fn(async (req: Request) => req.json()), })); -/** - * - * @param body - */ function createRequest(body: unknown): NextRequest { return new NextRequest("http://localhost/api/content/create", { method: "POST", @@ -54,6 +50,7 @@ describe("validateCreateContentBody", () => { artistSlug: "gatsby-grace", template: "artist-caption-bedroom", lipsync: true, + captionLength: "short", }); } }); @@ -115,3 +112,4 @@ describe("validateCreateContentBody", () => { } }); }); + diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index bbba54e9..f375b0aa 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -40,6 +40,7 @@ export async function createContentHandler(request: NextRequest): Promise Date: Thu, 5 Mar 2026 15:35:11 -0500 Subject: [PATCH 06/23] feat: add upscale param to content creation endpoint --- lib/content/__tests__/createContentHandler.test.ts | 3 +++ lib/content/__tests__/validateCreateContentBody.test.ts | 1 + lib/content/createContentHandler.ts | 1 + lib/content/validateCreateContentBody.ts | 3 +++ lib/trigger/triggerCreateContent.ts | 2 ++ 5 files changed, 10 insertions(+) diff --git a/lib/content/__tests__/createContentHandler.test.ts b/lib/content/__tests__/createContentHandler.test.ts index 19c4c830..24f663e0 100644 --- a/lib/content/__tests__/createContentHandler.test.ts +++ b/lib/content/__tests__/createContentHandler.test.ts @@ -53,6 +53,7 @@ describe("createContentHandler", () => { template: "artist-caption-bedroom", lipsync: false, captionLength: "short", + upscale: false, }); vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run_abc123" } as never); const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); @@ -77,6 +78,7 @@ describe("createContentHandler", () => { template: "artist-caption-bedroom", lipsync: false, captionLength: "short", + upscale: false, }); vi.mocked(triggerCreateContent).mockRejectedValue(new Error("Trigger unavailable")); const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); @@ -98,6 +100,7 @@ describe("createContentHandler", () => { template: "artist-caption-bedroom", lipsync: false, captionLength: "short", + upscale: false, }); vi.mocked(getArtistContentReadiness).mockResolvedValue({ artist_slug: "gatsby-grace", diff --git a/lib/content/__tests__/validateCreateContentBody.test.ts b/lib/content/__tests__/validateCreateContentBody.test.ts index 8871c52e..4d137f7b 100644 --- a/lib/content/__tests__/validateCreateContentBody.test.ts +++ b/lib/content/__tests__/validateCreateContentBody.test.ts @@ -51,6 +51,7 @@ describe("validateCreateContentBody", () => { template: "artist-caption-bedroom", lipsync: true, captionLength: "short", + upscale: false, }); } }); diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index f375b0aa..06515f62 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -41,6 +41,7 @@ export async function createContentHandler(request: NextRequest): Promise Date: Thu, 5 Mar 2026 15:48:05 -0500 Subject: [PATCH 07/23] fix: validation now searches org submodule repos for artist files - getArtistContentReadiness checks main repo first, falls back to org repos - reads .gitmodules to discover org submodule URLs - gatsby-grace now validates as ready=true --- .../__tests__/createContentHandler.test.ts | 3 - lib/content/getArtistContentReadiness.ts | 76 ++++++++++++++++++- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/lib/content/__tests__/createContentHandler.test.ts b/lib/content/__tests__/createContentHandler.test.ts index 24f663e0..19c4c830 100644 --- a/lib/content/__tests__/createContentHandler.test.ts +++ b/lib/content/__tests__/createContentHandler.test.ts @@ -53,7 +53,6 @@ describe("createContentHandler", () => { template: "artist-caption-bedroom", lipsync: false, captionLength: "short", - upscale: false, }); vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run_abc123" } as never); const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); @@ -78,7 +77,6 @@ describe("createContentHandler", () => { template: "artist-caption-bedroom", lipsync: false, captionLength: "short", - upscale: false, }); vi.mocked(triggerCreateContent).mockRejectedValue(new Error("Trigger unavailable")); const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); @@ -100,7 +98,6 @@ describe("createContentHandler", () => { template: "artist-caption-bedroom", lipsync: false, captionLength: "short", - upscale: false, }); vi.mocked(getArtistContentReadiness).mockResolvedValue({ artist_slug: "gatsby-grace", diff --git a/lib/content/getArtistContentReadiness.ts b/lib/content/getArtistContentReadiness.ts index e8fec432..d1bf1644 100644 --- a/lib/content/getArtistContentReadiness.ts +++ b/lib/content/getArtistContentReadiness.ts @@ -1,4 +1,4 @@ -import { getRepoFileTree } from "@/lib/github/getRepoFileTree"; +import { getRepoFileTree, type FileTreeEntry } from "@/lib/github/getRepoFileTree"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; type MissingSeverity = "required" | "recommended"; @@ -32,8 +32,79 @@ function getArtistRootPrefix(paths: string[], artistSlug: string): string { return preferredPrefix; } +/** + * Gets the file tree that contains the artist, checking the main repo + * first, then falling back to org submodule repos. + */ +async function getArtistFileTree( + githubRepo: string, + artistSlug: string, +): Promise { + // Try main repo first + const mainTree = await getRepoFileTree(githubRepo); + if (mainTree) { + const blobPaths = mainTree.filter(e => e.type === "blob").map(e => e.path); + const hasArtist = blobPaths.some( + p => p.startsWith(`artists/${artistSlug}/`) || p.startsWith(`${artistSlug}/`), + ); + if (hasArtist) return mainTree; + } + + // Not in main repo — check org submodule repos + const orgRepoUrls = await getOrgRepoUrls(githubRepo); + for (const orgUrl of orgRepoUrls) { + const orgTree = await getRepoFileTree(orgUrl); + if (orgTree) { + const blobPaths = orgTree.filter(e => e.type === "blob").map(e => e.path); + const hasArtist = blobPaths.some( + p => p.startsWith(`artists/${artistSlug}/`) || p.startsWith(`${artistSlug}/`), + ); + if (hasArtist) return orgTree; + } + } + + return mainTree; +} + +/** + * Reads .gitmodules from the repo and extracts org submodule URLs. + */ +async function getOrgRepoUrls(githubRepo: string): Promise { + const token = process.env.GITHUB_TOKEN; + if (!token) return []; + + const match = githubRepo.match(/github\.com\/([^/]+)\/([^/]+)/); + if (!match) return []; + const [, owner, repo] = match; + + try { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/.gitmodules`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3.raw", + "User-Agent": "Recoup-API", + }, + }, + ); + if (!response.ok) return []; + + const content = await response.text(); + const urls: string[] = []; + const urlMatches = content.matchAll(/url\s*=\s*(https:\/\/github\.com\/[^\s]+)/g); + for (const m of urlMatches) { + urls.push(m[1]); + } + return urls; + } catch { + return []; + } +} + /** * Checks whether an artist has the expected files for content creation. + * Searches the main repo and org submodule repos. */ export async function getArtistContentReadiness({ accountId, @@ -48,7 +119,7 @@ export async function getArtistContentReadiness({ throw new Error("No GitHub repository found for this account"); } - const tree = await getRepoFileTree(githubRepo); + const tree = await getArtistFileTree(githubRepo, artistSlug); if (!tree) { throw new Error("Failed to retrieve repository file tree"); } @@ -127,4 +198,3 @@ export async function getArtistContentReadiness({ githubRepo, }; } - From 5c6b09959f68b79c92c14f3da969240f1ebaec24 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:50:58 -0500 Subject: [PATCH 08/23] =?UTF-8?q?feat:=20add=20batch=20mode=20=E2=80=94=20?= =?UTF-8?q?trigger=20N=20videos=20in=20parallel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add batch param (1-30, default 1) - batch mode returns { runIds: [...] } instead of { runId } - each task runs independently with its own random selections --- .../validateCreateContentBody.test.ts | 1 + lib/content/createContentHandler.ts | 30 +++++++++++++++---- lib/content/validateCreateContentBody.ts | 3 ++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/lib/content/__tests__/validateCreateContentBody.test.ts b/lib/content/__tests__/validateCreateContentBody.test.ts index 4d137f7b..3db2a14d 100644 --- a/lib/content/__tests__/validateCreateContentBody.test.ts +++ b/lib/content/__tests__/validateCreateContentBody.test.ts @@ -52,6 +52,7 @@ describe("validateCreateContentBody", () => { lipsync: true, captionLength: "short", upscale: false, + batch: 1, }); } }); diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index 06515f62..83aa9552 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -35,7 +35,7 @@ export async function createContentHandler(request: NextRequest): Promise 1) { + // Batch mode: trigger N tasks in parallel + const handles = await Promise.all( + Array.from({ length: validated.batch }, () => triggerCreateContent(payload)), + ); + return NextResponse.json( + { + runIds: handles.map(h => h.id), + status: "triggered", + batch: validated.batch, + artist: validated.artistSlug, + template: validated.template, + lipsync: validated.lipsync, + }, + { status: 202, headers: getCorsHeaders() }, + ); + } + + // Single mode + const handle = await triggerCreateContent(payload); return NextResponse.json( { @@ -53,10 +74,7 @@ export async function createContentHandler(request: NextRequest): Promise Date: Thu, 5 Mar 2026 16:27:05 -0500 Subject: [PATCH 09/23] refactor: extract getOrgRepoUrls to shared lib/github/ (DRY) - move inline getOrgRepoUrls from getArtistContentReadiness into lib/github/ - reuse existing getRepoGitModules + parseGitModules instead of duplicating - getArtistContentReadiness now imports the shared utility --- lib/content/getArtistContentReadiness.ts | 43 ++---------------------- lib/github/getOrgRepoUrls.ts | 26 ++++++++++++++ 2 files changed, 29 insertions(+), 40 deletions(-) create mode 100644 lib/github/getOrgRepoUrls.ts diff --git a/lib/content/getArtistContentReadiness.ts b/lib/content/getArtistContentReadiness.ts index d1bf1644..a5b6d13b 100644 --- a/lib/content/getArtistContentReadiness.ts +++ b/lib/content/getArtistContentReadiness.ts @@ -1,4 +1,5 @@ import { getRepoFileTree, type FileTreeEntry } from "@/lib/github/getRepoFileTree"; +import { getOrgRepoUrls } from "@/lib/github/getOrgRepoUrls"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; type MissingSeverity = "required" | "recommended"; @@ -40,7 +41,6 @@ async function getArtistFileTree( githubRepo: string, artistSlug: string, ): Promise { - // Try main repo first const mainTree = await getRepoFileTree(githubRepo); if (mainTree) { const blobPaths = mainTree.filter(e => e.type === "blob").map(e => e.path); @@ -50,9 +50,8 @@ async function getArtistFileTree( if (hasArtist) return mainTree; } - // Not in main repo — check org submodule repos - const orgRepoUrls = await getOrgRepoUrls(githubRepo); - for (const orgUrl of orgRepoUrls) { + const orgUrls = await getOrgRepoUrls(githubRepo); + for (const orgUrl of orgUrls) { const orgTree = await getRepoFileTree(orgUrl); if (orgTree) { const blobPaths = orgTree.filter(e => e.type === "blob").map(e => e.path); @@ -66,42 +65,6 @@ async function getArtistFileTree( return mainTree; } -/** - * Reads .gitmodules from the repo and extracts org submodule URLs. - */ -async function getOrgRepoUrls(githubRepo: string): Promise { - const token = process.env.GITHUB_TOKEN; - if (!token) return []; - - const match = githubRepo.match(/github\.com\/([^/]+)\/([^/]+)/); - if (!match) return []; - const [, owner, repo] = match; - - try { - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/contents/.gitmodules`, - { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github.v3.raw", - "User-Agent": "Recoup-API", - }, - }, - ); - if (!response.ok) return []; - - const content = await response.text(); - const urls: string[] = []; - const urlMatches = content.matchAll(/url\s*=\s*(https:\/\/github\.com\/[^\s]+)/g); - for (const m of urlMatches) { - urls.push(m[1]); - } - return urls; - } catch { - return []; - } -} - /** * Checks whether an artist has the expected files for content creation. * Searches the main repo and org submodule repos. diff --git a/lib/github/getOrgRepoUrls.ts b/lib/github/getOrgRepoUrls.ts new file mode 100644 index 00000000..74d69bab --- /dev/null +++ b/lib/github/getOrgRepoUrls.ts @@ -0,0 +1,26 @@ +import { getRepoGitModules } from "./getRepoGitModules"; +import { parseGitHubRepoUrl } from "./parseGitHubRepoUrl"; + +/** + * Gets the GitHub URLs of all org submodule repos for a sandbox repository. + * + * Reads .gitmodules from the main repo and extracts the submodule URLs. + * Used by content readiness checks and artist file fetching. + * + * @param githubRepoUrl - Full GitHub repo URL (e.g. https://github.com/org/repo) + * @returns Array of org repo URLs, or empty array if none found + */ +export async function getOrgRepoUrls(githubRepoUrl: string): Promise { + const repoInfo = parseGitHubRepoUrl(githubRepoUrl); + if (!repoInfo) return []; + + const submodules = await getRepoGitModules({ + owner: repoInfo.owner, + repo: repoInfo.repo, + branch: "main", + }); + + if (!submodules) return []; + + return submodules.map(s => s.url); +} From 4c36aa0c707b023cc43e98e47273bfd6ef3ef2cb Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:32:39 -0500 Subject: [PATCH 10/23] fix: address CodeRabbit review feedback (round 2) - rename misleading 'missing_fields' to 'field' in validation error response - don't expose internal error messages in 500 responses (security) - log errors server-side, return generic message to clients --- lib/content/__tests__/createContentHandler.test.ts | 2 +- lib/content/createContentHandler.ts | 4 ++-- lib/content/validateCreateContentBody.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/content/__tests__/createContentHandler.test.ts b/lib/content/__tests__/createContentHandler.test.ts index 19c4c830..afc03efc 100644 --- a/lib/content/__tests__/createContentHandler.test.ts +++ b/lib/content/__tests__/createContentHandler.test.ts @@ -87,7 +87,7 @@ describe("createContentHandler", () => { expect(result.status).toBe(500); expect(body).toEqual({ status: "error", - error: "Trigger unavailable", + error: "Failed to trigger content creation", }); }); diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index 83aa9552..263e58be 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -77,11 +77,11 @@ export async function createContentHandler(request: NextRequest): Promise Date: Thu, 5 Mar 2026 17:09:42 -0500 Subject: [PATCH 11/23] fix: address all remaining CodeRabbit review comments - distinguish 'no repo configured' vs 'query failed' in readiness check - sanitize 500 error in validate handler (don't expose internals) - make persistence idempotent (re-select on insert race condition) - fix Zod .optional().default() ordering for correct behavior - batch mode uses Promise.allSettled (partial failures return successful runIds) - getOrgRepoUrls accepts configurable branch param --- .../getArtistContentReadiness.test.ts | 2 +- .../getContentValidateHandler.test.ts | 2 +- .../persistCreateContentRunVideo.test.ts | 3 +- lib/content/createContentHandler.ts | 14 +++++-- lib/content/getArtistContentReadiness.ts | 5 ++- lib/content/getContentValidateHandler.ts | 7 ++-- lib/content/persistCreateContentRunVideo.ts | 37 ++++++++++--------- lib/content/validateCreateContentBody.ts | 12 +++--- lib/github/getOrgRepoUrls.ts | 8 +++- 9 files changed, 54 insertions(+), 36 deletions(-) diff --git a/lib/content/__tests__/getArtistContentReadiness.test.ts b/lib/content/__tests__/getArtistContentReadiness.test.ts index 107d87c7..d1eb185a 100644 --- a/lib/content/__tests__/getArtistContentReadiness.test.ts +++ b/lib/content/__tests__/getArtistContentReadiness.test.ts @@ -80,7 +80,7 @@ describe("getArtistContentReadiness", () => { accountId: "acc_123", artistSlug: "gatsby-grace", }), - ).rejects.toThrow("No GitHub repository found for this account"); + ).rejects.toThrow("No GitHub repository configured for this account"); }); }); diff --git a/lib/content/__tests__/getContentValidateHandler.test.ts b/lib/content/__tests__/getContentValidateHandler.test.ts index 30ad289c..734fd6dd 100644 --- a/lib/content/__tests__/getContentValidateHandler.test.ts +++ b/lib/content/__tests__/getContentValidateHandler.test.ts @@ -80,6 +80,6 @@ describe("getContentValidateHandler", () => { const body = await result.json(); expect(result.status).toBe(500); - expect(body.error).toBe("Failed to retrieve repository file tree"); + expect(body.error).toBe("Failed to validate content readiness"); }); }); diff --git a/lib/content/__tests__/persistCreateContentRunVideo.test.ts b/lib/content/__tests__/persistCreateContentRunVideo.test.ts index dbe4abec..c1c16828 100644 --- a/lib/content/__tests__/persistCreateContentRunVideo.test.ts +++ b/lib/content/__tests__/persistCreateContentRunVideo.test.ts @@ -181,6 +181,7 @@ describe("persistCreateContentRunVideo", () => { }, }; - await expect(persistCreateContentRunVideo(run)).rejects.toThrow("create file record failed"); + await expect(persistCreateContentRunVideo(run)).rejects.toThrow("Failed to create or find file record"); }); }); + diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index 263e58be..58e2072b 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -46,15 +46,23 @@ export async function createContentHandler(request: NextRequest): Promise 1) { - // Batch mode: trigger N tasks in parallel - const handles = await Promise.all( + // Batch mode: trigger N tasks in parallel. + // Use allSettled so partial failures don't lose successful runIds. + const results = await Promise.allSettled( Array.from({ length: validated.batch }, () => triggerCreateContent(payload)), ); + const runIds = results + .filter((r): r is PromiseFulfilledResult<{ id: string }> => r.status === "fulfilled") + .map(r => r.value.id); + const failedCount = results.filter(r => r.status === "rejected").length; + return NextResponse.json( { - runIds: handles.map(h => h.id), + runIds, status: "triggered", batch: validated.batch, + triggered: runIds.length, + ...(failedCount > 0 && { failed: failedCount }), artist: validated.artistSlug, template: validated.template, lipsync: validated.lipsync, diff --git a/lib/content/getArtistContentReadiness.ts b/lib/content/getArtistContentReadiness.ts index a5b6d13b..b281d290 100644 --- a/lib/content/getArtistContentReadiness.ts +++ b/lib/content/getArtistContentReadiness.ts @@ -77,9 +77,12 @@ export async function getArtistContentReadiness({ artistSlug: string; }): Promise { const snapshots = await selectAccountSnapshots(accountId); + if (!snapshots) { + throw new Error("Failed to query account snapshots"); + } const githubRepo = snapshots[0]?.github_repo ?? null; if (!githubRepo) { - throw new Error("No GitHub repository found for this account"); + throw new Error("No GitHub repository configured for this account"); } const tree = await getArtistFileTree(githubRepo, artistSlug); diff --git a/lib/content/getContentValidateHandler.ts b/lib/content/getContentValidateHandler.ts index 77c83738..47107e35 100644 --- a/lib/content/getContentValidateHandler.ts +++ b/lib/content/getContentValidateHandler.ts @@ -8,8 +8,6 @@ import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadine * Handler for GET /api/content/validate. * NOTE: Phase 1 returns structural readiness scaffolding. Deep filesystem checks * are performed in the background task before spend-heavy steps. - * - * @param request */ export async function getContentValidateHandler(request: NextRequest): Promise { const validated = await validateGetContentValidateQuery(request); @@ -31,13 +29,14 @@ export async function getContentValidateHandler(request: NextRequest): Promise(run: T): Promise { if (run.taskIdentifier !== CREATE_CONTENT_TASK_ID || !isCompleted(run)) { @@ -100,17 +94,25 @@ export async function persistCreateContentRunVideo(run upsert: true, }); - const createdFile = await createFileRecord({ - ownerAccountId: output.accountId, - // Phase 1: artist account mapping is not wired yet, so we scope to owner account. - artistAccountId: output.accountId, - storageKey, - fileName, - mimeType, - sizeBytes: videoBlob.size, - description: `Content pipeline output for ${output.artistSlug}`, - tags: ["content", "video", output.template ?? "unknown-template"], - }); + let createdFile; + try { + createdFile = await createFileRecord({ + ownerAccountId: output.accountId, + // Phase 1: artist account mapping is not wired yet, so we scope to owner account. + artistAccountId: output.accountId, + storageKey, + fileName, + mimeType, + sizeBytes: videoBlob.size, + description: `Content pipeline output for ${output.artistSlug}`, + tags: ["content", "video", output.template ?? "unknown-template"], + }); + } catch { + // Race condition: another request may have created the record. Re-select. + const raceFile = await selectFileByStorageKey({ ownerAccountId: output.accountId, storageKey }); + if (!raceFile) throw new Error("Failed to create or find file record"); + createdFile = raceFile; + } const signedUrl = await createSignedFileUrlByKey({ key: createdFile.storage_key, @@ -131,3 +133,4 @@ export async function persistCreateContentRunVideo(run }, }; } + diff --git a/lib/content/validateCreateContentBody.ts b/lib/content/validateCreateContentBody.ts index 3eff0153..a16f4a48 100644 --- a/lib/content/validateCreateContentBody.ts +++ b/lib/content/validateCreateContentBody.ts @@ -16,12 +16,12 @@ export const createContentBodySchema = z.object({ template: z .string() .min(1, "template cannot be empty") - .default(DEFAULT_CONTENT_TEMPLATE) - .optional(), - lipsync: z.boolean().default(false).optional(), - caption_length: z.enum(CAPTION_LENGTHS).default("short").optional(), - upscale: z.boolean().default(false).optional(), - batch: z.number().int().min(1).max(30).default(1).optional(), + .optional() + .default(DEFAULT_CONTENT_TEMPLATE), + lipsync: z.boolean().optional().default(false), + caption_length: z.enum(CAPTION_LENGTHS).optional().default("short"), + upscale: z.boolean().optional().default(false), + batch: z.number().int().min(1).max(30).optional().default(1), }); export type ValidatedCreateContentBody = { diff --git a/lib/github/getOrgRepoUrls.ts b/lib/github/getOrgRepoUrls.ts index 74d69bab..1aca55c1 100644 --- a/lib/github/getOrgRepoUrls.ts +++ b/lib/github/getOrgRepoUrls.ts @@ -8,16 +8,20 @@ import { parseGitHubRepoUrl } from "./parseGitHubRepoUrl"; * Used by content readiness checks and artist file fetching. * * @param githubRepoUrl - Full GitHub repo URL (e.g. https://github.com/org/repo) + * @param branch - Branch to read .gitmodules from (defaults to "main") * @returns Array of org repo URLs, or empty array if none found */ -export async function getOrgRepoUrls(githubRepoUrl: string): Promise { +export async function getOrgRepoUrls( + githubRepoUrl: string, + branch = "main", +): Promise { const repoInfo = parseGitHubRepoUrl(githubRepoUrl); if (!repoInfo) return []; const submodules = await getRepoGitModules({ owner: repoInfo.owner, repo: repoInfo.repo, - branch: "main", + branch, }); if (!submodules) return []; From 0b83263c6f5f35f3e002effbc2927116e6bdc360 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:17:21 -0500 Subject: [PATCH 12/23] fix: resolve TypeScript build error in batch mode filter --- lib/content/createContentHandler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index 58e2072b..5b4d5861 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -52,8 +52,8 @@ export async function createContentHandler(request: NextRequest): Promise triggerCreateContent(payload)), ); const runIds = results - .filter((r): r is PromiseFulfilledResult<{ id: string }> => r.status === "fulfilled") - .map(r => r.value.id); + .filter(r => r.status === "fulfilled") + .map(r => (r as PromiseFulfilledResult<{ id: string }>).value.id); const failedCount = results.filter(r => r.status === "rejected").length; return NextResponse.json( From 27b0573a160a214f51a4c6a63698485ce10567ef Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:58:59 -0500 Subject: [PATCH 13/23] =?UTF-8?q?refactor:=20always=20return=20runIds=20ar?= =?UTF-8?q?ray=20(KISS=20=E2=80=94=20one=20response=20shape)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per reviewer feedback: single mode and batch mode now both return runIds array. Removes the runId/runIds split. Clients always handle one shape. --- .../__tests__/createContentHandler.test.ts | 28 ++++++------- lib/content/createContentHandler.ts | 42 ++++++------------- 2 files changed, 26 insertions(+), 44 deletions(-) diff --git a/lib/content/__tests__/createContentHandler.test.ts b/lib/content/__tests__/createContentHandler.test.ts index afc03efc..daebb793 100644 --- a/lib/content/__tests__/createContentHandler.test.ts +++ b/lib/content/__tests__/createContentHandler.test.ts @@ -46,13 +46,15 @@ describe("createContentHandler", () => { expect(result).toBe(errorResponse); }); - it("returns 202 with runId when trigger succeeds", async () => { + it("returns 202 with runIds when trigger succeeds", async () => { vi.mocked(validateCreateContentBody).mockResolvedValue({ accountId: "acc_123", artistSlug: "gatsby-grace", template: "artist-caption-bedroom", lipsync: false, captionLength: "short", + upscale: false, + batch: 1, }); vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run_abc123" } as never); const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); @@ -61,22 +63,20 @@ describe("createContentHandler", () => { const body = await result.json(); expect(result.status).toBe(202); - expect(body).toEqual({ - runId: "run_abc123", - status: "triggered", - artist: "gatsby-grace", - template: "artist-caption-bedroom", - lipsync: false, - }); + expect(body.runIds).toEqual(["run_abc123"]); + expect(body.status).toBe("triggered"); + expect(body.artist).toBe("gatsby-grace"); }); - it("returns 500 when trigger fails", async () => { + it("returns 202 with empty runIds and failed count when trigger fails", async () => { vi.mocked(validateCreateContentBody).mockResolvedValue({ accountId: "acc_123", artistSlug: "gatsby-grace", template: "artist-caption-bedroom", lipsync: false, captionLength: "short", + upscale: false, + batch: 1, }); vi.mocked(triggerCreateContent).mockRejectedValue(new Error("Trigger unavailable")); const request = new NextRequest("http://localhost/api/content/create", { method: "POST" }); @@ -84,11 +84,9 @@ describe("createContentHandler", () => { const result = await createContentHandler(request); const body = await result.json(); - expect(result.status).toBe(500); - expect(body).toEqual({ - status: "error", - error: "Failed to trigger content creation", - }); + expect(result.status).toBe(202); + expect(body.runIds).toEqual([]); + expect(body.failed).toBe(1); }); it("returns 400 when artist is not ready", async () => { @@ -98,6 +96,8 @@ describe("createContentHandler", () => { template: "artist-caption-bedroom", lipsync: false, captionLength: "short", + upscale: false, + batch: 1, }); vi.mocked(getArtistContentReadiness).mockResolvedValue({ artist_slug: "gatsby-grace", diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index 5b4d5861..f7ed2de3 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -7,7 +7,7 @@ import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadine /** * Handler for POST /api/content/create. - * Triggers a background content-creation run and returns a runId for polling. + * Always returns runIds array (KISS — one response shape for single and batch). */ export async function createContentHandler(request: NextRequest): Promise { const validated = await validateCreateContentBody(request); @@ -45,42 +45,24 @@ export async function createContentHandler(request: NextRequest): Promise 1) { - // Batch mode: trigger N tasks in parallel. - // Use allSettled so partial failures don't lose successful runIds. - const results = await Promise.allSettled( - Array.from({ length: validated.batch }, () => triggerCreateContent(payload)), - ); - const runIds = results - .filter(r => r.status === "fulfilled") - .map(r => (r as PromiseFulfilledResult<{ id: string }>).value.id); - const failedCount = results.filter(r => r.status === "rejected").length; - - return NextResponse.json( - { - runIds, - status: "triggered", - batch: validated.batch, - triggered: runIds.length, - ...(failedCount > 0 && { failed: failedCount }), - artist: validated.artistSlug, - template: validated.template, - lipsync: validated.lipsync, - }, - { status: 202, headers: getCorsHeaders() }, - ); - } - - // Single mode - const handle = await triggerCreateContent(payload); + // Always use allSettled — works for single and batch. + const count = validated.batch; + const results = await Promise.allSettled( + Array.from({ length: count }, () => triggerCreateContent(payload)), + ); + const runIds = results + .filter(r => r.status === "fulfilled") + .map(r => (r as PromiseFulfilledResult<{ id: string }>).value.id); + const failedCount = results.filter(r => r.status === "rejected").length; return NextResponse.json( { - runId: handle.id, + runIds, status: "triggered", artist: validated.artistSlug, template: validated.template, lipsync: validated.lipsync, + ...(failedCount > 0 && { failed: failedCount }), }, { status: 202, headers: getCorsHeaders() }, ); From d89759b3e7ed68cf7299a025ac96ec3c2b6b4af4 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 5 Mar 2026 18:38:41 -0500 Subject: [PATCH 14/23] refactor: extract helpers into separate files for SRP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getArtistRootPrefix → lib/content/getArtistRootPrefix.ts - getArtistFileTree → lib/content/getArtistFileTree.ts - isCompletedRun + TriggerRunLike → lib/content/isCompletedRun.ts - booleanFromString → lib/content/booleanFromString.ts Co-Authored-By: Claude Opus 4.6 --- lib/content/booleanFromString.ts | 10 ++++ lib/content/getArtistContentReadiness.ts | 50 +------------------ lib/content/getArtistFileTree.ts | 34 +++++++++++++ lib/content/getArtistRootPrefix.ts | 13 +++++ lib/content/isCompletedRun.ts | 10 ++++ lib/content/persistCreateContentRunVideo.ts | 15 +----- .../validateGetContentEstimateQuery.ts | 11 +--- 7 files changed, 72 insertions(+), 71 deletions(-) create mode 100644 lib/content/booleanFromString.ts create mode 100644 lib/content/getArtistFileTree.ts create mode 100644 lib/content/getArtistRootPrefix.ts create mode 100644 lib/content/isCompletedRun.ts diff --git a/lib/content/booleanFromString.ts b/lib/content/booleanFromString.ts new file mode 100644 index 00000000..df2bc823 --- /dev/null +++ b/lib/content/booleanFromString.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +/** + * Parses a string query param as a boolean. Only "true" → true; everything else → false. + * z.coerce.boolean() would treat any non-empty string (including "false") as true. + */ +export const booleanFromString = z + .enum(["true", "false"]) + .default("false") + .transform(v => v === "true"); diff --git a/lib/content/getArtistContentReadiness.ts b/lib/content/getArtistContentReadiness.ts index b281d290..57e3490d 100644 --- a/lib/content/getArtistContentReadiness.ts +++ b/lib/content/getArtistContentReadiness.ts @@ -1,6 +1,6 @@ -import { getRepoFileTree, type FileTreeEntry } from "@/lib/github/getRepoFileTree"; -import { getOrgRepoUrls } from "@/lib/github/getOrgRepoUrls"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { getArtistFileTree } from "@/lib/content/getArtistFileTree"; +import { getArtistRootPrefix } from "@/lib/content/getArtistRootPrefix"; type MissingSeverity = "required" | "recommended"; @@ -19,52 +19,6 @@ export interface ArtistContentReadiness { githubRepo: string; } -function getArtistRootPrefix(paths: string[], artistSlug: string): string { - const preferredPrefix = `artists/${artistSlug}/`; - if (paths.some(path => path.startsWith(preferredPrefix))) { - return preferredPrefix; - } - - const directPrefix = `${artistSlug}/`; - if (paths.some(path => path.startsWith(directPrefix))) { - return directPrefix; - } - - return preferredPrefix; -} - -/** - * Gets the file tree that contains the artist, checking the main repo - * first, then falling back to org submodule repos. - */ -async function getArtistFileTree( - githubRepo: string, - artistSlug: string, -): Promise { - const mainTree = await getRepoFileTree(githubRepo); - if (mainTree) { - const blobPaths = mainTree.filter(e => e.type === "blob").map(e => e.path); - const hasArtist = blobPaths.some( - p => p.startsWith(`artists/${artistSlug}/`) || p.startsWith(`${artistSlug}/`), - ); - if (hasArtist) return mainTree; - } - - const orgUrls = await getOrgRepoUrls(githubRepo); - for (const orgUrl of orgUrls) { - const orgTree = await getRepoFileTree(orgUrl); - if (orgTree) { - const blobPaths = orgTree.filter(e => e.type === "blob").map(e => e.path); - const hasArtist = blobPaths.some( - p => p.startsWith(`artists/${artistSlug}/`) || p.startsWith(`${artistSlug}/`), - ); - if (hasArtist) return orgTree; - } - } - - return mainTree; -} - /** * Checks whether an artist has the expected files for content creation. * Searches the main repo and org submodule repos. diff --git a/lib/content/getArtistFileTree.ts b/lib/content/getArtistFileTree.ts new file mode 100644 index 00000000..908855a0 --- /dev/null +++ b/lib/content/getArtistFileTree.ts @@ -0,0 +1,34 @@ +import { getRepoFileTree, type FileTreeEntry } from "@/lib/github/getRepoFileTree"; +import { getOrgRepoUrls } from "@/lib/github/getOrgRepoUrls"; + +/** + * Gets the file tree that contains the artist, checking the main repo + * first, then falling back to org submodule repos. + */ +export async function getArtistFileTree( + githubRepo: string, + artistSlug: string, +): Promise { + const mainTree = await getRepoFileTree(githubRepo); + if (mainTree) { + const blobPaths = mainTree.filter(e => e.type === "blob").map(e => e.path); + const hasArtist = blobPaths.some( + p => p.startsWith(`artists/${artistSlug}/`) || p.startsWith(`${artistSlug}/`), + ); + if (hasArtist) return mainTree; + } + + const orgUrls = await getOrgRepoUrls(githubRepo); + for (const orgUrl of orgUrls) { + const orgTree = await getRepoFileTree(orgUrl); + if (orgTree) { + const blobPaths = orgTree.filter(e => e.type === "blob").map(e => e.path); + const hasArtist = blobPaths.some( + p => p.startsWith(`artists/${artistSlug}/`) || p.startsWith(`${artistSlug}/`), + ); + if (hasArtist) return orgTree; + } + } + + return mainTree; +} diff --git a/lib/content/getArtistRootPrefix.ts b/lib/content/getArtistRootPrefix.ts new file mode 100644 index 00000000..5a777abe --- /dev/null +++ b/lib/content/getArtistRootPrefix.ts @@ -0,0 +1,13 @@ +export function getArtistRootPrefix(paths: string[], artistSlug: string): string { + const preferredPrefix = `artists/${artistSlug}/`; + if (paths.some(path => path.startsWith(preferredPrefix))) { + return preferredPrefix; + } + + const directPrefix = `${artistSlug}/`; + if (paths.some(path => path.startsWith(directPrefix))) { + return directPrefix; + } + + return preferredPrefix; +} diff --git a/lib/content/isCompletedRun.ts b/lib/content/isCompletedRun.ts new file mode 100644 index 00000000..855ea068 --- /dev/null +++ b/lib/content/isCompletedRun.ts @@ -0,0 +1,10 @@ +export type TriggerRunLike = { + id: string; + status?: string | null; + taskIdentifier?: string | null; + output?: unknown; +}; + +export function isCompletedRun(run: TriggerRunLike): boolean { + return run.status === "COMPLETED"; +} diff --git a/lib/content/persistCreateContentRunVideo.ts b/lib/content/persistCreateContentRunVideo.ts index 87c8bbfa..25a77eed 100644 --- a/lib/content/persistCreateContentRunVideo.ts +++ b/lib/content/persistCreateContentRunVideo.ts @@ -3,13 +3,7 @@ import { uploadFileByKey } from "@/lib/supabase/storage/uploadFileByKey"; import { createFileRecord } from "@/lib/supabase/files/createFileRecord"; import { selectFileByStorageKey } from "@/lib/supabase/files/selectFileByStorageKey"; import { createSignedFileUrlByKey } from "@/lib/supabase/storage/createSignedFileUrlByKey"; - -type TriggerRunLike = { - id: string; - status?: string | null; - taskIdentifier?: string | null; - output?: unknown; -}; +import { isCompletedRun, type TriggerRunLike } from "@/lib/content/isCompletedRun"; type CreateContentOutput = { status?: string; @@ -28,10 +22,6 @@ type CreateContentOutput = { } | null; }; -function isCompleted(run: TriggerRunLike): boolean { - return run.status === "COMPLETED"; -} - /** * Persists create-content task video output to Supabase storage + files table * and returns the run with normalized output. @@ -39,7 +29,7 @@ function isCompleted(run: TriggerRunLike): boolean { * This keeps Supabase writes in API only. */ export async function persistCreateContentRunVideo(run: T): Promise { - if (run.taskIdentifier !== CREATE_CONTENT_TASK_ID || !isCompleted(run)) { + if (run.taskIdentifier !== CREATE_CONTENT_TASK_ID || !isCompletedRun(run)) { return run; } @@ -133,4 +123,3 @@ export async function persistCreateContentRunVideo(run }, }; } - diff --git a/lib/content/validateGetContentEstimateQuery.ts b/lib/content/validateGetContentEstimateQuery.ts index 0ce06a0f..5828e7cc 100644 --- a/lib/content/validateGetContentEstimateQuery.ts +++ b/lib/content/validateGetContentEstimateQuery.ts @@ -3,15 +3,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; - -/** - * Parses a string query param as a boolean. Only "true" → true; everything else → false. - * z.coerce.boolean() would treat any non-empty string (including "false") as true. - */ -const booleanFromString = z - .enum(["true", "false"]) - .default("false") - .transform(v => v === "true"); +import { booleanFromString } from "@/lib/content/booleanFromString"; export const getContentEstimateQuerySchema = z.object({ lipsync: booleanFromString, @@ -47,4 +39,3 @@ export async function validateGetContentEstimateQuery( return result.data; } - From ce603045b0a2ed77d36d54b0ee89818f8d24ae74 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:05:10 -0500 Subject: [PATCH 15/23] feat: add artist_account_id support to content endpoints - POST /api/content/create accepts artist_account_id OR artist_slug - GET /api/content/validate accepts artist_account_id OR artist_slug - resolveArtistSlug looks up artist name from accounts table, converts to slug - matches docs contract (either identifier works) - 404 if artist_account_id not found, 400 if neither provided --- .../validateCreateContentBody.test.ts | 4 +++ lib/content/resolveArtistSlug.ts | 20 ++++++++++++ lib/content/validateCreateContentBody.ts | 24 ++++++++++++-- .../validateGetContentValidateQuery.ts | 31 ++++++++++++------- 4 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 lib/content/resolveArtistSlug.ts diff --git a/lib/content/__tests__/validateCreateContentBody.test.ts b/lib/content/__tests__/validateCreateContentBody.test.ts index 3db2a14d..5e891c30 100644 --- a/lib/content/__tests__/validateCreateContentBody.test.ts +++ b/lib/content/__tests__/validateCreateContentBody.test.ts @@ -16,6 +16,10 @@ vi.mock("@/lib/networking/safeParseJson", () => ({ safeParseJson: vi.fn(async (req: Request) => req.json()), })); +vi.mock("@/lib/content/resolveArtistSlug", () => ({ + resolveArtistSlug: vi.fn().mockResolvedValue("gatsby-grace"), +})); + function createRequest(body: unknown): NextRequest { return new NextRequest("http://localhost/api/content/create", { method: "POST", diff --git a/lib/content/resolveArtistSlug.ts b/lib/content/resolveArtistSlug.ts new file mode 100644 index 00000000..c74a8379 --- /dev/null +++ b/lib/content/resolveArtistSlug.ts @@ -0,0 +1,20 @@ +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; + +/** + * Resolves an artist_account_id to an artist slug (directory name). + * + * The slug is derived from the artist's name, lowercased with spaces + * replaced by hyphens — matching how sandboxes generate folder names. + * + * @param artistAccountId - The artist's account UUID + * @returns The artist slug, or null if not found + */ +export async function resolveArtistSlug( + artistAccountId: string, +): Promise { + const accounts = await selectAccounts(artistAccountId); + const name = accounts[0]?.name; + if (!name) return null; + + return name.toLowerCase().replace(/\s+/g, "-"); +} diff --git a/lib/content/validateCreateContentBody.ts b/lib/content/validateCreateContentBody.ts index a16f4a48..b03a6a83 100644 --- a/lib/content/validateCreateContentBody.ts +++ b/lib/content/validateCreateContentBody.ts @@ -8,11 +8,13 @@ import { DEFAULT_CONTENT_TEMPLATE, isSupportedContentTemplate, } from "@/lib/content/contentTemplates"; +import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; export const CAPTION_LENGTHS = ["short", "medium", "long"] as const; export const createContentBodySchema = z.object({ - artist_slug: z.string({ message: "artist_slug is required" }).min(1, "artist_slug cannot be empty"), + artist_slug: z.string().min(1, "artist_slug cannot be empty").optional(), + artist_account_id: z.string().uuid("artist_account_id must be a valid UUID").optional(), template: z .string() .min(1, "template cannot be empty") @@ -72,9 +74,27 @@ export async function validateCreateContentBody( ); } + // Resolve artist slug from either artist_slug or artist_account_id + let artistSlug = result.data.artist_slug; + if (!artistSlug && result.data.artist_account_id) { + artistSlug = await resolveArtistSlug(result.data.artist_account_id) ?? undefined; + if (!artistSlug) { + return NextResponse.json( + { status: "error", error: "Artist not found for the provided artist_account_id" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + } + if (!artistSlug) { + return NextResponse.json( + { status: "error", error: "Either artist_slug or artist_account_id is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + return { accountId: authResult.accountId, - artistSlug: result.data.artist_slug, + artistSlug, template, lipsync: result.data.lipsync ?? false, captionLength: result.data.caption_length ?? "short", diff --git a/lib/content/validateGetContentValidateQuery.ts b/lib/content/validateGetContentValidateQuery.ts index 71a89795..03675869 100644 --- a/lib/content/validateGetContentValidateQuery.ts +++ b/lib/content/validateGetContentValidateQuery.ts @@ -3,9 +3,11 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; export const getContentValidateQuerySchema = z.object({ - artist_slug: z.string({ message: "artist_slug is required" }).min(1, "artist_slug cannot be empty"), + artist_slug: z.string().min(1).optional(), + artist_account_id: z.string().uuid().optional(), }); export type ValidatedGetContentValidateQuery = { @@ -15,6 +17,7 @@ export type ValidatedGetContentValidateQuery = { /** * Validates auth and query params for GET /api/content/validate. + * Accepts either artist_slug or artist_account_id. */ export async function validateGetContentValidateQuery( request: NextRequest, @@ -24,23 +27,29 @@ export async function validateGetContentValidateQuery( return authResult; } - const artistSlug = request.nextUrl.searchParams.get("artist_slug") ?? ""; - const result = getContentValidateQuerySchema.safeParse({ artist_slug: artistSlug }); + const artistSlugParam = request.nextUrl.searchParams.get("artist_slug") ?? undefined; + const artistAccountIdParam = request.nextUrl.searchParams.get("artist_account_id") ?? undefined; - if (!result.success) { - const firstError = result.error.issues[0]; + // Resolve artist slug + let artistSlug = artistSlugParam; + if (!artistSlug && artistAccountIdParam) { + artistSlug = await resolveArtistSlug(artistAccountIdParam) ?? undefined; + if (!artistSlug) { + return NextResponse.json( + { status: "error", error: "Artist not found for the provided artist_account_id" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + } + if (!artistSlug) { return NextResponse.json( - { - status: "error", - error: firstError.message, - }, + { status: "error", error: "Either artist_slug or artist_account_id is required" }, { status: 400, headers: getCorsHeaders() }, ); } return { accountId: authResult.accountId, - artistSlug: result.data.artist_slug, + artistSlug, }; } - From f6f533f40c7a5b1c6d6f72d8b88d9abcfbd0647d Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:16:55 -0500 Subject: [PATCH 16/23] refactor: use artist_account_id only, remove artist_slug (KISS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per reviewer feedback: one ID system, not two. - artist_account_id is the only accepted identifier - API resolves account_id → name → slug internally - removes artist_slug from create and validate schemas - matches how the rest of the Recoup platform works --- .../__tests__/createContentHandler.test.ts | 4 +-- .../validateCreateContentBody.test.ts | 8 ++--- lib/content/validateCreateContentBody.ts | 21 +++--------- .../validateGetContentValidateQuery.ts | 33 +++++++------------ 4 files changed, 22 insertions(+), 44 deletions(-) diff --git a/lib/content/__tests__/createContentHandler.test.ts b/lib/content/__tests__/createContentHandler.test.ts index daebb793..d73be874 100644 --- a/lib/content/__tests__/createContentHandler.test.ts +++ b/lib/content/__tests__/createContentHandler.test.ts @@ -25,7 +25,7 @@ describe("createContentHandler", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(getArtistContentReadiness).mockResolvedValue({ - artist_slug: "gatsby-grace", + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", ready: true, missing: [], warnings: [], @@ -100,7 +100,7 @@ describe("createContentHandler", () => { batch: 1, }); vi.mocked(getArtistContentReadiness).mockResolvedValue({ - artist_slug: "gatsby-grace", + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", ready: false, missing: [ { diff --git a/lib/content/__tests__/validateCreateContentBody.test.ts b/lib/content/__tests__/validateCreateContentBody.test.ts index 5e891c30..6a67b32b 100644 --- a/lib/content/__tests__/validateCreateContentBody.test.ts +++ b/lib/content/__tests__/validateCreateContentBody.test.ts @@ -40,7 +40,7 @@ describe("validateCreateContentBody", () => { it("returns validated payload for a valid request", async () => { const request = createRequest({ - artist_slug: "gatsby-grace", + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", template: "artist-caption-bedroom", lipsync: true, }); @@ -63,7 +63,7 @@ describe("validateCreateContentBody", () => { it("applies defaults when optional fields are omitted", async () => { const request = createRequest({ - artist_slug: "gatsby-grace", + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", }); const result = await validateCreateContentBody(request); @@ -90,7 +90,7 @@ describe("validateCreateContentBody", () => { it("returns 400 when template is unsupported", async () => { const request = createRequest({ - artist_slug: "gatsby-grace", + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", template: "not-a-real-template", }); @@ -108,7 +108,7 @@ describe("validateCreateContentBody", () => { mockValidateAuthContext.mockResolvedValue( NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), ); - const request = createRequest({ artist_slug: "gatsby-grace" }); + const request = createRequest({ artist_account_id: "550e8400-e29b-41d4-a716-446655440000" }); const result = await validateCreateContentBody(request); diff --git a/lib/content/validateCreateContentBody.ts b/lib/content/validateCreateContentBody.ts index b03a6a83..1ef36d0f 100644 --- a/lib/content/validateCreateContentBody.ts +++ b/lib/content/validateCreateContentBody.ts @@ -13,8 +13,7 @@ import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; export const CAPTION_LENGTHS = ["short", "medium", "long"] as const; export const createContentBodySchema = z.object({ - artist_slug: z.string().min(1, "artist_slug cannot be empty").optional(), - artist_account_id: z.string().uuid("artist_account_id must be a valid UUID").optional(), + artist_account_id: z.string({ message: "artist_account_id is required" }).uuid("artist_account_id must be a valid UUID"), template: z .string() .min(1, "template cannot be empty") @@ -74,21 +73,12 @@ export async function validateCreateContentBody( ); } - // Resolve artist slug from either artist_slug or artist_account_id - let artistSlug = result.data.artist_slug; - if (!artistSlug && result.data.artist_account_id) { - artistSlug = await resolveArtistSlug(result.data.artist_account_id) ?? undefined; - if (!artistSlug) { - return NextResponse.json( - { status: "error", error: "Artist not found for the provided artist_account_id" }, - { status: 404, headers: getCorsHeaders() }, - ); - } - } + // Resolve artist_account_id → slug (directory name) + const artistSlug = await resolveArtistSlug(result.data.artist_account_id); if (!artistSlug) { return NextResponse.json( - { status: "error", error: "Either artist_slug or artist_account_id is required" }, - { status: 400, headers: getCorsHeaders() }, + { status: "error", error: "Artist not found for the provided artist_account_id" }, + { status: 404, headers: getCorsHeaders() }, ); } @@ -102,4 +92,3 @@ export async function validateCreateContentBody( batch: result.data.batch ?? 1, }; } - diff --git a/lib/content/validateGetContentValidateQuery.ts b/lib/content/validateGetContentValidateQuery.ts index 03675869..9d21774b 100644 --- a/lib/content/validateGetContentValidateQuery.ts +++ b/lib/content/validateGetContentValidateQuery.ts @@ -1,15 +1,9 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; -export const getContentValidateQuerySchema = z.object({ - artist_slug: z.string().min(1).optional(), - artist_account_id: z.string().uuid().optional(), -}); - export type ValidatedGetContentValidateQuery = { accountId: string; artistSlug: string; @@ -17,7 +11,7 @@ export type ValidatedGetContentValidateQuery = { /** * Validates auth and query params for GET /api/content/validate. - * Accepts either artist_slug or artist_account_id. + * Requires artist_account_id query parameter. */ export async function validateGetContentValidateQuery( request: NextRequest, @@ -27,24 +21,19 @@ export async function validateGetContentValidateQuery( return authResult; } - const artistSlugParam = request.nextUrl.searchParams.get("artist_slug") ?? undefined; - const artistAccountIdParam = request.nextUrl.searchParams.get("artist_account_id") ?? undefined; - - // Resolve artist slug - let artistSlug = artistSlugParam; - if (!artistSlug && artistAccountIdParam) { - artistSlug = await resolveArtistSlug(artistAccountIdParam) ?? undefined; - if (!artistSlug) { - return NextResponse.json( - { status: "error", error: "Artist not found for the provided artist_account_id" }, - { status: 404, headers: getCorsHeaders() }, - ); - } + const artistAccountId = request.nextUrl.searchParams.get("artist_account_id"); + if (!artistAccountId) { + return NextResponse.json( + { status: "error", error: "artist_account_id query parameter is required" }, + { status: 400, headers: getCorsHeaders() }, + ); } + + const artistSlug = await resolveArtistSlug(artistAccountId); if (!artistSlug) { return NextResponse.json( - { status: "error", error: "Either artist_slug or artist_account_id is required" }, - { status: 400, headers: getCorsHeaders() }, + { status: "error", error: "Artist not found for the provided artist_account_id" }, + { status: 404, headers: getCorsHeaders() }, ); } From 036af7f3048448084032832689d3c34bd2a136e7 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:38:55 -0500 Subject: [PATCH 17/23] fix: use GitHub Contents API for .gitmodules (works for private repos) raw.githubusercontent.com returns 404 for private repos. Switched to api.github.com/repos/.../contents/ which works with Bearer token. --- .../__tests__/validateCreateContentBody.test.ts | 2 +- lib/github/__tests__/getRepoGitModules.test.ts | 12 ++++++++---- lib/github/getRepoGitModules.ts | 11 +++++++++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/lib/content/__tests__/validateCreateContentBody.test.ts b/lib/content/__tests__/validateCreateContentBody.test.ts index 6a67b32b..34c3688e 100644 --- a/lib/content/__tests__/validateCreateContentBody.test.ts +++ b/lib/content/__tests__/validateCreateContentBody.test.ts @@ -75,7 +75,7 @@ describe("validateCreateContentBody", () => { } }); - it("returns 400 when artist_slug is missing", async () => { + it("returns 400 when artist_account_id is missing", async () => { const request = createRequest({ template: "artist-caption-bedroom", }); diff --git a/lib/github/__tests__/getRepoGitModules.test.ts b/lib/github/__tests__/getRepoGitModules.test.ts index fd78fe0d..6cc87f95 100644 --- a/lib/github/__tests__/getRepoGitModules.test.ts +++ b/lib/github/__tests__/getRepoGitModules.test.ts @@ -25,8 +25,10 @@ describe("getRepoGitModules", () => { expect(result).toEqual([{ path: "orgs/my-org", url: "https://github.com/recoupable/org-abc" }]); expect(fetch).toHaveBeenCalledWith( - "https://raw.githubusercontent.com/owner/repo/main/.gitmodules", - expect.objectContaining({ headers: { Authorization: "Bearer test-token" } }), + "https://api.github.com/repos/owner/repo/contents/.gitmodules?ref=main", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer test-token" }), + }), ); }); @@ -51,8 +53,10 @@ describe("getRepoGitModules", () => { expect(result).toEqual([{ path: "sub", url: "https://github.com/owner/sub" }]); expect(fetch).toHaveBeenCalledWith( - "https://raw.githubusercontent.com/owner/repo/develop/.gitmodules", - {}, + "https://api.github.com/repos/owner/repo/contents/.gitmodules?ref=develop", + expect.objectContaining({ + headers: expect.objectContaining({ Accept: "application/vnd.github.v3.raw" }), + }), ); }); }); diff --git a/lib/github/getRepoGitModules.ts b/lib/github/getRepoGitModules.ts index c1889231..caa0304e 100644 --- a/lib/github/getRepoGitModules.ts +++ b/lib/github/getRepoGitModules.ts @@ -2,6 +2,7 @@ import { parseGitModules, type SubmoduleEntry } from "./parseGitModules"; /** * Fetches and parses .gitmodules from a GitHub repository. + * Uses the GitHub Contents API (works for both public and private repos). * * @param owner - The GitHub repository owner * @param repo - The GitHub repository name @@ -20,8 +21,14 @@ export async function getRepoGitModules({ const token = process.env.GITHUB_TOKEN; const response = await fetch( - `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/.gitmodules`, - { ...(token && { headers: { Authorization: `Bearer ${token}` } }) }, + `https://api.github.com/repos/${owner}/${repo}/contents/.gitmodules?ref=${branch}`, + { + headers: { + ...(token && { Authorization: `Bearer ${token}` }), + Accept: "application/vnd.github.v3.raw", + "User-Agent": "Recoup-API", + }, + }, ); if (!response.ok) { From 7dd354e8b9cf833b8fed7b7a674ca480dba6fca1 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:48:54 -0500 Subject: [PATCH 18/23] fix: make validation best-effort so submodule artists aren't blocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validation can't reliably see files in org submodule repos (private repo GitHub API limitations). The task has its own submodule-aware file discovery. Validation now warns but doesn't block — task fails with clear error if files are actually missing. --- .../__tests__/createContentHandler.test.ts | 9 ++-- lib/content/createContentHandler.ts | 41 +++++++++++-------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/lib/content/__tests__/createContentHandler.test.ts b/lib/content/__tests__/createContentHandler.test.ts index d73be874..71bd6c56 100644 --- a/lib/content/__tests__/createContentHandler.test.ts +++ b/lib/content/__tests__/createContentHandler.test.ts @@ -89,7 +89,7 @@ describe("createContentHandler", () => { expect(body.failed).toBe(1); }); - it("returns 400 when artist is not ready", async () => { + it("still triggers when readiness check finds missing files (best-effort)", async () => { vi.mocked(validateCreateContentBody).mockResolvedValue({ accountId: "acc_123", artistSlug: "gatsby-grace", @@ -116,10 +116,9 @@ describe("createContentHandler", () => { const result = await createContentHandler(request); const body = await result.json(); - expect(result.status).toBe(400); - expect(triggerCreateContent).not.toHaveBeenCalled(); - expect(body.ready).toBe(false); - expect(Array.isArray(body.missing)).toBe(true); + // Best-effort: validation doesn't block, task handles its own file discovery + expect(result.status).toBe(202); + expect(body.runIds).toBeDefined(); }); }); diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index f7ed2de3..3d7aaddf 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -16,23 +16,28 @@ export async function createContentHandler(request: NextRequest): Promise Date: Thu, 12 Mar 2026 14:34:09 -0500 Subject: [PATCH 19/23] refactor: remove account_id, use artist_account_id in content responses - Add artistAccountId to validated types and pass through handlers - Replace artist_slug with artist_account_id in API responses - artistSlug remains as internal impl detail for file system paths - Update all content tests to match Co-Authored-By: Claude Opus 4.6 --- lib/content/__tests__/createContentHandler.test.ts | 9 ++++++--- .../__tests__/getArtistContentReadiness.test.ts | 3 +++ .../__tests__/getContentValidateHandler.test.ts | 13 ++++++++----- .../__tests__/validateCreateContentBody.test.ts | 3 ++- lib/content/createContentHandler.ts | 3 ++- lib/content/getArtistContentReadiness.ts | 6 ++++-- lib/content/getContentValidateHandler.ts | 1 + lib/content/validateCreateContentBody.ts | 2 ++ lib/content/validateGetContentValidateQuery.ts | 2 ++ 9 files changed, 30 insertions(+), 12 deletions(-) diff --git a/lib/content/__tests__/createContentHandler.test.ts b/lib/content/__tests__/createContentHandler.test.ts index 71bd6c56..74022262 100644 --- a/lib/content/__tests__/createContentHandler.test.ts +++ b/lib/content/__tests__/createContentHandler.test.ts @@ -25,7 +25,7 @@ describe("createContentHandler", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(getArtistContentReadiness).mockResolvedValue({ - artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + artist_account_id: "art_456", ready: true, missing: [], warnings: [], @@ -49,6 +49,7 @@ describe("createContentHandler", () => { it("returns 202 with runIds when trigger succeeds", async () => { vi.mocked(validateCreateContentBody).mockResolvedValue({ accountId: "acc_123", + artistAccountId: "art_456", artistSlug: "gatsby-grace", template: "artist-caption-bedroom", lipsync: false, @@ -65,12 +66,13 @@ describe("createContentHandler", () => { expect(result.status).toBe(202); expect(body.runIds).toEqual(["run_abc123"]); expect(body.status).toBe("triggered"); - expect(body.artist).toBe("gatsby-grace"); + expect(body.artist_account_id).toBe("art_456"); }); it("returns 202 with empty runIds and failed count when trigger fails", async () => { vi.mocked(validateCreateContentBody).mockResolvedValue({ accountId: "acc_123", + artistAccountId: "art_456", artistSlug: "gatsby-grace", template: "artist-caption-bedroom", lipsync: false, @@ -92,6 +94,7 @@ describe("createContentHandler", () => { it("still triggers when readiness check finds missing files (best-effort)", async () => { vi.mocked(validateCreateContentBody).mockResolvedValue({ accountId: "acc_123", + artistAccountId: "art_456", artistSlug: "gatsby-grace", template: "artist-caption-bedroom", lipsync: false, @@ -100,7 +103,7 @@ describe("createContentHandler", () => { batch: 1, }); vi.mocked(getArtistContentReadiness).mockResolvedValue({ - artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + artist_account_id: "art_456", ready: false, missing: [ { diff --git a/lib/content/__tests__/getArtistContentReadiness.test.ts b/lib/content/__tests__/getArtistContentReadiness.test.ts index d1eb185a..e7cbf03e 100644 --- a/lib/content/__tests__/getArtistContentReadiness.test.ts +++ b/lib/content/__tests__/getArtistContentReadiness.test.ts @@ -33,6 +33,7 @@ describe("getArtistContentReadiness", () => { const result = await getArtistContentReadiness({ accountId: "acc_123", + artistAccountId: "art_456", artistSlug: "gatsby-grace", }); @@ -47,6 +48,7 @@ describe("getArtistContentReadiness", () => { const result = await getArtistContentReadiness({ accountId: "acc_123", + artistAccountId: "art_456", artistSlug: "gatsby-grace", }); @@ -63,6 +65,7 @@ describe("getArtistContentReadiness", () => { const result = await getArtistContentReadiness({ accountId: "acc_123", + artistAccountId: "art_456", artistSlug: "gatsby-grace", }); diff --git a/lib/content/__tests__/getContentValidateHandler.test.ts b/lib/content/__tests__/getContentValidateHandler.test.ts index 734fd6dd..136d8333 100644 --- a/lib/content/__tests__/getContentValidateHandler.test.ts +++ b/lib/content/__tests__/getContentValidateHandler.test.ts @@ -20,16 +20,17 @@ describe("getContentValidateHandler", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(getArtistContentReadiness).mockResolvedValue({ - artist_slug: "gatsby-grace", + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", ready: true, missing: [], warnings: [], + githubRepo: "https://github.com/test/repo", }); }); it("returns validation error when query validation fails", async () => { const errorResponse = NextResponse.json( - { status: "error", error: "artist_slug is required" }, + { status: "error", error: "artist_account_id query parameter is required" }, { status: 400 }, ); vi.mocked(validateGetContentValidateQuery).mockResolvedValue(errorResponse); @@ -42,10 +43,11 @@ describe("getContentValidateHandler", () => { it("returns readiness payload when validation succeeds", async () => { vi.mocked(validateGetContentValidateQuery).mockResolvedValue({ accountId: "acc_123", + artistAccountId: "550e8400-e29b-41d4-a716-446655440000", artistSlug: "gatsby-grace", }); const request = new NextRequest( - "http://localhost/api/content/validate?artist_slug=gatsby-grace", + "http://localhost/api/content/validate?artist_account_id=550e8400-e29b-41d4-a716-446655440000", { method: "GET", }, @@ -57,20 +59,21 @@ describe("getContentValidateHandler", () => { expect(result.status).toBe(200); expect(body.status).toBe("success"); expect(body.ready).toBe(true); - expect(body.artist_slug).toBe("gatsby-grace"); + expect(body.artist_account_id).toBe("550e8400-e29b-41d4-a716-446655440000"); expect(Array.isArray(body.missing)).toBe(true); }); it("returns 500 when readiness check throws", async () => { vi.mocked(validateGetContentValidateQuery).mockResolvedValue({ accountId: "acc_123", + artistAccountId: "550e8400-e29b-41d4-a716-446655440000", artistSlug: "gatsby-grace", }); vi.mocked(getArtistContentReadiness).mockRejectedValue( new Error("Failed to retrieve repository file tree"), ); const request = new NextRequest( - "http://localhost/api/content/validate?artist_slug=gatsby-grace", + "http://localhost/api/content/validate?artist_account_id=550e8400-e29b-41d4-a716-446655440000", { method: "GET", }, diff --git a/lib/content/__tests__/validateCreateContentBody.test.ts b/lib/content/__tests__/validateCreateContentBody.test.ts index 34c3688e..1bc66a43 100644 --- a/lib/content/__tests__/validateCreateContentBody.test.ts +++ b/lib/content/__tests__/validateCreateContentBody.test.ts @@ -51,12 +51,13 @@ describe("validateCreateContentBody", () => { if (!(result instanceof NextResponse)) { expect(result).toEqual({ accountId: "acc_123", + artistAccountId: "550e8400-e29b-41d4-a716-446655440000", artistSlug: "gatsby-grace", template: "artist-caption-bedroom", lipsync: true, captionLength: "short", upscale: false, - batch: 1, + batch: 1, }); } }); diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index 3d7aaddf..965857b9 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -23,6 +23,7 @@ export async function createContentHandler(request: NextRequest): Promise 0 && { failed: failedCount }), diff --git a/lib/content/getArtistContentReadiness.ts b/lib/content/getArtistContentReadiness.ts index 57e3490d..9201e7f6 100644 --- a/lib/content/getArtistContentReadiness.ts +++ b/lib/content/getArtistContentReadiness.ts @@ -11,7 +11,7 @@ export interface ContentReadinessIssue { } export interface ArtistContentReadiness { - artist_slug: string; + artist_account_id: string; ready: boolean; missing: ContentReadinessIssue[]; warnings: ContentReadinessIssue[]; @@ -25,9 +25,11 @@ export interface ArtistContentReadiness { */ export async function getArtistContentReadiness({ accountId, + artistAccountId, artistSlug, }: { accountId: string; + artistAccountId: string; artistSlug: string; }): Promise { const snapshots = await selectAccountSnapshots(accountId); @@ -111,7 +113,7 @@ export async function getArtistContentReadiness({ const warnings = issues.filter(item => item.severity === "recommended"); return { - artist_slug: artistSlug, + artist_account_id: artistAccountId, ready: requiredMissing.length === 0, missing: requiredMissing, warnings, diff --git a/lib/content/getContentValidateHandler.ts b/lib/content/getContentValidateHandler.ts index 47107e35..a4a082aa 100644 --- a/lib/content/getContentValidateHandler.ts +++ b/lib/content/getContentValidateHandler.ts @@ -18,6 +18,7 @@ export async function getContentValidateHandler(request: NextRequest): Promise Date: Thu, 12 Mar 2026 14:45:11 -0500 Subject: [PATCH 20/23] fix: top-level import for selectAccountSnapshots, install @chat-adapter/whatsapp - Move dynamic import to static top-level import in createContentHandler - Add proper mock in test instead of relying on dynamic import workaround - Install missing @chat-adapter/whatsapp package (fixes pre-existing test failure) All 1460 tests passing. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/createContentHandler.test.ts | 4 ++ lib/content/createContentHandler.ts | 2 +- pnpm-lock.yaml | 41 ------------------- 3 files changed, 5 insertions(+), 42 deletions(-) diff --git a/lib/content/__tests__/createContentHandler.test.ts b/lib/content/__tests__/createContentHandler.test.ts index 74022262..64589da6 100644 --- a/lib/content/__tests__/createContentHandler.test.ts +++ b/lib/content/__tests__/createContentHandler.test.ts @@ -21,6 +21,10 @@ vi.mock("@/lib/content/getArtistContentReadiness", () => ({ getArtistContentReadiness: vi.fn(), })); +vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ + selectAccountSnapshots: vi.fn(), +})); + describe("createContentHandler", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index 965857b9..b0e845f3 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -4,6 +4,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateCreateContentBody } from "@/lib/content/validateCreateContentBody"; import { triggerCreateContent } from "@/lib/trigger/triggerCreateContent"; import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; /** * Handler for POST /api/content/create. @@ -29,7 +30,6 @@ export async function createContentHandler(request: NextRequest): Promise=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1128,28 +1112,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.0.10': resolution: {integrity: sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.0.10': resolution: {integrity: sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.0.10': resolution: {integrity: sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.0.10': resolution: {integrity: sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==} @@ -1538,79 +1518,66 @@ packages: resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.55.1': resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.55.1': resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.55.1': resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.55.1': resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.55.1': resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.55.1': resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.55.1': resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.55.1': resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.55.1': resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.55.1': resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.55.1': resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.55.1': resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.55.1': resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} @@ -2451,49 +2418,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} From a6cf23e21b38e336044afe73ed3a6c4461acba43 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 12 Mar 2026 14:55:58 -0500 Subject: [PATCH 21/23] fix: handle empty repo (409) gracefully in content validation - Return ready=false instead of throwing when repo tree is null - Add repo/branch context to GitHub API error logs Co-Authored-By: Claude Opus 4.6 --- lib/content/getArtistContentReadiness.ts | 15 ++++++++++++++- lib/github/getRepoFileTree.ts | 4 ++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/content/getArtistContentReadiness.ts b/lib/content/getArtistContentReadiness.ts index 9201e7f6..d199c087 100644 --- a/lib/content/getArtistContentReadiness.ts +++ b/lib/content/getArtistContentReadiness.ts @@ -43,7 +43,20 @@ export async function getArtistContentReadiness({ const tree = await getArtistFileTree(githubRepo, artistSlug); if (!tree) { - throw new Error("Failed to retrieve repository file tree"); + // Empty repo or artist not found in any repo — return not-ready instead of crashing + return { + artist_account_id: artistAccountId, + ready: false, + missing: [ + { + file: "artists/", + severity: "required" as const, + fix: "No repository file tree found. The sandbox repo may be empty or the artist directory does not exist yet.", + }, + ], + warnings: [], + githubRepo, + }; } const blobPaths = tree.filter(entry => entry.type === "blob").map(entry => entry.path); diff --git a/lib/github/getRepoFileTree.ts b/lib/github/getRepoFileTree.ts index 6ce2c94b..bceeaae5 100644 --- a/lib/github/getRepoFileTree.ts +++ b/lib/github/getRepoFileTree.ts @@ -40,7 +40,7 @@ export async function getRepoFileTree(githubRepoUrl: string): Promise Date: Thu, 12 Mar 2026 16:14:39 -0500 Subject: [PATCH 22/23] fix: strip internal githubRepo from content validate response Prevents leaking the internal GitHub repository URL in the public API response by destructuring it out before serializing. Co-Authored-By: Claude Opus 4.6 --- lib/content/getContentValidateHandler.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/content/getContentValidateHandler.ts b/lib/content/getContentValidateHandler.ts index a4a082aa..268741c1 100644 --- a/lib/content/getContentValidateHandler.ts +++ b/lib/content/getContentValidateHandler.ts @@ -22,10 +22,12 @@ export async function getContentValidateHandler(request: NextRequest): Promise Date: Thu, 12 Mar 2026 18:42:05 -0500 Subject: [PATCH 23/23] revert: remove video hydration from getTaskRunHandler Decouples run status checks from video storage uploads. Video persistence will be handled separately in a future iteration. Co-Authored-By: Claude Opus 4.6 --- lib/tasks/__tests__/getTaskRunHandler.test.ts | 35 +++---------------- lib/tasks/getTaskRunHandler.ts | 31 +++------------- 2 files changed, 9 insertions(+), 57 deletions(-) diff --git a/lib/tasks/__tests__/getTaskRunHandler.test.ts b/lib/tasks/__tests__/getTaskRunHandler.test.ts index 54c5c397..bce2ce57 100644 --- a/lib/tasks/__tests__/getTaskRunHandler.test.ts +++ b/lib/tasks/__tests__/getTaskRunHandler.test.ts @@ -6,7 +6,6 @@ import { getTaskRunHandler } from "../getTaskRunHandler"; import { validateGetTaskRunQuery } from "../validateGetTaskRunQuery"; import { retrieveTaskRun } from "@/lib/trigger/retrieveTaskRun"; import { listTaskRuns } from "@/lib/trigger/listTaskRuns"; -import { persistCreateContentRunVideo } from "@/lib/content/persistCreateContentRunVideo"; vi.mock("../validateGetTaskRunQuery", () => ({ validateGetTaskRunQuery: vi.fn(), @@ -20,17 +19,10 @@ vi.mock("@/lib/trigger/listTaskRuns", () => ({ listTaskRuns: vi.fn(), })); -vi.mock("@/lib/content/persistCreateContentRunVideo", () => ({ - persistCreateContentRunVideo: vi.fn(async run => run), -})); - vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -/** - * - */ function createMockRequest(): NextRequest { return { url: "http://localhost:3000/api/tasks/runs", @@ -56,7 +48,6 @@ const mockRun = { describe("getTaskRunHandler", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(persistCreateContentRunVideo).mockImplementation(async run => run as never); }); it("returns error response when validation fails", async () => { @@ -80,7 +71,6 @@ describe("getTaskRunHandler", () => { expect(json.status).toBe("success"); expect(json.runs).toHaveLength(1); expect(json.runs[0].id).toBe("run_123"); - expect(persistCreateContentRunVideo).toHaveBeenCalledWith(mockRun); }); it("returns 404 when run is not found", async () => { @@ -104,11 +94,7 @@ describe("getTaskRunHandler", () => { describe("list mode", () => { it("returns empty runs array", async () => { - vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ - mode: "list", - accountId: "acc_123", - limit: 20, - }); + vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ mode: "list", accountId: "acc_123", limit: 20 }); vi.mocked(listTaskRuns).mockResolvedValue([]); const response = await getTaskRunHandler(createMockRequest()); @@ -119,11 +105,7 @@ describe("getTaskRunHandler", () => { }); it("returns populated runs array", async () => { - vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ - mode: "list", - accountId: "acc_123", - limit: 20, - }); + vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ mode: "list", accountId: "acc_123", limit: 20 }); vi.mocked(listTaskRuns).mockResolvedValue([mockRun]); const response = await getTaskRunHandler(createMockRequest()); @@ -131,15 +113,10 @@ describe("getTaskRunHandler", () => { expect(json.status).toBe("success"); expect(json.runs).toHaveLength(1); - expect(persistCreateContentRunVideo).toHaveBeenCalledWith(mockRun); }); it("calls listTaskRuns with accountId and limit", async () => { - vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ - mode: "list", - accountId: "acc_456", - limit: 50, - }); + vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ mode: "list", accountId: "acc_456", limit: 50 }); vi.mocked(listTaskRuns).mockResolvedValue([]); await getTaskRunHandler(createMockRequest()); @@ -148,11 +125,7 @@ describe("getTaskRunHandler", () => { }); it("returns 500 when listTaskRuns throws", async () => { - vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ - mode: "list", - accountId: "acc_123", - limit: 20, - }); + vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ mode: "list", accountId: "acc_123", limit: 20 }); vi.mocked(listTaskRuns).mockRejectedValue(new Error("API error")); const response = await getTaskRunHandler(createMockRequest()); diff --git a/lib/tasks/getTaskRunHandler.ts b/lib/tasks/getTaskRunHandler.ts index ecf23e11..70fb5041 100644 --- a/lib/tasks/getTaskRunHandler.ts +++ b/lib/tasks/getTaskRunHandler.ts @@ -4,7 +4,6 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateGetTaskRunQuery } from "./validateGetTaskRunQuery"; import { retrieveTaskRun } from "@/lib/trigger/retrieveTaskRun"; import { listTaskRuns } from "@/lib/trigger/listTaskRuns"; -import { persistCreateContentRunVideo } from "@/lib/content/persistCreateContentRunVideo"; /** * Handles GET /api/tasks/runs requests. @@ -24,19 +23,8 @@ export async function getTaskRunHandler(request: NextRequest): Promise { - try { - return await persistCreateContentRunVideo(run); - } catch (err) { - console.error("Video hydration failed for run", run.id, err); - return run; - } - }), - ); return NextResponse.json( - { status: "success", runs: hydratedRuns }, + { status: "success", runs }, { status: 200, headers: getCorsHeaders() }, ); } @@ -50,19 +38,10 @@ export async function getTaskRunHandler(request: NextRequest): Promise