Comprehensive admin + user dashboards (production-ready)

This commit is contained in:
Leon Serfaty
2026-06-07 17:54:30 -04:00
parent 155507f21a
commit f033f00379
122 changed files with 7878 additions and 805 deletions
+41
View File
@@ -0,0 +1,41 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { rangeWindow, type Range } from "./range";
export const AUDIT_PAGE_SIZE = 30;
export async function listAudit(params: {
action?: string;
actor?: string;
range?: Range;
page: number;
pageSize?: number;
}) {
const pageSize = params.pageSize ?? AUDIT_PAGE_SIZE;
const where: Prisma.AuditLogWhereInput = {};
if (params.action) where.action = params.action;
if (params.actor) where.actor = { email: { contains: params.actor, mode: "insensitive" } };
if (params.range) where.createdAt = { gte: rangeWindow(params.range).since };
const [rows, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (params.page - 1) * pageSize,
take: pageSize,
include: { actor: { select: { email: true } } },
}),
prisma.auditLog.count({ where }),
]);
return { rows, total };
}
/** Distinct action names for the filter dropdown. */
export async function getAuditActions(): Promise<string[]> {
const rows = await prisma.auditLog.findMany({
distinct: ["action"],
select: { action: true },
orderBy: { action: "asc" },
});
return rows.map((r) => r.action);
}
+120
View File
@@ -0,0 +1,120 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { PLANS, type PlanKey } from "@/lib/billing/plans";
import { rangeWindow, type Range } from "./range";
export const SUBS_PAGE_SIZE = 25;
export interface AdminSubRow {
id: string;
customer: string;
referenceId: string;
plan: string;
provider: string;
status: string;
cancelAtPeriodEnd: boolean | null;
periodEnd: Date | null;
stripeSubscriptionId: string | null;
paypalSubscriptionId: string | null;
}
async function resolveNames(refIds: string[]): Promise<Map<string, string>> {
const [users, orgs] = await Promise.all([
prisma.user.findMany({ where: { id: { in: refIds } }, select: { id: true, email: true } }),
prisma.organization.findMany({ where: { id: { in: refIds } }, select: { id: true, name: true } }),
]);
const map = new Map<string, string>();
for (const u of users) map.set(u.id, u.email);
for (const o of orgs) map.set(o.id, o.name);
return map;
}
export async function listSubscriptions(params: {
status?: string;
provider?: string;
search?: string;
page: number;
pageSize?: number;
}): Promise<{ rows: AdminSubRow[]; total: number }> {
const pageSize = params.pageSize ?? SUBS_PAGE_SIZE;
const where: Prisma.SubscriptionWhereInput = {};
if (params.status) where.status = params.status;
if (params.provider) where.provider = params.provider;
if (params.search) {
const [users, orgs] = await Promise.all([
prisma.user.findMany({
where: {
OR: [
{ email: { contains: params.search, mode: "insensitive" } },
{ name: { contains: params.search, mode: "insensitive" } },
],
},
select: { id: true },
}),
prisma.organization.findMany({
where: { name: { contains: params.search, mode: "insensitive" } },
select: { id: true },
}),
]);
where.referenceId = { in: [...users.map((u) => u.id), ...orgs.map((o) => o.id)] };
}
const [subs, total] = await Promise.all([
prisma.subscription.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (params.page - 1) * pageSize,
take: pageSize,
}),
prisma.subscription.count({ where }),
]);
const names = await resolveNames(subs.map((s) => s.referenceId));
return {
total,
rows: subs.map((s) => ({
id: s.id,
customer: names.get(s.referenceId) ?? s.referenceId,
referenceId: s.referenceId,
plan: s.plan,
provider: s.provider,
status: s.status,
cancelAtPeriodEnd: s.cancelAtPeriodEnd,
periodEnd: s.periodEnd,
stripeSubscriptionId: s.stripeSubscriptionId,
paypalSubscriptionId: s.paypalSubscriptionId,
})),
};
}
/** Per-tier MRR breakdown + recent churn list, for the Revenue page. */
export async function getRevenueExtras(range: Range) {
const w = rangeWindow(range);
const [active, churn] = await Promise.all([
prisma.subscription.findMany({
where: { status: { in: ["active", "trialing"] }, plan: { not: "free" } },
select: { plan: true },
}),
prisma.subscription.findMany({
where: { status: "canceled", updatedAt: { gte: w.since } },
orderBy: { updatedAt: "desc" },
take: 20,
select: { referenceId: true, plan: true, provider: true, updatedAt: true },
}),
]);
const tierMrr: Record<string, number> = { creator: 0, pro: 0, agency: 0 };
for (const s of active) {
if (s.plan in tierMrr) tierMrr[s.plan] += PLANS[s.plan as PlanKey]?.priceMonthly ?? 0;
}
const names = await resolveNames(churn.map((c) => c.referenceId));
const churnList = churn.map((c) => ({
customer: names.get(c.referenceId) ?? c.referenceId,
plan: c.plan,
provider: c.provider,
at: c.updatedAt,
}));
return { tierMrr, churnList };
}
+51
View File
@@ -0,0 +1,51 @@
import { prisma } from "@/lib/db";
import { rangeWindow, type Range } from "./range";
export interface CostBreakdown {
total: number;
byProvider: { openai: number; elevenlabs: number };
byOperation: Record<string, number>;
outliers: { userId: string; email: string; cost: number }[];
}
export async function getCostBreakdown(range: Range): Promise<CostBreakdown> {
const w = rangeWindow(range);
const [logs, grouped] = await Promise.all([
prisma.aiCostLog.findMany({
where: { createdAt: { gte: w.since } },
select: { provider: true, operation: true, costUsd: true },
}),
prisma.aiCostLog.groupBy({
by: ["userId"],
where: { createdAt: { gte: w.since }, userId: { not: null } },
_sum: { costUsd: true },
orderBy: { _sum: { costUsd: "desc" } },
take: 10,
}),
]);
let total = 0;
const byProvider = { openai: 0, elevenlabs: 0 };
const byOperation: Record<string, number> = { script: 0, audio: 0, art: 0, repurpose: 0 };
for (const l of logs) {
const cost = Number(l.costUsd);
total += cost;
if (l.provider === "elevenlabs") byProvider.elevenlabs += cost;
else byProvider.openai += cost;
if (l.operation in byOperation) byOperation[l.operation] += cost;
}
const userIds = grouped.map((g) => g.userId!).filter(Boolean);
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, email: true },
});
const emailById = new Map(users.map((u) => [u.id, u.email]));
const outliers = grouped.map((g) => ({
userId: g.userId!,
email: emailById.get(g.userId!) ?? "—",
cost: Number(g._sum.costUsd ?? 0),
}));
return { total, byProvider, byOperation, outliers };
}
+53
View File
@@ -0,0 +1,53 @@
import { prisma } from "@/lib/db";
import { FLAG_REGISTRY } from "@/lib/flags";
export interface AdminFlagView {
key: string;
label: string;
description: string;
enabled: boolean;
rolloutPct: number;
metadata: unknown;
updatedAt: Date | null;
known: boolean;
}
const REGISTRY = new Map(FLAG_REGISTRY.map((f) => [f.key, f]));
/**
* Fresh (uncached) admin view of feature flags: registry-known flags merged with
* their DB rows (rolloutPct / metadata / updatedAt), plus any custom flags.
*/
export async function getAdminFlags(): Promise<AdminFlagView[]> {
const rows = await prisma.featureFlag.findMany({ orderBy: { key: "asc" } });
const byKey = new Map(rows.map((r) => [r.key, r]));
const known: AdminFlagView[] = FLAG_REGISTRY.map((def) => {
const row = byKey.get(def.key);
return {
key: def.key,
label: def.label,
description: def.description,
enabled: row?.enabled ?? def.default,
rolloutPct: row?.rolloutPct ?? 0,
metadata: row?.metadata ?? null,
updatedAt: row?.updatedAt ?? null,
known: true,
};
});
const custom: AdminFlagView[] = rows
.filter((r) => !REGISTRY.has(r.key))
.map((r) => ({
key: r.key,
label: r.key,
description: "Custom flag (no registry entry — not read by application code).",
enabled: r.enabled,
rolloutPct: r.rolloutPct,
metadata: r.metadata ?? null,
updatedAt: r.updatedAt,
known: false,
}));
return [...known, ...custom];
}
+82
View File
@@ -0,0 +1,82 @@
import { prisma } from "@/lib/db";
import { PLANS, type PlanKey } from "@/lib/billing/plans";
import { rangeWindow, pctDelta, type Range } from "./range";
function mrrOf(subs: { plan: string }[]): number {
return subs.reduce((sum, s) => sum + (PLANS[s.plan as PlanKey]?.priceMonthly ?? 0), 0);
}
export interface Overview {
mrr: number;
arr: number;
arpu: number;
paying: number;
activeSubs: number;
totalUsers: number;
signups: number;
signupsDelta: number | null;
episodes: number;
episodesDelta: number | null;
aiSpend: number;
aiSpendDelta: number | null;
churned: number;
successRate: number;
failedEpisodes: number;
tierCounts: Record<string, number>;
providerSplit: { stripe: number; paypal: number; comp: number };
}
export async function getOverview(range: Range): Promise<Overview> {
const w = rangeWindow(range);
const [activeSubs, signups, prevSignups, episodes, prevEpisodes, failed, spend, prevSpend, churned, totalUsers] =
await Promise.all([
prisma.subscription.findMany({
where: { status: { in: ["active", "trialing"] } },
select: { plan: true, provider: true },
}),
prisma.user.count({ where: { createdAt: { gte: w.since } } }),
prisma.user.count({ where: { createdAt: { gte: w.prevSince, lt: w.prevUntil } } }),
prisma.episode.count({ where: { createdAt: { gte: w.since } } }),
prisma.episode.count({ where: { createdAt: { gte: w.prevSince, lt: w.prevUntil } } }),
prisma.episode.count({ where: { status: "FAILED", createdAt: { gte: w.since } } }),
prisma.aiCostLog.aggregate({ _sum: { costUsd: true }, where: { createdAt: { gte: w.since } } }),
prisma.aiCostLog.aggregate({
_sum: { costUsd: true },
where: { createdAt: { gte: w.prevSince, lt: w.prevUntil } },
}),
prisma.subscription.count({ where: { status: "canceled", updatedAt: { gte: w.since } } }),
prisma.user.count(),
]);
const mrr = mrrOf(activeSubs);
const paying = activeSubs.filter((s) => s.plan !== "free").length;
const aiSpend = Number(spend._sum.costUsd ?? 0);
const prevAiSpend = Number(prevSpend._sum.costUsd ?? 0);
const tierCounts: Record<string, number> = { free: 0, creator: 0, pro: 0, agency: 0 };
for (const s of activeSubs) if (s.plan in tierCounts) tierCounts[s.plan]++;
return {
mrr,
arr: mrr * 12,
arpu: paying > 0 ? Math.round(mrr / paying) : 0,
paying,
activeSubs: activeSubs.length,
totalUsers,
signups,
signupsDelta: pctDelta(signups, prevSignups),
episodes,
episodesDelta: pctDelta(episodes, prevEpisodes),
aiSpend,
aiSpendDelta: pctDelta(aiSpend, prevAiSpend),
churned,
successRate: episodes > 0 ? Math.round(((episodes - failed) / episodes) * 100) : 100,
failedEpisodes: failed,
tierCounts,
providerSplit: {
stripe: activeSubs.filter((s) => s.provider === "stripe").length,
paypal: activeSubs.filter((s) => s.provider === "paypal").length,
comp: activeSubs.filter((s) => s.provider === "comp").length,
},
};
}
+67
View File
@@ -0,0 +1,67 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
export const JOBS_PAGE_SIZE = 25;
export const WEBHOOKS_PAGE_SIZE = 30;
export async function listJobs(params: { status?: string; page: number; pageSize?: number }) {
const pageSize = params.pageSize ?? JOBS_PAGE_SIZE;
const where: Prisma.GenerationJobWhereInput = {};
if (params.status) where.status = params.status;
const [rows, total] = await Promise.all([
prisma.generationJob.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (params.page - 1) * pageSize,
take: pageSize,
include: { episode: { select: { id: true, title: true } } },
}),
prisma.generationJob.count({ where }),
]);
return { rows, total };
}
export async function getJobStatusCounts(): Promise<Record<string, number>> {
const groups = await prisma.generationJob.groupBy({ by: ["status"], _count: true });
const counts: Record<string, number> = { queued: 0, running: 0, completed: 0, failed: 0 };
for (const g of groups) counts[g.status] = g._count;
return counts;
}
export async function getEpisodeStatusCounts() {
const groups = await prisma.episode.groupBy({ by: ["status"], _count: true });
return groups.map((g) => ({ status: g.status as string, count: g._count }));
}
export async function getModerationQueue() {
return prisma.contentFlag.findMany({
where: { status: "open" },
orderBy: { createdAt: "desc" },
include: { episode: { select: { id: true, title: true } } },
});
}
export async function listWebhookEvents(params: {
provider?: string;
status?: string;
page: number;
pageSize?: number;
}) {
const pageSize = params.pageSize ?? WEBHOOKS_PAGE_SIZE;
const where: Prisma.WebhookEventWhereInput = {};
if (params.provider) where.provider = params.provider;
if (params.status) where.status = params.status;
const dayAgo = new Date(Date.now() - 86_400_000);
const [rows, total, recentFailures, recentTotal] = await Promise.all([
prisma.webhookEvent.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (params.page - 1) * pageSize,
take: pageSize,
}),
prisma.webhookEvent.count({ where }),
prisma.webhookEvent.count({ where: { status: "failed", createdAt: { gte: dayAgo } } }),
prisma.webhookEvent.count({ where: { createdAt: { gte: dayAgo } } }),
]);
return { rows, total, recentFailures, recentTotal };
}
+80
View File
@@ -0,0 +1,80 @@
// Shared date-range parsing + bucketing for admin analytics.
export type Range = "7d" | "30d" | "90d" | "12m";
export const RANGES: Range[] = ["7d", "30d", "90d", "12m"];
export function parseRange(v?: string | null): Range {
return RANGES.includes(v as Range) ? (v as Range) : "30d";
}
export interface RangeWindow {
range: Range;
since: Date;
until: Date;
/** The equally-sized window immediately before `since` (for deltas). */
prevSince: Date;
prevUntil: Date;
days: number;
bucket: "day" | "month";
}
const DAYS: Record<Range, number> = { "7d": 7, "30d": 30, "90d": 90, "12m": 365 };
const DAY_MS = 86_400_000;
export function rangeWindow(range: Range, now = new Date()): RangeWindow {
const days = DAYS[range];
const until = now;
const since = new Date(now.getTime() - days * DAY_MS);
return {
range,
since,
until,
prevSince: new Date(since.getTime() - days * DAY_MS),
prevUntil: since,
days,
bucket: range === "12m" ? "month" : "day",
};
}
const isoDay = (d: Date) => d.toISOString().slice(0, 10); // YYYY-MM-DD (UTC)
const ym = (d: Date) => d.toISOString().slice(0, 7); // YYYY-MM
const dayLabel = (iso: string) => {
const [, m, d] = iso.split("-");
return `${Number(m)}/${Number(d)}`;
};
const monthLabel = (key: string) => {
const [y, m] = key.split("-");
return new Date(Date.UTC(Number(y), Number(m) - 1, 1)).toLocaleString("en", { month: "short" });
};
export interface Buckets {
buckets: { key: string; label: string }[];
keyOf: (d: Date) => string;
}
/** Ordered chart buckets across the window + a fn mapping a date to its bucket key. */
export function buildBuckets(w: RangeWindow): Buckets {
if (w.bucket === "month") {
const base = new Date(Date.UTC(w.until.getUTCFullYear(), w.until.getUTCMonth(), 1));
const buckets = [];
for (let i = 11; i >= 0; i--) {
const m = new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth() - i, 1));
const key = ym(m);
buckets.push({ key, label: monthLabel(key) });
}
return { buckets, keyOf: (d) => ym(d) };
}
const buckets = [];
for (let i = w.days - 1; i >= 0; i--) {
const day = new Date(w.until.getTime() - i * DAY_MS);
const key = isoDay(day);
buckets.push({ key, label: dayLabel(key) });
}
return { buckets, keyOf: (d) => isoDay(d) };
}
/** Percent change a→b, null when there's no meaningful base. */
export function pctDelta(current: number, previous: number): number | null {
if (previous === 0) return current === 0 ? 0 : null;
return ((current - previous) / previous) * 100;
}
+93
View File
@@ -0,0 +1,93 @@
import { prisma } from "@/lib/db";
import { PLANS, type PlanKey } from "@/lib/billing/plans";
import { rangeWindow, buildBuckets, type Range } from "./range";
/** Signups per bucket. */
export async function getSignupSeries(range: Range) {
const w = rangeWindow(range);
const { buckets, keyOf } = buildBuckets(w);
const rows = await prisma.user.findMany({
where: { createdAt: { gte: w.since } },
select: { createdAt: true },
});
const map = new Map(buckets.map((b) => [b.key, 0]));
for (const r of rows) {
const k = keyOf(r.createdAt);
if (map.has(k)) map.set(k, map.get(k)! + 1);
}
return buckets.map((b) => ({ date: b.label, signups: map.get(b.key) ?? 0 }));
}
/** Episodes created vs failed per bucket. */
export async function getEpisodeSeries(range: Range) {
const w = rangeWindow(range);
const { buckets, keyOf } = buildBuckets(w);
const rows = await prisma.episode.findMany({
where: { createdAt: { gte: w.since } },
select: { createdAt: true, status: true },
});
const map = new Map(buckets.map((b) => [b.key, { created: 0, failed: 0 }]));
for (const r of rows) {
const k = keyOf(r.createdAt);
const cell = map.get(k);
if (!cell) continue;
cell.created++;
if (r.status === "FAILED") cell.failed++;
}
return buckets.map((b) => ({ date: b.label, ...map.get(b.key)! }));
}
/** AI cost by provider per bucket. */
export async function getAiCostSeries(range: Range) {
const w = rangeWindow(range);
const { buckets, keyOf } = buildBuckets(w);
const rows = await prisma.aiCostLog.findMany({
where: { createdAt: { gte: w.since } },
select: { createdAt: true, provider: true, costUsd: true },
});
const map = new Map(buckets.map((b) => [b.key, { openai: 0, elevenlabs: 0 }]));
for (const r of rows) {
const cell = map.get(keyOf(r.createdAt));
if (!cell) continue;
const cost = Number(r.costUsd);
if (r.provider === "elevenlabs") cell.elevenlabs += cost;
else cell.openai += cost;
}
return buckets.map((b) => {
const c = map.get(b.key)!;
return { date: b.label, openai: round2(c.openai), elevenlabs: round2(c.elevenlabs) };
});
}
/** New vs churned MRR per bucket (proxy from sub create/cancel timestamps). */
export async function getRevenueSeries(range: Range) {
const w = rangeWindow(range);
const { buckets, keyOf } = buildBuckets(w);
const [created, canceled] = await Promise.all([
prisma.subscription.findMany({
where: { createdAt: { gte: w.since }, plan: { not: "free" } },
select: { createdAt: true, plan: true },
}),
prisma.subscription.findMany({
where: { status: "canceled", updatedAt: { gte: w.since }, plan: { not: "free" } },
select: { updatedAt: true, plan: true },
}),
]);
const map = new Map(buckets.map((b) => [b.key, { newMrr: 0, churnedMrr: 0 }]));
for (const s of created) {
const cell = map.get(keyOf(s.createdAt));
if (cell) cell.newMrr += (PLANS[s.plan as PlanKey]?.priceMonthly ?? 0) / 100;
}
for (const s of canceled) {
const cell = map.get(keyOf(s.updatedAt));
if (cell) cell.churnedMrr -= (PLANS[s.plan as PlanKey]?.priceMonthly ?? 0) / 100;
}
return buckets.map((b) => {
const c = map.get(b.key)!;
return { date: b.label, newMrr: round2(c.newMrr), churnedMrr: round2(c.churnedMrr) };
});
}
function round2(n: number) {
return Math.round(n * 100) / 100;
}
+126
View File
@@ -0,0 +1,126 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { getUsageSummary } from "@/lib/usage/meter";
import { getActiveSubscription } from "@/lib/billing/subscription";
import { PLANS, type PlanKey } from "@/lib/billing/plans";
export const USERS_PAGE_SIZE = 25;
export interface AdminUserRow {
id: string;
name: string;
email: string;
role: string;
banned: boolean;
plan: string;
createdAt: Date;
}
function orderBy(sort?: string): Prisma.UserOrderByWithRelationInput {
const [key, dir] = (sort ?? "createdAt.desc").split(".");
const d = dir === "asc" ? "asc" : "desc";
if (key === "name") return { name: d };
if (key === "email") return { email: d };
return { createdAt: d };
}
export async function listUsers(params: {
search?: string;
plan?: string;
role?: string;
status?: string;
sort?: string;
page: number;
pageSize?: number;
}): Promise<{ rows: AdminUserRow[]; total: number }> {
const pageSize = params.pageSize ?? USERS_PAGE_SIZE;
const where: Prisma.UserWhereInput = {};
if (params.search) {
where.OR = [
{ name: { contains: params.search, mode: "insensitive" } },
{ email: { contains: params.search, mode: "insensitive" } },
];
}
if (params.role === "admin" || params.role === "user") where.role = params.role;
if (params.status === "banned") where.banned = true;
else if (params.status === "active") where.banned = { not: true };
if (params.plan) {
const paid = await prisma.subscription.findMany({
where: {
status: { in: ["active", "trialing"] },
plan: params.plan === "free" ? { not: "free" } : params.plan,
},
select: { referenceId: true },
});
const ids = paid.map((p) => p.referenceId);
where.id = params.plan === "free" ? { notIn: ids } : { in: ids };
}
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
orderBy: orderBy(params.sort),
skip: (params.page - 1) * pageSize,
take: pageSize,
}),
prisma.user.count({ where }),
]);
const subs = await prisma.subscription.findMany({
where: { referenceId: { in: users.map((u) => u.id) }, status: { in: ["active", "trialing"] } },
select: { referenceId: true, plan: true },
});
const planByRef = new Map(subs.map((s) => [s.referenceId, s.plan]));
return {
total,
rows: users.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role ?? "user",
banned: !!u.banned,
plan: planByRef.get(u.id) ?? "free",
createdAt: u.createdAt,
})),
};
}
export async function getUserDetail(id: string) {
const user = await prisma.user.findUnique({ where: { id } });
if (!user) return null;
const [sub, usage, episodes, episodeCount, cost, audit] = await Promise.all([
getActiveSubscription(id),
getUsageSummary(id),
prisma.episode.findMany({
where: { userId: id },
orderBy: { createdAt: "desc" },
take: 8,
select: { id: true, title: true, status: true, format: true, createdAt: true },
}),
prisma.episode.count({ where: { userId: id } }),
prisma.aiCostLog.aggregate({ _sum: { costUsd: true }, where: { userId: id } }),
prisma.auditLog.findMany({
where: { OR: [{ target: id }, { actorId: id }] },
orderBy: { createdAt: "desc" },
take: 15,
include: { actor: { select: { email: true } } },
}),
]);
const planKey = (sub?.plan as PlanKey) ?? "free";
return {
user,
subscription: sub,
plan: PLANS[planKey] ?? PLANS.free,
planKey,
usage,
episodes,
episodeCount,
lifetimeCost: Number(cost._sum.costUsd ?? 0),
audit,
};
}
+40
View File
@@ -0,0 +1,40 @@
import { openai } from "./openai";
const MODERATION_MODEL = process.env.OPENAI_MODERATION_MODEL ?? "omni-moderation-latest";
export interface ModerationResult {
flagged: boolean;
/** OpenAI category keys that tripped, e.g. ["hate", "violence"]. */
categories: string[];
}
/**
* Screen text with OpenAI's moderation endpoint.
*
* Fails OPEN: if the moderation call errors (outage, quota), we log and return
* `flagged: false` so a moderation hiccup never blocks the product. The result
* is advisory — callers decide whether to reject input or flag generated output.
*/
export async function moderateText(input: string): Promise<ModerationResult> {
const text = input.trim();
if (!text) return { flagged: false, categories: [] };
try {
const res = await openai().moderations.create({ model: MODERATION_MODEL, input: text });
const result = res.results[0];
if (!result) return { flagged: false, categories: [] };
const categories = Object.entries(result.categories)
.filter(([, tripped]) => tripped)
.map(([key]) => key);
return { flagged: result.flagged, categories };
} catch (err) {
console.error("[moderation] check failed — failing open", err);
return { flagged: false, categories: [] };
}
}
/** Short human-readable reason for a content flag from a moderation result. */
export function moderationReason(result: ModerationResult): string {
const cats = result.categories.length ? result.categories.join(", ") : "policy violation";
return `Automated moderation flagged: ${cats}`;
}
+71 -20
View File
@@ -7,20 +7,65 @@ import { segmentScript } from "./segment";
import { stitchMp3 } from "./stitch";
import { storage, assetKey } from "@/lib/storage";
import { recordCost, scriptCostUsd, audioCostUsd, artCostUsd } from "@/lib/ai/cost";
import { incrementUsage } from "@/lib/usage/meter";
import { refundUsage } from "@/lib/usage/meter";
import { isFlagEnabled } from "@/lib/flags";
import { moderateText, moderationReason } from "@/lib/ai/moderation";
import { sendEmail, emailLayout } from "@/lib/email";
import { DEFAULT_VOICE_IDS } from "@/lib/ai/voices";
import type { EpisodeConfig, StructuredScript } from "@/lib/ai/types";
import type { GenerationType } from "@/lib/queue/jobs";
import type { UsageMetric } from "@/lib/billing/plans";
type EpisodeWithRelations = Prisma.EpisodeGetPayload<{
include: { speakers: true; user: true };
}>;
/**
* Usage metrics RESERVED by the enqueuing caller for a given generation `type`.
* The worker uses this only to REFUND on terminal failure — it never increments
* (see the metering invariant in lib/usage/meter.ts). Must stay in sync with the
* reservations made in the create/regenerate paths.
*/
export function reservedMetricsFor(type: GenerationType): UsageMetric[] {
switch (type) {
case "full":
return ["script", "audio", "art"];
case "script":
return ["script"];
case "audio":
return ["audio"];
case "art":
return ["art"];
default:
return [];
}
}
/** Refund every metric reserved for `type` (used by the worker on terminal failure). */
export async function refundEpisodeUsage(
episodeId: string,
type: GenerationType
): Promise<void> {
const episode = await prisma.episode.findUnique({
where: { id: episodeId },
select: { userId: true, organizationId: true },
});
if (!episode) return;
const ownerId = episode.organizationId ?? episode.userId;
const ownerType = episode.organizationId ? "organization" : "user";
for (const metric of reservedMetricsFor(type)) {
await refundUsage(ownerId, ownerType, metric);
}
}
/**
* The episode generation pipeline, run by the worker.
* Stages: script → segment → synthesize → stitch → art → save → meter.
* Stages: script → segment → synthesize → stitch → art → save.
* `type` selects which stages run (full, or a single re-generation).
*
* NOTE: usage is NOT metered here. The enqueuing caller already RESERVED the
* relevant metrics (script/audio/art) up front; on terminal failure the worker
* refunds them. See the metering invariant in lib/usage/meter.ts.
*/
export async function runEpisodeGeneration(
episodeId: string,
@@ -29,23 +74,18 @@ export async function runEpisodeGeneration(
const episode = await loadEpisode(episodeId);
const config = toConfig(episode);
const did = { script: false, audio: false, art: false };
if (type === "full" || type === "script") {
await generateScript(episode, config);
did.script = true;
}
if (type === "full" || type === "script" || type === "audio") {
await generateAudio(episode);
did.audio = true;
}
if (type === "full" || type === "art") {
await generateArt(episode);
did.art = true;
}
await setEpisodeStatus(episodeId, "SAVING", { stage: "Finalizing your episode" });
await meter(episode, did);
// No metering here: usage was reserved at enqueue time. See meter.ts invariant.
await setEpisodeStatus(episodeId, "READY", { stage: "Done" });
await notifyReady(episode);
}
@@ -105,6 +145,29 @@ async function generateScript(episode: EpisodeWithRelations, config: EpisodeConf
episodeId: episode.id,
userId: episode.userId,
});
await flagScriptIfFlagged(episode.id, script);
}
/**
* Screen the generated script with automated moderation. On a violation we queue
* a ContentFlag for admin review (rather than hard-failing the episode the user
* asked for); the admin moderation queue is the consumer. No-op when the
* moderation flag is off or an open flag already exists for the episode.
*/
async function flagScriptIfFlagged(episodeId: string, script: StructuredScript) {
if (!(await isFlagEnabled("ai_moderation_enabled"))) return;
const text = script.sections.flatMap((s) => s.turns.map((t) => t.text)).join("\n");
const result = await moderateText(text);
if (!result.flagged) return;
const existing = await prisma.contentFlag.findFirst({ where: { episodeId, status: "open" } });
if (existing) return;
await prisma.contentFlag.create({
data: { episodeId, reason: moderationReason(result), source: "moderation", severity: "high" },
});
console.warn(`[moderation] flagged episode ${episodeId}: ${result.categories.join(", ")}`);
}
// ─────────────── Stages 24: segment → synthesize → stitch ───────────────
@@ -197,18 +260,6 @@ async function generateArt(episode: EpisodeWithRelations) {
});
}
// ─────────────── Stage 7: meter ───────────────
async function meter(
episode: EpisodeWithRelations,
did: { script: boolean; audio: boolean; art: boolean }
) {
const ownerId = episode.organizationId ?? episode.userId;
const ownerType = episode.organizationId ? "organization" : "user";
if (did.script) await incrementUsage(ownerId, ownerType, "script");
if (did.audio) await incrementUsage(ownerId, ownerType, "audio");
if (did.art) await incrementUsage(ownerId, ownerType, "art");
}
async function notifyReady(episode: EpisodeWithRelations) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
try {
+19 -3
View File
@@ -9,15 +9,28 @@ const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const googleConfigured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
// Fail fast in production if the signing secret is missing — sessions/cookies are
// only secure when BETTER_AUTH_SECRET is set. Stay frictionless in dev/test.
if (!process.env.BETTER_AUTH_SECRET && process.env.NODE_ENV === "production") {
throw new Error("BETTER_AUTH_SECRET must be set in production.");
}
export const auth = betterAuth({
appName: "PodcastYes",
secret: process.env.BETTER_AUTH_SECRET,
baseURL: process.env.BETTER_AUTH_URL ?? appUrl,
database: prismaAdapter(prisma, { provider: "postgresql" }),
// Built-in brute-force protection for auth endpoints (login, password reset, etc).
// 30 requests per 60s window per IP.
rateLimit: { enabled: true, window: 60, max: 30 },
emailAndPassword: {
enabled: true,
// Flip on once email delivery is verified in prod.
// SECURITY GATE (currently OPEN): unverified emails can sign in. Flip this to
// `true` once email delivery is confirmed working in prod. Left `false` for now
// so existing dev accounts aren't locked out. Verification emails ARE sent on
// signup (see emailVerification.sendOnSignUp below), so users can verify already.
requireEmailVerification: false,
minPasswordLength: 8,
async sendResetPassword({ user, url }) {
@@ -35,7 +48,7 @@ export const auth = betterAuth({
},
emailVerification: {
sendOnSignUp: false,
sendOnSignUp: true,
async sendVerificationEmail({ user, url }) {
await sendEmail({
to: user.email,
@@ -62,7 +75,10 @@ export const auth = betterAuth({
session: {
expiresIn: 60 * 60 * 24 * 30, // 30 days
updateAge: 60 * 60 * 24, // refresh daily
cookieCache: { enabled: true, maxAge: 5 * 60 },
// Cache the session in a signed cookie to avoid a DB hit on every request.
// Tradeoff: a banned/demoted user keeps cached access until the cache expires,
// so we keep the window short (60s). Shorter = faster revocation but more DB hits.
cookieCache: { enabled: true, maxAge: 60 },
},
account: {
+11 -1
View File
@@ -1,8 +1,18 @@
import type { PlanKey } from "./plans";
const base = () => process.env.PAYPAL_API_BASE ?? "https://api-m.sandbox.paypal.com";
const SANDBOX_BASE = "https://api-m.sandbox.paypal.com";
const base = () => process.env.PAYPAL_API_BASE ?? SANDBOX_BASE;
const appUrl = () => process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
// Loudly warn (but don't throw — never break deploys) if PayPal is pointed at the
// sandbox while running in production. The sandbox default is fine for dev.
if (process.env.NODE_ENV === "production" && base().includes("sandbox.paypal.com")) {
console.warn(
"[paypal] WARNING: running against the PayPal SANDBOX in production. " +
"Set PAYPAL_API_BASE=https://api-m.paypal.com for live billing."
);
}
function creds(): { id: string; secret: string } {
const id = process.env.PAYPAL_CLIENT_ID;
const secret = process.env.PAYPAL_CLIENT_SECRET;
+24
View File
@@ -0,0 +1,24 @@
import { prisma } from "@/lib/db";
/** True if we've already handled this provider event (idempotency). */
export async function alreadyProcessed(eventId: string): Promise<boolean> {
const existing = await prisma.webhookEvent.findUnique({ where: { eventId } });
return !!existing;
}
/** Record a webhook delivery for the admin log (best-effort; unique on eventId). */
export async function logWebhook(
provider: "stripe" | "paypal",
eventId: string,
type: string,
status: "processed" | "failed" | "skipped",
error?: string
): Promise<void> {
await prisma.webhookEvent
.upsert({
where: { eventId },
create: { provider, eventId, type, status, error },
update: { status, error },
})
.catch((e) => console.error("[webhook-log] write failed", e));
}
+18 -2
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { upsertSubscription } from "../subscription";
import { planFromPaypalPlan } from "../catalog";
import type { PlanKey } from "../plans";
import { PLAN_ORDER, type PlanKey } from "../plans";
interface PaypalResource {
id?: string;
@@ -14,15 +15,30 @@ interface PaypalEvent {
resource: PaypalResource;
}
// custom_id is attacker-influenceable JSON; validate it strictly and never trust
// it to default to a paid plan.
const customSchema = z.object({
subjectId: z.string().min(1),
subjectType: z.enum(["user", "organization"]),
plan: z.enum(PLAN_ORDER as [PlanKey, ...PlanKey[]]),
});
function parseCustom(
custom?: string
): { subjectId: string; subjectType: "user" | "organization"; plan: PlanKey } | null {
if (!custom) return null;
let raw: unknown;
try {
return JSON.parse(custom);
raw = JSON.parse(custom);
} catch {
return null;
}
const parsed = customSchema.safeParse(raw);
if (!parsed.success) {
console.warn("[paypal] invalid custom_id payload, skipping");
return null;
}
return parsed.data;
}
async function sync(resource: PaypalResource, status: string) {
+10 -3
View File
@@ -2,7 +2,12 @@ import type Stripe from "stripe";
import { stripe } from "../stripe";
import { upsertSubscription } from "../subscription";
import { planFromStripePrice } from "../catalog";
import type { PlanKey } from "../plans";
import { PLAN_ORDER, type PlanKey } from "../plans";
/** Narrow attacker-influenceable metadata to a known PlanKey, else null. */
function planFromMetadata(value?: string | null): PlanKey | null {
return value && (PLAN_ORDER as string[]).includes(value) ? (value as PlanKey) : null;
}
function normalizeStatus(status: Stripe.Subscription.Status): string {
switch (status) {
@@ -23,9 +28,11 @@ async function syncStripeSubscription(
const item = sub.items.data[0];
const priceId = item?.price?.id;
const mapped = priceId ? planFromStripePrice(priceId) : null;
const plan = ((metadata?.plan as PlanKey) || mapped?.plan || "free") as PlanKey;
// metadata.plan is attacker-influenceable; only honour it if it's a known plan.
// The price-mapping fallback (derived from the real Stripe price) is preferred.
const plan: PlanKey = planFromMetadata(metadata?.plan) ?? mapped?.plan ?? "free";
const referenceId = metadata?.subjectId || sub.metadata?.subjectId;
if (!referenceId) {
if (!referenceId || referenceId.trim() === "") {
console.warn("[stripe] subscription without subjectId metadata, skipping", sub.id);
return;
}
+71
View File
@@ -0,0 +1,71 @@
import "server-only";
import { prisma } from "@/lib/db";
import { getEffectivePlan } from "@/lib/billing/subscription";
export interface ActiveBranding {
brandName: string | null;
logoUrl: string | null;
primaryColor: string | null; // hex (#rrggbb)
removePoweredBy: boolean;
}
/**
* Resolve the white-label branding that should apply to the current request.
* Only returns branding when an organization is active AND its plan includes
* the `custom_branding` feature AND a branding row exists. Otherwise null
* (the default PodcastYes look).
*/
export async function getActiveBranding(
userId: string,
activeOrgId?: string | null
): Promise<ActiveBranding | null> {
if (!activeOrgId) return null;
const { plan } = await getEffectivePlan(userId, activeOrgId);
if (!plan.features.includes("custom_branding")) return null;
const b = await prisma.orgBranding.findUnique({ where: { organizationId: activeOrgId } });
if (!b) return null;
return {
brandName: b.brandName,
logoUrl: b.logoUrl,
primaryColor: b.primaryColor,
removePoweredBy: b.removePoweredBy,
};
}
/**
* Convert a #rrggbb hex color to an `"H S% L%"` triplet for the `--brand` CSS
* variable (the app's accent is consumed as `hsl(var(--brand))`). Returns null
* for anything that isn't a valid 6-digit hex.
*/
export function hexToHslTriplet(hex: string | null | undefined): string | null {
if (!hex) return null;
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
if (!m) return null;
const int = parseInt(m[1], 16);
const r = ((int >> 16) & 255) / 255;
const g = ((int >> 8) & 255) / 255;
const b = (int & 255) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
default:
h = (r - g) / d + 4;
}
h /= 6;
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
}
+30 -3
View File
@@ -23,15 +23,42 @@ export async function sendEmail({ to, subject, html, text }: SendEmailInput): Pr
if (error) throw new Error(`Resend error: ${error.message}`);
}
/** Minimal branded wrapper so transactional emails share a consistent look. */
/** Escape text for safe interpolation into HTML/attribute contexts. */
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** Allow only http/https/mailto URLs; fall back to "#" for anything else (e.g. javascript:). */
function safeUrl(url: string): string {
try {
const scheme = new URL(url).protocol;
if (scheme === "http:" || scheme === "https:" || scheme === "mailto:") return url;
} catch {
// Not a parseable absolute URL — reject.
}
return "#";
}
/**
* Minimal branded wrapper so transactional emails share a consistent look.
*
* NOTE: `body` is interpolated as TRUSTED raw HTML and is intentionally NOT escaped.
* Callers must only ever pass static, trusted markup — never user-supplied input.
* `title` and `cta.label`/`cta.url` are escaped/validated for defense in depth.
*/
export function emailLayout(title: string, body: string, cta?: { label: string; url: string }) {
const button = cta
? `<a href="${cta.url}" style="display:inline-block;background:#7c3aed;color:#fff;text-decoration:none;padding:12px 20px;border-radius:8px;font-weight:600;margin-top:16px">${cta.label}</a>`
? `<a href="${escapeHtml(safeUrl(cta.url))}" style="display:inline-block;background:#7c3aed;color:#fff;text-decoration:none;padding:12px 20px;border-radius:8px;font-weight:600;margin-top:16px">${escapeHtml(cta.label)}</a>`
: "";
return `
<div style="font-family:Inter,Arial,sans-serif;max-width:480px;margin:0 auto;padding:24px;color:#0a0a0a">
<h1 style="font-size:20px;margin:0 0 12px">🎙️ PodcastYes</h1>
<h2 style="font-size:18px;margin:0 0 12px">${title}</h2>
<h2 style="font-size:18px;margin:0 0 12px">${escapeHtml(title)}</h2>
<div style="font-size:14px;line-height:1.6;color:#404040">${body}</div>
${button}
<p style="font-size:12px;color:#a3a3a3;margin-top:32px">If you didn't request this, you can ignore this email.</p>
+105
View File
@@ -0,0 +1,105 @@
import "server-only";
import { prisma } from "@/lib/db";
/**
* Feature flags with runtime effect.
*
* Flags are stored in the `feature_flag` table (admin-toggleable, no deploy) and
* READ here to actually gate behavior. Known flags have a registry entry that
* supplies a label, description, and a safe default when the row doesn't exist
* yet. Admins may also create ad-hoc custom flags from the admin UI.
*/
export interface FlagDef {
key: string;
label: string;
description: string;
default: boolean;
}
export const FLAG_REGISTRY: FlagDef[] = [
{
key: "signups_enabled",
label: "Public sign-ups",
description: "Allow new users to create an account. Turn off to pause registration.",
default: true,
},
{
key: "episode_generation_enabled",
label: "Episode generation",
description:
"Master kill-switch for AI generation (new episodes, regenerations, series). Turn off during incidents or provider outages.",
default: true,
},
{
key: "ai_moderation_enabled",
label: "AI content moderation",
description:
"Screen episode topics and generated scripts with OpenAI moderation; flag violations for review.",
default: true,
},
{
key: "maintenance_banner",
label: "Maintenance banner",
description: "Show a site-wide notice in the app that maintenance is in progress.",
default: false,
},
];
const DEFAULTS = new Map(FLAG_REGISTRY.map((f) => [f.key, f.default]));
// Short per-process cache so hot paths (layout, generation) don't hit the DB on
// every request. Admin toggles propagate within TTL_MS; the admin UI itself
// reads fresh (uncached) data via getAllFlags().
const TTL_MS = 10_000;
let cache: { at: number; map: Map<string, boolean> } | null = null;
async function loadFlags(): Promise<Map<string, boolean>> {
if (cache && Date.now() - cache.at < TTL_MS) return cache.map;
const rows = await prisma.featureFlag.findMany({ select: { key: true, enabled: true } });
const map = new Map<string, boolean>(rows.map((r) => [r.key, r.enabled]));
cache = { at: Date.now(), map };
return map;
}
/** Resolve a flag: DB value if present, else the registry default (else false). */
export async function isFlagEnabled(key: string): Promise<boolean> {
const map = await loadFlags();
if (map.has(key)) return map.get(key)!;
return DEFAULTS.get(key) ?? false;
}
/** Invalidate the in-process cache (call after an admin toggles a flag). */
export function bustFlagCache(): void {
cache = null;
}
export interface FlagView {
key: string;
label: string;
description: string;
enabled: boolean;
known: boolean;
}
/** Fresh (uncached) merged view of known + custom flags for the admin screen. */
export async function getAllFlags(): Promise<FlagView[]> {
const rows = await prisma.featureFlag.findMany();
const byKey = new Map(rows.map((r) => [r.key, r.enabled]));
const known: FlagView[] = FLAG_REGISTRY.map((f) => ({
key: f.key,
label: f.label,
description: f.description,
enabled: byKey.get(f.key) ?? f.default,
known: true,
}));
const custom: FlagView[] = rows
.filter((r) => !DEFAULTS.has(r.key))
.map((r) => ({
key: r.key,
label: r.key,
description: "Custom flag (no registry entry — not read by application code).",
enabled: r.enabled,
known: false,
}));
return [...known, ...custom];
}
+74
View File
@@ -0,0 +1,74 @@
import { prisma } from "@/lib/db";
const STALE_MS = 60_000;
/** Worker calls this on an interval so the admin can tell it's alive. */
export async function recordHeartbeat(
name: string,
stats?: { queued?: number; running?: number }
): Promise<void> {
const now = new Date();
await prisma.workerHeartbeat.upsert({
where: { name },
create: { name, lastBeatAt: now, queued: stats?.queued, running: stats?.running },
update: { lastBeatAt: now, queued: stats?.queued, running: stats?.running },
});
}
export interface WorkerHealth {
name: string;
alive: boolean;
lastBeatAt: Date;
secondsAgo: number;
queued: number | null;
running: number | null;
}
export async function getWorkerHealth(): Promise<WorkerHealth[]> {
const rows = await prisma.workerHeartbeat.findMany({ orderBy: { name: "asc" } });
const now = Date.now();
return rows.map((r) => ({
name: r.name,
alive: now - r.lastBeatAt.getTime() < STALE_MS,
lastBeatAt: r.lastBeatAt,
secondsAgo: Math.round((now - r.lastBeatAt.getTime()) / 1000),
queued: r.queued,
running: r.running,
}));
}
export interface QueueStat {
queue: string;
queued: number;
active: number;
completed: number;
retry: number;
failed: number;
}
/**
* Per-queue job counts straight from pg-boss's own tables (same Postgres).
* Returns [] if the pgboss schema isn't reachable (e.g. worker never started).
*/
export async function getQueueStats(): Promise<QueueStat[]> {
try {
const rows = await prisma.$queryRawUnsafe<{ name: string; state: string; n: number }[]>(
`SELECT name, state, count(*)::int AS n FROM pgboss.job GROUP BY name, state`
);
const map = new Map<string, QueueStat>();
for (const r of rows) {
const stat =
map.get(r.name) ??
({ queue: r.name, queued: 0, active: 0, completed: 0, retry: 0, failed: 0 } as QueueStat);
if (r.state === "created") stat.queued += r.n;
else if (r.state === "active") stat.active += r.n;
else if (r.state === "completed") stat.completed += r.n;
else if (r.state === "retry") stat.retry += r.n;
else if (r.state === "failed") stat.failed += r.n;
map.set(r.name, stat);
}
return Array.from(map.values()).sort((a, b) => a.queue.localeCompare(b.queue));
} catch {
return [];
}
}
+3 -1
View File
@@ -38,5 +38,7 @@ export async function rateLimit(
export const LIMITS = {
generation: { points: 10, durationSec: 60 }, // 10 generations / min / user
repurpose: { points: 15, durationSec: 60 },
api: { points: 60, durationSec: 60 }, // 60 API calls / min / key
api: { points: 60, durationSec: 60 }, // 60 API calls / min / key (writes)
read: { points: 120, durationSec: 60 }, // 120 read/list calls / min / key
stream: { points: 30, durationSec: 60 }, // SSE (re)connects / min / user
} as const;
+54
View File
@@ -0,0 +1,54 @@
import "server-only";
import { prisma } from "@/lib/db";
import { periodKey } from "@/lib/utils";
import type { UsageMetric } from "@/lib/billing/plans";
const METRICS: UsageMetric[] = ["script", "audio", "art", "repurpose"];
export interface UsageHistoryPoint {
/** Monthly bucket key, e.g. "2026-06". */
period: string;
script: number;
audio: number;
art: number;
repurpose: number;
}
/**
* Read-only usage history for a billing subject: the last `months` monthly
* buckets (oldest → newest), with per-metric counts. Missing buckets are zero
* so the series is always dense (handy for a usage chart). `ownerId` is a
* user.id or organization.id (the billing subject).
*/
export async function getUsageHistory(
ownerId: string,
months = 6,
now = new Date()
): Promise<UsageHistoryPoint[]> {
const n = Math.max(1, Math.min(36, months));
// Build the ordered list of period keys we care about (oldest first).
const periods: string[] = [];
for (let i = n - 1; i >= 0; i--) {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1));
periods.push(periodKey(d));
}
const rows = await prisma.usageRecord.findMany({
where: { ownerId, periodKey: { in: periods } },
select: { periodKey: true, metric: true, count: true },
});
const byPeriod = new Map<string, UsageHistoryPoint>();
for (const period of periods) {
byPeriod.set(period, { period, script: 0, audio: 0, art: 0, repurpose: 0 });
}
for (const row of rows) {
const point = byPeriod.get(row.periodKey);
if (point && (METRICS as string[]).includes(row.metric)) {
point[row.metric as UsageMetric] = row.count;
}
}
return periods.map((p) => byPeriod.get(p)!);
}
+31 -1
View File
@@ -1,5 +1,5 @@
import { getEffectivePlan } from "@/lib/billing/subscription";
import { getUsage } from "./meter";
import { getUsage, reserveUsage } from "./meter";
import { PLANS, UNLIMITED, withinLimit, type PlanKey, type UsageMetric } from "@/lib/billing/plans";
export interface LimitCheck {
@@ -46,6 +46,36 @@ export async function enforceLimit(
return check;
}
/**
* Atomically RESERVE one unit of `metric` against the subject's plan cap.
* Unlike `enforceLimit` (a read-only check), this consumes quota up front and
* is race-safe — the canonical write path for any action that will generate
* content. Throws `LimitExceededError` when the cap is already reached.
*
* Callers that proceed to enqueue/generate must REFUND (see `refundUsage`) if
* the downstream work fails, so quota isn't permanently consumed by a failure.
*/
export async function reserveLimit(
userId: string,
metric: UsageMetric,
activeOrgId?: string | null
): Promise<LimitCheck> {
const { key, subjectId, subjectType } = await getEffectivePlan(userId, activeOrgId);
const limit = PLANS[key].limits[metric];
const ok = await reserveUsage(subjectId, subjectType, metric, limit);
// Re-read for an accurate `used` in the result; reservation already enforced.
const used = await getUsage(subjectId, metric);
const check: LimitCheck = {
allowed: ok,
used,
limit,
plan: key,
metric,
};
if (!ok) throw new LimitExceededError(check);
return check;
}
export function isUnlimited(limit: number): boolean {
return limit === UNLIMITED;
}
+80 -1
View File
@@ -1,9 +1,32 @@
import { prisma } from "@/lib/db";
import { periodKey } from "@/lib/utils";
import type { UsageMetric } from "@/lib/billing/plans";
import { UNLIMITED, type UsageMetric } from "@/lib/billing/plans";
export type OwnerType = "user" | "organization";
/**
* METERING INVARIANT (reserve-then-verify; see security finding M2)
* ----------------------------------------------------------------
* Usage is RESERVED atomically up front by the enqueuing caller (server
* actions / API route) via `reserveUsage` (through `reserveLimit`), BEFORE the
* episode is created/enqueued. Reservation atomically increments the counter
* and rejects (refunding itself) when the post-increment count exceeds the cap,
* which closes the time-of-check/time-of-use race that let concurrent requests
* all pass a plain read-check and then blow past the monthly cap.
*
* Consequences for the rest of the system:
* - The worker / generation pipeline DOES NOT increment usage. The metric was
* already counted at enqueue time. Double counting is therefore impossible.
* - On TERMINAL generation failure the worker REFUNDS the reserved metrics via
* `refundUsage`, so a failed job does not permanently consume quota.
* - Inline (synchronous) generations that don't go through the worker
* (e.g. regenerate-section, repurpose) likewise RESERVE once up front and
* never call `incrementUsage` afterwards; they refund on failure.
*
* In short: "the enqueuing caller reserves; the worker refunds on terminal
* failure; the worker does not increment."
*/
/** Increment a monthly usage counter for a billing subject. */
export async function incrementUsage(
ownerId: string,
@@ -19,6 +42,62 @@ export async function incrementUsage(
});
}
/**
* Atomically reserve `by` units of `metric` and verify the post-increment count
* against `limit`. The upsert's `increment` is serialized by the
* (ownerId, periodKey, metric) unique constraint and returns the row's
* POST-increment count, so concurrent reservations can't both slip under the
* cap. If the reservation would exceed `limit`, it is REFUNDED (decremented)
* and `false` is returned. UNLIMITED (-1) is always allowed.
*
* Returns `true` when the reservation succeeded (quota consumed), `false` when
* it was rejected (and already refunded — caller need not undo anything).
*/
export async function reserveUsage(
ownerId: string,
ownerType: OwnerType,
metric: UsageMetric,
limit: number,
by = 1
): Promise<boolean> {
const key = periodKey(new Date());
const row = await prisma.usageRecord.upsert({
where: { ownerId_periodKey_metric: { ownerId, periodKey: key, metric } },
create: { ownerId, ownerType, periodKey: key, metric, count: by },
update: { count: { increment: by } },
});
if (limit !== UNLIMITED && row.count > limit) {
// Over cap — undo our own increment and reject.
await refundUsage(ownerId, ownerType, metric, by);
return false;
}
return true;
}
/**
* Refund `by` units of a previously reserved metric (floored at 0). Used when a
* reserved generation fails downstream, or by `reserveUsage` to undo a
* rejected reservation.
*/
export async function refundUsage(
ownerId: string,
ownerType: OwnerType,
metric: UsageMetric,
by = 1
): Promise<void> {
const key = periodKey(new Date());
const existing = await prisma.usageRecord.findUnique({
where: { ownerId_periodKey_metric: { ownerId, periodKey: key, metric } },
});
if (!existing) return;
const next = Math.max(0, existing.count - by);
await prisma.usageRecord.update({
where: { ownerId_periodKey_metric: { ownerId, periodKey: key, metric } },
data: { count: next },
});
}
/** Current-period count for a single metric. */
export async function getUsage(
ownerId: string,
+20
View File
@@ -22,3 +22,23 @@ export function periodKey(date: Date): string {
const m = String(date.getUTCMonth() + 1).padStart(2, "0");
return `${y}-${m}`;
}
/**
* Returns `path` only if it is a safe same-origin relative path; otherwise
* falls back to "/dashboard". Guards against open-redirect attacks by rejecting
* protocol-relative ("//", "/\"), absolute ("https://…"), and backslash URLs.
*/
export function safeRedirect(path: string | null | undefined): string {
if (!path) return "/dashboard";
// Must be a single-slash-rooted relative path with no scheme or backslash escapes.
if (
!path.startsWith("/") ||
path.startsWith("//") ||
path.startsWith("/\\") ||
path.startsWith("\\") ||
path.includes("://")
) {
return "/dashboard";
}
return path;
}