"use server"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { getServerSession } from "@/lib/auth/guards"; 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 { rateLimit, LIMITS } from "@/lib/ratelimit"; import type { GenerationType } from "@/lib/queue/jobs"; import type { Prisma } from "@prisma/client"; const speakerSchema = z.object({ speakerKey: z.string().min(1).max(40), displayName: z.string().min(1).max(60), elevenVoiceId: z.string().min(1).max(60), }); const createSchema = z.object({ title: z.string().max(120).optional(), topic: z.string().min(10, "Describe your topic in a bit more detail").max(2000), tone: z.string().min(1), format: z.enum(["SOLO", "INTERVIEW", "MULTI_HOST"]), language: z.string().min(2).max(5), targetLengthMin: z.number().int().min(1).max(180), audience: z.string().max(200).optional(), speakers: z.array(speakerSchema).min(1).max(6), }); export type CreateEpisodeInput = z.infer; export type CreateEpisodeResult = | { ok: true; episodeId: string } | { ok: false; error: string; limited?: boolean }; export async function createEpisodeAction(input: CreateEpisodeInput): Promise { const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; const rl = await rateLimit("generation", session.user.id, LIMITS.generation); if (!rl.ok) { return { ok: false, error: `Too many requests. Try again in ${rl.retryAfterSec}s.` }; } const parsed = createSchema.safeParse(input); if (!parsed.success) { return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." }; } const data = parsed.data; const activeOrgId = session.session.activeOrganizationId; const { plan } = await getEffectivePlan(session.user.id, activeOrgId); if (data.targetLengthMin > plan.limits.maxEpisodeMinutes) { return { ok: false, error: `The ${plan.name} plan supports episodes up to ${plan.limits.maxEpisodeMinutes} minutes.`, limited: true, }; } try { await enforceLimit(session.user.id, "script", activeOrgId); await enforceLimit(session.user.id, "audio", activeOrgId); } catch (err) { if (err instanceof LimitExceededError) { return { ok: false, error: `You've reached your monthly ${err.check.metric} limit on the ${err.check.plan} plan. Upgrade to keep creating.`, limited: true, }; } throw err; } const episode = await prisma.episode.create({ data: { userId: session.user.id, organizationId: activeOrgId ?? undefined, title: data.title?.trim() || deriveTitle(data.topic), 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: data.speakers.map((s) => ({ speakerKey: s.speakerKey, displayName: s.displayName, elevenVoiceId: s.elevenVoiceId, })), }, jobs: { create: { type: "full", status: "queued" } }, }, }); await enqueueEpisodeGeneration( { episodeId: episode.id, type: "full" }, { priority: plan.features.includes("priority_generation") ? 10 : 0 } ); revalidatePath("/episodes"); revalidatePath("/dashboard"); return { ok: true, episodeId: episode.id }; } export async function regenerateAction( episodeId: string, type: GenerationType ): Promise<{ ok: boolean; error?: string }> { const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; const episode = await prisma.episode.findUnique({ where: { id: episodeId }, select: { userId: true, organizationId: true }, }); if (!episode) return { ok: false, error: "Episode not found." }; if (episode.userId !== session.user.id && session.user.role !== "admin") { return { ok: false, error: "Not allowed." }; } // Gate the metrics this regeneration will consume. const metrics: ("script" | "audio" | "art")[] = type === "art" ? ["art"] : type === "audio" ? ["audio"] : ["script", "audio"]; try { for (const m of metrics) await enforceLimit(session.user.id, m, session.session.activeOrganizationId); } catch (err) { if (err instanceof LimitExceededError) { return { ok: false, error: `Monthly ${err.check.metric} limit reached on the ${err.check.plan} plan.` }; } throw err; } await prisma.episode.update({ where: { id: episodeId }, data: { status: "QUEUED", stage: "Queued for regeneration", errorMessage: null }, }); await prisma.generationJob.create({ data: { episodeId, type, status: "queued" } }); await enqueueEpisodeGeneration({ episodeId, type }); revalidatePath(`/episodes/${episodeId}`); return { ok: true }; } const scriptContentSchema = z.object({ title: z.string().min(1), sections: z .array( z.object({ id: z.string().min(1), title: z.string().min(1), turns: z.array(z.object({ speakerKey: z.string(), text: z.string() })).min(1), }) ) .min(1), }); export async function updateScriptAction( episodeId: string, content: unknown ): Promise<{ ok: boolean; error?: string }> { const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; const episode = await prisma.episode.findUnique({ where: { id: episodeId }, select: { userId: true }, }); if (!episode || episode.userId !== session.user.id) return { ok: false, error: "Not allowed." }; const parsed = scriptContentSchema.safeParse(content); if (!parsed.success) return { ok: false, error: "Invalid script format." }; await prisma.script.update({ where: { episodeId }, data: { content: parsed.data as unknown as Prisma.InputJsonValue, version: { increment: 1 } }, }); revalidatePath(`/episodes/${episodeId}`); return { ok: true }; } export async function regenerateSectionAction( episodeId: string, sectionId: string ): Promise<{ ok: boolean; error?: string; section?: { id: string; title: string; turns: { speakerKey: string; text: string }[] } }> { const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; const episode = await prisma.episode.findUnique({ where: { id: episodeId }, include: { speakers: true, script: true }, }); if (!episode || (episode.userId !== session.user.id && session.user.role !== "admin")) { return { ok: false, error: "Not allowed." }; } if (!episode.script) return { ok: false, error: "No script to edit yet." }; try { await enforceLimit(session.user.id, "script", session.session.activeOrganizationId); } catch (err) { if (err instanceof LimitExceededError) { return { ok: false, error: `Monthly script limit reached on the ${err.check.plan} plan.` }; } throw err; } // Imported lazily so the AI SDK never reaches client bundles importing this file. const { scriptProvider } = await import("@/lib/ai/providers"); const { recordCost, scriptCostUsd } = await import("@/lib/ai/cost"); const { incrementUsage } = await import("@/lib/usage/meter"); const config = { title: episode.title, topic: episode.topic, tone: episode.tone, format: episode.format, language: episode.language, targetLengthMin: episode.targetLengthMin, audience: episode.audience ?? undefined, speakers: episode.speakers.map((s) => ({ speakerKey: s.speakerKey, displayName: s.displayName })), }; const current = episode.script.content as unknown as { title: string; sections: { id: string; title: string; turns: { speakerKey: string; text: string }[] }[]; }; const { section, usage } = await scriptProvider().regenerateSection(config, current, sectionId); const updated = { ...current, sections: current.sections.map((s) => (s.id === sectionId ? section : s)), }; await prisma.script.update({ where: { episodeId }, data: { content: updated as unknown as Prisma.InputJsonValue, version: { increment: 1 } }, }); const ownerId = episode.organizationId ?? episode.userId; const ownerType = episode.organizationId ? "organization" : "user"; await incrementUsage(ownerId, ownerType, "script"); await recordCost({ provider: "openai", operation: "script", units: usage.inputTokens + usage.outputTokens, costUsd: scriptCostUsd(usage), episodeId, userId: episode.userId, }); revalidatePath(`/episodes/${episodeId}`); return { ok: true, section }; } export async function repurposeAction( episodeId: string, format: "blog" | "social_thread" | "newsletter" ): Promise<{ ok: boolean; error?: string; content?: { title: string; body: string } }> { const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; const episode = await prisma.episode.findUnique({ where: { id: episodeId }, include: { script: true }, }); if (!episode || (episode.userId !== session.user.id && session.user.role !== "admin")) { return { ok: false, error: "Not allowed." }; } if (!episode.script) return { ok: false, error: "Generate the episode first." }; try { await enforceLimit(session.user.id, "repurpose", session.session.activeOrganizationId); } catch (err) { if (err instanceof LimitExceededError) { return { ok: false, error: `Monthly repurpose limit reached on the ${err.check.plan} plan.` }; } throw err; } const { repurposeScript } = await import("@/lib/ai/pipeline/repurpose"); const { recordCost, scriptCostUsd } = await import("@/lib/ai/cost"); const { incrementUsage } = await import("@/lib/usage/meter"); const { content, usage } = await repurposeScript( episode.script.content as unknown as Parameters[0], format ); await prisma.repurposedContent.create({ data: { episodeId, type: format, content: content as unknown as Prisma.InputJsonValue }, }); const ownerId = episode.organizationId ?? episode.userId; const ownerType = episode.organizationId ? "organization" : "user"; await incrementUsage(ownerId, ownerType, "repurpose"); await recordCost({ provider: "openai", operation: "repurpose", units: usage.inputTokens + usage.outputTokens, costUsd: scriptCostUsd(usage), episodeId, userId: episode.userId, }); revalidatePath(`/episodes/${episodeId}/repurpose`); return { ok: true, content }; } export async function deleteEpisodeAction(episodeId: string): Promise<{ ok: boolean; error?: string }> { const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; const episode = await prisma.episode.findUnique({ where: { id: episodeId }, select: { userId: true }, }); if (!episode || (episode.userId !== session.user.id && session.user.role !== "admin")) { return { ok: false, error: "Not allowed." }; } await prisma.episode.delete({ where: { id: episodeId } }); revalidatePath("/episodes"); return { ok: true }; } function deriveTitle(topic: string): string { const trimmed = topic.trim().replace(/\s+/g, " "); return trimmed.length <= 60 ? trimmed : trimmed.slice(0, 57) + "…"; }