From 7d023faec2ec47b9542059d5c88f3dcd8eb1cf8f Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:50:15 -0500 Subject: [PATCH 1/2] docs: make SRP and DRY review expectations explicit --- AGENTS.md | 45 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e7190b8a..8ef33610 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,28 @@ export async function selectTableName({ - All API routes should have JSDoc comments - Run `pnpm lint` before committing +### Reviewer Non-Negotiables (PR-blocking) + +These rules reflect current code review expectations and should be treated as blocking quality gates: + +1. **Barrel files are re-export only** + - `index.ts` files should export symbols; they should not contain domain/business logic. + +2. **One exported responsibility per file** + - If a file grows multiple exported helpers (parsers, mappers, type guards), split them. + - Name files by purpose (e.g., `parseJsonLike.ts`, `isFlamingoGenerateResult.ts`). + +3. **Shared schemas for shared contracts** + - Do not duplicate Zod schema shape across API route + MCP tool for the same feature. + - Reuse a shared schema/validator from `lib/[domain]/validate*.ts`. + +4. **Contract parity across interfaces** + - API route and MCP tool for the same capability must enforce equivalent validation rules + (required fields, exclusivity constraints, defaults, limits). + +5. **Extract repeated transforms** + - If parsing/normalization logic appears more than once in a domain, move it to a shared helper. + ### Terminology Use **"account"** terminology, never "entity" or "user". All entities in the system (individuals, artists, workspaces, organizations) are "accounts". When referring to specific types, use the specific name: @@ -418,6 +440,7 @@ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/proto import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; // Import shared domain logic @@ -436,15 +459,17 @@ export function registerToolNameTool(server: McpServer): void { }, async (args, extra: RequestHandlerExtra) => { const authInfo = extra.authInfo as McpAuthInfo | undefined; - const accountId = authInfo?.extra?.accountId; - const orgId = authInfo?.extra?.orgId ?? null; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); - if (!accountId) { - return getToolResultError("Authentication required."); + if (error) { + return getToolResultError(error); } // Use shared domain logic (same as API route) - const result = await sharedDomainFunction({ accountId, orgId, ...args }); + const result = await sharedDomainFunction({ accountId, ...args }); if (!result) { return getToolResultError("Operation failed"); @@ -462,6 +487,16 @@ export function registerToolNameTool(server: McpServer): void { - `getToolResultError(message)` - Wrap error responses - `resolveAccountId({ authInfo, accountIdOverride })` - Resolve account from auth +### Pre-Push SRP/DRY Checklist + +Before commit/push, verify all items: + +- [ ] I did not place business logic in a barrel `index.ts`. +- [ ] New helper/parser/type guard functions are in dedicated files. +- [ ] I reused existing validation schema/validator when API + MCP share the same contract. +- [ ] I did not duplicate domain transforms that already exist. +- [ ] Matching API + MCP feature paths enforce the same validation behavior. + ## Constants (`lib/const.ts`) All shared constants live in `lib/const.ts`: From b10017e782367c9342c91e37796af6140287b70f 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 2/2] 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 f2987964..9cd8cae9 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. @@ -34,10 +35,4 @@ export const RECOUP_API_KEY = process.env.RECOUP_API_KEY || ""; // 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; +}