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 { reserveLimit, LimitExceededError } from "@/lib/usage/limits"; import { refundUsage } from "@/lib/usage/meter"; 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"; import { isFlagEnabled } from "@/lib/flags"; import { moderateText } from "@/lib/ai/moderation"; import type { UsageMetric } from "@/lib/billing/plans"; 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 rl = await rateLimit("read", auth.userId, LIMITS.read); if (!rl.ok) { return Response.json( { error: "Rate limit exceeded" }, { status: 429, headers: { "Retry-After": String(rl.retryAfterSec ?? 60) } } ); } 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) } } ); } if (!(await isFlagEnabled("episode_generation_enabled"))) { return Response.json({ error: "Episode generation is temporarily paused" }, { status: 503 }); } 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, subjectId, subjectType } = 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 } ); } // Screen the requested topic before reserving quota or spending AI budget. if (await isFlagEnabled("ai_moderation_enabled")) { const mod = await moderateText([data.title, data.topic, data.audience].filter(Boolean).join("\n")); if (mod.flagged) { return Response.json( { error: "Topic violates the content policy and cannot be generated" }, { status: 422 } ); } } // Atomically reserve quota up front (full generation = script+audio+art). The // worker won't re-meter; we refund below if create/enqueue fails. See the // metering invariant in lib/usage/meter.ts. const reserved: UsageMetric[] = []; const refundReserved = async () => { for (const m of reserved) await refundUsage(subjectId, subjectType, m); }; try { await reserveLimit(auth.userId, "script"); reserved.push("script"); await reserveLimit(auth.userId, "audio"); reserved.push("audio"); await reserveLimit(auth.userId, "art"); reserved.push("art"); } catch (err) { await refundReserved(); 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, })); try { 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 }); } catch (err) { await refundReserved(); throw err; } }