Initial commit: PodcastYes — AI podcast platform

This commit is contained in:
Leon Serfaty
2026-06-07 03:58:32 -04:00
commit 155507f21a
151 changed files with 19826 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
"use server";
import { revalidatePath } from "next/cache";
import type { Prisma } from "@prisma/client";
import { getServerSession } from "@/lib/auth/guards";
import { prisma } from "@/lib/db";
async function adminSession() {
const s = await getServerSession();
if (!s || s.user.role !== "admin") return null;
return s;
}
async function audit(
actorId: string,
action: string,
target?: string,
metadata?: Prisma.InputJsonValue
) {
await prisma.auditLog.create({
data: { actorId, actorType: "admin", action, target, metadata },
});
}
export async function banUserAction(userId: string, ban: boolean): Promise<{ ok: boolean; error?: string }> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
if (userId === s.user.id) return { ok: false, error: "You can't ban yourself." };
await prisma.user.update({ where: { id: userId }, data: { banned: ban } });
// Revoke sessions on ban so access stops immediately.
if (ban) await prisma.session.deleteMany({ where: { userId } });
await audit(s.user.id, ban ? "user.ban" : "user.unban", userId);
revalidatePath("/admin/users");
return { ok: true };
}
export async function setRoleAction(userId: string, role: "admin" | "user"): Promise<{ ok: boolean; error?: string }> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
await prisma.user.update({ where: { id: userId }, data: { role } });
await audit(s.user.id, "user.role", userId, { role });
revalidatePath("/admin/users");
return { ok: true };
}
export async function toggleFeatureFlagAction(
key: string,
enabled: boolean
): Promise<{ ok: boolean; error?: string }> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
await prisma.featureFlag.upsert({ where: { key }, create: { key, enabled }, update: { enabled } });
await audit(s.user.id, "flag.toggle", key, { enabled });
revalidatePath("/admin/flags");
return { ok: true };
}
export async function reviewContentFlagAction(
flagId: string,
status: "reviewed" | "removed"
): Promise<{ ok: boolean; error?: string }> {
const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." };
await prisma.contentFlag.update({ where: { id: flagId }, data: { status, reviewedBy: s.user.id } });
await audit(s.user.id, "content.review", flagId, { status });
revalidatePath("/admin/moderation");
return { ok: true };
}
+94
View File
@@ -0,0 +1,94 @@
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 { CostChart, type CostPoint } from "@/components/admin/cost-chart";
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 },
});
// 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,
}));
return (
<>
<PageHeader title="AI usage & cost" description="Spend across providers over the last 14 days." />
<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>
<Card>
<CardHeader>
<CardTitle>Daily AI spend</CardTitle>
</CardHeader>
<CardContent>
<CostChart data={data} />
</CardContent>
</Card>
<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">
<p className="text-xs capitalize text-muted-foreground">{op}</p>
<p className="mt-1 text-lg font-semibold">${(byOperation[op] ?? 0).toFixed(2)}</p>
</div>
))}
</div>
</CardContent>
</Card>
</>
);
}
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>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
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 } } },
});
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>
</div>
)}
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { FlagsClient } from "@/components/admin/flags-client";
export const metadata: Metadata = { title: "Admin · Feature flags" };
export default async function AdminFlagsPage() {
const flags = await prisma.featureFlag.findMany({ orderBy: { key: "asc" } });
return (
<>
<PageHeader title="Feature flags" description="Toggle features without a deploy." />
<FlagsClient flags={flags.map((f) => ({ key: f.key, enabled: f.enabled }))} />
</>
);
}
+93
View File
@@ -0,0 +1,93 @@
import type { Metadata } from "next";
import { Activity, CheckCircle2, 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";
export const metadata: Metadata = { title: "Admin · 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 jobCounts = Object.fromEntries(jobGroups.map((g) => [g.status, g._count]));
const queueHealthy = recentFailures < 5;
return (
<>
<PageHeader title="System health" description="Generation pipeline and queue status." />
<div className="mb-6 grid gap-4 sm:grid-cols-3">
<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
</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" />
) : (
<AlertTriangle className="h-5 w-5 text-warning" />
)}
<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>
<CardContent>
<p className="font-display text-3xl font-extrabold tracking-tight">{jobCounts["running"] ?? 0}</p>
</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>
<Card>
<CardHeader>
<CardTitle>Episodes by status</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{episodeGroups.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>
</div>
))}
</CardContent>
</Card>
</div>
</>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { ShieldCheck } from "lucide-react";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { ModerationActions } from "@/components/admin/moderation-actions";
export const metadata: Metadata = { title: "Admin · Moderation" };
export default async function AdminModerationPage() {
const flags = await prisma.contentFlag.findMany({
where: { status: "open" },
orderBy: { createdAt: "desc" },
include: { episode: { select: { id: true, title: true } } },
});
return (
<>
<PageHeader title="Content moderation" description="Review flagged episodes." />
{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>
</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>
<p className="text-sm text-muted-foreground">{f.reason}</p>
</div>
<ModerationActions flagId={f.id} />
</CardContent>
</Card>
))}
</div>
)}
</>
);
}
+96
View File
@@ -0,0 +1,96 @@
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 { formatPrice } from "@/lib/utils";
export const metadata: Metadata = { title: "Admin" };
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 } } }),
]);
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;
return (
<>
<PageHeader title="Overview" description="Platform health at a glance." />
<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>
<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>
</>
);
}
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>
);
}
+91
View File
@@ -0,0 +1,91 @@
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 { formatPrice } from "@/lib/utils";
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 } }),
]);
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;
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>
<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>
</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>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { PageHeader } from "@/components/app/page-header";
import { UsersTable } from "@/components/admin/users-table";
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]));
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(),
}))}
/>
</>
);
}
+45
View File
@@ -0,0 +1,45 @@
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 { UserMenu } from "@/components/app/user-menu";
import { Button } from "@/components/ui/button";
// Authed, DB-backed admin surface — never statically prerender.
export const dynamic = "force-dynamic";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await requireAdmin();
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-3">
<Button asChild variant="ghost" size="sm">
<Link href="/dashboard">
<ArrowLeft className="h-4 w-4" /> Back to app
</Link>
</Button>
<UserMenu name={session.user.name} email={session.user.email} image={session.user.image} 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">
<AdminSidebar />
</div>
</aside>
<main className="flex-1 bg-secondary/50">
<div className="container max-w-6xl py-8 md:py-10">{children}</div>
</main>
</div>
</div>
);
}