Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
42ade7b
feat(content): add create-content API flow and run video hydration
sidneyswift Mar 4, 2026
cc04d75
fix: address code review feedback on content-creation endpoints
sidneyswift Mar 5, 2026
f43db30
refactor: make config.json optional with sensible pipeline defaults
sidneyswift Mar 5, 2026
125f3e2
feat: pass githubRepo to create-content task payload
sidneyswift Mar 5, 2026
da2662b
feat: add caption_length param to content creation endpoint
sidneyswift Mar 5, 2026
88bba09
feat: add upscale param to content creation endpoint
sidneyswift Mar 5, 2026
8e4631a
fix: validation now searches org submodule repos for artist files
sidneyswift Mar 5, 2026
5c6b099
feat: add batch mode — trigger N videos in parallel
sidneyswift Mar 5, 2026
018910c
refactor: extract getOrgRepoUrls to shared lib/github/ (DRY)
sidneyswift Mar 5, 2026
4c36aa0
fix: address CodeRabbit review feedback (round 2)
sidneyswift Mar 5, 2026
49d447b
fix: address all remaining CodeRabbit review comments
sidneyswift Mar 5, 2026
0b83263
fix: resolve TypeScript build error in batch mode filter
sidneyswift Mar 5, 2026
27b0573
refactor: always return runIds array (KISS — one response shape)
sidneyswift Mar 5, 2026
d89759b
refactor: extract helpers into separate files for SRP
sweetmantech Mar 5, 2026
ce60304
feat: add artist_account_id support to content endpoints
sidneyswift Mar 6, 2026
f6f533f
refactor: use artist_account_id only, remove artist_slug (KISS)
sidneyswift Mar 6, 2026
036af7f
fix: use GitHub Contents API for .gitmodules (works for private repos)
sidneyswift Mar 6, 2026
7dd354e
fix: make validation best-effort so submodule artists aren't blocked
sidneyswift Mar 6, 2026
25c21a1
Merge remote-tracking branch 'origin/test' into feat/content-creation…
sweetmantech Mar 12, 2026
27968af
refactor: remove account_id, use artist_account_id in content responses
sweetmantech Mar 12, 2026
36d82f8
fix: top-level import for selectAccountSnapshots, install @chat-adapt…
sweetmantech Mar 12, 2026
a6cf23e
fix: handle empty repo (409) gracefully in content validation
sweetmantech Mar 12, 2026
095349c
fix: strip internal githubRepo from content validate response
sweetmantech Mar 12, 2026
a6000a6
revert: remove video hydration from getTaskRunHandler
sweetmantech Mar 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/api/content/create/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
return createContentHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;

32 changes: 32 additions & 0 deletions app/api/content/estimate/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
return getContentEstimateHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;

32 changes: 32 additions & 0 deletions app/api/content/templates/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
return getContentTemplatesHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;

32 changes: 32 additions & 0 deletions app/api/content/validate/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
return getContentValidateHandler(request);
}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;

9 changes: 2 additions & 7 deletions lib/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com";
export const RECOUP_FROM_EMAIL = `Agent by Recoup <agent${OUTBOUND_EMAIL_DOMAIN}>`;

export const SUPABASE_STORAGE_BUCKET = "user-files";
export const CREATE_CONTENT_TASK_ID = "create-content";

/**
* UUID of the Recoup admin organization.
Expand All @@ -41,10 +42,4 @@ export const SNAPSHOT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
// 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"];
131 changes: 131 additions & 0 deletions lib/content/__tests__/createContentHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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(),
}));

vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({
selectAccountSnapshots: vi.fn(),
}));

describe("createContentHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getArtistContentReadiness).mockResolvedValue({
artist_account_id: "art_456",
ready: true,
missing: [],
warnings: [],
githubRepo: "https://github.com/test/repo",
});
});

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 runIds when trigger succeeds", async () => {
vi.mocked(validateCreateContentBody).mockResolvedValue({
accountId: "acc_123",
artistAccountId: "art_456",
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" });

const result = await createContentHandler(request);
const body = await result.json();

expect(result.status).toBe(202);
expect(body.runIds).toEqual(["run_abc123"]);
expect(body.status).toBe("triggered");
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,
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" });

const result = await createContentHandler(request);
const body = await result.json();

expect(result.status).toBe(202);
expect(body.runIds).toEqual([]);
expect(body.failed).toBe(1);
});

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,
captionLength: "short",
upscale: false,
batch: 1,
});
vi.mocked(getArtistContentReadiness).mockResolvedValue({
artist_account_id: "art_456",
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();

// Best-effort: validation doesn't block, task handles its own file discovery
expect(result.status).toBe(202);
expect(body.runIds).toBeDefined();
});
});

89 changes: 89 additions & 0 deletions lib/content/__tests__/getArtistContentReadiness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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",
artistAccountId: "art_456",
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",
artistAccountId: "art_456",
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 === "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",
artistAccountId: "art_456",
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);

await expect(
getArtistContentReadiness({
accountId: "acc_123",
artistSlug: "gatsby-grace",
}),
).rejects.toThrow("No GitHub repository configured for this account");
});
});

Loading
Loading