import { NextRequest } from "next/server"; import { z } from "zod"; import { verifyApiKey, bearerKey } from "@/lib/apikeys"; import { prisma } from "@/lib/db"; import { getEffectivePlan } from "@/lib/billing/subscription"; import { enforceLimit, LimitExceededError } from "@/lib/usage/limits"; import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss"; import { FORMAT_SPEAKERS } from "@/lib/episodes/options"; import { DEFAULT_VOICE_IDS, VOICE_CATALOG } from "@/lib/ai/voices"; import { rateLimit, LIMITS } from "@/lib/ratelimit"; export const dynamic = "force-dynamic"; async function authorize(req: NextRequest) { return verifyApiKey(bearerKey(req.headers.get("authorization"))); } /** GET /api/v1/episodes — list the caller's episodes. */ export async function GET(req: NextRequest) { const auth = await authorize(req); if (!auth) return Response.json({ error: "Invalid API key" }, { status: 401 }); const episodes = await prisma.episode.findMany({ where: { userId: auth.userId }, orderBy: { createdAt: "desc" }, take: 50, select: { id: true, title: true, status: true, format: true, language: true, createdAt: true }, }); return Response.json({ episodes }); } const createSchema = z.object({ topic: z.string().min(10).max(2000), title: z.string().max(120).optional(), tone: z.string().default("Conversational"), format: z.enum(["SOLO", "INTERVIEW", "MULTI_HOST"]).default("SOLO"), language: z.string().min(2).max(5).default("en"), targetLengthMin: z.number().int().min(1).max(180).default(5), audience: z.string().max(200).optional(), }); /** POST /api/v1/episodes — create + generate an episode. */ export async function POST(req: NextRequest) { const auth = await authorize(req); if (!auth) return Response.json({ error: "Invalid API key" }, { status: 401 }); const rl = await rateLimit("api", auth.userId, LIMITS.api); if (!rl.ok) { return Response.json( { error: "Rate limit exceeded" }, { status: 429, headers: { "Retry-After": String(rl.retryAfterSec ?? 60) } } ); } const parsed = createSchema.safeParse(await req.json().catch(() => ({}))); if (!parsed.success) { return Response.json({ error: parsed.error.issues[0]?.message ?? "Invalid body" }, { status: 400 }); } const data = parsed.data; const { plan } = await getEffectivePlan(auth.userId); if (data.targetLengthMin > plan.limits.maxEpisodeMinutes) { return Response.json( { error: `Plan supports episodes up to ${plan.limits.maxEpisodeMinutes} minutes` }, { status: 402 } ); } try { await enforceLimit(auth.userId, "script"); await enforceLimit(auth.userId, "audio"); } catch (err) { if (err instanceof LimitExceededError) { return Response.json({ error: `Monthly ${err.check.metric} limit reached` }, { status: 402 }); } throw err; } const speakers = FORMAT_SPEAKERS[data.format].map((s, i) => ({ speakerKey: s.speakerKey, displayName: s.defaultName, elevenVoiceId: DEFAULT_VOICE_IDS[s.speakerKey] ?? VOICE_CATALOG[i % VOICE_CATALOG.length].id, })); const episode = await prisma.episode.create({ data: { userId: auth.userId, title: data.title?.trim() || data.topic.slice(0, 60), topic: data.topic, tone: data.tone, format: data.format, language: data.language, targetLengthMin: data.targetLengthMin, audience: data.audience, status: "QUEUED", stage: "Queued for generation", speakers: { create: speakers }, jobs: { create: { type: "full", status: "queued" } }, }, }); await enqueueEpisodeGeneration({ episodeId: episode.id, type: "full" }); return Response.json({ id: episode.id, status: episode.status }, { status: 201 }); }