Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 40 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -436,15 +459,17 @@ export function registerToolNameTool(server: McpServer): void {
},
async (args, extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => {
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");
Expand All @@ -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`:
Expand Down
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 @@ -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"];
120 changes: 120 additions & 0 deletions lib/content/__tests__/createContentHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading