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