Comprehensive admin + user dashboards (production-ready)
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { bustFlagCache } from "@/lib/flags";
|
||||
import { stripe } from "@/lib/billing/stripe";
|
||||
import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss";
|
||||
import type { GenerationType } from "@/lib/queue/jobs";
|
||||
|
||||
type ActionResult = { ok: boolean; error?: string };
|
||||
|
||||
async function adminSession() {
|
||||
const s = await getServerSession();
|
||||
@@ -50,6 +56,7 @@ export async function toggleFeatureFlagAction(
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
await prisma.featureFlag.upsert({ where: { key }, create: { key, enabled }, update: { enabled } });
|
||||
bustFlagCache(); // make the new value take effect immediately on this node
|
||||
await audit(s.user.id, "flag.toggle", key, { enabled });
|
||||
revalidatePath("/admin/flags");
|
||||
return { ok: true };
|
||||
@@ -66,3 +73,314 @@ export async function reviewContentFlagAction(
|
||||
revalidatePath("/admin/moderation");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ─────────────────────────── Phase 4: billing ops ───────────────────────────
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/** Grant a comped subscription (no payment provider) to a user. */
|
||||
export async function compPlanAction(
|
||||
userId: string,
|
||||
plan: "creator" | "pro" | "agency",
|
||||
interval: "month" | "year"
|
||||
): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const now = new Date();
|
||||
const periodEnd = new Date(now.getTime() + (interval === "year" ? 365 : 30) * DAY_MS);
|
||||
|
||||
// One row per (referenceId, provider:"comp"): reuse an existing comp row if present.
|
||||
const existing = await prisma.subscription.findFirst({
|
||||
where: { referenceId: userId, provider: "comp" },
|
||||
});
|
||||
const data = {
|
||||
plan,
|
||||
status: "active",
|
||||
billingInterval: interval,
|
||||
periodStart: now,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: false,
|
||||
};
|
||||
if (existing) {
|
||||
await prisma.subscription.update({ where: { id: existing.id }, data });
|
||||
} else {
|
||||
await prisma.subscription.create({
|
||||
data: { referenceId: userId, provider: "comp", ...data },
|
||||
});
|
||||
}
|
||||
|
||||
await audit(s.user.id, "subscription.comp", userId, { plan, interval });
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
revalidatePath("/admin/subscriptions");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Cancel a subscription. Stripe cancels at period end; comp/paypal flip to canceled. */
|
||||
export async function cancelSubscriptionAdminAction(subId: string): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const sub = await prisma.subscription.findUnique({ where: { id: subId } });
|
||||
if (!sub) return { ok: false, error: "Subscription not found." };
|
||||
|
||||
try {
|
||||
if (sub.provider === "stripe") {
|
||||
if (!sub.stripeSubscriptionId) return { ok: false, error: "Missing Stripe subscription id." };
|
||||
await stripe().subscriptions.update(sub.stripeSubscriptionId, { cancel_at_period_end: true });
|
||||
await prisma.subscription.update({
|
||||
where: { id: subId },
|
||||
data: { cancelAtPeriodEnd: true },
|
||||
});
|
||||
} else {
|
||||
await prisma.subscription.update({
|
||||
where: { id: subId },
|
||||
data: { status: "canceled", cancelAtPeriodEnd: true },
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : "Cancel failed." };
|
||||
}
|
||||
|
||||
await audit(s.user.id, "subscription.cancel", subId, { provider: sub.provider });
|
||||
revalidatePath("/admin/subscriptions");
|
||||
revalidatePath(`/admin/users/${sub.referenceId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Refund the latest charge on a Stripe subscription. PayPal/comp are unsupported here. */
|
||||
export async function refundLatestAction(subId: string): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const sub = await prisma.subscription.findUnique({ where: { id: subId } });
|
||||
if (!sub) return { ok: false, error: "Subscription not found." };
|
||||
if (sub.provider === "paypal") {
|
||||
return { ok: false, error: "Refund PayPal payments from the PayPal dashboard." };
|
||||
}
|
||||
if (sub.provider === "comp") {
|
||||
return { ok: false, error: "Comped subscriptions have no payment to refund." };
|
||||
}
|
||||
if (!sub.stripeSubscriptionId) return { ok: false, error: "Missing Stripe subscription id." };
|
||||
|
||||
try {
|
||||
const stripeSub = await stripe().subscriptions.retrieve(sub.stripeSubscriptionId, {
|
||||
expand: ["latest_invoice.payment_intent"],
|
||||
});
|
||||
const invoice = stripeSub.latest_invoice;
|
||||
if (!invoice || typeof invoice === "string") {
|
||||
return { ok: false, error: "No invoice found for this subscription." };
|
||||
}
|
||||
// payment_intent is expanded above; normalize to its id (cast via unknown to be
|
||||
// resilient to Stripe type changes across API versions).
|
||||
const pi = (invoice as unknown as { payment_intent?: string | { id: string } | null })
|
||||
.payment_intent;
|
||||
const paymentIntentId = typeof pi === "string" ? pi : pi?.id;
|
||||
if (!paymentIntentId) {
|
||||
return { ok: false, error: "No payment to refund on the latest invoice." };
|
||||
}
|
||||
const refund = await stripe().refunds.create({ payment_intent: paymentIntentId });
|
||||
await audit(s.user.id, "subscription.refund", subId, {
|
||||
refundId: refund.id,
|
||||
paymentIntent: paymentIntentId,
|
||||
amount: refund.amount,
|
||||
});
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : "Refund failed." };
|
||||
}
|
||||
|
||||
revalidatePath("/admin/subscriptions");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ─────────────────────────── Phase 4: job ops ───────────────────────────
|
||||
|
||||
/** Requeue a job: reset the episode to QUEUED and enqueue a fresh generation. */
|
||||
export async function retryJobAction(jobId: string): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const job = await prisma.generationJob.findUnique({
|
||||
where: { id: jobId },
|
||||
include: { episode: { select: { id: true } } },
|
||||
});
|
||||
if (!job) return { ok: false, error: "Job not found." };
|
||||
|
||||
const type = (job.type as GenerationType) ?? "full";
|
||||
try {
|
||||
await prisma.episode.update({
|
||||
where: { id: job.episodeId },
|
||||
data: { status: "QUEUED", errorMessage: null },
|
||||
});
|
||||
await prisma.generationJob.create({
|
||||
data: { episodeId: job.episodeId, type: job.type, status: "queued" },
|
||||
});
|
||||
await enqueueEpisodeGeneration({ episodeId: job.episodeId, type });
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : "Retry failed." };
|
||||
}
|
||||
|
||||
await audit(s.user.id, "job.retry", jobId, { episodeId: job.episodeId, type });
|
||||
revalidatePath("/admin/jobs");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Cancel a job: mark it failed and fail the episode if it was still in progress. */
|
||||
export async function cancelJobAction(jobId: string): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const job = await prisma.generationJob.findUnique({
|
||||
where: { id: jobId },
|
||||
include: { episode: { select: { id: true, status: true } } },
|
||||
});
|
||||
if (!job) return { ok: false, error: "Job not found." };
|
||||
|
||||
await prisma.generationJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "failed", error: job.error ?? "Canceled by admin", finishedAt: new Date() },
|
||||
});
|
||||
|
||||
// Only fail the episode if it hasn't already reached a terminal state.
|
||||
const inProgress = !["READY", "FAILED", "DRAFT"].includes(job.episode.status);
|
||||
if (inProgress) {
|
||||
await prisma.episode.update({
|
||||
where: { id: job.episodeId },
|
||||
data: { status: "FAILED", errorMessage: "Canceled by admin" },
|
||||
});
|
||||
}
|
||||
|
||||
await audit(s.user.id, "job.cancel", jobId, { episodeId: job.episodeId });
|
||||
revalidatePath("/admin/jobs");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ─────────────────────────── Phase 5: feature flags ───────────────────────────
|
||||
|
||||
/** Update a flag's rollout %, enabled state, and metadata in one shot. */
|
||||
export async function setRolloutAction(
|
||||
key: string,
|
||||
rolloutPct: number,
|
||||
enabled: boolean,
|
||||
metadata?: unknown
|
||||
): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const pct = Math.max(0, Math.min(100, Math.round(rolloutPct)));
|
||||
// null/undefined clears the column; any JSON value is stored as-is.
|
||||
const meta: Prisma.InputJsonValue | typeof Prisma.JsonNull =
|
||||
metadata == null ? Prisma.JsonNull : (metadata as Prisma.InputJsonValue);
|
||||
await prisma.featureFlag.upsert({
|
||||
where: { key },
|
||||
create: { key, enabled, rolloutPct: pct, metadata: meta },
|
||||
update: { enabled, rolloutPct: pct, metadata: meta },
|
||||
});
|
||||
bustFlagCache();
|
||||
await audit(s.user.id, "flag.rollout", key, { rolloutPct: pct, enabled });
|
||||
revalidatePath("/admin/flags");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Delete a feature flag row entirely. */
|
||||
export async function deleteFlagAction(key: string): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
await prisma.featureFlag.delete({ where: { key } });
|
||||
bustFlagCache();
|
||||
await audit(s.user.id, "flag.delete", key);
|
||||
revalidatePath("/admin/flags");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ─────────────────────────── Phase 5: plan tuning ───────────────────────────
|
||||
|
||||
export interface PlanUpdateInput {
|
||||
priceMonthly: number;
|
||||
priceYearly: number;
|
||||
limits: {
|
||||
script: number;
|
||||
audio: number;
|
||||
art: number;
|
||||
repurpose: number;
|
||||
seats: number;
|
||||
maxEpisodeMinutes: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Override a plan's price/limits in the DB (an override on lib/billing/plans.ts). */
|
||||
export async function updatePlanAction(key: string, input: PlanUpdateInput): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const existing = await prisma.plan.findUnique({ where: { key } });
|
||||
if (!existing) return { ok: false, error: "Plan not found." };
|
||||
|
||||
await prisma.plan.update({
|
||||
where: { key },
|
||||
data: {
|
||||
priceMonthly: Math.max(0, Math.round(input.priceMonthly)),
|
||||
priceYearly: Math.max(0, Math.round(input.priceYearly)),
|
||||
limits: input.limits as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
await audit(s.user.id, "plan.update", key, {
|
||||
priceMonthly: input.priceMonthly,
|
||||
priceYearly: input.priceYearly,
|
||||
});
|
||||
revalidatePath("/admin/settings");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ─────────────────────────── Phase 5: audit export ───────────────────────────
|
||||
|
||||
function csvCell(value: unknown): string {
|
||||
const str = value == null ? "" : typeof value === "string" ? value : JSON.stringify(value);
|
||||
// Quote always; escape embedded quotes by doubling.
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
/** Build a CSV of the currently-filtered audit log (up to a sane cap). */
|
||||
export async function exportAuditCsvAction(filters: {
|
||||
action?: string;
|
||||
actor?: string;
|
||||
}): Promise<{ ok: boolean; csv?: string; error?: string }> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
|
||||
const where: Prisma.AuditLogWhereInput = {};
|
||||
if (filters.action) where.action = filters.action;
|
||||
if (filters.actor) where.actor = { email: { contains: filters.actor, mode: "insensitive" } };
|
||||
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5000,
|
||||
include: { actor: { select: { email: true } } },
|
||||
});
|
||||
|
||||
const header = ["When", "Actor", "Actor type", "Action", "Target", "Details"];
|
||||
const lines = [header.map(csvCell).join(",")];
|
||||
for (const r of rows) {
|
||||
lines.push(
|
||||
[
|
||||
r.createdAt.toISOString(),
|
||||
r.actor?.email ?? "",
|
||||
r.actorType,
|
||||
r.action,
|
||||
r.target ?? "",
|
||||
r.metadata ?? "",
|
||||
]
|
||||
.map(csvCell)
|
||||
.join(",")
|
||||
);
|
||||
}
|
||||
|
||||
await audit(s.user.id, "audit.export", undefined, {
|
||||
rows: rows.length,
|
||||
action: filters.action ?? null,
|
||||
});
|
||||
return { ok: true, csv: lines.join("\r\n") };
|
||||
}
|
||||
|
||||
@@ -1,94 +1,82 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Wallet, Sparkles, AudioLines } from "lucide-react";
|
||||
import { getCostBreakdown } from "@/lib/admin/cost";
|
||||
import { getAiCostSeries } from "@/lib/admin/series";
|
||||
import { parseRange } from "@/lib/admin/range";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CostChart, type CostPoint } from "@/components/admin/cost-chart";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { ChartCard } from "@/components/admin/ui/chart-card";
|
||||
import { BarSeries } from "@/components/admin/ui/charts";
|
||||
import { RangePicker } from "@/components/admin/ui/table-controls";
|
||||
import { DataTable, type Column } from "@/components/admin/ui/data-table";
|
||||
import { CHART } from "@/components/admin/ui/chart-theme";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · AI usage" };
|
||||
|
||||
export default async function AdminAiUsagePage() {
|
||||
const since = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
|
||||
const logs = await prisma.aiCostLog.findMany({
|
||||
where: { createdAt: { gte: since } },
|
||||
select: { provider: true, operation: true, costUsd: true, createdAt: true },
|
||||
});
|
||||
type Outlier = { userId: string; email: string; cost: number };
|
||||
|
||||
// Daily totals by provider for the last 14 days.
|
||||
const byDay = new Map<string, CostPoint>();
|
||||
for (let i = 13; i >= 0; i--) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
const key = `${d.getUTCMonth() + 1}/${d.getUTCDate()}`;
|
||||
byDay.set(key, { date: key, openai: 0, elevenlabs: 0 });
|
||||
}
|
||||
let totalOpenai = 0;
|
||||
let totalEleven = 0;
|
||||
const byOperation: Record<string, number> = {};
|
||||
for (const log of logs) {
|
||||
const d = log.createdAt;
|
||||
const key = `${d.getUTCMonth() + 1}/${d.getUTCDate()}`;
|
||||
const point = byDay.get(key);
|
||||
const cost = Number(log.costUsd);
|
||||
if (point) {
|
||||
if (log.provider === "elevenlabs") point.elevenlabs += cost;
|
||||
else point.openai += cost;
|
||||
}
|
||||
if (log.provider === "elevenlabs") totalEleven += cost;
|
||||
else totalOpenai += cost;
|
||||
byOperation[log.operation] = (byOperation[log.operation] ?? 0) + cost;
|
||||
}
|
||||
const data = Array.from(byDay.values()).map((p) => ({
|
||||
date: p.date,
|
||||
openai: Math.round(p.openai * 100) / 100,
|
||||
elevenlabs: Math.round(p.elevenlabs * 100) / 100,
|
||||
}));
|
||||
export default async function AdminAiUsagePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ range?: string }>;
|
||||
}) {
|
||||
const range = parseRange((await searchParams).range);
|
||||
const [breakdown, series] = await Promise.all([getCostBreakdown(range), getAiCostSeries(range)]);
|
||||
const usd = (n: number) => `$${n.toFixed(2)}`;
|
||||
|
||||
const outlierColumns: Column<Outlier>[] = [
|
||||
{ key: "email", header: "User", cell: (o) => <span className="font-medium">{o.email}</span> },
|
||||
{ key: "cost", header: "Spend", align: "right", cell: (o) => <span className="font-semibold">{usd(o.cost)}</span> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="AI usage & cost" description="Spend across providers over the last 14 days." />
|
||||
<PageHeader
|
||||
title="AI usage & cost"
|
||||
description="Spend across OpenAI and ElevenLabs."
|
||||
action={<RangePicker value={range} />}
|
||||
/>
|
||||
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-3">
|
||||
<Stat label="OpenAI (14d)" value={`$${totalOpenai.toFixed(2)}`} />
|
||||
<Stat label="ElevenLabs (14d)" value={`$${totalEleven.toFixed(2)}`} />
|
||||
<Stat label="Total (14d)" value={`$${(totalOpenai + totalEleven).toFixed(2)}`} />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<StatCard label="OpenAI" value={usd(breakdown.byProvider.openai)} icon={Sparkles} />
|
||||
<StatCard label="ElevenLabs" value={usd(breakdown.byProvider.elevenlabs)} icon={AudioLines} />
|
||||
<StatCard label="Total spend" value={usd(breakdown.total)} icon={Wallet} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Daily AI spend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CostChart data={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="mt-6">
|
||||
<ChartCard title="Daily AI spend" description="Stacked by provider.">
|
||||
<BarSeries
|
||||
data={series}
|
||||
xKey="date"
|
||||
format={(v) => `$${v}`}
|
||||
series={[
|
||||
{ key: "openai", name: "OpenAI", color: CHART.brand },
|
||||
{ key: "elevenlabs", name: "ElevenLabs", color: CHART.success },
|
||||
]}
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>By operation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{["script", "audio", "art", "repurpose"].map((op) => (
|
||||
<div key={op} className="rounded-lg border p-3">
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<ChartCard title="By operation" description="Where the spend goes.">
|
||||
<div className="grid grid-cols-2 gap-3 py-2">
|
||||
{(["script", "audio", "art", "repurpose"] as const).map((op) => (
|
||||
<div key={op} className="rounded-xl border border-border p-3">
|
||||
<p className="text-xs capitalize text-muted-foreground">{op}</p>
|
||||
<p className="mt-1 text-lg font-semibold">${(byOperation[op] ?? 0).toFixed(2)}</p>
|
||||
<p className="mt-1 font-display text-lg font-bold">{usd(breakdown.byOperation[op] ?? 0)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ChartCard>
|
||||
<ChartCard title="Top spenders" description="Highest AI cost per user this period.">
|
||||
<DataTable
|
||||
columns={outlierColumns}
|
||||
rows={breakdown.outliers}
|
||||
getRowKey={(o) => o.userId}
|
||||
empty="No usage in this period."
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { listAudit, getAuditActions, AUDIT_PAGE_SIZE } from "@/lib/admin/audit";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
|
||||
import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
|
||||
import { AuditExport } from "@/components/admin/audit-export";
|
||||
import { AuditMetaViewer } from "@/components/admin/audit-meta-viewer";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Audit log" };
|
||||
|
||||
export default async function AdminAuditPage() {
|
||||
const logs = await prisma.auditLog.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: { actor: { select: { email: true } } },
|
||||
});
|
||||
type Row = Awaited<ReturnType<typeof listAudit>>["rows"][number];
|
||||
|
||||
export default async function AdminAuditPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, Number(sp.page ?? "1"));
|
||||
const [{ rows, total }, actions] = await Promise.all([
|
||||
listAudit({ action: sp.action, actor: sp.q, page }),
|
||||
getAuditActions(),
|
||||
]);
|
||||
|
||||
const columns: Column<Row>[] = [
|
||||
{
|
||||
key: "when",
|
||||
header: "When",
|
||||
cell: (l) => <span className="whitespace-nowrap text-muted-foreground">{l.createdAt.toLocaleString()}</span>,
|
||||
},
|
||||
{ key: "actor", header: "Actor", cell: (l) => l.actor?.email ?? l.actorType },
|
||||
{ key: "action", header: "Action", cell: (l) => <Badge variant="outline">{l.action}</Badge> },
|
||||
{
|
||||
key: "target",
|
||||
header: "Target",
|
||||
cell: (l) => <span className="font-mono text-xs text-muted-foreground">{l.target ?? "—"}</span>,
|
||||
},
|
||||
{
|
||||
key: "meta",
|
||||
header: "Details",
|
||||
cell: (l) => <AuditMetaViewer metadata={l.metadata} action={l.action} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Audit log" description="Recent administrative actions." />
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-center text-sm text-muted-foreground">No audit entries yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/40 text-left text-xs uppercase text-muted-foreground">
|
||||
<tr>
|
||||
<th className="p-3 font-medium">When</th>
|
||||
<th className="p-3 font-medium">Actor</th>
|
||||
<th className="p-3 font-medium">Action</th>
|
||||
<th className="p-3 font-medium">Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{logs.map((l) => (
|
||||
<tr key={l.id} className="hover:bg-muted/20">
|
||||
<td className="p-3 text-muted-foreground">{l.createdAt.toLocaleString()}</td>
|
||||
<td className="p-3">{l.actor?.email ?? l.actorType}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">{l.action}</Badge>
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs text-muted-foreground">{l.target ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<PageHeader title="Audit log" description="Every administrative action, recorded." />
|
||||
<TableToolbar>
|
||||
<SearchInput placeholder="Filter by actor email…" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterSelect
|
||||
param="action"
|
||||
placeholder="Action"
|
||||
allLabel="All actions"
|
||||
options={actions.map((a) => ({ value: a, label: a }))}
|
||||
/>
|
||||
<AuditExport filters={{ action: sp.action, actor: sp.q }} />
|
||||
</div>
|
||||
)}
|
||||
</TableToolbar>
|
||||
<DataTable columns={columns} rows={rows} getRowKey={(l) => l.id} empty="No audit entries yet." />
|
||||
<div className="mt-4">
|
||||
<Pagination page={page} pageSize={AUDIT_PAGE_SIZE} total={total} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, RotateCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default function AdminError({ error, reset }: { error: Error; reset: () => void }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-display text-lg font-bold">Something went wrong</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{error.message || "This admin view failed to load."}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={reset}>
|
||||
<RotateCw className="h-4 w-4" /> Try again
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { FlagsClient } from "@/components/admin/flags-client";
|
||||
import { getAdminFlags } from "@/lib/admin/flags";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Feature flags" };
|
||||
|
||||
export default async function AdminFlagsPage() {
|
||||
const flags = await prisma.featureFlag.findMany({ orderBy: { key: "asc" } });
|
||||
const flags = await getAdminFlags();
|
||||
const serialized = flags.map((f) => ({
|
||||
...f,
|
||||
updatedAt: f.updatedAt ? f.updatedAt.toISOString() : null,
|
||||
}));
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Feature flags" description="Toggle features without a deploy." />
|
||||
<FlagsClient flags={flags.map((f) => ({ key: f.key, enabled: f.enabled }))} />
|
||||
<PageHeader
|
||||
title="Feature flags"
|
||||
description="Toggle features, tune rollout, and attach metadata — no deploy required."
|
||||
/>
|
||||
<FlagsClient flags={serialized} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,88 +1,139 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Activity, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import { Activity, CheckCircle2, AlertTriangle, Cpu, Webhook, Database } from "lucide-react";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getWorkerHealth, getQueueStats } from "@/lib/queue/health";
|
||||
import { getJobStatusCounts, getEpisodeStatusCounts, listWebhookEvents } from "@/lib/admin/ops";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { ChartCard } from "@/components/admin/ui/chart-card";
|
||||
import { AutoRefresh } from "@/components/admin/ui/auto-refresh";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Health" };
|
||||
export const metadata: Metadata = { title: "Admin · System health" };
|
||||
|
||||
export default async function AdminHealthPage() {
|
||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const [jobGroups, episodeGroups, recentFailures, queuePending] = await Promise.all([
|
||||
prisma.generationJob.groupBy({ by: ["status"], _count: true }),
|
||||
prisma.episode.groupBy({ by: ["status"], _count: true }),
|
||||
prisma.episode.count({ where: { status: "FAILED", updatedAt: { gte: dayAgo } } }),
|
||||
prisma.generationJob.count({ where: { status: { in: ["queued", "running"] } } }),
|
||||
const t0 = Date.now();
|
||||
await prisma.$queryRawUnsafe("SELECT 1");
|
||||
const dbMs = Date.now() - t0;
|
||||
|
||||
const [workers, queues, jobCounts, episodeCounts, webhooks] = await Promise.all([
|
||||
getWorkerHealth(),
|
||||
getQueueStats(),
|
||||
getJobStatusCounts(),
|
||||
getEpisodeStatusCounts(),
|
||||
listWebhookEvents({ page: 1, pageSize: 1 }),
|
||||
]);
|
||||
|
||||
const jobCounts = Object.fromEntries(jobGroups.map((g) => [g.status, g._count]));
|
||||
const queueHealthy = recentFailures < 5;
|
||||
const anyWorker = workers.length > 0;
|
||||
const workersAlive = anyWorker && workers.every((w) => w.alive);
|
||||
const queueDepth = queues.reduce((s, q) => s + q.queued + q.active, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="System health" description="Generation pipeline and queue status." />
|
||||
<AutoRefresh seconds={10} />
|
||||
<PageHeader title="System health" description="Live worker, queue, and delivery status." />
|
||||
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label="Worker"
|
||||
value={anyWorker ? (workersAlive ? "Alive" : "Stale") : "No heartbeat"}
|
||||
icon={Cpu}
|
||||
hint={anyWorker ? `last beat ${workers[0].secondsAgo}s ago` : "worker never started"}
|
||||
/>
|
||||
<StatCard label="Queue depth" value={String(queueDepth)} icon={Activity} hint={`${jobCounts.running} running`} />
|
||||
<StatCard label="Failed jobs" value={String(jobCounts.failed)} icon={AlertTriangle} />
|
||||
<StatCard
|
||||
label="Webhook failures (24h)"
|
||||
value={String(webhooks.recentFailures)}
|
||||
icon={Webhook}
|
||||
hint={`${webhooks.recentTotal} delivered`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Activity className="h-4 w-4" /> Queue
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" /> Workers
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{queuePending}</p>
|
||||
<Badge variant={queueHealthy ? "success" : "warning"}>
|
||||
{queueHealthy ? "Healthy" : "Degraded"}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Failures (24h)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center gap-2">
|
||||
{recentFailures === 0 ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-success" />
|
||||
<CardContent className="space-y-2">
|
||||
{workers.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
No worker heartbeat yet. Start the worker (<code>npm run worker:dev</code>).
|
||||
</p>
|
||||
) : (
|
||||
<AlertTriangle className="h-5 w-5 text-warning" />
|
||||
workers.map((w) => (
|
||||
<div key={w.name} className="flex items-center justify-between rounded-xl border border-border p-3">
|
||||
<div>
|
||||
<p className="font-medium">{w.name}</p>
|
||||
<p className="text-xs text-muted-foreground">last beat {w.secondsAgo}s ago</p>
|
||||
</div>
|
||||
<Badge variant={w.alive ? "success" : "destructive"}>{w.alive ? "alive" : "stale"}</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{recentFailures}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Running jobs</CardTitle>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" /> Database
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{jobCounts["running"] ?? 0}</p>
|
||||
<div className="flex items-center justify-between rounded-xl border border-border p-3">
|
||||
<span className="text-sm text-muted-foreground">Query latency</span>
|
||||
<Badge variant={dbMs < 200 ? "success" : dbMs < 800 ? "warning" : "destructive"}>{dbMs} ms</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Generation jobs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{["queued", "running", "completed", "failed"].map((s) => (
|
||||
<div key={s} className="flex items-center justify-between text-sm">
|
||||
<span className="capitalize text-muted-foreground">{s}</span>
|
||||
<span className="font-medium">{jobCounts[s] ?? 0}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-3">
|
||||
<ChartCard className="lg:col-span-2" title="Queues" description="Per-queue job state (from pg-boss).">
|
||||
{queues.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No queue activity yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-xs uppercase text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2 font-medium">Queue</th>
|
||||
<th className="py-2 text-right font-medium">Queued</th>
|
||||
<th className="py-2 text-right font-medium">Active</th>
|
||||
<th className="py-2 text-right font-medium">Retry</th>
|
||||
<th className="py-2 text-right font-medium">Failed</th>
|
||||
<th className="py-2 text-right font-medium">Done</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{queues.map((q) => (
|
||||
<tr key={q.queue}>
|
||||
<td className="py-2 font-mono text-xs">{q.queue}</td>
|
||||
<td className="py-2 text-right">{q.queued}</td>
|
||||
<td className="py-2 text-right">{q.active}</td>
|
||||
<td className="py-2 text-right">{q.retry}</td>
|
||||
<td className="py-2 text-right text-destructive">{q.failed}</td>
|
||||
<td className="py-2 text-right text-muted-foreground">{q.completed}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Episodes by status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{episodeGroups.map((g) => (
|
||||
{episodeCounts.map((g) => (
|
||||
<div key={g.status} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{g.status}</span>
|
||||
<span className="font-medium">{g._count}</span>
|
||||
<span className="font-medium">{g.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Clock, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { listJobs, JOBS_PAGE_SIZE, getJobStatusCounts } from "@/lib/admin/ops";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
|
||||
import { FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
|
||||
import { JobRowActions } from "@/components/admin/job-row-actions";
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Jobs" };
|
||||
|
||||
type Row = Awaited<ReturnType<typeof listJobs>>["rows"][number];
|
||||
|
||||
const STATUS_VARIANT: Record<string, BadgeProps["variant"]> = {
|
||||
queued: "secondary",
|
||||
running: "warning",
|
||||
completed: "success",
|
||||
failed: "destructive",
|
||||
};
|
||||
|
||||
function duration(start: Date | null, end: Date | null): string {
|
||||
if (!start || !end) return "—";
|
||||
return `${Math.max(0, Math.round((end.getTime() - start.getTime()) / 1000))}s`;
|
||||
}
|
||||
|
||||
export default async function AdminJobsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, Number(sp.page ?? "1"));
|
||||
const [{ rows, total }, counts] = await Promise.all([
|
||||
listJobs({ status: sp.status, page }),
|
||||
getJobStatusCounts(),
|
||||
]);
|
||||
|
||||
const columns: Column<Row>[] = [
|
||||
{
|
||||
key: "episode",
|
||||
header: "Episode",
|
||||
cell: (j) => (
|
||||
<Link href={`/episodes/${j.episode.id}`} className="truncate font-medium hover:text-brand">
|
||||
{j.episode.title}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ key: "type", header: "Type", cell: (j) => <span className="capitalize">{j.type}</span> },
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
cell: (j) => <Badge variant={STATUS_VARIANT[j.status] ?? "secondary"}>{j.status}</Badge>,
|
||||
},
|
||||
{ key: "attempts", header: "Attempts", align: "right", cell: (j) => j.attempts },
|
||||
{
|
||||
key: "duration",
|
||||
header: "Duration",
|
||||
align: "right",
|
||||
cell: (j) => <span className="text-muted-foreground">{duration(j.startedAt, j.finishedAt)}</span>,
|
||||
},
|
||||
{
|
||||
key: "error",
|
||||
header: "Error",
|
||||
cell: (j) =>
|
||||
j.error ? (
|
||||
<span className="truncate font-mono text-xs text-destructive" title={j.error}>
|
||||
{j.error.slice(0, 40)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (j) => <JobRowActions job={{ id: j.id, status: j.status }} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Generation jobs" description="The episode pipeline, job by job." />
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="Queued" value={String(counts.queued)} icon={Clock} />
|
||||
<StatCard label="Running" value={String(counts.running)} icon={Loader2} />
|
||||
<StatCard label="Completed" value={String(counts.completed)} icon={CheckCircle2} />
|
||||
<StatCard label="Failed" value={String(counts.failed)} icon={XCircle} />
|
||||
</div>
|
||||
|
||||
<TableToolbar>
|
||||
<div />
|
||||
<FilterSelect
|
||||
param="status"
|
||||
placeholder="Status"
|
||||
allLabel="All status"
|
||||
options={[
|
||||
{ value: "queued", label: "Queued" },
|
||||
{ value: "running", label: "Running" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
]}
|
||||
/>
|
||||
</TableToolbar>
|
||||
<DataTable columns={columns} rows={rows} getRowKey={(j) => j.id} empty="No jobs yet." />
|
||||
<div className="mt-4">
|
||||
<Pagination page={page} pageSize={JOBS_PAGE_SIZE} total={total} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 w-52 rounded-xl bg-secondary" />
|
||||
<div className="h-4 w-72 rounded-lg bg-secondary/70" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-28 rounded-2xl border border-border bg-card" />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-80 rounded-2xl border border-border bg-card" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getModerationQueue } from "@/lib/admin/ops";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
import { ModerationActions } from "@/components/admin/moderation-actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Moderation" };
|
||||
|
||||
const SEVERITY: Record<string, BadgeProps["variant"]> = {
|
||||
high: "destructive",
|
||||
medium: "warning",
|
||||
low: "secondary",
|
||||
};
|
||||
|
||||
export default async function AdminModerationPage() {
|
||||
const flags = await prisma.contentFlag.findMany({
|
||||
where: { status: "open" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { episode: { select: { id: true, title: true } } },
|
||||
});
|
||||
const flags = await getModerationQueue();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Content moderation" description="Review flagged episodes." />
|
||||
<PageHeader
|
||||
title="Content moderation"
|
||||
description="Episodes auto-flagged by AI moderation, awaiting review."
|
||||
/>
|
||||
{flags.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<ShieldCheck className="h-10 w-10 text-success" />
|
||||
<p className="font-medium">Nothing to review</p>
|
||||
<p className="text-sm text-muted-foreground">There are no open content flags.</p>
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-success/12 text-success">
|
||||
<ShieldCheck className="h-6 w-6" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-display text-lg font-bold">All clear</p>
|
||||
<p className="text-sm text-muted-foreground">There are no open content flags.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{flags.map((f) => (
|
||||
<Card key={f.id}>
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div>
|
||||
<p className="font-medium">{f.episode.title}</p>
|
||||
<CardContent className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link href={`/episodes/${f.episode.id}`} className="font-medium hover:text-brand">
|
||||
{f.episode.title}
|
||||
</Link>
|
||||
<Badge variant={SEVERITY[f.severity] ?? "secondary"}>{f.severity}</Badge>
|
||||
<Badge variant="outline" className="capitalize">{f.source}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{f.reason}</p>
|
||||
</div>
|
||||
<ModerationActions flagId={f.id} />
|
||||
|
||||
+74
-81
@@ -1,96 +1,89 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Users, CreditCard, Mic2, DollarSign, TrendingUp, AlertTriangle } from "lucide-react";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PLANS, PLAN_ORDER, type PlanKey } from "@/lib/billing/plans";
|
||||
import { DollarSign, TrendingUp, Users, CreditCard, UserPlus, Mic2, Wallet, CheckCircle2 } from "lucide-react";
|
||||
import { getOverview } from "@/lib/admin/metrics";
|
||||
import { getSignupSeries, getRevenueSeries } from "@/lib/admin/series";
|
||||
import { parseRange } from "@/lib/admin/range";
|
||||
import { PLANS, PLAN_ORDER } from "@/lib/billing/plans";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { ChartCard } from "@/components/admin/ui/chart-card";
|
||||
import { BarSeries, Donut } from "@/components/admin/ui/charts";
|
||||
import { RangePicker } from "@/components/admin/ui/table-controls";
|
||||
import { CHART, TIER_COLORS } from "@/components/admin/ui/chart-theme";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin" };
|
||||
export const metadata: Metadata = { title: "Admin · Overview" };
|
||||
|
||||
export default async function AdminOverviewPage() {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [userCount, newUsers, activeSubs, episodeCount, failedCount, spend] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||
prisma.subscription.findMany({
|
||||
where: { status: { in: ["active", "trialing"] } },
|
||||
select: { plan: true },
|
||||
}),
|
||||
prisma.episode.count(),
|
||||
prisma.episode.count({ where: { status: "FAILED" } }),
|
||||
prisma.aiCostLog.aggregate({ _sum: { costUsd: true }, where: { createdAt: { gte: startOfMonth } } }),
|
||||
export default async function AdminOverviewPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ range?: string }>;
|
||||
}) {
|
||||
const range = parseRange((await searchParams).range);
|
||||
const [m, signups, revenue] = await Promise.all([
|
||||
getOverview(range),
|
||||
getSignupSeries(range),
|
||||
getRevenueSeries(range),
|
||||
]);
|
||||
|
||||
const tierCounts: Record<string, number> = {};
|
||||
let mrr = 0;
|
||||
for (const sub of activeSubs) {
|
||||
tierCounts[sub.plan] = (tierCounts[sub.plan] ?? 0) + 1;
|
||||
mrr += PLANS[(sub.plan as PlanKey) in PLANS ? (sub.plan as PlanKey) : "free"].priceMonthly;
|
||||
}
|
||||
const aiSpend = Number(spend._sum.costUsd ?? 0);
|
||||
const errorRate = episodeCount > 0 ? Math.round((failedCount / episodeCount) * 100) : 0;
|
||||
const tierData = PLAN_ORDER.map((k) => ({
|
||||
name: PLANS[k].name,
|
||||
value: m.tierCounts[k] ?? 0,
|
||||
color: TIER_COLORS[k],
|
||||
})).filter((d) => d.value > 0);
|
||||
const usd = (n: number) => `$${n.toFixed(2)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Overview" description="Platform health at a glance." />
|
||||
<PageHeader
|
||||
title="Overview"
|
||||
description="Revenue, growth, and platform health at a glance."
|
||||
action={<RangePicker value={range} />}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Kpi icon={DollarSign} label="MRR" value={formatPrice(mrr)} hint={`${activeSubs.length} active subs`} />
|
||||
<Kpi icon={Users} label="Users" value={String(userCount)} hint={`+${newUsers} in 30 days`} />
|
||||
<Kpi icon={Mic2} label="Episodes generated" value={String(episodeCount)} />
|
||||
<Kpi icon={TrendingUp} label="AI spend (MTD)" value={`$${aiSpend.toFixed(2)}`} />
|
||||
<Kpi icon={CreditCard} label="Paying customers" value={String(activeSubs.filter((s) => s.plan !== "free").length)} />
|
||||
<Kpi icon={AlertTriangle} label="Episode error rate" value={`${errorRate}%`} />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="MRR" value={formatPrice(m.mrr)} icon={DollarSign} hint={`${m.activeSubs} active subs`} />
|
||||
<StatCard label="ARR" value={formatPrice(m.arr)} icon={TrendingUp} />
|
||||
<StatCard label="Paying customers" value={String(m.paying)} icon={CreditCard} hint={`ARPU ${formatPrice(m.arpu)}`} />
|
||||
<StatCard label="Total users" value={String(m.totalUsers)} icon={Users} />
|
||||
<StatCard label="Signups" value={String(m.signups)} delta={m.signupsDelta} icon={UserPlus} />
|
||||
<StatCard label="Episodes" value={String(m.episodes)} delta={m.episodesDelta} icon={Mic2} />
|
||||
<StatCard label="AI spend" value={usd(m.aiSpend)} delta={m.aiSpendDelta} invertDelta icon={Wallet} />
|
||||
<StatCard label="Success rate" value={`${m.successRate}%`} icon={CheckCircle2} hint={`${m.failedEpisodes} failed · ${m.churned} churned`} />
|
||||
</div>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Subscriptions by tier</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{PLAN_ORDER.map((key) => (
|
||||
<div key={key} className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium capitalize">{PLANS[key].name}</span>
|
||||
<Badge variant="secondary">{tierCounts[key] ?? 0}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{formatPrice(PLANS[key].priceMonthly)}/mo</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-3">
|
||||
<ChartCard className="lg:col-span-2" title="Revenue movement" description="New vs churned MRR over the period.">
|
||||
<BarSeries
|
||||
data={revenue}
|
||||
xKey="date"
|
||||
stacked={false}
|
||||
format={(v) => `$${v}`}
|
||||
series={[
|
||||
{ key: "newMrr", name: "New MRR", color: CHART.brand },
|
||||
{ key: "churnedMrr", name: "Churned", color: CHART.warning },
|
||||
]}
|
||||
/>
|
||||
</ChartCard>
|
||||
<ChartCard title="Plan distribution" description="Active subscriptions by tier.">
|
||||
{tierData.length > 0 ? (
|
||||
<Donut data={tierData} colors={tierData.map((d) => d.color)} />
|
||||
) : (
|
||||
<p className="py-16 text-center text-sm text-muted-foreground">No active subscriptions yet.</p>
|
||||
)}
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<ChartCard title="New signups" description="Account creations per day.">
|
||||
<BarSeries
|
||||
data={signups}
|
||||
xKey="date"
|
||||
height={240}
|
||||
series={[{ key: "signups", name: "Signups", color: CHART.brand }]}
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Kpi({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{value}</p>
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DollarSign, TrendingUp, Wallet, UserMinus } from "lucide-react";
|
||||
import { getOverview } from "@/lib/admin/metrics";
|
||||
import { getRevenueSeries } from "@/lib/admin/series";
|
||||
import { getRevenueExtras } from "@/lib/admin/billing";
|
||||
import { parseRange } from "@/lib/admin/range";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { ChartCard } from "@/components/admin/ui/chart-card";
|
||||
import { BarSeries } from "@/components/admin/ui/charts";
|
||||
import { RangePicker } from "@/components/admin/ui/table-controls";
|
||||
import { DataTable, type Column } from "@/components/admin/ui/data-table";
|
||||
import { CHART, TIER_COLORS } from "@/components/admin/ui/chart-theme";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Revenue" };
|
||||
|
||||
type Churn = { customer: string; plan: string; provider: string; at: Date };
|
||||
|
||||
export default async function AdminRevenuePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ range?: string }>;
|
||||
}) {
|
||||
const range = parseRange((await searchParams).range);
|
||||
const [m, revenue, extras] = await Promise.all([
|
||||
getOverview(range),
|
||||
getRevenueSeries(range),
|
||||
getRevenueExtras(range),
|
||||
]);
|
||||
|
||||
const churnColumns: Column<Churn>[] = [
|
||||
{ key: "customer", header: "Customer", cell: (c) => <span className="font-medium">{c.customer}</span> },
|
||||
{ key: "plan", header: "Plan", cell: (c) => <span className="capitalize">{c.plan}</span> },
|
||||
{ key: "provider", header: "Provider", cell: (c) => <span className="capitalize">{c.provider}</span> },
|
||||
{ key: "at", header: "Canceled", cell: (c) => <span className="text-muted-foreground">{c.at.toLocaleDateString()}</span> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Revenue"
|
||||
description="Recurring revenue, growth, and churn."
|
||||
action={<RangePicker value={range} />}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="MRR" value={formatPrice(m.mrr)} icon={DollarSign} hint={`${m.paying} paying`} />
|
||||
<StatCard label="ARR" value={formatPrice(m.arr)} icon={TrendingUp} />
|
||||
<StatCard label="ARPU" value={formatPrice(m.arpu)} icon={Wallet} />
|
||||
<StatCard label="Churned (period)" value={String(m.churned)} icon={UserMinus} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-3">
|
||||
<ChartCard className="lg:col-span-2" title="New vs churned MRR" description="Movement over the period.">
|
||||
<BarSeries
|
||||
data={revenue}
|
||||
xKey="date"
|
||||
stacked={false}
|
||||
format={(v) => `$${v}`}
|
||||
series={[
|
||||
{ key: "newMrr", name: "New MRR", color: CHART.brand },
|
||||
{ key: "churnedMrr", name: "Churned", color: CHART.warning },
|
||||
]}
|
||||
/>
|
||||
</ChartCard>
|
||||
<ChartCard title="MRR by tier" description="Where recurring revenue comes from.">
|
||||
<div className="space-y-3 py-2">
|
||||
{(["creator", "pro", "agency"] as const).map((tier) => {
|
||||
const cents = extras.tierMrr[tier] ?? 0;
|
||||
const pct = m.mrr > 0 ? Math.round((cents / m.mrr) * 100) : 0;
|
||||
return (
|
||||
<div key={tier} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="capitalize">{tier}</span>
|
||||
<span className="font-medium">{formatPrice(cents)}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${pct}%`, backgroundColor: TIER_COLORS[tier] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-muted-foreground">
|
||||
Recent churn <Badge variant="secondary">{extras.churnList.length}</Badge>
|
||||
</h2>
|
||||
<DataTable
|
||||
columns={churnColumns}
|
||||
rows={extras.churnList}
|
||||
getRowKey={(c, i) => `${c.customer}-${i}`}
|
||||
empty="No cancellations in this period. 🎉"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Info } from "lucide-react";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PLANS, PLAN_ORDER, type PlanKey, type PlanLimits } from "@/lib/billing/plans";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { PlanEditor, type EditablePlan, type PlanLimitsValue } from "@/components/admin/plan-editor";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Settings" };
|
||||
|
||||
// Only paid tiers are editable here (Free has no price to tune).
|
||||
const PAID_TIERS = PLAN_ORDER.filter((k) => k !== "free") as Exclude<PlanKey, "free">[];
|
||||
|
||||
function coerceLimits(raw: unknown, fallback: PlanLimits): PlanLimitsValue {
|
||||
const obj = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||||
const num = (k: keyof PlanLimits) =>
|
||||
typeof obj[k] === "number" ? (obj[k] as number) : fallback[k];
|
||||
return {
|
||||
script: num("script"),
|
||||
audio: num("audio"),
|
||||
art: num("art"),
|
||||
repurpose: num("repurpose"),
|
||||
seats: num("seats"),
|
||||
maxEpisodeMinutes: num("maxEpisodeMinutes"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AdminSettingsPage() {
|
||||
const dbPlans = await prisma.plan.findMany();
|
||||
const byKey = new Map(dbPlans.map((p) => [p.key, p]));
|
||||
|
||||
const editable: EditablePlan[] = PAID_TIERS.map((key) => {
|
||||
const code = PLANS[key];
|
||||
const db = byKey.get(key);
|
||||
return {
|
||||
key,
|
||||
name: code.name,
|
||||
priceMonthly: db?.priceMonthly ?? code.priceMonthly,
|
||||
priceYearly: db?.priceYearly ?? code.priceYearly,
|
||||
limits: db ? coerceLimits(db.limits, code.limits) : { ...code.limits },
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="Tune plan prices and limits without a deploy."
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex items-start gap-3 rounded-2xl border border-border bg-brand/5 p-4 text-sm">
|
||||
<Info className="mt-0.5 h-5 w-5 shrink-0 text-brand" />
|
||||
<p className="text-muted-foreground">
|
||||
<code className="text-foreground">lib/billing/plans.ts</code> is the code source of
|
||||
truth for tiers and features. Values saved here are stored in the{" "}
|
||||
<code className="text-foreground">Plan</code> table and act as a runtime{" "}
|
||||
<span className="font-semibold text-foreground">override</span> for price and limits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{editable.map((plan) => (
|
||||
<PlanEditor key={plan.key} plan={plan} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +1,118 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PLANS, type PlanKey } from "@/lib/billing/plans";
|
||||
import { DollarSign, TrendingUp, CreditCard } from "lucide-react";
|
||||
import { listSubscriptions, SUBS_PAGE_SIZE, type AdminSubRow } from "@/lib/admin/billing";
|
||||
import { getOverview } from "@/lib/admin/metrics";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
|
||||
import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
|
||||
import { SubscriptionRowActions } from "@/components/admin/subscription-row-actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Subscriptions" };
|
||||
|
||||
export default async function AdminSubscriptionsPage() {
|
||||
const subs = await prisma.subscription.findMany({ orderBy: { createdAt: "desc" }, take: 200 });
|
||||
|
||||
const refIds = subs.map((s) => s.referenceId);
|
||||
const [users, orgs] = await Promise.all([
|
||||
prisma.user.findMany({ where: { id: { in: refIds } }, select: { id: true, name: true, email: true } }),
|
||||
prisma.organization.findMany({ where: { id: { in: refIds } }, select: { id: true, name: true } }),
|
||||
export default async function AdminSubscriptionsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, Number(sp.page ?? "1"));
|
||||
const [m, { rows, total }] = await Promise.all([
|
||||
getOverview("30d"),
|
||||
listSubscriptions({ status: sp.status, provider: sp.provider, search: sp.q, page }),
|
||||
]);
|
||||
const nameByRef = new Map<string, string>();
|
||||
for (const u of users) nameByRef.set(u.id, u.email);
|
||||
for (const o of orgs) nameByRef.set(o.id, o.name);
|
||||
|
||||
const active = subs.filter((s) => ["active", "trialing"].includes(s.status));
|
||||
const mrr = active.reduce((sum, s) => sum + (PLANS[s.plan as PlanKey]?.priceMonthly ?? 0), 0);
|
||||
const stripeCount = active.filter((s) => s.provider === "stripe").length;
|
||||
const paypalCount = active.filter((s) => s.provider === "paypal").length;
|
||||
const columns: Column<AdminSubRow>[] = [
|
||||
{
|
||||
key: "customer",
|
||||
header: "Customer",
|
||||
cell: (s) => <span className="truncate font-medium">{s.customer}</span>,
|
||||
},
|
||||
{ key: "plan", header: "Plan", cell: (s) => <span className="capitalize">{s.plan}</span> },
|
||||
{ key: "provider", header: "Provider", cell: (s) => <span className="capitalize">{s.provider}</span> },
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
cell: (s) => (
|
||||
<Badge variant={["active", "trialing"].includes(s.status) ? "success" : "secondary"}>
|
||||
{s.cancelAtPeriodEnd ? "cancels soon" : s.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "renews",
|
||||
header: "Renews",
|
||||
cell: (s) => (
|
||||
<span className="text-muted-foreground">{s.periodEnd ? s.periodEnd.toLocaleDateString() : "—"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "right",
|
||||
cell: (s) => (
|
||||
<SubscriptionRowActions
|
||||
sub={{
|
||||
id: s.id,
|
||||
referenceId: s.referenceId,
|
||||
provider: s.provider,
|
||||
status: s.status,
|
||||
cancelAtPeriodEnd: s.cancelAtPeriodEnd,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Subscriptions" description="Revenue and active subscriptions." />
|
||||
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-4">
|
||||
<Stat label="MRR" value={formatPrice(mrr)} />
|
||||
<Stat label="ARR" value={formatPrice(mrr * 12)} />
|
||||
<Stat label="Stripe" value={String(stripeCount)} />
|
||||
<Stat label="PayPal" value={String(paypalCount)} />
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="MRR" value={formatPrice(m.mrr)} icon={DollarSign} />
|
||||
<StatCard label="ARR" value={formatPrice(m.arr)} icon={TrendingUp} />
|
||||
<StatCard label="Stripe" value={String(m.providerSplit.stripe)} icon={CreditCard} />
|
||||
<StatCard
|
||||
label="PayPal"
|
||||
value={String(m.providerSplit.paypal)}
|
||||
icon={CreditCard}
|
||||
hint={m.providerSplit.comp ? `${m.providerSplit.comp} comped` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/40 text-left text-xs uppercase text-muted-foreground">
|
||||
<tr>
|
||||
<th className="p-3 font-medium">Customer</th>
|
||||
<th className="p-3 font-medium">Plan</th>
|
||||
<th className="p-3 font-medium">Provider</th>
|
||||
<th className="p-3 font-medium">Status</th>
|
||||
<th className="p-3 font-medium">Renews</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{subs.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-muted/20">
|
||||
<td className="p-3">{nameByRef.get(s.referenceId) ?? s.referenceId}</td>
|
||||
<td className="p-3 capitalize">{s.plan}</td>
|
||||
<td className="p-3 capitalize">{s.provider}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={["active", "trialing"].includes(s.status) ? "success" : "secondary"}>
|
||||
{s.cancelAtPeriodEnd ? "cancels soon" : s.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{s.periodEnd ? s.periodEnd.toLocaleDateString() : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{subs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||
No subscriptions yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<TableToolbar>
|
||||
<SearchInput placeholder="Search customer…" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<FilterSelect
|
||||
param="status"
|
||||
placeholder="Status"
|
||||
allLabel="All status"
|
||||
options={[
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "trialing", label: "Trialing" },
|
||||
{ value: "past_due", label: "Past due" },
|
||||
{ value: "canceled", label: "Canceled" },
|
||||
{ value: "paused", label: "Paused" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
param="provider"
|
||||
placeholder="Provider"
|
||||
allLabel="All providers"
|
||||
options={[
|
||||
{ value: "stripe", label: "Stripe" },
|
||||
{ value: "paypal", label: "PayPal" },
|
||||
{ value: "comp", label: "Comped" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</TableToolbar>
|
||||
<DataTable columns={columns} rows={rows} getRowKey={(s) => s.id} empty="No subscriptions match your filters." />
|
||||
<div className="mt-4">
|
||||
<Pagination page={page} pageSize={SUBS_PAGE_SIZE} total={total} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CreditCard, DollarSign, Mic, Activity } from "lucide-react";
|
||||
import { getUserDetail } from "@/lib/admin/users";
|
||||
import { UNLIMITED, type UsageMetric } from "@/lib/billing/plans";
|
||||
import { formatPrice } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { DataTable, type Column } from "@/components/admin/ui/data-table";
|
||||
import { UserDetailActions } from "@/components/admin/user-detail-actions";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · User detail" };
|
||||
|
||||
const USAGE_METRICS: { metric: UsageMetric; label: string }[] = [
|
||||
{ metric: "script", label: "Scripts" },
|
||||
{ metric: "audio", label: "Audio" },
|
||||
{ metric: "art", label: "Cover art" },
|
||||
{ metric: "repurpose", label: "Repurpose" },
|
||||
];
|
||||
|
||||
function initials(name: string): string {
|
||||
return (
|
||||
name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase())
|
||||
.join("") || "?"
|
||||
);
|
||||
}
|
||||
|
||||
type UserDetail = NonNullable<Awaited<ReturnType<typeof getUserDetail>>>;
|
||||
type AuditRow = UserDetail["audit"][number];
|
||||
|
||||
export default async function AdminUserDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const detail = await getUserDetail(id);
|
||||
if (!detail) notFound();
|
||||
|
||||
const { user, subscription, plan, usage, episodes, episodeCount, lifetimeCost, audit } = detail;
|
||||
|
||||
const usageSummary = USAGE_METRICS.map(({ metric, label }) => {
|
||||
const used = usage[metric] ?? 0;
|
||||
const limit = plan.limits[metric];
|
||||
const unlimited = limit === UNLIMITED;
|
||||
const pct = unlimited ? 0 : limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
||||
return { metric, label, used, limit, unlimited, pct };
|
||||
});
|
||||
|
||||
const auditColumns: Column<AuditRow>[] = [
|
||||
{
|
||||
key: "when",
|
||||
header: "When",
|
||||
cell: (l) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground">
|
||||
{l.createdAt.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: "actor", header: "Actor", cell: (l) => l.actor?.email ?? l.actorType },
|
||||
{ key: "action", header: "Action", cell: (l) => <Badge variant="outline">{l.action}</Badge> },
|
||||
{
|
||||
key: "meta",
|
||||
header: "Details",
|
||||
cell: (l) =>
|
||||
l.metadata ? (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{JSON.stringify(l.metadata).slice(0, 80)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="User detail"
|
||||
description="Account, usage, and history for this customer."
|
||||
/>
|
||||
|
||||
{/* Identity header */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-14 w-14">
|
||||
{user.image && <AvatarImage src={user.image} alt={user.name} />}
|
||||
<AvatarFallback className="text-base">{initials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-display text-xl font-extrabold tracking-tight">{user.name}</p>
|
||||
{user.role === "admin" && <Badge>admin</Badge>}
|
||||
{user.banned ? (
|
||||
<Badge variant="destructive">banned</Badge>
|
||||
) : (
|
||||
<Badge variant="success">active</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{user.email}</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Joined {user.createdAt.toLocaleDateString()} · <span className="font-mono">{user.id}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UserDetailActions
|
||||
user={{ id: user.id, role: user.role ?? "user", banned: !!user.banned }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Key stats */}
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label="Plan"
|
||||
value={plan.name}
|
||||
icon={CreditCard}
|
||||
hint={
|
||||
subscription
|
||||
? `${subscription.provider} · ${subscription.status}`
|
||||
: "No active subscription"
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Lifetime AI cost"
|
||||
value={`$${lifetimeCost.toFixed(2)}`}
|
||||
icon={DollarSign}
|
||||
hint="Across all generations"
|
||||
/>
|
||||
<StatCard label="Episodes" value={String(episodeCount)} icon={Mic} hint="Total created" />
|
||||
<StatCard
|
||||
label="Subscription"
|
||||
value={subscription ? formatPrice(plan.priceMonthly) : "Free"}
|
||||
icon={Activity}
|
||||
hint={subscription?.periodEnd ? `Renews ${subscription.periodEnd.toLocaleDateString()}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Usage vs plan limits */}
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Usage this period</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{usageSummary.map((u) => (
|
||||
<div key={u.metric} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{u.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{u.used} / {u.unlimited ? "∞" : u.limit}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={u.pct}
|
||||
indicatorClassName={
|
||||
!u.unlimited && u.pct >= 90 ? "bg-destructive" : u.pct >= 75 ? "bg-warning" : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent episodes */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent episodes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{episodes.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No episodes yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{episodes.map((ep) => (
|
||||
<li key={ep.id} className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/episodes/${ep.id}`}
|
||||
className="truncate font-medium hover:text-brand"
|
||||
>
|
||||
{ep.title}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ep.format} · {ep.createdAt.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{ep.status}</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Audit history */}
|
||||
<div className="mt-6 space-y-3">
|
||||
<h2 className="font-display text-lg font-bold tracking-tight">Audit history</h2>
|
||||
<DataTable
|
||||
columns={auditColumns}
|
||||
rows={audit}
|
||||
getRowKey={(l) => l.id}
|
||||
empty="No audit entries for this user."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,104 @@
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { listUsers, USERS_PAGE_SIZE, type AdminUserRow } from "@/lib/admin/users";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { UsersTable } from "@/components/admin/users-table";
|
||||
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
|
||||
import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
|
||||
import { UserRowActions } from "@/components/admin/user-row-actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Users" };
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const [users, subs] = await Promise.all([
|
||||
prisma.user.findMany({ orderBy: { createdAt: "desc" }, take: 200 }),
|
||||
prisma.subscription.findMany({
|
||||
where: { status: { in: ["active", "trialing"] } },
|
||||
select: { referenceId: true, plan: true },
|
||||
}),
|
||||
]);
|
||||
const planByRef = new Map(subs.map((s) => [s.referenceId, s.plan]));
|
||||
export default async function AdminUsersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, Number(sp.page ?? "1"));
|
||||
const { rows, total } = await listUsers({
|
||||
search: sp.q,
|
||||
plan: sp.plan,
|
||||
role: sp.role,
|
||||
status: sp.status,
|
||||
sort: sp.sort,
|
||||
page,
|
||||
});
|
||||
|
||||
const columns: Column<AdminUserRow>[] = [
|
||||
{
|
||||
key: "user",
|
||||
header: "User",
|
||||
sortKey: "name",
|
||||
cell: (u) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{u.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{u.email}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "plan", header: "Plan", cell: (u) => <span className="capitalize">{u.plan}</span> },
|
||||
{
|
||||
key: "role",
|
||||
header: "Role",
|
||||
cell: (u) =>
|
||||
u.role === "admin" ? <Badge>admin</Badge> : <span className="text-muted-foreground">user</span>,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
cell: (u) =>
|
||||
u.banned ? <Badge variant="destructive">banned</Badge> : <Badge variant="success">active</Badge>,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
header: "Joined",
|
||||
sortKey: "createdAt",
|
||||
cell: (u) => <span className="text-muted-foreground">{u.createdAt.toLocaleDateString()}</span>,
|
||||
},
|
||||
{ key: "actions", header: "", align: "right", cell: (u) => <UserRowActions user={u} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Users" description={`${users.length} most recent users.`} />
|
||||
<UsersTable
|
||||
users={users.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
role: u.role ?? "user",
|
||||
banned: !!u.banned,
|
||||
plan: planByRef.get(u.id) ?? "free",
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
}))}
|
||||
/>
|
||||
<PageHeader title="Users" description={`${total} total user${total === 1 ? "" : "s"}.`} />
|
||||
<TableToolbar>
|
||||
<SearchInput placeholder="Search name or email…" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<FilterSelect
|
||||
param="plan"
|
||||
placeholder="Plan"
|
||||
allLabel="All plans"
|
||||
options={[
|
||||
{ value: "free", label: "Free" },
|
||||
{ value: "creator", label: "Creator" },
|
||||
{ value: "pro", label: "Pro" },
|
||||
{ value: "agency", label: "Agency" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
param="role"
|
||||
placeholder="Role"
|
||||
allLabel="All roles"
|
||||
options={[
|
||||
{ value: "admin", label: "Admin" },
|
||||
{ value: "user", label: "User" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
param="status"
|
||||
placeholder="Status"
|
||||
allLabel="All status"
|
||||
options={[
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "banned", label: "Banned" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</TableToolbar>
|
||||
<DataTable columns={columns} rows={rows} getRowKey={(u) => u.id} empty="No users match your filters." />
|
||||
<div className="mt-4">
|
||||
<Pagination page={page} pageSize={USERS_PAGE_SIZE} total={total} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Webhook, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import { listWebhookEvents, WEBHOOKS_PAGE_SIZE } from "@/lib/admin/ops";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table";
|
||||
import { FilterSelect, Pagination } from "@/components/admin/ui/table-controls";
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = { title: "Admin · Webhooks" };
|
||||
|
||||
type Row = Awaited<ReturnType<typeof listWebhookEvents>>["rows"][number];
|
||||
|
||||
const VARIANT: Record<string, BadgeProps["variant"]> = {
|
||||
processed: "success",
|
||||
failed: "destructive",
|
||||
skipped: "secondary",
|
||||
};
|
||||
|
||||
export default async function AdminWebhooksPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, Number(sp.page ?? "1"));
|
||||
const { rows, total, recentFailures, recentTotal } = await listWebhookEvents({
|
||||
provider: sp.provider,
|
||||
status: sp.status,
|
||||
page,
|
||||
});
|
||||
|
||||
const columns: Column<Row>[] = [
|
||||
{ key: "provider", header: "Provider", cell: (w) => <span className="capitalize">{w.provider}</span> },
|
||||
{ key: "type", header: "Event", cell: (w) => <span className="font-mono text-xs">{w.type}</span> },
|
||||
{ key: "status", header: "Status", cell: (w) => <Badge variant={VARIANT[w.status] ?? "secondary"}>{w.status}</Badge> },
|
||||
{
|
||||
key: "error",
|
||||
header: "Error",
|
||||
cell: (w) =>
|
||||
w.error ? (
|
||||
<span className="truncate font-mono text-xs text-destructive" title={w.error}>
|
||||
{w.error.slice(0, 40)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "when",
|
||||
header: "When",
|
||||
cell: (w) => <span className="whitespace-nowrap text-muted-foreground">{w.createdAt.toLocaleString()}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Webhook deliveries" description="Stripe & PayPal events received." />
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-3">
|
||||
<StatCard label="Delivered (24h)" value={String(recentTotal)} icon={Webhook} />
|
||||
<StatCard label="Succeeded (24h)" value={String(recentTotal - recentFailures)} icon={CheckCircle2} />
|
||||
<StatCard label="Failed (24h)" value={String(recentFailures)} icon={AlertTriangle} />
|
||||
</div>
|
||||
|
||||
<TableToolbar>
|
||||
<div />
|
||||
<div className="flex gap-2">
|
||||
<FilterSelect
|
||||
param="provider"
|
||||
placeholder="Provider"
|
||||
allLabel="All providers"
|
||||
options={[
|
||||
{ value: "stripe", label: "Stripe" },
|
||||
{ value: "paypal", label: "PayPal" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
param="status"
|
||||
placeholder="Status"
|
||||
allLabel="All status"
|
||||
options={[
|
||||
{ value: "processed", label: "Processed" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
{ value: "skipped", label: "Skipped" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</TableToolbar>
|
||||
<DataTable columns={columns} rows={rows} getRowKey={(w) => w.id} empty="No webhook deliveries recorded yet." />
|
||||
<div className="mt-4">
|
||||
<Pagination page={page} pageSize={WEBHOOKS_PAGE_SIZE} total={total} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+10
-6
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { ShieldCheck, ArrowLeft } from "lucide-react";
|
||||
import { requireAdmin } from "@/lib/auth/guards";
|
||||
import { AdminSidebar } from "@/components/admin/admin-sidebar";
|
||||
import { AdminMobileNav } from "@/components/admin/admin-mobile-nav";
|
||||
import { UserMenu } from "@/components/app/user-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -14,12 +15,15 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-border bg-background/85 px-4 backdrop-blur-md md:px-6">
|
||||
<Link href="/admin" className="flex items-center gap-2.5 font-display font-bold tracking-tight">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-foreground text-background">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</span>
|
||||
<span>PodcastYes Admin</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-1">
|
||||
<AdminMobileNav />
|
||||
<Link href="/admin" className="flex items-center gap-2.5 font-display font-bold tracking-tight">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-foreground text-background">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</span>
|
||||
<span>PodcastYes Admin</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/dashboard">
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { HeaderSkeleton, Skeleton, ListSkeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function ApiKeysLoading() {
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<Skeleton className="h-20 rounded-2xl" />
|
||||
<ListSkeleton rows={3} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { HeaderSkeleton, Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function BillingLoading() {
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<Skeleton className="mb-8 h-24 rounded-2xl" />
|
||||
<div className="grid gap-4 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-80 rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { HeaderSkeleton, StatRowSkeleton, ListSkeleton, Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<StatRowSkeleton count={3} />
|
||||
<div className="mt-6 space-y-3">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<ListSkeleton rows={5} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+133
-52
@@ -1,22 +1,39 @@
|
||||
import Link from "next/link";
|
||||
import { Mic2, Plus, Sparkles, ArrowRight } from "lucide-react";
|
||||
import { Mic2, Plus, Sparkles, ArrowRight, Mic, Gauge, Crown, Infinity as InfinityIcon } from "lucide-react";
|
||||
import { requireAuth } from "@/lib/auth/guards";
|
||||
import { getEffectivePlan } from "@/lib/billing/subscription";
|
||||
import { getUsageSummary } from "@/lib/usage/meter";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { UNLIMITED, type UsageMetric } from "@/lib/billing/plans";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { EpisodeStatusBadge } from "@/components/app/episode-status-badge";
|
||||
import { StatCard } from "@/components/admin/ui/stat-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EpisodeStatusBadge } from "@/components/app/episode-status-badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
const METRIC_LABELS: Record<UsageMetric, string> = {
|
||||
script: "Scripts",
|
||||
audio: "Audio generations",
|
||||
art: "Cover art",
|
||||
repurpose: "Repurposed content",
|
||||
};
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await requireAuth();
|
||||
const { plan, key } = await getEffectivePlan(
|
||||
const { plan, key, subjectId } = await getEffectivePlan(
|
||||
session.user.id,
|
||||
session.session.activeOrganizationId
|
||||
);
|
||||
|
||||
const [episodeCount, recent] = await Promise.all([
|
||||
// Episodes from the last 8 weeks for the sparkline + a 30-day rolling count.
|
||||
const now = new Date();
|
||||
const eightWeeksAgo = new Date(now);
|
||||
eightWeeksAgo.setDate(eightWeeksAgo.getDate() - 7 * 8);
|
||||
|
||||
const [episodeCount, recent, recentForSpark, usage] = await Promise.all([
|
||||
prisma.episode.count({ where: { userId: session.user.id } }),
|
||||
prisma.episode.findMany({
|
||||
where: { userId: session.user.id },
|
||||
@@ -24,8 +41,38 @@ export default async function DashboardPage() {
|
||||
take: 5,
|
||||
select: { id: true, title: true, status: true, format: true, createdAt: true },
|
||||
}),
|
||||
prisma.episode.findMany({
|
||||
where: { userId: session.user.id, createdAt: { gte: eightWeeksAgo } },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
getUsageSummary(subjectId),
|
||||
]);
|
||||
|
||||
// Bucket episodes into 8 weekly counts (oldest → newest) for the sparkline.
|
||||
const weeklySpark = Array.from({ length: 8 }, (_, i) => {
|
||||
const start = new Date(eightWeeksAgo);
|
||||
start.setDate(start.getDate() + i * 7);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 7);
|
||||
return recentForSpark.filter((e) => e.createdAt >= start && e.createdAt < end).length;
|
||||
});
|
||||
const thisWeek = weeklySpark[weeklySpark.length - 1];
|
||||
|
||||
// Tightest metered limit: the metric closest to its cap (excluding unlimited).
|
||||
const metrics = (Object.keys(METRIC_LABELS) as UsageMetric[])
|
||||
.map((m) => {
|
||||
const limit = plan.limits[m];
|
||||
const used = usage[m];
|
||||
const unlimited = limit === UNLIMITED;
|
||||
const pct = unlimited ? 0 : Math.min(100, Math.round((used / Math.max(1, limit)) * 100));
|
||||
return { metric: m, used, limit, unlimited, pct };
|
||||
});
|
||||
const tightest =
|
||||
metrics
|
||||
.filter((m) => !m.unlimited)
|
||||
.sort((a, b) => b.pct - a.pct)[0] ?? metrics[0];
|
||||
const tightestAtLimit = !tightest.unlimited && tightest.used >= tightest.limit;
|
||||
|
||||
const firstName = session.user.name.split(" ")[0];
|
||||
|
||||
return (
|
||||
@@ -42,74 +89,108 @@ export default async function DashboardPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<StatCard
|
||||
label="Episodes created"
|
||||
value={String(episodeCount)}
|
||||
icon={Mic}
|
||||
spark={weeklySpark.some((v) => v > 0) ? weeklySpark : undefined}
|
||||
hint={`${thisWeek} this week`}
|
||||
/>
|
||||
|
||||
{/* Live usage meter — tightest metered metric */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Episodes created</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-display text-4xl font-extrabold tracking-tight">{episodeCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Current plan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<p className="font-display text-4xl font-extrabold capitalize tracking-tight">{plan.name}</p>
|
||||
{key === "free" && (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href="/billing">Upgrade</Link>
|
||||
</Button>
|
||||
<CardContent className="space-y-3 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{METRIC_LABELS[tightest.metric]} this month
|
||||
</span>
|
||||
<Gauge className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className="font-display text-3xl font-extrabold tracking-tight">{tightest.used}</p>
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
{tightest.unlimited ? (
|
||||
<>
|
||||
<InfinityIcon className="h-4 w-4" /> Unlimited
|
||||
</>
|
||||
) : (
|
||||
<>of {tightest.limit}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{!tightest.unlimited ? (
|
||||
<Progress
|
||||
value={tightest.pct}
|
||||
indicatorClassName={tightestAtLimit ? "bg-warning" : undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No limits on this metric.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Usage this month</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Button asChild size="sm" variant="ghost" className="-ml-2 h-8 px-2 text-brand">
|
||||
<Link href="/usage">
|
||||
View usage <ArrowRight className="h-4 w-4" />
|
||||
View all usage <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Plan card with inline upgrade */}
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Current plan</span>
|
||||
<Crown className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-display text-3xl font-extrabold capitalize tracking-tight">
|
||||
{plan.name}
|
||||
</p>
|
||||
{key !== "free" && <Badge variant="brand">Active</Badge>}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{plan.tagline}</p>
|
||||
{key !== "agency" && (
|
||||
<Button asChild size="sm" variant={key === "free" ? "default" : "outline"}>
|
||||
<Link href="/billing">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Upgrade
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>Recent episodes</CardTitle>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/episodes">View all</Link>
|
||||
</Button>
|
||||
{recent.length > 0 && (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/episodes">View all</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recent.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-12 text-center">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand/10 text-brand">
|
||||
<Mic2 className="h-6 w-6" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">No episodes yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create your first AI-produced episode to get started.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/episodes/new">
|
||||
<Sparkles className="h-4 w-4" /> Create your first episode
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<EmptyState
|
||||
bordered={false}
|
||||
icon={Mic2}
|
||||
title="No episodes yet"
|
||||
description="Create your first AI-produced episode to get started."
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/episodes/new">
|
||||
<Sparkles className="h-4 w-4" /> Create your first episode
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{recent.map((ep) => (
|
||||
<li key={ep.id}>
|
||||
<Link
|
||||
href={`/episodes/${ep.id}`}
|
||||
className="flex items-center justify-between gap-3 py-3 hover:opacity-80"
|
||||
className="flex items-center justify-between gap-3 py-3 transition-colors hover:opacity-80"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{ep.title}</p>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export default function EpisodeLoading() {
|
||||
return (
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="h-9 w-72 rounded-xl bg-secondary" />
|
||||
<div className="h-4 w-48 rounded-lg bg-secondary/70" />
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-1">
|
||||
<div className="aspect-square rounded-2xl border border-border bg-secondary" />
|
||||
<div className="h-32 rounded-2xl border border-border bg-card" />
|
||||
<div className="h-11 rounded-full bg-secondary" />
|
||||
</div>
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
<div className="h-14 rounded-2xl border border-border bg-card" />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-40 rounded-2xl border border-border bg-card" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from "next/link";
|
||||
import { MicOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function EpisodeNotFound() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-5 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-secondary text-muted-foreground">
|
||||
<MicOff className="h-7 w-7" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="font-display text-2xl font-extrabold tracking-tight">Episode not found</h1>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
This episode doesn't exist, or you don't have access to it.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/episodes">Back to episodes</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,11 @@ export default async function EpisodePage({ params }: { params: Promise<{ id: st
|
||||
<PageHeader
|
||||
title={episode.title}
|
||||
description={`${episode.format.replace("_", "-").toLowerCase()} · ${episode.language.toUpperCase()} · ${episode.targetLengthMin} min`}
|
||||
action={!inProgress ? <EpisodeActions episodeId={episode.id} /> : undefined}
|
||||
action={
|
||||
!inProgress ? (
|
||||
<EpisodeActions episodeId={episode.id} initialShareId={episode.shareId} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{episode.status === "FAILED" || inProgress ? (
|
||||
@@ -68,6 +72,7 @@ export default async function EpisodePage({ params }: { params: Promise<{ id: st
|
||||
<AudioPlayer
|
||||
storageKey={episode.audioAsset.storageKey}
|
||||
durationSec={episode.audioAsset.durationSec}
|
||||
episodeId={episode.id}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
+213
-71
@@ -1,13 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
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 { reserveLimit, LimitExceededError } from "@/lib/usage/limits";
|
||||
import { refundUsage } from "@/lib/usage/meter";
|
||||
import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss";
|
||||
import { rateLimit, LIMITS } from "@/lib/ratelimit";
|
||||
import { isFlagEnabled } from "@/lib/flags";
|
||||
import { moderateText } from "@/lib/ai/moderation";
|
||||
import type { UsageMetric } from "@/lib/billing/plans";
|
||||
import type { GenerationType } from "@/lib/queue/jobs";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
@@ -42,6 +47,10 @@ export async function createEpisodeAction(input: CreateEpisodeInput): Promise<Cr
|
||||
return { ok: false, error: `Too many requests. Try again in ${rl.retryAfterSec}s.` };
|
||||
}
|
||||
|
||||
if (!(await isFlagEnabled("episode_generation_enabled"))) {
|
||||
return { ok: false, error: "Episode generation is temporarily paused. Please try again shortly." };
|
||||
}
|
||||
|
||||
const parsed = createSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." };
|
||||
@@ -49,7 +58,7 @@ export async function createEpisodeAction(input: CreateEpisodeInput): Promise<Cr
|
||||
const data = parsed.data;
|
||||
const activeOrgId = session.session.activeOrganizationId;
|
||||
|
||||
const { plan } = await getEffectivePlan(session.user.id, activeOrgId);
|
||||
const { plan, subjectId, subjectType } = await getEffectivePlan(session.user.id, activeOrgId);
|
||||
if (data.targetLengthMin > plan.limits.maxEpisodeMinutes) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -58,10 +67,33 @@ export async function createEpisodeAction(input: CreateEpisodeInput): Promise<Cr
|
||||
};
|
||||
}
|
||||
|
||||
// Screen the requested topic before spending any quota or 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 {
|
||||
ok: false,
|
||||
error: "This topic may violate our content policy and can't be generated. Please revise it and try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve quota atomically up front (a full generation consumes script,
|
||||
// audio and 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 enforceLimit(session.user.id, "script", activeOrgId);
|
||||
await enforceLimit(session.user.id, "audio", activeOrgId);
|
||||
await reserveLimit(session.user.id, "script", activeOrgId);
|
||||
reserved.push("script");
|
||||
await reserveLimit(session.user.id, "audio", activeOrgId);
|
||||
reserved.push("audio");
|
||||
await reserveLimit(session.user.id, "art", activeOrgId);
|
||||
reserved.push("art");
|
||||
} catch (err) {
|
||||
await refundReserved();
|
||||
if (err instanceof LimitExceededError) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -72,38 +104,43 @@ export async function createEpisodeAction(input: CreateEpisodeInput): Promise<Cr
|
||||
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,
|
||||
})),
|
||||
try {
|
||||
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" } },
|
||||
},
|
||||
jobs: { create: { type: "full", status: "queued" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await enqueueEpisodeGeneration(
|
||||
{ episodeId: episode.id, type: "full" },
|
||||
{ priority: plan.features.includes("priority_generation") ? 10 : 0 }
|
||||
);
|
||||
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 };
|
||||
revalidatePath("/episodes");
|
||||
revalidatePath("/dashboard");
|
||||
return { ok: true, episodeId: episode.id };
|
||||
} catch (err) {
|
||||
await refundReserved();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function regenerateAction(
|
||||
@@ -113,6 +150,15 @@ export async function regenerateAction(
|
||||
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.` };
|
||||
}
|
||||
|
||||
if (!(await isFlagEnabled("episode_generation_enabled"))) {
|
||||
return { ok: false, error: "Episode generation is temporarily paused. Please try again shortly." };
|
||||
}
|
||||
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { id: episodeId },
|
||||
select: { userId: true, organizationId: true },
|
||||
@@ -122,24 +168,41 @@ export async function regenerateAction(
|
||||
return { ok: false, error: "Not allowed." };
|
||||
}
|
||||
|
||||
// Gate the metrics this regeneration will consume.
|
||||
const metrics: ("script" | "audio" | "art")[] =
|
||||
const activeOrgId = session.session.activeOrganizationId;
|
||||
const { subjectId, subjectType } = await getEffectivePlan(session.user.id, activeOrgId);
|
||||
|
||||
// Reserve the metrics this regeneration will consume up front. The worker
|
||||
// won't re-meter; refund below if enqueue fails. See meter.ts invariant.
|
||||
const metrics: UsageMetric[] =
|
||||
type === "art" ? ["art"] : type === "audio" ? ["audio"] : ["script", "audio"];
|
||||
const reserved: UsageMetric[] = [];
|
||||
const refundReserved = async () => {
|
||||
for (const m of reserved) await refundUsage(subjectId, subjectType, m);
|
||||
};
|
||||
try {
|
||||
for (const m of metrics) await enforceLimit(session.user.id, m, session.session.activeOrganizationId);
|
||||
for (const m of metrics) {
|
||||
await reserveLimit(session.user.id, m, activeOrgId);
|
||||
reserved.push(m);
|
||||
}
|
||||
} catch (err) {
|
||||
await refundReserved();
|
||||
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 });
|
||||
try {
|
||||
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 });
|
||||
} catch (err) {
|
||||
await refundReserved();
|
||||
throw err;
|
||||
}
|
||||
|
||||
revalidatePath(`/episodes/${episodeId}`);
|
||||
return { ok: true };
|
||||
@@ -198,8 +261,16 @@ export async function regenerateSectionAction(
|
||||
}
|
||||
if (!episode.script) return { ok: false, error: "No script to edit yet." };
|
||||
|
||||
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 activeOrgId = session.session.activeOrganizationId;
|
||||
// Reserve the script unit atomically up front. This synchronous action does
|
||||
// NOT increment afterwards (that would double-count) — it refunds on failure.
|
||||
try {
|
||||
await enforceLimit(session.user.id, "script", session.session.activeOrganizationId);
|
||||
await reserveLimit(session.user.id, "script", activeOrgId);
|
||||
} catch (err) {
|
||||
if (err instanceof LimitExceededError) {
|
||||
return { ok: false, error: `Monthly script limit reached on the ${err.check.plan} plan.` };
|
||||
@@ -207,10 +278,12 @@ export async function regenerateSectionAction(
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ownerId = episode.organizationId ?? episode.userId;
|
||||
const ownerType = episode.organizationId ? "organization" : "user";
|
||||
|
||||
// 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,
|
||||
@@ -227,20 +300,26 @@ export async function regenerateSectionAction(
|
||||
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)),
|
||||
};
|
||||
let section: { id: string; title: string; turns: { speakerKey: string; text: string }[] };
|
||||
let usage: { inputTokens: number; outputTokens: number };
|
||||
try {
|
||||
({ 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 } },
|
||||
});
|
||||
await prisma.script.update({
|
||||
where: { episodeId },
|
||||
data: { content: updated as unknown as Prisma.InputJsonValue, version: { increment: 1 } },
|
||||
});
|
||||
} catch (err) {
|
||||
// Generation/save failed — refund the script unit we reserved.
|
||||
await refundUsage(ownerId, ownerType, "script");
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ownerId = episode.organizationId ?? episode.userId;
|
||||
const ownerType = episode.organizationId ? "organization" : "user";
|
||||
await incrementUsage(ownerId, ownerType, "script");
|
||||
// No incrementUsage here: the unit was already reserved above.
|
||||
await recordCost({
|
||||
provider: "openai",
|
||||
operation: "script",
|
||||
@@ -270,8 +349,16 @@ export async function repurposeAction(
|
||||
}
|
||||
if (!episode.script) return { ok: false, error: "Generate the episode first." };
|
||||
|
||||
const rl = await rateLimit("repurpose", session.user.id, LIMITS.repurpose);
|
||||
if (!rl.ok) {
|
||||
return { ok: false, error: `Too many requests. Try again in ${rl.retryAfterSec}s.` };
|
||||
}
|
||||
|
||||
const activeOrgId = session.session.activeOrganizationId;
|
||||
// Reserve the repurpose unit atomically up front; refund on failure. This
|
||||
// synchronous action does NOT increment afterwards (that would double-count).
|
||||
try {
|
||||
await enforceLimit(session.user.id, "repurpose", session.session.activeOrganizationId);
|
||||
await reserveLimit(session.user.id, "repurpose", activeOrgId);
|
||||
} catch (err) {
|
||||
if (err instanceof LimitExceededError) {
|
||||
return { ok: false, error: `Monthly repurpose limit reached on the ${err.check.plan} plan.` };
|
||||
@@ -279,22 +366,30 @@ export async function repurposeAction(
|
||||
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<typeof repurposeScript>[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");
|
||||
|
||||
const { repurposeScript } = await import("@/lib/ai/pipeline/repurpose");
|
||||
const { recordCost, scriptCostUsd } = await import("@/lib/ai/cost");
|
||||
|
||||
let content: { title: string; body: string };
|
||||
let usage: { inputTokens: number; outputTokens: number };
|
||||
try {
|
||||
({ content, usage } = await repurposeScript(
|
||||
episode.script.content as unknown as Parameters<typeof repurposeScript>[0],
|
||||
format
|
||||
));
|
||||
|
||||
await prisma.repurposedContent.create({
|
||||
data: { episodeId, type: format, content: content as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
} catch (err) {
|
||||
// Generation/save failed — refund the repurpose unit we reserved.
|
||||
await refundUsage(ownerId, ownerType, "repurpose");
|
||||
throw err;
|
||||
}
|
||||
|
||||
// No incrementUsage here: the unit was already reserved above.
|
||||
await recordCost({
|
||||
provider: "openai",
|
||||
operation: "repurpose",
|
||||
@@ -323,6 +418,53 @@ export async function deleteEpisodeAction(episodeId: string): Promise<{ ok: bool
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle public sharing for an episode. When enabled, mints a random url-safe
|
||||
* `shareId` (reachable at /p/<shareId> with no auth) and stamps `sharedAt`;
|
||||
* when disabled, clears both so the public page 404s. Ownership-checked.
|
||||
*/
|
||||
export async function setEpisodeShareAction(
|
||||
episodeId: string,
|
||||
enabled: boolean
|
||||
): Promise<{ ok: boolean; error?: string; shareId?: string | null }> {
|
||||
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, shareId: true, status: true },
|
||||
});
|
||||
if (!episode || (episode.userId !== session.user.id && session.user.role !== "admin")) {
|
||||
return { ok: false, error: "Not allowed." };
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
if (episode.status !== "READY") {
|
||||
return { ok: false, error: "Finish generating the episode before sharing it." };
|
||||
}
|
||||
// Reuse an existing shareId if one was already minted (stable public URL).
|
||||
const shareId = episode.shareId ?? randomShareId();
|
||||
await prisma.episode.update({
|
||||
where: { id: episodeId },
|
||||
data: { shareId, sharedAt: new Date() },
|
||||
});
|
||||
revalidatePath(`/episodes/${episodeId}`);
|
||||
return { ok: true, shareId };
|
||||
}
|
||||
|
||||
await prisma.episode.update({
|
||||
where: { id: episodeId },
|
||||
data: { shareId: null, sharedAt: null },
|
||||
});
|
||||
revalidatePath(`/episodes/${episodeId}`);
|
||||
return { ok: true, shareId: null };
|
||||
}
|
||||
|
||||
/** url-safe base64 token (~22 chars, 128 bits) for public share links. */
|
||||
function randomShareId(): string {
|
||||
return randomBytes(16).toString("base64url");
|
||||
}
|
||||
|
||||
function deriveTitle(topic: string): string {
|
||||
const trimmed = topic.trim().replace(/\s+/g, " ");
|
||||
return trimmed.length <= 60 ? trimmed : trimmed.slice(0, 57) + "…";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { HeaderSkeleton, EpisodeGridSkeleton, Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function EpisodesLoading() {
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
<Skeleton className="h-10 w-full sm:max-w-xs" />
|
||||
<Skeleton className="h-10 w-[150px]" />
|
||||
</div>
|
||||
<EpisodeGridSkeleton count={8} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+60
-16
@@ -1,22 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Mic2, Plus } from "lucide-react";
|
||||
import type { Prisma, EpisodeStatus } from "@prisma/client";
|
||||
import { requireAuth } from "@/lib/auth/guards";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { EpisodeCard } from "@/components/app/episode-card";
|
||||
import { SearchInput, FilterSelect } from "@/components/admin/ui/table-controls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
export const metadata: Metadata = { title: "Episodes" };
|
||||
|
||||
export default async function EpisodesPage() {
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "QUEUED", label: "Queued" },
|
||||
{ value: "SCRIPTING", label: "Writing script" },
|
||||
{ value: "SYNTHESIZING", label: "Recording audio" },
|
||||
{ value: "STITCHING", label: "Mixing audio" },
|
||||
{ value: "ART", label: "Designing art" },
|
||||
{ value: "SAVING", label: "Finalizing" },
|
||||
{ value: "READY", label: "Ready" },
|
||||
{ value: "FAILED", label: "Failed" },
|
||||
];
|
||||
|
||||
const VALID_STATUSES = new Set(STATUS_OPTIONS.map((o) => o.value));
|
||||
|
||||
export default async function EpisodesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; status?: string }>;
|
||||
}) {
|
||||
const session = await requireAuth();
|
||||
const { q, status } = await searchParams;
|
||||
const query = q?.trim();
|
||||
const statusFilter = status && VALID_STATUSES.has(status) ? status : undefined;
|
||||
|
||||
const where: Prisma.EpisodeWhereInput = {
|
||||
userId: session.user.id,
|
||||
...(query ? { title: { contains: query, mode: "insensitive" } } : {}),
|
||||
...(statusFilter ? { status: statusFilter as EpisodeStatus } : {}),
|
||||
};
|
||||
|
||||
const episodes = await prisma.episode.findMany({
|
||||
where: { userId: session.user.id },
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { coverArt: { select: { storageKey: true } } },
|
||||
});
|
||||
|
||||
const filtering = Boolean(query || statusFilter);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -31,21 +64,32 @@ export default async function EpisodesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<SearchInput placeholder="Search episodes…" />
|
||||
<FilterSelect param="status" placeholder="Status" options={STATUS_OPTIONS} allLabel="All statuses" />
|
||||
</div>
|
||||
|
||||
{episodes.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border py-16 text-center">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-brand/10 text-brand">
|
||||
<Mic2 className="h-6 w-6" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">No episodes yet</p>
|
||||
<p className="text-sm text-muted-foreground">Create your first AI-produced episode.</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/episodes/new">
|
||||
<Plus className="h-4 w-4" /> New episode
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
filtering ? (
|
||||
<EmptyState
|
||||
icon={Mic2}
|
||||
title="No matching episodes"
|
||||
description="Try a different search term or status filter."
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Mic2}
|
||||
title="No episodes yet"
|
||||
description="Create your first AI-produced episode."
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/episodes/new">
|
||||
<Plus className="h-4 w-4" /> New episode
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{episodes.map((ep) => (
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, RotateCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default function AppError({ error, reset }: { error: Error; reset: () => void }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-display text-lg font-bold tracking-tight">Something went wrong</p>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
{error.message || "This page failed to load. Please try again."}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={reset}>
|
||||
<RotateCw className="h-4 w-4" /> Try again
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+82
-37
@@ -1,9 +1,15 @@
|
||||
import Link from "next/link";
|
||||
import { Mic, Plus } from "lucide-react";
|
||||
import { Mic, Plus, Wrench } from "lucide-react";
|
||||
import { requireAuth } from "@/lib/auth/guards";
|
||||
import { getEffectivePlan } from "@/lib/billing/subscription";
|
||||
import { getActiveBranding, hexToHslTriplet } from "@/lib/branding";
|
||||
import { isFlagEnabled } from "@/lib/flags";
|
||||
import { SidebarNav } from "@/components/app/sidebar-nav";
|
||||
import { AppMobileNav } from "@/components/app/app-mobile-nav";
|
||||
import { UserMenu } from "@/components/app/user-menu";
|
||||
import { CommandPalette } from "@/components/app/command-palette";
|
||||
import { ImpersonationBanner } from "@/components/app/impersonation-banner";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// Authed, DB-backed dashboard — never statically prerender.
|
||||
@@ -11,46 +17,85 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await requireAuth();
|
||||
const { key: plan } = await getEffectivePlan(
|
||||
session.user.id,
|
||||
session.session.activeOrganizationId
|
||||
);
|
||||
const activeOrgId = session.session.activeOrganizationId;
|
||||
const [{ key: plan }, branding, maintenance] = await Promise.all([
|
||||
getEffectivePlan(session.user.id, activeOrgId),
|
||||
getActiveBranding(session.user.id, activeOrgId),
|
||||
isFlagEnabled("maintenance_banner"),
|
||||
]);
|
||||
const isAdmin = session.user.role === "admin";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-border bg-background/85 px-4 backdrop-blur-md md:px-6">
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5 font-display font-bold tracking-tight">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-brand text-brand-foreground">
|
||||
<Mic className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="hidden sm:inline">PodcastYes</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button asChild size="sm">
|
||||
<Link href="/episodes/new">
|
||||
<Plus className="h-4 w-4" /> New episode
|
||||
</Link>
|
||||
</Button>
|
||||
<UserMenu
|
||||
name={session.user.name}
|
||||
email={session.user.email}
|
||||
image={session.user.image}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
// White-label: override the brand accent with the org's primary color so the
|
||||
// whole app shell (logo tile, active nav, buttons) adopts it.
|
||||
const brandHsl = hexToHslTriplet(branding?.primaryColor);
|
||||
const brandStyle = brandHsl
|
||||
? ({ "--brand": brandHsl, "--ring": brandHsl } as React.CSSProperties)
|
||||
: undefined;
|
||||
const workspaceName = branding?.brandName ?? "PodcastYes";
|
||||
|
||||
<div className="flex flex-1">
|
||||
<aside className="hidden w-64 shrink-0 border-r border-border bg-background md:block">
|
||||
<div className="sticky top-16">
|
||||
<SidebarNav plan={plan} />
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<div className="flex min-h-screen flex-col" style={brandStyle}>
|
||||
<ImpersonationBanner />
|
||||
{maintenance && (
|
||||
<div className="flex items-center justify-center gap-2 bg-warning px-4 py-2 text-center text-sm font-medium text-warning-foreground">
|
||||
<Wrench className="h-4 w-4" />
|
||||
We're performing scheduled maintenance — some features may be briefly unavailable.
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 bg-secondary/50">
|
||||
<div className="container max-w-6xl py-8 md:py-10">{children}</div>
|
||||
</main>
|
||||
)}
|
||||
|
||||
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-border bg-background/85 px-4 backdrop-blur-md md:px-6">
|
||||
<div className="flex items-center gap-1">
|
||||
<AppMobileNav plan={plan} workspaceName={workspaceName} />
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5 font-display font-bold tracking-tight">
|
||||
{branding?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={branding.logoUrl}
|
||||
alt={workspaceName}
|
||||
className="h-8 w-auto max-w-[160px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-2xl bg-brand text-brand-foreground">
|
||||
<Mic className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="hidden sm:inline">{workspaceName}</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button asChild size="sm">
|
||||
<Link href="/episodes/new">
|
||||
<Plus className="h-4 w-4" /> New episode
|
||||
</Link>
|
||||
</Button>
|
||||
<UserMenu
|
||||
name={session.user.name}
|
||||
email={session.user.email}
|
||||
image={session.user.image}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1">
|
||||
<aside className="hidden w-64 shrink-0 border-r border-border bg-background md:block">
|
||||
<div className="sticky top-16">
|
||||
<SidebarNav plan={plan} />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 bg-secondary/50">
|
||||
<div className="container max-w-6xl py-8 md:py-10">{children}</div>
|
||||
{branding && !branding.removePoweredBy && (
|
||||
<p className="pb-8 text-center text-xs text-muted-foreground">Powered by PodcastYes</p>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<CommandPalette />
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 { isFlagEnabled } from "@/lib/flags";
|
||||
|
||||
const createSchema = z.object({
|
||||
theme: z.string().min(5).max(500),
|
||||
@@ -27,6 +28,9 @@ export async function createSeriesAction(
|
||||
if (!(await subjectHasFeature(session.user.id, "series_generator", session.session.activeOrganizationId))) {
|
||||
return { ok: false, error: "The series generator requires the Pro plan." };
|
||||
}
|
||||
if (!(await isFlagEnabled("episode_generation_enabled"))) {
|
||||
return { ok: false, error: "Generation is temporarily paused. Please try again shortly." };
|
||||
}
|
||||
const parsed = createSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input." };
|
||||
|
||||
@@ -54,6 +58,10 @@ export async function generateFromSeriesAction(
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
|
||||
if (!(await isFlagEnabled("episode_generation_enabled"))) {
|
||||
return { ok: false, error: "Episode generation is temporarily paused. Please try again shortly." };
|
||||
}
|
||||
|
||||
const series = await prisma.series.findUnique({ where: { id: seriesId } });
|
||||
if (!series || series.userId !== session.user.id) return { ok: false, error: "Not allowed." };
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { HeaderSkeleton, Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function SeriesLoading() {
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<Skeleton className="h-72 max-w-2xl rounded-2xl" />
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-2">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { PageHeader } from "@/components/app/page-header";
|
||||
import { UpgradeGate } from "@/components/app/upgrade-gate";
|
||||
import { SeriesCreateForm } from "@/components/app/series-create-form";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
export const metadata: Metadata = { title: "Series" };
|
||||
|
||||
@@ -47,13 +48,13 @@ export default async function SeriesPage() {
|
||||
|
||||
<SeriesCreateForm />
|
||||
|
||||
{series.length > 0 && (
|
||||
<div className="mt-8 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">Your seasons</h2>
|
||||
<div className="mt-8 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">Your seasons</h2>
|
||||
{series.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{series.map((s) => (
|
||||
<Link key={s.id} href={`/series/${s.id}`}>
|
||||
<Card className="transition-shadow hover:shadow-md">
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-xl bg-brand/10 text-brand">
|
||||
<ListMusic className="h-5 w-5" />
|
||||
@@ -69,8 +70,14 @@ export default async function SeriesPage() {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title="No seasons yet"
|
||||
description="Plan a cohesive season above, then generate each episode in a couple of clicks."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { LANGUAGES } from "@/lib/episodes/options";
|
||||
import { VOICE_CATALOG } from "@/lib/ai/voices";
|
||||
|
||||
const VALID_LANGUAGES = new Set<string>(LANGUAGES.map((l) => l.code));
|
||||
const VALID_VOICES = new Set<string>(VOICE_CATALOG.map((v) => v.id));
|
||||
|
||||
const preferencesSchema = z.object({
|
||||
defaultVoiceId: z.string().nullable().optional(),
|
||||
defaultLanguage: z.string().min(2).max(5).optional(),
|
||||
emailOnEpisodeReady: z.boolean().optional(),
|
||||
productEmails: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type PreferencesInput = z.infer<typeof preferencesSchema>;
|
||||
|
||||
/**
|
||||
* Persist the current user's editor defaults and notification preferences.
|
||||
* Auth-checked; upserts the single per-user preferences row. Only validated,
|
||||
* known voice/language values are stored.
|
||||
*/
|
||||
export async function savePreferencesAction(
|
||||
input: PreferencesInput
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
|
||||
const parsed = preferencesSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: "Invalid settings." };
|
||||
const data = parsed.data;
|
||||
|
||||
if (data.defaultVoiceId && !VALID_VOICES.has(data.defaultVoiceId)) {
|
||||
return { ok: false, error: "Unknown voice." };
|
||||
}
|
||||
if (data.defaultLanguage && !VALID_LANGUAGES.has(data.defaultLanguage)) {
|
||||
return { ok: false, error: "Unsupported language." };
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
// Normalize "none"/empty voice to null.
|
||||
const defaultVoiceId =
|
||||
data.defaultVoiceId === undefined ? undefined : data.defaultVoiceId || null;
|
||||
|
||||
await prisma.userPreferences.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
defaultVoiceId: defaultVoiceId ?? null,
|
||||
defaultLanguage: data.defaultLanguage ?? "en",
|
||||
emailOnEpisodeReady: data.emailOnEpisodeReady ?? true,
|
||||
productEmails: data.productEmails ?? true,
|
||||
},
|
||||
update: {
|
||||
...(defaultVoiceId !== undefined ? { defaultVoiceId } : {}),
|
||||
...(data.defaultLanguage !== undefined ? { defaultLanguage: data.defaultLanguage } : {}),
|
||||
...(data.emailOnEpisodeReady !== undefined
|
||||
? { emailOnEpisodeReady: data.emailOnEpisodeReady }
|
||||
: {}),
|
||||
...(data.productEmails !== undefined ? { productEmails: data.productEmails } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/settings");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete the current user's account. Auth-checked and gated by a
|
||||
* typed email confirmation that must match the session email. The User delete
|
||||
* cascades to sessions, accounts, episodes, series, usage and preferences. The
|
||||
* client signs out after a successful response.
|
||||
*/
|
||||
export async function deleteAccountAction(
|
||||
confirmEmail: string
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
|
||||
if (confirmEmail.trim().toLowerCase() !== session.user.email.toLowerCase()) {
|
||||
return { ok: false, error: "The email you typed doesn't match your account." };
|
||||
}
|
||||
|
||||
// Deleting the User row cascades to all owned data via onDelete: Cascade.
|
||||
await prisma.user.delete({ where: { id: session.user.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireAuth } from "@/lib/auth/guards";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PageHeader } from "@/components/app/page-header";
|
||||
import { SettingsClient } from "@/components/app/settings-client";
|
||||
|
||||
@@ -7,10 +8,24 @@ export const metadata: Metadata = { title: "Settings" };
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await requireAuth();
|
||||
|
||||
const prefs = await prisma.userPreferences.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Settings" description="Manage your account." />
|
||||
<SettingsClient name={session.user.name} email={session.user.email} />
|
||||
<SettingsClient
|
||||
name={session.user.name}
|
||||
email={session.user.email}
|
||||
preferences={{
|
||||
defaultVoiceId: prefs?.defaultVoiceId ?? null,
|
||||
defaultLanguage: prefs?.defaultLanguage ?? "en",
|
||||
emailOnEpisodeReady: prefs?.emailOnEpisodeReady ?? true,
|
||||
productEmails: prefs?.productEmails ?? true,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { headers } from "next/headers";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { getEffectivePlan } from "@/lib/billing/subscription";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** http(s)-only URL guard: rejects javascript:/data: and other schemes. */
|
||||
const httpUrl = z
|
||||
.string()
|
||||
.url()
|
||||
.refine(
|
||||
(v) => {
|
||||
try {
|
||||
const proto = new URL(v).protocol;
|
||||
return proto === "http:" || proto === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "Logo URL must be an http(s) URL." }
|
||||
);
|
||||
|
||||
const brandingSchema = z.object({
|
||||
brandName: z.string().max(60).optional(),
|
||||
primaryColor: z
|
||||
@@ -12,10 +31,68 @@ const brandingSchema = z.object({
|
||||
.regex(/^#([0-9a-fA-F]{6})$/, "Use a hex colour like #7c3aed")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
logoUrl: z.string().url().optional().or(z.literal("")),
|
||||
logoUrl: httpUrl.optional().or(z.literal("")),
|
||||
removePoweredBy: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const inviteSchema = z.object({
|
||||
email: z.string().email("Enter a valid email address."),
|
||||
});
|
||||
|
||||
/**
|
||||
* Invite a member — the server is the authority on the seat check.
|
||||
*
|
||||
* Better Auth only enforces `membershipLimit: 5` (see lib/auth/auth.ts), which is
|
||||
* NOT the same as the plan's `seats`. So we enforce the plan seat count here before
|
||||
* delegating to Better Auth's server API (`auth.api.createInvitation`), which itself
|
||||
* re-verifies that the caller is allowed to invite. The client-side check in
|
||||
* team-client.tsx remains only as a fast UX guard.
|
||||
*/
|
||||
export async function inviteMemberAction(
|
||||
organizationId: string,
|
||||
email: string
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const session = await getServerSession();
|
||||
if (!session) return { ok: false, error: "You must be signed in." };
|
||||
|
||||
const parsed = inviteSchema.safeParse({ email });
|
||||
if (!parsed.success) return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid email." };
|
||||
|
||||
// Caller must be an owner/admin of this organization.
|
||||
const member = await prisma.member.findFirst({
|
||||
where: { organizationId, userId: session.user.id },
|
||||
select: { role: true },
|
||||
});
|
||||
if (!member || !["owner", "admin"].includes(member.role)) {
|
||||
return { ok: false, error: "Only workspace owners can invite members." };
|
||||
}
|
||||
|
||||
// Seat enforcement against the effective plan, counting members + pending invites.
|
||||
const { plan } = await getEffectivePlan(session.user.id, organizationId);
|
||||
const [memberCount, pendingInvites] = await Promise.all([
|
||||
prisma.member.count({ where: { organizationId } }),
|
||||
prisma.invitation.count({ where: { organizationId, status: "pending" } }),
|
||||
]);
|
||||
if (memberCount + pendingInvites >= plan.limits.seats) {
|
||||
return { ok: false, error: `Your plan includes ${plan.limits.seats} seats.` };
|
||||
}
|
||||
|
||||
// Server-side invite via Better Auth's organization plugin API. Passing the
|
||||
// request headers authenticates the call; Better Auth also re-checks permissions.
|
||||
try {
|
||||
await auth.api.createInvitation({
|
||||
body: { email: parsed.data.email, role: "member", organizationId },
|
||||
headers: await headers(),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Could not send invitation.";
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
revalidatePath("/team");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function saveBrandingAction(
|
||||
organizationId: string,
|
||||
data: z.infer<typeof brandingSchema>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { HeaderSkeleton, Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function TeamLoading() {
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-64 rounded-2xl" />
|
||||
<Skeleton className="h-80 rounded-2xl" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { HeaderSkeleton, Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function UsageLoading() {
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<Skeleton className="mb-6 h-6 w-44" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { isFlagEnabled } from "@/lib/flags";
|
||||
import { SignUpForm } from "@/components/auth/sign-up-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
||||
export const metadata: Metadata = { title: "Create account" };
|
||||
|
||||
export default async function SignUpPage() {
|
||||
const session = await getServerSession();
|
||||
if (session) redirect("/dashboard");
|
||||
|
||||
if (!(await isFlagEnabled("signups_enabled"))) {
|
||||
return (
|
||||
<Card className="rounded-3xl shadow-md">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-2xl">Sign-ups are paused</CardTitle>
|
||||
<CardDescription>
|
||||
New account registration is temporarily closed. Please check back soon.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/sign-in">Back to sign in</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const googleEnabled = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
return <SignUpForm googleEnabled={googleEnabled} />;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,107 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
|
||||
|
||||
export const metadata: Metadata = { title: "Privacy Policy" };
|
||||
|
||||
const UPDATED = "June 7, 2026";
|
||||
|
||||
const SECTIONS: LegalSection[] = [
|
||||
{
|
||||
heading: "Information we collect",
|
||||
paragraphs: ["We collect the following categories of information to operate PodcastYes:"],
|
||||
bullets: [
|
||||
"Account information you provide — your name, email address, and password (passwords are stored only as salted hashes).",
|
||||
"Content you create — episode topics, prompts, configuration (tone, format, language, voices), generated scripts, audio, cover art, and repurposed content.",
|
||||
"Billing information — your plan, subscription status, and payment-provider customer IDs. Card and bank details are handled by our payment processors and are never stored on our servers.",
|
||||
"Usage and technical data — feature usage, monthly generation counts, API-key activity, IP address, browser/user-agent, and server logs used for security and debugging.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "How we use your information",
|
||||
paragraphs: [
|
||||
"We use your information to provide and improve the service: to authenticate you, generate and store your episodes, enforce plan limits, process payments, send transactional email, prevent abuse, and meet legal obligations. We do not sell your personal data, and we do not use your private content to train our own models.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "AI processing and sub-processors",
|
||||
paragraphs: [
|
||||
"Generating an episode requires sending the content you submit (and content derived from it) to our AI sub-processors so they can produce your script, audio, and artwork:",
|
||||
],
|
||||
bullets: [
|
||||
"OpenAI — script generation, content moderation, and cover-art generation.",
|
||||
"ElevenLabs — text-to-speech and multi-speaker dialogue synthesis.",
|
||||
"These providers process your prompts and generated text under their own terms and security commitments. Only the data needed to perform the requested generation is shared.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Payments",
|
||||
paragraphs: [
|
||||
"Subscriptions are processed by Stripe and PayPal. When you check out you interact with the provider directly; we receive only confirmation of your subscription status and identifiers needed to manage it. Please review the privacy policies of Stripe and PayPal for how they handle your payment data.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Email",
|
||||
paragraphs: [
|
||||
"Transactional email (account verification, password resets, and episode-ready notifications) is delivered through Resend. We send only the information necessary to deliver these messages.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Data retention",
|
||||
paragraphs: [
|
||||
"We retain your account and content for as long as your account is active. Generated assets (audio and artwork) are stored so we can deliver your episodes. When you delete an episode it is removed from our systems; when you delete your account, your personal data and content are deleted or anonymized except where we must retain records to comply with legal, tax, or accounting obligations.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Your rights",
|
||||
paragraphs: [
|
||||
"Depending on where you live, you may have rights to access, correct, export, or delete your personal data, and to object to or restrict certain processing. You can edit your profile and delete your content from within the app, or contact us to exercise any of these rights. We will not discriminate against you for exercising them.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Cookies and sessions",
|
||||
paragraphs: [
|
||||
"We use strictly-necessary cookies to keep you signed in and to secure your session. We do not use third-party advertising cookies.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Security",
|
||||
paragraphs: [
|
||||
"We protect your data with encryption in transit, hashed credentials and API keys, scoped access controls, signed billing webhooks, and least-privilege storage. No method of transmission or storage is perfectly secure, but we work to protect your information and to respond promptly to any incident.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Children",
|
||||
paragraphs: [
|
||||
"PodcastYes is not directed to children under 13 (or the minimum age required in your jurisdiction), and we do not knowingly collect their personal data.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "International transfers",
|
||||
paragraphs: [
|
||||
"We and our sub-processors may process your data in countries other than your own. Where required, we rely on appropriate safeguards for such transfers.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Changes to this policy",
|
||||
paragraphs: [
|
||||
"We may update this policy from time to time. Material changes will be reflected by updating the date above and, where appropriate, by notifying you.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Contact",
|
||||
paragraphs: [
|
||||
"Questions about this policy or your data? Email privacy@podcastyes.app and we'll be glad to help.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="container max-w-3xl py-20 md:py-24">
|
||||
<h1 className="font-display text-4xl font-extrabold tracking-tight md:text-5xl">Privacy Policy</h1>
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
We collect the account information you provide (name, email) and the content you create to
|
||||
operate PodcastYes. Episode prompts are sent to our AI providers (OpenAI and ElevenLabs) to
|
||||
generate scripts, audio, and artwork. Generated assets are stored to deliver your episodes.
|
||||
We do not sell your personal data. Payment processing is handled by Stripe and PayPal.
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
This is placeholder copy — replace with your reviewed privacy policy before launch.
|
||||
</p>
|
||||
</div>
|
||||
<LegalDoc
|
||||
title="Privacy Policy"
|
||||
updated={UPDATED}
|
||||
intro="This Privacy Policy explains what information PodcastYes collects, how we use it, who we share it with, and the choices you have. It applies to your use of the PodcastYes website and application."
|
||||
sections={SECTIONS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,106 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
|
||||
|
||||
export const metadata: Metadata = { title: "Terms of Service" };
|
||||
|
||||
const UPDATED = "June 7, 2026";
|
||||
|
||||
const SECTIONS: LegalSection[] = [
|
||||
{
|
||||
heading: "Acceptance of these terms",
|
||||
paragraphs: [
|
||||
"By creating an account or using PodcastYes (the \"Service\"), you agree to these Terms of Service. If you are using the Service on behalf of an organization, you represent that you are authorized to bind that organization to these terms.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "The Service",
|
||||
paragraphs: [
|
||||
"PodcastYes is an AI platform that turns a topic into a produced podcast episode — writing the script, synthesizing multi-voice audio, and generating cover art — and helps you repurpose that content. Features and limits vary by plan and may change as we improve the Service.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Accounts and eligibility",
|
||||
paragraphs: [
|
||||
"You must provide accurate account information, keep your credentials and API keys confidential, and are responsible for all activity under your account. You must be old enough to form a binding contract in your jurisdiction. Notify us promptly of any unauthorized use.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Acceptable use",
|
||||
paragraphs: [
|
||||
"You agree not to use the Service to create or distribute content that is illegal, infringing, deceptive, hateful, harassing, sexually exploitative, or that impersonates a real person's voice or identity without authorization. You may not attempt to bypass usage limits, security controls, or rate limits, or use the Service to build a competing model.",
|
||||
],
|
||||
bullets: [
|
||||
"We screen topics and generated scripts with automated moderation and may flag, hold, or remove content that violates these terms.",
|
||||
"We may suspend or terminate accounts that abuse the Service or put it, our providers, or other users at risk.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "AI-generated content and ownership",
|
||||
paragraphs: [
|
||||
"Subject to your compliance with these terms and the terms of our AI providers, you own the scripts, audio, and artwork you generate and are responsible for how you use and publish them. AI output can be inaccurate, biased, or unintentionally similar to existing works — you are responsible for reviewing it for accuracy, rights, and suitability before publishing. The Service and generated output are provided on an \"as is\" basis.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Plans, billing, and cancellation",
|
||||
paragraphs: [
|
||||
"Paid plans are billed in advance through Stripe or PayPal on a recurring basis until cancelled. By subscribing you authorize us and our processors to charge your payment method for each renewal at the then-current price. You can cancel at any time from the billing page or provider portal; cancellation takes effect at the end of the current billing period. Except where required by law, payments are non-refundable.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Usage limits",
|
||||
paragraphs: [
|
||||
"Each plan includes monthly allowances for scripts, audio, artwork, repurposing, seats, and maximum episode length. Allowances reset at the start of each calendar month and are enforced at generation time. Exceeding a limit pauses the relevant feature until the next period or until you upgrade.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "API access",
|
||||
paragraphs: [
|
||||
"Eligible plans may access the PodcastYes API using keys you generate. Keep keys secret; requests are attributed to, and count against, the owning account, and are subject to rate and usage limits. We may revoke keys that are abused.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Third-party services",
|
||||
paragraphs: [
|
||||
"The Service relies on third parties including OpenAI, ElevenLabs, Stripe, PayPal, and Resend. Your use of features powered by these providers is also subject to their terms, and we are not responsible for their acts or omissions.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Disclaimers and limitation of liability",
|
||||
paragraphs: [
|
||||
"To the maximum extent permitted by law, the Service is provided without warranties of any kind, and PodcastYes is not liable for indirect, incidental, or consequential damages, or for any loss of data, profits, or goodwill. Our total liability for any claim relating to the Service is limited to the amount you paid us in the twelve months before the claim.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Termination",
|
||||
paragraphs: [
|
||||
"You may stop using the Service and delete your account at any time. We may suspend or terminate access if you breach these terms or to protect the Service. Provisions that by their nature should survive termination (such as ownership, disclaimers, and limitations of liability) will survive.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Changes to these terms",
|
||||
paragraphs: [
|
||||
"We may update these terms as the Service evolves. Material changes will be reflected by updating the date above, and your continued use after changes take effect constitutes acceptance.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Governing law",
|
||||
paragraphs: [
|
||||
"These terms are governed by the laws of the jurisdiction in which PodcastYes is established, without regard to conflict-of-laws rules. Nothing here limits any non-waivable rights you have under your local law.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Contact",
|
||||
paragraphs: ["Questions about these terms? Email legal@podcastyes.app."],
|
||||
},
|
||||
];
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div className="container max-w-3xl py-20 md:py-24">
|
||||
<h1 className="font-display text-4xl font-extrabold tracking-tight md:text-5xl">Terms of Service</h1>
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
These terms govern your use of PodcastYes. By creating an account you agree to use the
|
||||
service lawfully and to retain responsibility for the content you generate. AI-generated
|
||||
scripts, audio, and artwork are provided as-is; review them before publishing. Subscriptions
|
||||
renew automatically until cancelled, and usage limits reset monthly.
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
This is placeholder copy — replace with your reviewed legal terms before launch.
|
||||
</p>
|
||||
</div>
|
||||
<LegalDoc
|
||||
title="Terms of Service"
|
||||
updated={UPDATED}
|
||||
intro="These Terms of Service govern your access to and use of PodcastYes. Please read them carefully — they include important information about your rights, billing, acceptable use, and the limits of our liability."
|
||||
sections={SECTIONS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Public, unauthenticated route group (e.g. shared episode pages). No app shell,
|
||||
// no sidebar, no session requirement — just a clean centered canvas. The root
|
||||
// layout already provides <html>/<body>, fonts and the toaster.
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="min-h-screen bg-background">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Mic2 } from "lucide-react";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { storage } from "@/lib/storage";
|
||||
import { getActiveBranding, hexToHslTriplet } from "@/lib/branding";
|
||||
import { WaveformPlayer } from "@/components/app/waveform-player";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { StructuredScript } from "@/lib/ai/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function loadShared(shareId: string) {
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { shareId },
|
||||
include: { audioAsset: true, coverArt: true, script: true, speakers: true },
|
||||
});
|
||||
// 404 when no episode, sharing disabled, or not finished.
|
||||
if (!episode || !episode.shareId || episode.status !== "READY") return null;
|
||||
return episode;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ shareId: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { shareId } = await params;
|
||||
const episode = await loadShared(shareId);
|
||||
if (!episode) return { title: "Episode not found" };
|
||||
return {
|
||||
title: episode.title,
|
||||
description: episode.topic.slice(0, 160),
|
||||
robots: { index: false },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PublicSharePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ shareId: string }>;
|
||||
}) {
|
||||
const { shareId } = await params;
|
||||
const episode = await loadShared(shareId);
|
||||
if (!episode) notFound();
|
||||
|
||||
// Resolve the owning org's white-label branding (Agency custom_branding only).
|
||||
const branding = await getActiveBranding(episode.userId, episode.organizationId);
|
||||
const brandHsl = hexToHslTriplet(branding?.primaryColor);
|
||||
const brandStyle = brandHsl
|
||||
? ({ "--brand": brandHsl, "--ring": brandHsl } as React.CSSProperties)
|
||||
: undefined;
|
||||
const brandName = branding?.brandName ?? "PodcastYes";
|
||||
const removePoweredBy = branding?.removePoweredBy ?? false;
|
||||
|
||||
// Prefer a directly-fetchable public URL (e.g. nginx /media); otherwise fall
|
||||
// back to the share-authorized public cover route.
|
||||
const coverUrl = episode.coverArt
|
||||
? storage().publicUrl(episode.coverArt.storageKey) ??
|
||||
`/api/public/episodes/${shareId}/cover`
|
||||
: null;
|
||||
|
||||
const speakerNames: Record<string, string> = {};
|
||||
for (const s of episode.speakers) speakerNames[s.speakerKey] = s.displayName;
|
||||
const script = episode.script?.content as unknown as StructuredScript | undefined;
|
||||
|
||||
return (
|
||||
<div style={brandStyle} className="mx-auto max-w-3xl px-6 py-10 sm:py-16">
|
||||
{/* Header / brand wordmark */}
|
||||
<header className="mb-8 flex items-center gap-2.5">
|
||||
{branding?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={branding.logoUrl} alt={brandName} className="h-7 w-auto" />
|
||||
) : (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-2xl bg-brand text-brand-foreground">
|
||||
<Mic2 className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
<span className="font-display text-base font-extrabold tracking-tight">{brandName}</span>
|
||||
</header>
|
||||
|
||||
<article className="space-y-8">
|
||||
<div className="grid gap-6 sm:grid-cols-[200px_1fr] sm:items-end">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="aspect-square bg-muted">
|
||||
{coverUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={coverUrl} alt={episode.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||
<Mic2 className="h-10 w-10" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Badge variant="brand" className="uppercase tracking-[0.04em]">
|
||||
Podcast episode
|
||||
</Badge>
|
||||
<h1 className="font-display text-3xl font-extrabold leading-[1.1] tracking-tight sm:text-4xl">
|
||||
{episode.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{episode.format.replace("_", "-").toLowerCase()} · {episode.language.toUpperCase()} ·{" "}
|
||||
{episode.targetLengthMin} min
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{episode.audioAsset && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<WaveformPlayer
|
||||
src={`/api/public/episodes/${shareId}/audio`}
|
||||
durationSec={episode.audioAsset.durationSec}
|
||||
hideDownloads
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight">About this episode</h2>
|
||||
<p className="whitespace-pre-wrap leading-relaxed text-foreground/90">{episode.topic}</p>
|
||||
</section>
|
||||
|
||||
{script && script.sections?.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight">Show notes</h2>
|
||||
<ul className="space-y-1.5 text-sm text-muted-foreground">
|
||||
{script.sections.map((s) => (
|
||||
<li key={s.id} className="flex gap-2">
|
||||
<span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-brand" />
|
||||
{s.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
|
||||
{!removePoweredBy && (
|
||||
<footer className="mt-16 border-t pt-6 text-center text-xs text-muted-foreground">
|
||||
Made with{" "}
|
||||
<a href="/" className="font-semibold text-brand hover:underline">
|
||||
PodcastYes
|
||||
</a>{" "}
|
||||
— turn any topic into a podcast with AI.
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import JSZip from "jszip";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { storage } from "@/lib/storage";
|
||||
import type { StructuredScript } from "@/lib/ai/types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Bundle everything for an episode into a single .zip download:
|
||||
* - audio.mp3 (the rendered episode, if present)
|
||||
* - cover.png (the cover art, if present)
|
||||
* - script.txt (the full transcript, speaker-labelled)
|
||||
* - show-notes.md (title + section outline)
|
||||
*
|
||||
* Ownership-gated: only the owning user (or an admin) may export.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
const session = await getServerSession();
|
||||
if (!session) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
script: true,
|
||||
audioAsset: true,
|
||||
coverArt: true,
|
||||
speakers: true,
|
||||
},
|
||||
});
|
||||
if (!episode) return new Response("Not found", { status: 404 });
|
||||
if (episode.userId !== session.user.id && session.user.role !== "admin") {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const speakerNames: Record<string, string> = {};
|
||||
for (const s of episode.speakers) speakerNames[s.speakerKey] = s.displayName;
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
// Audio (if rendered).
|
||||
if (episode.audioAsset && (await storage().exists(episode.audioAsset.storageKey))) {
|
||||
const audio = await storage().get(episode.audioAsset.storageKey);
|
||||
const ext = episode.audioAsset.format || "mp3";
|
||||
zip.file(`audio.${ext}`, audio);
|
||||
}
|
||||
|
||||
// Cover art (if present).
|
||||
if (episode.coverArt && (await storage().exists(episode.coverArt.storageKey))) {
|
||||
const art = await storage().get(episode.coverArt.storageKey);
|
||||
const ext = episode.coverArt.storageKey.split(".").pop()?.toLowerCase() ?? "png";
|
||||
zip.file(`cover.${ext}`, art);
|
||||
}
|
||||
|
||||
// Transcript + show notes derived from the structured script.
|
||||
if (episode.script) {
|
||||
const script = episode.script.content as unknown as StructuredScript;
|
||||
zip.file("script.txt", buildTranscript(episode.title, script, speakerNames));
|
||||
zip.file("show-notes.md", buildShowNotes(episode, script));
|
||||
}
|
||||
|
||||
const blob = await zip.generateAsync({ type: "nodebuffer" });
|
||||
const filename = `${slugify(episode.title) || "episode"}.zip`;
|
||||
|
||||
return new Response(blob as BodyInit, {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Length": String(blob.byteLength),
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildTranscript(
|
||||
title: string,
|
||||
script: StructuredScript,
|
||||
speakerNames: Record<string, string>
|
||||
): string {
|
||||
const lines: string[] = [title, "=".repeat(title.length), ""];
|
||||
for (const section of script.sections ?? []) {
|
||||
lines.push(`## ${section.title}`, "");
|
||||
for (const turn of section.turns ?? []) {
|
||||
const name = speakerNames[turn.speakerKey] ?? turn.speakerKey;
|
||||
lines.push(`${name}: ${turn.text}`, "");
|
||||
}
|
||||
}
|
||||
return lines.join("\n").trimEnd() + "\n";
|
||||
}
|
||||
|
||||
function buildShowNotes(
|
||||
episode: { title: string; topic: string; format: string; language: string; targetLengthMin: number },
|
||||
script: StructuredScript
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
`# ${episode.title}`,
|
||||
"",
|
||||
episode.topic,
|
||||
"",
|
||||
`- **Format:** ${episode.format.replace("_", "-").toLowerCase()}`,
|
||||
`- **Language:** ${episode.language.toUpperCase()}`,
|
||||
`- **Target length:** ${episode.targetLengthMin} min`,
|
||||
"",
|
||||
"## In this episode",
|
||||
"",
|
||||
];
|
||||
for (const section of script.sections ?? []) {
|
||||
lines.push(`- ${section.title}`);
|
||||
}
|
||||
lines.push("", "---", "Generated with PodcastYes.");
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
function slugify(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
}
|
||||
@@ -5,6 +5,28 @@ import { isTerminal } from "@/lib/episodes/status";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Per-user concurrency cap for SSE streams. Each open connection polls the DB
|
||||
* every 1.5s, so unbounded streams per user are a cheap DoS / resource leak.
|
||||
* This is an in-process counter (one web instance); see the rate-limiter note
|
||||
* about scaling to multiple nodes.
|
||||
*/
|
||||
const MAX_STREAMS_PER_USER = 5;
|
||||
const activeStreams = new Map<string, number>();
|
||||
|
||||
function tryAcquireStream(userId: string): boolean {
|
||||
const current = activeStreams.get(userId) ?? 0;
|
||||
if (current >= MAX_STREAMS_PER_USER) return false;
|
||||
activeStreams.set(userId, current + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
function releaseStream(userId: string): void {
|
||||
const current = activeStreams.get(userId) ?? 0;
|
||||
if (current <= 1) activeStreams.delete(userId);
|
||||
else activeStreams.set(userId, current - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-Sent Events stream of an episode's generation status. Polls the row
|
||||
* every 1.5s and emits on change until the episode reaches a terminal state.
|
||||
@@ -22,7 +44,19 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
// Cap concurrent streams per user. Released in EVERY stop path below.
|
||||
const streamUserId = session.user.id;
|
||||
if (!tryAcquireStream(streamUserId)) {
|
||||
return new Response("Too many concurrent streams", {
|
||||
status: 429,
|
||||
headers: { "Retry-After": "5" },
|
||||
});
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
// Exposed so the ReadableStream's `cancel` can also release the slot if the
|
||||
// consumer tears down without an abort signal.
|
||||
let stopRef: () => void = () => releaseStream(streamUserId);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
@@ -38,6 +72,9 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
// Release the per-user stream slot exactly once (terminal status,
|
||||
// abort, not-found, or error all route through here).
|
||||
releaseStream(streamUserId);
|
||||
clearInterval(pollTimer);
|
||||
clearInterval(pingTimer);
|
||||
try {
|
||||
@@ -66,6 +103,8 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
if (isTerminal(e.status)) stop();
|
||||
};
|
||||
|
||||
stopRef = stop;
|
||||
|
||||
send({ type: "open" });
|
||||
void poll();
|
||||
pollTimer = setInterval(poll, 1500);
|
||||
@@ -75,6 +114,10 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
req.signal.addEventListener("abort", stop);
|
||||
},
|
||||
cancel() {
|
||||
// Consumer disconnected/cancelled — ensure the slot is released.
|
||||
stopRef();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { storage } from "@/lib/storage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Stream an episode's MP3 to anonymous visitors, authorized purely by a valid,
|
||||
* still-enabled public `shareId` (NOT a session). Returns 404 when the share is
|
||||
* disabled or the audio is missing so we never disclose private episode state.
|
||||
*
|
||||
* Supports HTTP Range requests so the audio element can seek/scrub.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ shareId: string }> }
|
||||
) {
|
||||
const { shareId } = await params;
|
||||
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { shareId },
|
||||
select: { audioAsset: { select: { storageKey: true } } },
|
||||
});
|
||||
const key = episode?.audioAsset?.storageKey;
|
||||
if (!key) return new Response("Not found", { status: 404 });
|
||||
|
||||
if (!(await storage().exists(key))) return new Response("Not found", { status: 404 });
|
||||
|
||||
const data = await storage().get(key);
|
||||
const total = data.byteLength;
|
||||
const contentType = "audio/mpeg";
|
||||
|
||||
const range = req.headers.get("range");
|
||||
if (range) {
|
||||
const match = /bytes=(\d+)-(\d*)/.exec(range);
|
||||
if (match) {
|
||||
const start = Number(match[1]);
|
||||
const end = match[2] ? Math.min(Number(match[2]), total - 1) : total - 1;
|
||||
if (start <= end && start < total) {
|
||||
const chunk = data.subarray(start, end + 1);
|
||||
return new Response(chunk as BodyInit, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(chunk.byteLength),
|
||||
"Content-Range": `bytes ${start}-${end}/${total}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(data as BodyInit, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(total),
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { storage } from "@/lib/storage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
webp: "image/webp",
|
||||
};
|
||||
|
||||
/**
|
||||
* Serve an episode's cover art to anonymous visitors, authorized by a valid,
|
||||
* still-enabled public `shareId`. Used as a fallback when the storage provider
|
||||
* doesn't expose a directly-fetchable public URL for cover art.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ shareId: string }> }
|
||||
) {
|
||||
const { shareId } = await params;
|
||||
|
||||
const episode = await prisma.episode.findUnique({
|
||||
where: { shareId },
|
||||
select: { coverArt: { select: { storageKey: true } } },
|
||||
});
|
||||
const key = episode?.coverArt?.storageKey;
|
||||
if (!key) return new Response("Not found", { status: 404 });
|
||||
if (!(await storage().exists(key))) return new Response("Not found", { status: 404 });
|
||||
|
||||
const data = await storage().get(key);
|
||||
const ext = key.split(".").pop()?.toLowerCase() ?? "png";
|
||||
return new Response(data as BodyInit, {
|
||||
headers: {
|
||||
"Content-Type": CONTENT_TYPES[ext] ?? "image/png",
|
||||
"Content-Length": String(data.byteLength),
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,11 +3,15 @@ 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 { 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";
|
||||
|
||||
@@ -20,6 +24,14 @@ 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" },
|
||||
@@ -52,23 +64,51 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
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 } = await getEffectivePlan(auth.userId);
|
||||
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 enforceLimit(auth.userId, "script");
|
||||
await enforceLimit(auth.userId, "audio");
|
||||
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 });
|
||||
}
|
||||
@@ -81,23 +121,28 @@ export async function POST(req: NextRequest) {
|
||||
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" });
|
||||
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 });
|
||||
return Response.json({ id: episode.id, status: episode.status }, { status: 201 });
|
||||
} catch (err) {
|
||||
await refundReserved();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { verifyPaypalWebhook } from "@/lib/billing/paypal";
|
||||
import { handlePaypalEvent } from "@/lib/billing/webhooks/paypal";
|
||||
import { alreadyProcessed, logWebhook } from "@/lib/billing/webhook-log";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -23,10 +24,16 @@ export async function POST(req: NextRequest) {
|
||||
return new Response("Invalid signature", { status: 400 });
|
||||
}
|
||||
|
||||
const event = JSON.parse(body) as { id?: string; event_type?: string };
|
||||
const eventId = event.id ?? `paypal_${Date.now()}`;
|
||||
if (event.id && (await alreadyProcessed(eventId))) return new Response("ok (duplicate)");
|
||||
|
||||
try {
|
||||
await handlePaypalEvent(JSON.parse(body));
|
||||
await handlePaypalEvent(event as Parameters<typeof handlePaypalEvent>[0]);
|
||||
await logWebhook("paypal", eventId, event.event_type ?? "unknown", "processed");
|
||||
} catch (err) {
|
||||
console.error("[paypal webhook] handler error", err);
|
||||
await logWebhook("paypal", eventId, event.event_type ?? "unknown", "failed", err instanceof Error ? err.message : String(err));
|
||||
return new Response("Handler error", { status: 500 });
|
||||
}
|
||||
return new Response("ok");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { stripe } from "@/lib/billing/stripe";
|
||||
import { handleStripeEvent } from "@/lib/billing/webhooks/stripe";
|
||||
import { alreadyProcessed, logWebhook } from "@/lib/billing/webhook-log";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -19,10 +20,15 @@ export async function POST(req: NextRequest) {
|
||||
return new Response("Invalid signature", { status: 400 });
|
||||
}
|
||||
|
||||
// Idempotency: skip events we've already processed (Stripe retries deliveries).
|
||||
if (await alreadyProcessed(event.id)) return new Response("ok (duplicate)");
|
||||
|
||||
try {
|
||||
await handleStripeEvent(event);
|
||||
await logWebhook("stripe", event.id, event.type, "processed");
|
||||
} catch (err) {
|
||||
console.error("[stripe webhook] handler error", err);
|
||||
await logWebhook("stripe", event.id, event.type, "failed", err instanceof Error ? err.message : String(err));
|
||||
return new Response("Handler error", { status: 500 });
|
||||
}
|
||||
return new Response("ok");
|
||||
|
||||
Reference in New Issue
Block a user