Skip to content

Commit f1d9035

Browse files
sidneyswiftsweetmantechclaude
authored
feat: add content-creation API endpoints and video persistence (#259)
* feat(content): add create-content API flow and run video hydration * 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 * 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 * feat: pass githubRepo to create-content task payload - extend TriggerCreateContentPayload with githubRepo field - getArtistContentReadiness now returns githubRepo - createContentHandler passes githubRepo when triggering task * 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 * feat: add upscale param to content creation endpoint * 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 * feat: add batch mode — trigger N videos in parallel - add batch param (1-30, default 1) - batch mode returns { runIds: [...] } instead of { runId } - each task runs independently with its own random selections * 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 * 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 * 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 * fix: resolve TypeScript build error in batch mode filter * refactor: always return runIds array (KISS — one response shape) Per reviewer feedback: single mode and batch mode now both return runIds array. Removes the runId/runIds split. Clients always handle one shape. * refactor: extract helpers into separate files for SRP - 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 <noreply@anthropic.com> * 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 * refactor: use artist_account_id only, remove artist_slug (KISS) 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 * 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. * fix: make validation best-effort so submodule artists aren't blocked 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. * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Sweets Sweetman <sweetmantech@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 75a42c2 commit f1d9035

36 files changed

+1782
-56
lines changed

app/api/content/create/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
3+
import { createContentHandler } from "@/lib/content/createContentHandler";
4+
5+
/**
6+
* OPTIONS handler for CORS preflight requests.
7+
*
8+
* @returns Empty 204 response with CORS headers.
9+
*/
10+
export async function OPTIONS() {
11+
return new NextResponse(null, {
12+
status: 204,
13+
headers: getCorsHeaders(),
14+
});
15+
}
16+
17+
/**
18+
* POST /api/content/create
19+
*
20+
* Triggers the background content-creation pipeline and returns a run ID.
21+
*
22+
* @param request - Incoming API request.
23+
* @returns Trigger response for the created task run.
24+
*/
25+
export async function POST(request: NextRequest): Promise<NextResponse> {
26+
return createContentHandler(request);
27+
}
28+
29+
export const dynamic = "force-dynamic";
30+
export const fetchCache = "force-no-store";
31+
export const revalidate = 0;
32+

app/api/content/estimate/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
3+
import { getContentEstimateHandler } from "@/lib/content/getContentEstimateHandler";
4+
5+
/**
6+
* OPTIONS handler for CORS preflight requests.
7+
*
8+
* @returns Empty 204 response with CORS headers.
9+
*/
10+
export async function OPTIONS() {
11+
return new NextResponse(null, {
12+
status: 204,
13+
headers: getCorsHeaders(),
14+
});
15+
}
16+
17+
/**
18+
* GET /api/content/estimate
19+
*
20+
* Returns estimated content-creation costs.
21+
*
22+
* @param request - Incoming API request.
23+
* @returns Cost estimate response.
24+
*/
25+
export async function GET(request: NextRequest): Promise<NextResponse> {
26+
return getContentEstimateHandler(request);
27+
}
28+
29+
export const dynamic = "force-dynamic";
30+
export const fetchCache = "force-no-store";
31+
export const revalidate = 0;
32+

app/api/content/templates/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
3+
import { getContentTemplatesHandler } from "@/lib/content/getContentTemplatesHandler";
4+
5+
/**
6+
* OPTIONS handler for CORS preflight requests.
7+
*
8+
* @returns Empty 204 response with CORS headers.
9+
*/
10+
export async function OPTIONS() {
11+
return new NextResponse(null, {
12+
status: 204,
13+
headers: getCorsHeaders(),
14+
});
15+
}
16+
17+
/**
18+
* GET /api/content/templates
19+
*
20+
* Lists available templates for the content-creation pipeline.
21+
*
22+
* @param request - Incoming API request.
23+
* @returns Template list response.
24+
*/
25+
export async function GET(request: NextRequest): Promise<NextResponse> {
26+
return getContentTemplatesHandler(request);
27+
}
28+
29+
export const dynamic = "force-dynamic";
30+
export const fetchCache = "force-no-store";
31+
export const revalidate = 0;
32+

app/api/content/validate/route.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
3+
import { getContentValidateHandler } from "@/lib/content/getContentValidateHandler";
4+
5+
/**
6+
* OPTIONS handler for CORS preflight requests.
7+
*
8+
* @returns Empty 204 response with CORS headers.
9+
*/
10+
export async function OPTIONS() {
11+
return new NextResponse(null, {
12+
status: 204,
13+
headers: getCorsHeaders(),
14+
});
15+
}
16+
17+
/**
18+
* GET /api/content/validate
19+
*
20+
* Validates whether an artist is ready for content creation.
21+
*
22+
* @param request - Incoming API request.
23+
* @returns Artist readiness response.
24+
*/
25+
export async function GET(request: NextRequest): Promise<NextResponse> {
26+
return getContentValidateHandler(request);
27+
}
28+
29+
export const dynamic = "force-dynamic";
30+
export const fetchCache = "force-no-store";
31+
export const revalidate = 0;
32+

lib/const.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const OUTBOUND_EMAIL_DOMAIN = "@recoupable.com";
2222
export const RECOUP_FROM_EMAIL = `Agent by Recoup <agent${OUTBOUND_EMAIL_DOMAIN}>`;
2323

2424
export const SUPABASE_STORAGE_BUCKET = "user-files";
25+
export const CREATE_CONTENT_TASK_ID = "create-content";
2526

2627
/**
2728
* UUID of the Recoup admin organization.
@@ -41,10 +42,4 @@ export const SNAPSHOT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
4142
// EVALS
4243
export const EVAL_ACCOUNT_ID = "fb678396-a68f-4294-ae50-b8cacf9ce77b";
4344
export const EVAL_ACCESS_TOKEN = process.env.EVAL_ACCESS_TOKEN || "";
44-
export const EVAL_ARTISTS = [
45-
"Gliiico",
46-
"Mac Miller",
47-
"Wiz Khalifa",
48-
"Mod Sun",
49-
"Julius Black",
50-
];
45+
export const EVAL_ARTISTS = ["Gliiico", "Mac Miller", "Wiz Khalifa", "Mod Sun", "Julius Black"];
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { NextRequest, NextResponse } from "next/server";
3+
import { createContentHandler } from "@/lib/content/createContentHandler";
4+
import { validateCreateContentBody } from "@/lib/content/validateCreateContentBody";
5+
import { triggerCreateContent } from "@/lib/trigger/triggerCreateContent";
6+
import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness";
7+
8+
vi.mock("@/lib/networking/getCorsHeaders", () => ({
9+
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
10+
}));
11+
12+
vi.mock("@/lib/content/validateCreateContentBody", () => ({
13+
validateCreateContentBody: vi.fn(),
14+
}));
15+
16+
vi.mock("@/lib/trigger/triggerCreateContent", () => ({
17+
triggerCreateContent: vi.fn(),
18+
}));
19+
20+
vi.mock("@/lib/content/getArtistContentReadiness", () => ({
21+
getArtistContentReadiness: vi.fn(),
22+
}));
23+
24+
vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({
25+
selectAccountSnapshots: vi.fn(),
26+
}));
27+
28+
describe("createContentHandler", () => {
29+
beforeEach(() => {
30+
vi.clearAllMocks();
31+
vi.mocked(getArtistContentReadiness).mockResolvedValue({
32+
artist_account_id: "art_456",
33+
ready: true,
34+
missing: [],
35+
warnings: [],
36+
githubRepo: "https://github.com/test/repo",
37+
});
38+
});
39+
40+
it("returns validation/auth error when validation fails", async () => {
41+
const errorResponse = NextResponse.json(
42+
{ status: "error", error: "Unauthorized" },
43+
{ status: 401 },
44+
);
45+
vi.mocked(validateCreateContentBody).mockResolvedValue(errorResponse);
46+
const request = new NextRequest("http://localhost/api/content/create", { method: "POST" });
47+
48+
const result = await createContentHandler(request);
49+
50+
expect(result).toBe(errorResponse);
51+
});
52+
53+
it("returns 202 with runIds when trigger succeeds", async () => {
54+
vi.mocked(validateCreateContentBody).mockResolvedValue({
55+
accountId: "acc_123",
56+
artistAccountId: "art_456",
57+
artistSlug: "gatsby-grace",
58+
template: "artist-caption-bedroom",
59+
lipsync: false,
60+
captionLength: "short",
61+
upscale: false,
62+
batch: 1,
63+
});
64+
vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run_abc123" } as never);
65+
const request = new NextRequest("http://localhost/api/content/create", { method: "POST" });
66+
67+
const result = await createContentHandler(request);
68+
const body = await result.json();
69+
70+
expect(result.status).toBe(202);
71+
expect(body.runIds).toEqual(["run_abc123"]);
72+
expect(body.status).toBe("triggered");
73+
expect(body.artist_account_id).toBe("art_456");
74+
});
75+
76+
it("returns 202 with empty runIds and failed count when trigger fails", async () => {
77+
vi.mocked(validateCreateContentBody).mockResolvedValue({
78+
accountId: "acc_123",
79+
artistAccountId: "art_456",
80+
artistSlug: "gatsby-grace",
81+
template: "artist-caption-bedroom",
82+
lipsync: false,
83+
captionLength: "short",
84+
upscale: false,
85+
batch: 1,
86+
});
87+
vi.mocked(triggerCreateContent).mockRejectedValue(new Error("Trigger unavailable"));
88+
const request = new NextRequest("http://localhost/api/content/create", { method: "POST" });
89+
90+
const result = await createContentHandler(request);
91+
const body = await result.json();
92+
93+
expect(result.status).toBe(202);
94+
expect(body.runIds).toEqual([]);
95+
expect(body.failed).toBe(1);
96+
});
97+
98+
it("still triggers when readiness check finds missing files (best-effort)", async () => {
99+
vi.mocked(validateCreateContentBody).mockResolvedValue({
100+
accountId: "acc_123",
101+
artistAccountId: "art_456",
102+
artistSlug: "gatsby-grace",
103+
template: "artist-caption-bedroom",
104+
lipsync: false,
105+
captionLength: "short",
106+
upscale: false,
107+
batch: 1,
108+
});
109+
vi.mocked(getArtistContentReadiness).mockResolvedValue({
110+
artist_account_id: "art_456",
111+
ready: false,
112+
missing: [
113+
{
114+
file: "context/images/face-guide.png",
115+
severity: "required",
116+
fix: "Generate a face guide image before creating content.",
117+
},
118+
],
119+
warnings: [],
120+
});
121+
const request = new NextRequest("http://localhost/api/content/create", { method: "POST" });
122+
123+
const result = await createContentHandler(request);
124+
const body = await result.json();
125+
126+
// Best-effort: validation doesn't block, task handles its own file discovery
127+
expect(result.status).toBe(202);
128+
expect(body.runIds).toBeDefined();
129+
});
130+
});
131+
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness";
3+
import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots";
4+
import { getRepoFileTree } from "@/lib/github/getRepoFileTree";
5+
6+
vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({
7+
selectAccountSnapshots: vi.fn(),
8+
}));
9+
10+
vi.mock("@/lib/github/getRepoFileTree", () => ({
11+
getRepoFileTree: vi.fn(),
12+
}));
13+
14+
describe("getArtistContentReadiness", () => {
15+
beforeEach(() => {
16+
vi.clearAllMocks();
17+
vi.mocked(selectAccountSnapshots).mockResolvedValue([
18+
{
19+
github_repo: "https://github.com/test-org/test-repo",
20+
},
21+
] as never);
22+
});
23+
24+
it("returns ready=true when required files exist", async () => {
25+
vi.mocked(getRepoFileTree).mockResolvedValue([
26+
{ path: "artists/gatsby-grace/context/images/face-guide.png", type: "blob", sha: "1" },
27+
{ path: "artists/gatsby-grace/config/content-creation/config.json", type: "blob", sha: "2" },
28+
{ path: "artists/gatsby-grace/songs/song-a.mp3", type: "blob", sha: "3" },
29+
{ path: "artists/gatsby-grace/context/artist.md", type: "blob", sha: "4" },
30+
{ path: "artists/gatsby-grace/context/audience.md", type: "blob", sha: "5" },
31+
{ path: "artists/gatsby-grace/context/era.json", type: "blob", sha: "6" },
32+
]);
33+
34+
const result = await getArtistContentReadiness({
35+
accountId: "acc_123",
36+
artistAccountId: "art_456",
37+
artistSlug: "gatsby-grace",
38+
});
39+
40+
expect(result.ready).toBe(true);
41+
expect(result.missing).toEqual([]);
42+
});
43+
44+
it("returns ready=false with required issues when core files are missing", async () => {
45+
vi.mocked(getRepoFileTree).mockResolvedValue([
46+
{ path: "artists/gatsby-grace/context/artist.md", type: "blob", sha: "1" },
47+
]);
48+
49+
const result = await getArtistContentReadiness({
50+
accountId: "acc_123",
51+
artistAccountId: "art_456",
52+
artistSlug: "gatsby-grace",
53+
});
54+
55+
expect(result.ready).toBe(false);
56+
expect(result.missing.some(item => item.file === "context/images/face-guide.png")).toBe(true);
57+
expect(result.missing.some(item => item.file === "songs/*.mp3")).toBe(true);
58+
});
59+
60+
it("returns ready=true when only face-guide and mp3 exist (config.json is optional)", async () => {
61+
vi.mocked(getRepoFileTree).mockResolvedValue([
62+
{ path: "artists/gatsby-grace/context/images/face-guide.png", type: "blob", sha: "1" },
63+
{ path: "artists/gatsby-grace/songs/track.mp3", type: "blob", sha: "2" },
64+
]);
65+
66+
const result = await getArtistContentReadiness({
67+
accountId: "acc_123",
68+
artistAccountId: "art_456",
69+
artistSlug: "gatsby-grace",
70+
});
71+
72+
expect(result.ready).toBe(true);
73+
expect(result.missing).toEqual([]);
74+
// config.json appears as a warning, not a blocker
75+
expect(result.warnings.some(item => item.file === "config/content-creation/config.json")).toBe(true);
76+
});
77+
78+
it("throws when account has no github repo", async () => {
79+
vi.mocked(selectAccountSnapshots).mockResolvedValue([] as never);
80+
81+
await expect(
82+
getArtistContentReadiness({
83+
accountId: "acc_123",
84+
artistSlug: "gatsby-grace",
85+
}),
86+
).rejects.toThrow("No GitHub repository configured for this account");
87+
});
88+
});
89+

0 commit comments

Comments
 (0)