From f033f00379576a5758d44c825fa88e7f59be7817 Mon Sep 17 00:00:00 2001 From: Leon Serfaty <80597822+silkoserfo@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:54:30 -0400 Subject: [PATCH] Comprehensive admin + user dashboards (production-ready) --- app/(admin)/admin/actions.ts | 320 +++++++++++++++++- app/(admin)/admin/ai-usage/page.tsx | 136 ++++---- app/(admin)/admin/audit/page.tsx | 89 +++-- app/(admin)/admin/error.tsx | 26 ++ app/(admin)/admin/flags/page.tsx | 15 +- app/(admin)/admin/health/page.tsx | 151 ++++++--- app/(admin)/admin/jobs/page.tsx | 113 +++++++ app/(admin)/admin/loading.tsx | 16 + app/(admin)/admin/moderation/page.tsx | 43 ++- app/(admin)/admin/page.tsx | 155 ++++----- app/(admin)/admin/revenue/page.tsx | 105 ++++++ app/(admin)/admin/settings/page.tsx | 67 ++++ app/(admin)/admin/subscriptions/page.tsx | 173 ++++++---- app/(admin)/admin/users/[id]/page.tsx | 219 ++++++++++++ app/(admin)/admin/users/page.tsx | 116 +++++-- app/(admin)/admin/webhooks/page.tsx | 95 ++++++ app/(admin)/layout.tsx | 16 +- app/(app)/api-keys/loading.tsx | 13 + app/(app)/billing/loading.tsx | 15 + app/(app)/dashboard/loading.tsx | 14 + app/(app)/dashboard/page.tsx | 185 +++++++--- app/(app)/episodes/[id]/loading.tsx | 23 ++ app/(app)/episodes/[id]/not-found.tsx | 22 ++ app/(app)/episodes/[id]/page.tsx | 7 +- app/(app)/episodes/actions.ts | 284 ++++++++++++---- app/(app)/episodes/loading.tsx | 14 + app/(app)/episodes/page.tsx | 76 ++++- app/(app)/error.tsx | 26 ++ app/(app)/layout.tsx | 119 +++++-- app/(app)/series/actions.ts | 8 + app/(app)/series/loading.tsx | 15 + app/(app)/series/page.tsx | 19 +- app/(app)/settings/actions.ts | 91 +++++ app/(app)/settings/page.tsx | 17 +- app/(app)/team/actions.ts | 79 ++++- app/(app)/team/loading.tsx | 13 + app/(app)/usage/loading.tsx | 15 + app/(auth)/sign-up/page.tsx | 23 ++ app/(marketing)/privacy/page.tsx | 111 +++++- app/(marketing)/terms/page.tsx | 110 +++++- app/(public)/layout.tsx | 6 + app/(public)/p/[shareId]/page.tsx | 155 +++++++++ app/api/episodes/[id]/export/route.ts | 126 +++++++ app/api/episodes/[id]/stream/route.ts | 43 +++ .../public/episodes/[shareId]/audio/route.ts | 63 ++++ .../public/episodes/[shareId]/cover/route.ts | 42 +++ app/api/v1/episodes/route.ts | 89 +++-- app/api/webhooks/paypal/route.ts | 9 +- app/api/webhooks/stripe/route.ts | 6 + components/admin/admin-mobile-nav.tsx | 36 ++ components/admin/admin-sidebar.tsx | 102 ++++-- components/admin/audit-export.tsx | 47 +++ components/admin/audit-meta-viewer.tsx | 48 +++ components/admin/flags-client.tsx | 163 ++++++++- components/admin/job-row-actions.tsx | 65 ++++ components/admin/plan-editor.tsx | 158 +++++++++ components/admin/subscription-row-actions.tsx | 90 +++++ components/admin/ui/auto-refresh.tsx | 14 + components/admin/ui/chart-card.tsx | 29 ++ components/admin/ui/chart-theme.ts | 28 ++ components/admin/ui/charts.tsx | 196 +++++++++++ components/admin/ui/confirm-dialog.tsx | 83 +++++ components/admin/ui/data-table.tsx | 79 +++++ components/admin/ui/stat-card.tsx | 65 ++++ components/admin/ui/table-controls.tsx | 217 ++++++++++++ components/admin/user-detail-actions.tsx | 164 +++++++++ components/admin/user-row-actions.tsx | 68 ++++ components/app/api-keys-client.tsx | 41 ++- components/app/app-mobile-nav.tsx | 42 +++ components/app/audio-player.tsx | 38 +-- components/app/billing-client.tsx | 27 +- components/app/command-palette.tsx | 146 ++++++++ components/app/episode-actions.tsx | 208 ++++++++++-- components/app/episode-card.tsx | 2 +- components/app/impersonation-banner.tsx | 52 +++ components/app/repurpose-client.tsx | 47 ++- components/app/script-editor.tsx | 145 +++++--- components/app/settings-client.tsx | 205 ++++++++++- components/app/sidebar-nav.tsx | 3 +- components/app/team-client.tsx | 109 +++++- components/app/theme-toggle.tsx | 31 ++ components/app/user-menu.tsx | 2 + components/app/waveform-player.tsx | 254 ++++++++++++++ components/auth/sign-in-form.tsx | 4 +- components/auth/sign-up-form.tsx | 3 + components/marketing/legal-doc.tsx | 60 ++++ components/providers/theme-provider.tsx | 22 ++ components/ui/dialog.tsx | 97 ++++++ components/ui/empty-state.tsx | 45 +++ components/ui/skeleton.tsx | 66 ++++ lib/admin/audit.ts | 41 +++ lib/admin/billing.ts | 120 +++++++ lib/admin/cost.ts | 51 +++ lib/admin/flags.ts | 53 +++ lib/admin/metrics.ts | 82 +++++ lib/admin/ops.ts | 67 ++++ lib/admin/range.ts | 80 +++++ lib/admin/series.ts | 93 +++++ lib/admin/users.ts | 126 +++++++ lib/ai/moderation.ts | 40 +++ lib/ai/pipeline/generate-episode.ts | 91 +++-- lib/auth/auth.ts | 22 +- lib/billing/paypal.ts | 12 +- lib/billing/webhook-log.ts | 24 ++ lib/billing/webhooks/paypal.ts | 20 +- lib/billing/webhooks/stripe.ts | 13 +- lib/branding.ts | 71 ++++ lib/email/index.ts | 33 +- lib/flags/index.ts | 105 ++++++ lib/queue/health.ts | 74 ++++ lib/ratelimit/index.ts | 4 +- lib/usage/history.ts | 54 +++ lib/usage/limits.ts | 32 +- lib/usage/meter.ts | 81 ++++- lib/utils.ts | 20 ++ next.config.mjs | 36 +- package-lock.json | 98 ++++++ package.json | 2 + .../migration.sql | 45 +++ .../migration.sql | 25 ++ prisma/schema.prisma | 55 ++- worker/index.ts | 19 +- 122 files changed, 7878 insertions(+), 805 deletions(-) create mode 100644 app/(admin)/admin/error.tsx create mode 100644 app/(admin)/admin/jobs/page.tsx create mode 100644 app/(admin)/admin/loading.tsx create mode 100644 app/(admin)/admin/revenue/page.tsx create mode 100644 app/(admin)/admin/settings/page.tsx create mode 100644 app/(admin)/admin/users/[id]/page.tsx create mode 100644 app/(admin)/admin/webhooks/page.tsx create mode 100644 app/(app)/api-keys/loading.tsx create mode 100644 app/(app)/billing/loading.tsx create mode 100644 app/(app)/dashboard/loading.tsx create mode 100644 app/(app)/episodes/[id]/loading.tsx create mode 100644 app/(app)/episodes/[id]/not-found.tsx create mode 100644 app/(app)/episodes/loading.tsx create mode 100644 app/(app)/error.tsx create mode 100644 app/(app)/series/loading.tsx create mode 100644 app/(app)/settings/actions.ts create mode 100644 app/(app)/team/loading.tsx create mode 100644 app/(app)/usage/loading.tsx create mode 100644 app/(public)/layout.tsx create mode 100644 app/(public)/p/[shareId]/page.tsx create mode 100644 app/api/episodes/[id]/export/route.ts create mode 100644 app/api/public/episodes/[shareId]/audio/route.ts create mode 100644 app/api/public/episodes/[shareId]/cover/route.ts create mode 100644 components/admin/admin-mobile-nav.tsx create mode 100644 components/admin/audit-export.tsx create mode 100644 components/admin/audit-meta-viewer.tsx create mode 100644 components/admin/job-row-actions.tsx create mode 100644 components/admin/plan-editor.tsx create mode 100644 components/admin/subscription-row-actions.tsx create mode 100644 components/admin/ui/auto-refresh.tsx create mode 100644 components/admin/ui/chart-card.tsx create mode 100644 components/admin/ui/chart-theme.ts create mode 100644 components/admin/ui/charts.tsx create mode 100644 components/admin/ui/confirm-dialog.tsx create mode 100644 components/admin/ui/data-table.tsx create mode 100644 components/admin/ui/stat-card.tsx create mode 100644 components/admin/ui/table-controls.tsx create mode 100644 components/admin/user-detail-actions.tsx create mode 100644 components/admin/user-row-actions.tsx create mode 100644 components/app/app-mobile-nav.tsx create mode 100644 components/app/command-palette.tsx create mode 100644 components/app/impersonation-banner.tsx create mode 100644 components/app/theme-toggle.tsx create mode 100644 components/app/waveform-player.tsx create mode 100644 components/marketing/legal-doc.tsx create mode 100644 components/providers/theme-provider.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/empty-state.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 lib/admin/audit.ts create mode 100644 lib/admin/billing.ts create mode 100644 lib/admin/cost.ts create mode 100644 lib/admin/flags.ts create mode 100644 lib/admin/metrics.ts create mode 100644 lib/admin/ops.ts create mode 100644 lib/admin/range.ts create mode 100644 lib/admin/series.ts create mode 100644 lib/admin/users.ts create mode 100644 lib/ai/moderation.ts create mode 100644 lib/billing/webhook-log.ts create mode 100644 lib/branding.ts create mode 100644 lib/flags/index.ts create mode 100644 lib/queue/health.ts create mode 100644 lib/usage/history.ts create mode 100644 prisma/migrations/20260607083455_admin_monitoring/migration.sql create mode 100644 prisma/migrations/20260607090000_user_dashboard_features/migration.sql diff --git a/app/(admin)/admin/actions.ts b/app/(admin)/admin/actions.ts index 2d1b778..f5f11c6 100644 --- a/app/(admin)/admin/actions.ts +++ b/app/(admin)/admin/actions.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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") }; +} diff --git a/app/(admin)/admin/ai-usage/page.tsx b/app/(admin)/admin/ai-usage/page.tsx index dc94484..2707c83 100644 --- a/app/(admin)/admin/ai-usage/page.tsx +++ b/app/(admin)/admin/ai-usage/page.tsx @@ -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(); - 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 = {}; - 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[] = [ + { key: "email", header: "User", cell: (o) => {o.email} }, + { key: "cost", header: "Spend", align: "right", cell: (o) => {usd(o.cost)} }, + ]; return ( <> - + } + /> -
- - - +
+ + +
- - - Daily AI spend - - - - - +
+ + `$${v}`} + series={[ + { key: "openai", name: "OpenAI", color: CHART.brand }, + { key: "elevenlabs", name: "ElevenLabs", color: CHART.success }, + ]} + /> + +
- - - By operation - - -
- {["script", "audio", "art", "repurpose"].map((op) => ( -
+
+ +
+ {(["script", "audio", "art", "repurpose"] as const).map((op) => ( +

{op}

-

${(byOperation[op] ?? 0).toFixed(2)}

+

{usd(breakdown.byOperation[op] ?? 0)}

))}
- - +
+ + o.userId} + empty="No usage in this period." + /> + +
); } - -function Stat({ label, value }: { label: string; value: string }) { - return ( - - - {label} - - -

{value}

-
-
- ); -} diff --git a/app/(admin)/admin/audit/page.tsx b/app/(admin)/admin/audit/page.tsx index 4064d14..69da8b2 100644 --- a/app/(admin)/admin/audit/page.tsx +++ b/app/(admin)/admin/audit/page.tsx @@ -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>["rows"][number]; + +export default async function AdminAuditPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + 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[] = [ + { + key: "when", + header: "When", + cell: (l) => {l.createdAt.toLocaleString()}, + }, + { key: "actor", header: "Actor", cell: (l) => l.actor?.email ?? l.actorType }, + { key: "action", header: "Action", cell: (l) => {l.action} }, + { + key: "target", + header: "Target", + cell: (l) => {l.target ?? "—"}, + }, + { + key: "meta", + header: "Details", + cell: (l) => , + }, + ]; return ( <> - - {logs.length === 0 ? ( -

No audit entries yet.

- ) : ( -
- - - - - - - - - - - {logs.map((l) => ( - - - - - - - ))} - -
WhenActorActionTarget
{l.createdAt.toLocaleString()}{l.actor?.email ?? l.actorType} - {l.action} - {l.target ?? "—"}
+ + + +
+ ({ value: a, label: a }))} + /> +
- )} +
+ l.id} empty="No audit entries yet." /> +
+ +
); } diff --git a/app/(admin)/admin/error.tsx b/app/(admin)/admin/error.tsx new file mode 100644 index 0000000..0ccf464 --- /dev/null +++ b/app/(admin)/admin/error.tsx @@ -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 ( + + + + + +
+

Something went wrong

+

+ {error.message || "This admin view failed to load."} +

+
+ +
+
+ ); +} diff --git a/app/(admin)/admin/flags/page.tsx b/app/(admin)/admin/flags/page.tsx index 4c55492..57fa9b2 100644 --- a/app/(admin)/admin/flags/page.tsx +++ b/app/(admin)/admin/flags/page.tsx @@ -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 ( <> - - ({ key: f.key, enabled: f.enabled }))} /> + + ); } diff --git a/app/(admin)/admin/health/page.tsx b/app/(admin)/admin/health/page.tsx index 8aede3b..ee020f1 100644 --- a/app/(admin)/admin/health/page.tsx +++ b/app/(admin)/admin/health/page.tsx @@ -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 ( <> - + + -
+
+ + + + +
+ +
- - - Queue + + + Workers - -

{queuePending}

- - {queueHealthy ? "Healthy" : "Degraded"} - -
-
- - - Failures (24h) - - - {recentFailures === 0 ? ( - + + {workers.length === 0 ? ( +

+ No worker heartbeat yet. Start the worker (npm run worker:dev). +

) : ( - + workers.map((w) => ( +
+
+

{w.name}

+

last beat {w.secondsAgo}s ago

+
+ {w.alive ? "alive" : "stale"} +
+ )) )} -

{recentFailures}

+ - - Running jobs + + + Database + -

{jobCounts["running"] ?? 0}

+
+ Query latency + {dbMs} ms +
-
- - - Generation jobs - - - {["queued", "running", "completed", "failed"].map((s) => ( -
- {s} - {jobCounts[s] ?? 0} -
- ))} -
-
+
+ + {queues.length === 0 ? ( +

No queue activity yet.

+ ) : ( +
+ + + + + + + + + + + + + {queues.map((q) => ( + + + + + + + + + ))} + +
QueueQueuedActiveRetryFailedDone
{q.queue}{q.queued}{q.active}{q.retry}{q.failed}{q.completed}
+
+ )} +
Episodes by status - {episodeGroups.map((g) => ( + {episodeCounts.map((g) => (
{g.status} - {g._count} + {g.count}
))}
diff --git a/app/(admin)/admin/jobs/page.tsx b/app/(admin)/admin/jobs/page.tsx new file mode 100644 index 0000000..2949021 --- /dev/null +++ b/app/(admin)/admin/jobs/page.tsx @@ -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>["rows"][number]; + +const STATUS_VARIANT: Record = { + 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>; +}) { + 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[] = [ + { + key: "episode", + header: "Episode", + cell: (j) => ( + + {j.episode.title} + + ), + }, + { key: "type", header: "Type", cell: (j) => {j.type} }, + { + key: "status", + header: "Status", + cell: (j) => {j.status}, + }, + { key: "attempts", header: "Attempts", align: "right", cell: (j) => j.attempts }, + { + key: "duration", + header: "Duration", + align: "right", + cell: (j) => {duration(j.startedAt, j.finishedAt)}, + }, + { + key: "error", + header: "Error", + cell: (j) => + j.error ? ( + + {j.error.slice(0, 40)} + + ) : ( + "—" + ), + }, + { + key: "actions", + header: "", + align: "right", + cell: (j) => , + }, + ]; + + return ( + <> + +
+ + + + +
+ + +
+ + + j.id} empty="No jobs yet." /> +
+ +
+ + ); +} diff --git a/app/(admin)/admin/loading.tsx b/app/(admin)/admin/loading.tsx new file mode 100644 index 0000000..5fe738c --- /dev/null +++ b/app/(admin)/admin/loading.tsx @@ -0,0 +1,16 @@ +export default function AdminLoading() { + return ( +
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+
+ ); +} diff --git a/app/(admin)/admin/moderation/page.tsx b/app/(admin)/admin/moderation/page.tsx index 4feac43..81545a2 100644 --- a/app/(admin)/admin/moderation/page.tsx +++ b/app/(admin)/admin/moderation/page.tsx @@ -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 = { + 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 ( <> - + {flags.length === 0 ? ( - -

Nothing to review

-

There are no open content flags.

+ + + +
+

All clear

+

There are no open content flags.

+
) : (
{flags.map((f) => ( - -
-

{f.episode.title}

+ +
+
+ + {f.episode.title} + + {f.severity} + {f.source} +

{f.reason}

diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx index d29026c..304c77d 100644 --- a/app/(admin)/admin/page.tsx +++ b/app/(admin)/admin/page.tsx @@ -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 = {}; - 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 ( <> - + } + /> -
- - - - - s.plan !== "free").length)} /> - +
+ + + + + + + +
- - - Subscriptions by tier - - -
- {PLAN_ORDER.map((key) => ( -
-
- {PLANS[key].name} - {tierCounts[key] ?? 0} -
-

{formatPrice(PLANS[key].priceMonthly)}/mo

-
- ))} -
-
-
+
+ + `$${v}`} + series={[ + { key: "newMrr", name: "New MRR", color: CHART.brand }, + { key: "churnedMrr", name: "Churned", color: CHART.warning }, + ]} + /> + + + {tierData.length > 0 ? ( + d.color)} /> + ) : ( +

No active subscriptions yet.

+ )} +
+
+ +
+ + + +
); } - -function Kpi({ - icon: Icon, - label, - value, - hint, -}: { - icon: React.ComponentType<{ className?: string }>; - label: string; - value: string; - hint?: string; -}) { - return ( - - - {label} - - - -

{value}

- {hint &&

{hint}

} -
-
- ); -} diff --git a/app/(admin)/admin/revenue/page.tsx b/app/(admin)/admin/revenue/page.tsx new file mode 100644 index 0000000..f078fbb --- /dev/null +++ b/app/(admin)/admin/revenue/page.tsx @@ -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[] = [ + { key: "customer", header: "Customer", cell: (c) => {c.customer} }, + { key: "plan", header: "Plan", cell: (c) => {c.plan} }, + { key: "provider", header: "Provider", cell: (c) => {c.provider} }, + { key: "at", header: "Canceled", cell: (c) => {c.at.toLocaleDateString()} }, + ]; + + return ( + <> + } + /> + +
+ + + + +
+ +
+ + `$${v}`} + series={[ + { key: "newMrr", name: "New MRR", color: CHART.brand }, + { key: "churnedMrr", name: "Churned", color: CHART.warning }, + ]} + /> + + +
+ {(["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 ( +
+
+ {tier} + {formatPrice(cents)} +
+
+
+
+
+ ); + })} +
+ +
+ +
+

+ Recent churn {extras.churnList.length} +

+ `${c.customer}-${i}`} + empty="No cancellations in this period. 🎉" + /> +
+ + ); +} diff --git a/app/(admin)/admin/settings/page.tsx b/app/(admin)/admin/settings/page.tsx new file mode 100644 index 0000000..674244c --- /dev/null +++ b/app/(admin)/admin/settings/page.tsx @@ -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[]; + +function coerceLimits(raw: unknown, fallback: PlanLimits): PlanLimitsValue { + const obj = (raw && typeof raw === "object" ? raw : {}) as Record; + 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 ( + <> + + +
+ +

+ lib/billing/plans.ts is the code source of + truth for tiers and features. Values saved here are stored in the{" "} + Plan table and act as a runtime{" "} + override for price and limits. +

+
+ +
+ {editable.map((plan) => ( + + ))} +
+ + ); +} diff --git a/app/(admin)/admin/subscriptions/page.tsx b/app/(admin)/admin/subscriptions/page.tsx index 35ca673..1252b57 100644 --- a/app/(admin)/admin/subscriptions/page.tsx +++ b/app/(admin)/admin/subscriptions/page.tsx @@ -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>; +}) { + 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(); - 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[] = [ + { + key: "customer", + header: "Customer", + cell: (s) => {s.customer}, + }, + { key: "plan", header: "Plan", cell: (s) => {s.plan} }, + { key: "provider", header: "Provider", cell: (s) => {s.provider} }, + { + key: "status", + header: "Status", + cell: (s) => ( + + {s.cancelAtPeriodEnd ? "cancels soon" : s.status} + + ), + }, + { + key: "renews", + header: "Renews", + cell: (s) => ( + {s.periodEnd ? s.periodEnd.toLocaleDateString() : "—"} + ), + }, + { + key: "actions", + header: "", + align: "right", + cell: (s) => ( + + ), + }, + ]; return ( <> -
- - - - +
+ + + +
-
- - - - - - - - - - - - {subs.map((s) => ( - - - - - - - - ))} - {subs.length === 0 && ( - - - - )} - -
CustomerPlanProviderStatusRenews
{nameByRef.get(s.referenceId) ?? s.referenceId}{s.plan}{s.provider} - - {s.cancelAtPeriodEnd ? "cancels soon" : s.status} - - - {s.periodEnd ? s.periodEnd.toLocaleDateString() : "—"} -
- No subscriptions yet. -
+ + +
+ + +
+
+ s.id} empty="No subscriptions match your filters." /> +
+
); } - -function Stat({ label, value }: { label: string; value: string }) { - return ( - - - {label} - - -

{value}

-
-
- ); -} diff --git a/app/(admin)/admin/users/[id]/page.tsx b/app/(admin)/admin/users/[id]/page.tsx new file mode 100644 index 0000000..630f8ce --- /dev/null +++ b/app/(admin)/admin/users/[id]/page.tsx @@ -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>>; +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[] = [ + { + key: "when", + header: "When", + cell: (l) => ( + + {l.createdAt.toLocaleString()} + + ), + }, + { key: "actor", header: "Actor", cell: (l) => l.actor?.email ?? l.actorType }, + { key: "action", header: "Action", cell: (l) => {l.action} }, + { + key: "meta", + header: "Details", + cell: (l) => + l.metadata ? ( + + {JSON.stringify(l.metadata).slice(0, 80)} + + ) : ( + "—" + ), + }, + ]; + + return ( + <> + + + {/* Identity header */} + + +
+ + {user.image && } + {initials(user.name)} + +
+
+

{user.name}

+ {user.role === "admin" && admin} + {user.banned ? ( + banned + ) : ( + active + )} +
+

{user.email}

+

+ Joined {user.createdAt.toLocaleDateString()} · {user.id} +

+
+
+ +
+
+ + {/* Key stats */} +
+ + + + +
+ +
+ {/* Usage vs plan limits */} + + + Usage this period + + + {usageSummary.map((u) => ( +
+
+ {u.label} + + {u.used} / {u.unlimited ? "∞" : u.limit} + +
+ = 90 ? "bg-destructive" : u.pct >= 75 ? "bg-warning" : undefined + } + /> +
+ ))} +
+
+ + {/* Recent episodes */} + + + Recent episodes + + + {episodes.length === 0 ? ( +

No episodes yet.

+ ) : ( +
    + {episodes.map((ep) => ( +
  • +
    + + {ep.title} + +

    + {ep.format} · {ep.createdAt.toLocaleDateString()} +

    +
    + {ep.status} +
  • + ))} +
+ )} +
+
+
+ + {/* Audit history */} +
+

