Comprehensive admin + user dashboards (production-ready)
This commit is contained in:
@@ -7,20 +7,65 @@ import { segmentScript } from "./segment";
|
||||
import { stitchMp3 } from "./stitch";
|
||||
import { storage, assetKey } from "@/lib/storage";
|
||||
import { recordCost, scriptCostUsd, audioCostUsd, artCostUsd } from "@/lib/ai/cost";
|
||||
import { incrementUsage } from "@/lib/usage/meter";
|
||||
import { refundUsage } from "@/lib/usage/meter";
|
||||
import { isFlagEnabled } from "@/lib/flags";
|
||||
import { moderateText, moderationReason } from "@/lib/ai/moderation";
|
||||
import { sendEmail, emailLayout } from "@/lib/email";
|
||||
import { DEFAULT_VOICE_IDS } from "@/lib/ai/voices";
|
||||
import type { EpisodeConfig, StructuredScript } from "@/lib/ai/types";
|
||||
import type { GenerationType } from "@/lib/queue/jobs";
|
||||
import type { UsageMetric } from "@/lib/billing/plans";
|
||||
|
||||
type EpisodeWithRelations = Prisma.EpisodeGetPayload<{
|
||||
include: { speakers: true; user: true };
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Usage metrics RESERVED by the enqueuing caller for a given generation `type`.
|
||||
* The worker uses this only to REFUND on terminal failure — it never increments
|
||||
* (see the metering invariant in lib/usage/meter.ts). Must stay in sync with the
|
||||
* reservations made in the create/regenerate paths.
|
||||
*/
|
||||
export function reservedMetricsFor(type: GenerationType): UsageMetric[] {
|
||||
switch (type) {
|
||||
case "full":
|
||||
return ["script", "audio", "art"];
|
||||
case "script":
|
||||
return ["script"];
|
||||
case "audio":
|
||||
return ["audio"];
|
||||
case "art":
|
||||
return ["art"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Refund every metric reserved for `type` (used by the worker on terminal failure). */
|
||||
export async function refundEpisodeUsage(
|
||||
episodeId: string,
|
||||
type: GenerationType
|
||||
): Promise<void> {
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { id: episodeId },
|
||||
select: { userId: true, organizationId: true },
|
||||
});
|
||||
if (!episode) return;
|
||||
const ownerId = episode.organizationId ?? episode.userId;
|
||||
const ownerType = episode.organizationId ? "organization" : "user";
|
||||
for (const metric of reservedMetricsFor(type)) {
|
||||
await refundUsage(ownerId, ownerType, metric);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The episode generation pipeline, run by the worker.
|
||||
* Stages: script → segment → synthesize → stitch → art → save → meter.
|
||||
* Stages: script → segment → synthesize → stitch → art → save.
|
||||
* `type` selects which stages run (full, or a single re-generation).
|
||||
*
|
||||
* NOTE: usage is NOT metered here. The enqueuing caller already RESERVED the
|
||||
* relevant metrics (script/audio/art) up front; on terminal failure the worker
|
||||
* refunds them. See the metering invariant in lib/usage/meter.ts.
|
||||
*/
|
||||
export async function runEpisodeGeneration(
|
||||
episodeId: string,
|
||||
@@ -29,23 +74,18 @@ export async function runEpisodeGeneration(
|
||||
const episode = await loadEpisode(episodeId);
|
||||
const config = toConfig(episode);
|
||||
|
||||
const did = { script: false, audio: false, art: false };
|
||||
|
||||
if (type === "full" || type === "script") {
|
||||
await generateScript(episode, config);
|
||||
did.script = true;
|
||||
}
|
||||
if (type === "full" || type === "script" || type === "audio") {
|
||||
await generateAudio(episode);
|
||||
did.audio = true;
|
||||
}
|
||||
if (type === "full" || type === "art") {
|
||||
await generateArt(episode);
|
||||
did.art = true;
|
||||
}
|
||||
|
||||
await setEpisodeStatus(episodeId, "SAVING", { stage: "Finalizing your episode" });
|
||||
await meter(episode, did);
|
||||
// No metering here: usage was reserved at enqueue time. See meter.ts invariant.
|
||||
await setEpisodeStatus(episodeId, "READY", { stage: "Done" });
|
||||
await notifyReady(episode);
|
||||
}
|
||||
@@ -105,6 +145,29 @@ async function generateScript(episode: EpisodeWithRelations, config: EpisodeConf
|
||||
episodeId: episode.id,
|
||||
userId: episode.userId,
|
||||
});
|
||||
|
||||
await flagScriptIfFlagged(episode.id, script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen the generated script with automated moderation. On a violation we queue
|
||||
* a ContentFlag for admin review (rather than hard-failing the episode the user
|
||||
* asked for); the admin moderation queue is the consumer. No-op when the
|
||||
* moderation flag is off or an open flag already exists for the episode.
|
||||
*/
|
||||
async function flagScriptIfFlagged(episodeId: string, script: StructuredScript) {
|
||||
if (!(await isFlagEnabled("ai_moderation_enabled"))) return;
|
||||
const text = script.sections.flatMap((s) => s.turns.map((t) => t.text)).join("\n");
|
||||
const result = await moderateText(text);
|
||||
if (!result.flagged) return;
|
||||
|
||||
const existing = await prisma.contentFlag.findFirst({ where: { episodeId, status: "open" } });
|
||||
if (existing) return;
|
||||
|
||||
await prisma.contentFlag.create({
|
||||
data: { episodeId, reason: moderationReason(result), source: "moderation", severity: "high" },
|
||||
});
|
||||
console.warn(`[moderation] flagged episode ${episodeId}: ${result.categories.join(", ")}`);
|
||||
}
|
||||
|
||||
// ─────────────── Stages 2–4: segment → synthesize → stitch ───────────────
|
||||
@@ -197,18 +260,6 @@ async function generateArt(episode: EpisodeWithRelations) {
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────── Stage 7: meter ───────────────
|
||||
async function meter(
|
||||
episode: EpisodeWithRelations,
|
||||
did: { script: boolean; audio: boolean; art: boolean }
|
||||
) {
|
||||
const ownerId = episode.organizationId ?? episode.userId;
|
||||
const ownerType = episode.organizationId ? "organization" : "user";
|
||||
if (did.script) await incrementUsage(ownerId, ownerType, "script");
|
||||
if (did.audio) await incrementUsage(ownerId, ownerType, "audio");
|
||||
if (did.art) await incrementUsage(ownerId, ownerType, "art");
|
||||
}
|
||||
|
||||
async function notifyReady(episode: EpisodeWithRelations) {
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user