Audit history

+ l.id} + empty="No audit entries for this user." + /> +
+ + ); +} diff --git a/app/(admin)/admin/users/page.tsx b/app/(admin)/admin/users/page.tsx index 88d3687..a09ec53 100644 --- a/app/(admin)/admin/users/page.tsx +++ b/app/(admin)/admin/users/page.tsx @@ -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>; +}) { + 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[] = [ + { + key: "user", + header: "User", + sortKey: "name", + cell: (u) => ( +
+

{u.name}

+

{u.email}

+
+ ), + }, + { key: "plan", header: "Plan", cell: (u) => {u.plan} }, + { + key: "role", + header: "Role", + cell: (u) => + u.role === "admin" ? admin : user, + }, + { + key: "status", + header: "Status", + cell: (u) => + u.banned ? banned : active, + }, + { + key: "createdAt", + header: "Joined", + sortKey: "createdAt", + cell: (u) => {u.createdAt.toLocaleDateString()}, + }, + { key: "actions", header: "", align: "right", cell: (u) => }, + ]; return ( <> - - ({ - 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(), - }))} - /> + + + +
+ + + +
+
+ u.id} empty="No users match your filters." /> +
+ +
); } diff --git a/app/(admin)/admin/webhooks/page.tsx b/app/(admin)/admin/webhooks/page.tsx new file mode 100644 index 0000000..352b3cb --- /dev/null +++ b/app/(admin)/admin/webhooks/page.tsx @@ -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>["rows"][number]; + +const VARIANT: Record = { + processed: "success", + failed: "destructive", + skipped: "secondary", +}; + +export default async function AdminWebhooksPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + 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[] = [ + { key: "provider", header: "Provider", cell: (w) => {w.provider} }, + { key: "type", header: "Event", cell: (w) => {w.type} }, + { key: "status", header: "Status", cell: (w) => {w.status} }, + { + key: "error", + header: "Error", + cell: (w) => + w.error ? ( + + {w.error.slice(0, 40)} + + ) : ( + "—" + ), + }, + { + key: "when", + header: "When", + cell: (w) => {w.createdAt.toLocaleString()}, + }, + ]; + + return ( + <> + +
+ + + +
+ + +
+
+ + +
+ + w.id} empty="No webhook deliveries recorded yet." /> +
+ +
+ + ); +} diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx index c610058..b206ee0 100644 --- a/app/(admin)/layout.tsx +++ b/app/(admin)/layout.tsx @@ -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 (
- - - - - PodcastYes Admin - +
+ + + + + + PodcastYes Admin + +
+ +
+ + {METRIC_LABELS[tightest.metric]} this month + + +
+
+

{tightest.used}

+ + {tightest.unlimited ? ( + <> + Unlimited + + ) : ( + <>of {tightest.limit} + )} + +
+ {!tightest.unlimited ? ( + + ) : ( +

No limits on this metric.

)} -
- - - - Usage this month - - - + + {/* Plan card with inline upgrade */} + + +
+ Current plan + +
+
+

+ {plan.name} +

+ {key !== "free" && Active} +
+

{plan.tagline}

+ {key !== "agency" && ( + + )} +
+
- + Recent episodes - + {recent.length > 0 && ( + + )} {recent.length === 0 ? ( -
- - - -
-

No episodes yet

-

- Create your first AI-produced episode to get started. -

-
- -
+ + + Create your first episode + + + } + /> ) : (
    {recent.map((ep) => (
  • {ep.title}

    diff --git a/app/(app)/episodes/[id]/loading.tsx b/app/(app)/episodes/[id]/loading.tsx new file mode 100644 index 0000000..3542e70 --- /dev/null +++ b/app/(app)/episodes/[id]/loading.tsx @@ -0,0 +1,23 @@ +export default function EpisodeLoading() { + return ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + {Array.from({ length: 3 }).map((_, i) => ( +
    + ))} +
    +
    +
    + ); +} diff --git a/app/(app)/episodes/[id]/not-found.tsx b/app/(app)/episodes/[id]/not-found.tsx new file mode 100644 index 0000000..6d7d51f --- /dev/null +++ b/app/(app)/episodes/[id]/not-found.tsx @@ -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 ( +
    +
    + +
    +
    +

    Episode not found

    +

    + This episode doesn't exist, or you don't have access to it. +

    +
    + +
    + ); +} diff --git a/app/(app)/episodes/[id]/page.tsx b/app/(app)/episodes/[id]/page.tsx index 9294d6a..f4e96fd 100644 --- a/app/(app)/episodes/[id]/page.tsx +++ b/app/(app)/episodes/[id]/page.tsx @@ -32,7 +32,11 @@ export default async function EpisodePage({ params }: { params: Promise<{ id: st : undefined} + action={ + !inProgress ? ( + + ) : undefined + } /> {episode.status === "FAILED" || inProgress ? ( @@ -68,6 +72,7 @@ export default async function EpisodePage({ params }: { params: Promise<{ id: st diff --git a/app/(app)/episodes/actions.ts b/app/(app)/episodes/actions.ts index 276ec31..a817571 100644 --- a/app/(app)/episodes/actions.ts +++ b/app/(app)/episodes/actions.ts @@ -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 plan.limits.maxEpisodeMinutes) { return { ok: false, @@ -58,10 +67,33 @@ export async function createEpisodeAction(input: CreateEpisodeInput): Promise { + 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 ({ - 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[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[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/ 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) + "…"; diff --git a/app/(app)/episodes/loading.tsx b/app/(app)/episodes/loading.tsx new file mode 100644 index 0000000..880446a --- /dev/null +++ b/app/(app)/episodes/loading.tsx @@ -0,0 +1,14 @@ +import { HeaderSkeleton, EpisodeGridSkeleton, Skeleton } from "@/components/ui/skeleton"; + +export default function EpisodesLoading() { + return ( + <> + +
    + + +
    + + + ); +} diff --git a/app/(app)/episodes/page.tsx b/app/(app)/episodes/page.tsx index 0ed394f..645c15c 100644 --- a/app/(app)/episodes/page.tsx +++ b/app/(app)/episodes/page.tsx @@ -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 ( <> +
    + + +
    + {episodes.length === 0 ? ( -
    - - - -
    -

    No episodes yet

    -

    Create your first AI-produced episode.

    -
    - -
    + filtering ? ( + + ) : ( + + + New episode + + + } + /> + ) ) : (
    {episodes.map((ep) => ( diff --git a/app/(app)/error.tsx b/app/(app)/error.tsx new file mode 100644 index 0000000..e5d49c0 --- /dev/null +++ b/app/(app)/error.tsx @@ -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 ( + + + + + +
    +

    Something went wrong

    +

    + {error.message || "This page failed to load. Please try again."} +

    +
    + +
    +
    + ); +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 451744f..15a8ad7 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -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 ( -
    -
    - - - - - PodcastYes - -
    - - -
    -
    + // 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"; -
    - -
    -
    {children}
    -
    + )} + +
    +
    + + + {branding?.logoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {workspaceName} + ) : ( + <> + + + + {workspaceName} + + )} + +
    +
    + + +
    +
    + +
    + +
    +
    {children}
    + {branding && !branding.removePoweredBy && ( +

    Powered by PodcastYes

    + )} +
    +
    + +
    -
    + ); } diff --git a/app/(app)/series/actions.ts b/app/(app)/series/actions.ts index 0c87010..cefba6a 100644 --- a/app/(app)/series/actions.ts +++ b/app/(app)/series/actions.ts @@ -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." }; diff --git a/app/(app)/series/loading.tsx b/app/(app)/series/loading.tsx new file mode 100644 index 0000000..379745e --- /dev/null +++ b/app/(app)/series/loading.tsx @@ -0,0 +1,15 @@ +import { HeaderSkeleton, Skeleton } from "@/components/ui/skeleton"; + +export default function SeriesLoading() { + return ( + <> + + +
    + {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
    + + ); +} diff --git a/app/(app)/series/page.tsx b/app/(app)/series/page.tsx index 61d313e..6105523 100644 --- a/app/(app)/series/page.tsx +++ b/app/(app)/series/page.tsx @@ -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() { - {series.length > 0 && ( -
    -

    Your seasons

    +
    +

    Your seasons

    + {series.length > 0 ? (
    {series.map((s) => ( - + @@ -69,8 +70,14 @@ export default async function SeriesPage() { ))}
    -
    - )} + ) : ( + + )} +
    ); } diff --git a/app/(app)/settings/actions.ts b/app/(app)/settings/actions.ts new file mode 100644 index 0000000..c112924 --- /dev/null +++ b/app/(app)/settings/actions.ts @@ -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(LANGUAGES.map((l) => l.code)); +const VALID_VOICES = new Set(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; + +/** + * 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 }; +} diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 57c37ca..055dadb 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -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 ( <> - + ); } diff --git a/app/(app)/team/actions.ts b/app/(app)/team/actions.ts index 7403144..8b7196f 100644 --- a/app/(app)/team/actions.ts +++ b/app/(app)/team/actions.ts @@ -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 diff --git a/app/(app)/team/loading.tsx b/app/(app)/team/loading.tsx new file mode 100644 index 0000000..3c70c03 --- /dev/null +++ b/app/(app)/team/loading.tsx @@ -0,0 +1,13 @@ +import { HeaderSkeleton, Skeleton } from "@/components/ui/skeleton"; + +export default function TeamLoading() { + return ( + <> + +
    + + +
    + + ); +} diff --git a/app/(app)/usage/loading.tsx b/app/(app)/usage/loading.tsx new file mode 100644 index 0000000..c1c1462 --- /dev/null +++ b/app/(app)/usage/loading.tsx @@ -0,0 +1,15 @@ +import { HeaderSkeleton, Skeleton } from "@/components/ui/skeleton"; + +export default function UsageLoading() { + return ( + <> + + +
    + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
    + + ); +} diff --git a/app/(auth)/sign-up/page.tsx b/app/(auth)/sign-up/page.tsx index 1b10cb2..fb98dda 100644 --- a/app/(auth)/sign-up/page.tsx +++ b/app/(auth)/sign-up/page.tsx @@ -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 ( + + + Sign-ups are paused + + New account registration is temporarily closed. Please check back soon. + + + + + + + ); + } + const googleEnabled = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); return ; } diff --git a/app/(marketing)/privacy/page.tsx b/app/(marketing)/privacy/page.tsx index b3fd8b7..2c58dc1 100644 --- a/app/(marketing)/privacy/page.tsx +++ b/app/(marketing)/privacy/page.tsx @@ -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 ( -
    -

    Privacy Policy

    -

    - 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. -

    -

    - This is placeholder copy — replace with your reviewed privacy policy before launch. -

    -
    + ); } diff --git a/app/(marketing)/terms/page.tsx b/app/(marketing)/terms/page.tsx index 79fa3b0..1134f36 100644 --- a/app/(marketing)/terms/page.tsx +++ b/app/(marketing)/terms/page.tsx @@ -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 ( -
    -

    Terms of Service

    -

    - 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. -

    -

    - This is placeholder copy — replace with your reviewed legal terms before launch. -

    -
    + ); } diff --git a/app/(public)/layout.tsx b/app/(public)/layout.tsx new file mode 100644 index 0000000..c7e5cc1 --- /dev/null +++ b/app/(public)/layout.tsx @@ -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 /, fonts and the toaster. +export default function PublicLayout({ children }: { children: React.ReactNode }) { + return
    {children}
    ; +} diff --git a/app/(public)/p/[shareId]/page.tsx b/app/(public)/p/[shareId]/page.tsx new file mode 100644 index 0000000..941972f --- /dev/null +++ b/app/(public)/p/[shareId]/page.tsx @@ -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 { + 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 = {}; + for (const s of episode.speakers) speakerNames[s.speakerKey] = s.displayName; + const script = episode.script?.content as unknown as StructuredScript | undefined; + + return ( +
    + {/* Header / brand wordmark */} +
    + {branding?.logoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {brandName} + ) : ( + + + + )} + {brandName} +
    + +
    +
    + +
    + {coverUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {episode.title} + ) : ( +
    + +
    + )} +
    +
    + +
    + + Podcast episode + +

    + {episode.title} +

    +

    + {episode.format.replace("_", "-").toLowerCase()} · {episode.language.toUpperCase()} ·{" "} + {episode.targetLengthMin} min +

    +
    +
    + + {episode.audioAsset && ( + + + + + + )} + +
    +

    About this episode

    +

    {episode.topic}

    +
    + + {script && script.sections?.length > 0 && ( +
    +

    Show notes

    +
      + {script.sections.map((s) => ( +
    • + + {s.title} +
    • + ))} +
    +
    + )} +
    + + {!removePoweredBy && ( +
    + Made with{" "} + + PodcastYes + {" "} + — turn any topic into a podcast with AI. +
    + )} +
    + ); +} diff --git a/app/api/episodes/[id]/export/route.ts b/app/api/episodes/[id]/export/route.ts new file mode 100644 index 0000000..442e4eb --- /dev/null +++ b/app/api/episodes/[id]/export/route.ts @@ -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 = {}; + 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 { + 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); +} diff --git a/app/api/episodes/[id]/stream/route.ts b/app/api/episodes/[id]/stream/route.ts index 0d9bb44..b7f4f85 100644 --- a/app/api/episodes/[id]/stream/route.ts +++ b/app/api/episodes/[id]/stream/route.ts @@ -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(); + +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, { diff --git a/app/api/public/episodes/[shareId]/audio/route.ts b/app/api/public/episodes/[shareId]/audio/route.ts new file mode 100644 index 0000000..e647e20 --- /dev/null +++ b/app/api/public/episodes/[shareId]/audio/route.ts @@ -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", + }, + }); +} diff --git a/app/api/public/episodes/[shareId]/cover/route.ts b/app/api/public/episodes/[shareId]/cover/route.ts new file mode 100644 index 0000000..3dc1a0a --- /dev/null +++ b/app/api/public/episodes/[shareId]/cover/route.ts @@ -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 = { + 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", + }, + }); +} diff --git a/app/api/v1/episodes/route.ts b/app/api/v1/episodes/route.ts index cec62cc..095592a 100644 --- a/app/api/v1/episodes/route.ts +++ b/app/api/v1/episodes/route.ts @@ -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; + } } diff --git a/app/api/webhooks/paypal/route.ts b/app/api/webhooks/paypal/route.ts index 8905582..2f14fd7 100644 --- a/app/api/webhooks/paypal/route.ts +++ b/app/api/webhooks/paypal/route.ts @@ -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[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"); diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index ddd080a..5f35649 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -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"); diff --git a/components/admin/admin-mobile-nav.tsx b/components/admin/admin-mobile-nav.tsx new file mode 100644 index 0000000..46d4ab1 --- /dev/null +++ b/components/admin/admin-mobile-nav.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useState } from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { Menu, X, ShieldCheck } from "lucide-react"; +import { AdminSidebar } from "./admin-sidebar"; + +export function AdminMobileNav() { + const [open, setOpen] = useState(false); + return ( + + + + Open menu + + + + +
    + + + + + Admin + + + + Close + +
    + setOpen(false)} /> +
    +
    + + ); +} diff --git a/components/admin/admin-sidebar.tsx b/components/admin/admin-sidebar.tsx index 0e21de0..3ed4e1e 100644 --- a/components/admin/admin-sidebar.tsx +++ b/components/admin/admin-sidebar.tsx @@ -4,49 +4,89 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { LayoutDashboard, + TrendingUp, + BarChart3, Users, CreditCard, - BarChart3, - ShieldAlert, + ListChecks, Activity, + Webhook, + ShieldAlert, Flag, ScrollText, + Settings, } from "lucide-react"; import { cn } from "@/lib/utils"; -const NAV = [ - { label: "Overview", href: "/admin", icon: LayoutDashboard, exact: true }, - { label: "Users", href: "/admin/users", icon: Users }, - { label: "Subscriptions", href: "/admin/subscriptions", icon: CreditCard }, - { label: "AI usage & cost", href: "/admin/ai-usage", icon: BarChart3 }, - { label: "Moderation", href: "/admin/moderation", icon: ShieldAlert }, - { label: "System health", href: "/admin/health", icon: Activity }, - { label: "Feature flags", href: "/admin/flags", icon: Flag }, - { label: "Audit log", href: "/admin/audit", icon: ScrollText }, +interface Item { + label: string; + href: string; + icon: React.ComponentType<{ className?: string }>; + exact?: boolean; +} + +const GROUPS: { label: string; items: Item[] }[] = [ + { + label: "Insights", + items: [ + { label: "Overview", href: "/admin", icon: LayoutDashboard, exact: true }, + { label: "Revenue", href: "/admin/revenue", icon: TrendingUp }, + { label: "AI cost", href: "/admin/ai-usage", icon: BarChart3 }, + ], + }, + { + label: "Operations", + items: [ + { label: "Users", href: "/admin/users", icon: Users }, + { label: "Subscriptions", href: "/admin/subscriptions", icon: CreditCard }, + { label: "Jobs", href: "/admin/jobs", icon: ListChecks }, + { label: "System health", href: "/admin/health", icon: Activity }, + { label: "Webhooks", href: "/admin/webhooks", icon: Webhook }, + ], + }, + { + label: "Governance", + items: [ + { label: "Moderation", href: "/admin/moderation", icon: ShieldAlert }, + { label: "Feature flags", href: "/admin/flags", icon: Flag }, + { label: "Audit log", href: "/admin/audit", icon: ScrollText }, + { label: "Settings", href: "/admin/settings", icon: Settings }, + ], + }, ]; -export function AdminSidebar() { +export function AdminSidebar({ onNavigate }: { onNavigate?: () => void }) { const pathname = usePathname(); return ( -