Strip Songs2VID to a payment-free self-hosted OSS core.

Remove Stripe/billing/pricing/marketing, simplify schema and entitlements for unlimited self-host use, and keep auth, encode, and YouTube upload.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Atakan Doğan Özban
2026-08-03 06:52:00 +02:00
co-authored by Cursor
parent 935abfb22e
commit 925aadae75
108 changed files with 397 additions and 8061 deletions
-10
View File
@@ -1,6 +1,5 @@
import { NextResponse } from "next/server";
import { createUserApiKey, getUserApiKeyStatus, revokeUserApiKey } from "@/lib/api-keys";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
async function requireApiKeyAccess() {
@@ -8,15 +7,6 @@ async function requireApiKeyAccess() {
if (!user) {
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
}
if (!hasProFeatures(user.plan)) {
return {
error: NextResponse.json(
{ error: "API keys are available on the Pro plan only" },
{ status: 403 },
),
user: null,
};
}
return { error: null, user };
}
@@ -1,51 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
createQuotaExtensionRequest,
EXTENSION_KIND,
getQuotaExtensionUsage,
} from "@/lib/quota-extensions";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasProFeatures(user.plan)) {
return NextResponse.json({ error: "Pro plan required" }, { status: 403 });
}
const usage = await getQuotaExtensionUsage(user.id, EXTENSION_KIND.API_RATE_LIMIT);
return NextResponse.json(usage);
}
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasProFeatures(user.plan)) {
return NextResponse.json({ error: "Pro plan required" }, { status: 403 });
}
let message = "";
try {
const body = await req.json();
if (typeof body.message === "string") message = body.message;
} catch {
// optional body
}
try {
const result = await createQuotaExtensionRequest(
user.id,
message,
EXTENSION_KIND.API_RATE_LIMIT,
);
return NextResponse.json(result);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to submit request";
return NextResponse.json({ error: msg }, { status: 400 });
}
}
-8
View File
@@ -1,6 +1,5 @@
import { NextResponse } from "next/server";
import { getApiRateLimitStatus } from "@/lib/api-rate-limit";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
export async function GET() {
@@ -8,13 +7,6 @@ export async function GET() {
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasProFeatures(user.plan)) {
return NextResponse.json(
{ error: "API rate limits are available on the Pro plan only" },
{ status: 403 },
);
}
const status = await getApiRateLimitStatus(user.id);
return NextResponse.json(status);
}
-64
View File
@@ -1,64 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/session";
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
/**
* Stripe Customer Portal update payment method, view invoices, cancel (per Dashboard config).
* @see https://docs.stripe.com/customer-management/integrate-customer-portal
*/
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (isBillingDevMock()) {
return NextResponse.json(
{ error: "Billing portal is unavailable in mock mode." },
{ status: 503 },
);
}
if (!isStripeConfigured()) {
return NextResponse.json(
{ error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." },
{ status: 503 },
);
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { stripeCustomerId: true },
});
if (!dbUser.stripeCustomerId) {
return NextResponse.json(
{ error: "No Stripe customer on this account. Complete a checkout first." },
{ status: 400 },
);
}
const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin;
const stripe = getStripe();
try {
const session = await stripe.billingPortal.sessions.create({
customer: dbUser.stripeCustomerId,
return_url: `${origin}/dashboard/settings`,
});
return NextResponse.json({ url: session.url });
} catch (err) {
const message = err instanceof Error ? err.message : "Could not open billing portal";
console.error("[stripe] billing portal failed", err);
return NextResponse.json(
{
error:
message.includes("No configuration") || message.includes("portal")
? "Billing portal is not configured in Stripe Dashboard yet. Enable it under Settings → Billing → Customer portal."
: message,
},
{ status: 502 },
);
}
}
@@ -1,217 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { downgradeToFreePlan } from "@/lib/billing";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/session";
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
type CancelWhen = "immediate" | "period_end";
type CancelAction = "cancel" | "resume";
function periodEndIso(sub: {
cancel_at?: number | null;
current_period_end?: number;
items?: { data?: Array<{ current_period_end?: number }> };
}): string | null {
const fromItem = sub.items?.data?.[0]?.current_period_end;
const ts = sub.cancel_at ?? fromItem ?? sub.current_period_end ?? null;
return ts ? new Date(ts * 1000).toISOString() : null;
}
/**
* Cancel Pro subscription via Stripe.
* Body: { when: "immediate" | "period_end" } or { action: "resume" }
*/
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.plan !== "PREMIUM") {
return NextResponse.json({ error: "No active subscription to cancel" }, { status: 400 });
}
let action: CancelAction = "cancel";
let when: CancelWhen = "immediate";
try {
const body = await req.json();
if (body?.action === "resume") action = "resume";
if (body?.when === "period_end" || body?.when === "immediate") when = body.when;
} catch {
// empty body → default immediate cancel (legacy)
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { stripeSubscriptionId: true },
});
const subId = dbUser.stripeSubscriptionId;
const isMockSub = !subId || subId.startsWith("dev_sub_");
const useStripe =
Boolean(subId) &&
!isMockSub &&
isStripeConfigured() &&
!isBillingDevMock();
// Resume (undo cancel-at-period-end)
if (action === "resume") {
if (useStripe && subId) {
try {
const stripe = getStripe();
const sub = await stripe.subscriptions.update(subId, {
cancel_at_period_end: false,
});
return NextResponse.json({
ok: true,
action: "resume",
cancelAtPeriodEnd: false,
currentPeriodEnd: periodEndIso(sub as { current_period_end?: number }),
});
} catch (err) {
console.error("[stripe] resume subscription failed", err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Failed to keep subscription" },
{ status: 502 },
);
}
}
return NextResponse.json({
ok: true,
action: "resume",
cancelAtPeriodEnd: false,
mocked: true,
});
}
// Cancel
if (when === "period_end") {
if (useStripe && subId) {
try {
const stripe = getStripe();
const sub = await stripe.subscriptions.update(subId, {
cancel_at_period_end: true,
});
const endsAt = periodEndIso(
sub as {
cancel_at?: number | null;
current_period_end?: number;
items?: { data?: Array<{ current_period_end?: number }> };
},
);
return NextResponse.json({
ok: true,
when: "period_end",
cancelAtPeriodEnd: true,
endsAt,
// Keep Pro until Stripe sends customer.subscription.deleted
});
} catch (err) {
console.error("[stripe] cancel at period end failed", err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Failed to schedule cancellation" },
{ status: 502 },
);
}
}
// Mock / no Stripe id: schedule locally by leaving Pro and reporting next month
const endsAt = new Date();
endsAt.setMonth(endsAt.getMonth() + 1);
return NextResponse.json({
ok: true,
when: "period_end",
cancelAtPeriodEnd: true,
endsAt: endsAt.toISOString(),
mocked: true,
});
}
// immediate
if (useStripe && subId) {
try {
const stripe = getStripe();
await stripe.subscriptions.cancel(subId);
} catch (err) {
console.error("[stripe] cancel subscription failed", err);
return NextResponse.json(
{
error:
err instanceof Error
? err.message
: "Stripe could not cancel the subscription. Your plan was not changed.",
},
{ status: 502 },
);
}
}
await downgradeToFreePlan(user.id);
return NextResponse.json({
ok: true,
when: "immediate",
cancelAtPeriodEnd: false,
plan: "FREE",
});
}
/** Current Stripe subscription cancel status for settings UI. */
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.plan !== "PREMIUM") {
return NextResponse.json({
plan: user.plan,
cancelAtPeriodEnd: false,
endsAt: null,
});
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { stripeSubscriptionId: true },
});
const subId = dbUser.stripeSubscriptionId;
if (
!subId ||
subId.startsWith("dev_sub_") ||
!isStripeConfigured() ||
isBillingDevMock()
) {
return NextResponse.json({
plan: "PREMIUM",
cancelAtPeriodEnd: false,
endsAt: null,
mocked: true,
});
}
try {
const stripe = getStripe();
const sub = await stripe.subscriptions.retrieve(subId);
return NextResponse.json({
plan: "PREMIUM",
status: sub.status,
cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end),
endsAt: periodEndIso(
sub as {
cancel_at?: number | null;
current_period_end?: number;
items?: { data?: Array<{ current_period_end?: number }> };
},
),
});
} catch (err) {
console.error("[stripe] retrieve subscription failed", err);
return NextResponse.json({
plan: "PREMIUM",
cancelAtPeriodEnd: false,
endsAt: null,
error: "Could not load subscription status from Stripe",
});
}
}
-192
View File
@@ -1,192 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
CREDIT_PRICE_CENTS,
FREE_EXTRA_CREDITS_MAX,
FREE_TOP_UP_MAX,
FREE_TOP_UP_MIN,
formatCreditPrice,
validateFreeTopUpAmount,
} from "@/lib/credits";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/session";
import {
checkoutTaxAndReferenceOptions,
ensureStripeCustomer,
priceDataTaxFields,
productDataWithTaxCode,
} from "@/lib/stripe-checkout";
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
import { grantExtraCredits } from "@/lib/billing";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: {
extraCredits: true,
plan: true,
monthlyCredits: true,
videosUsed: true,
},
});
const purchases = await prisma.creditPurchase.findMany({
where: { userId: user.id, status: "COMPLETED" },
orderBy: { completedAt: "desc" },
take: 10,
});
const cap = FREE_EXTRA_CREDITS_MAX;
const room = Math.max(0, cap - dbUser.extraCredits);
return NextResponse.json({
extraCredits: dbUser.extraCredits,
videoCredits: dbUser.extraCredits,
topUpMin: FREE_TOP_UP_MIN,
topUpMax: FREE_TOP_UP_MAX,
priceCentsPerCredit: CREDIT_PRICE_CENTS,
priceLabelPerCredit: formatCreditPrice(1),
creditBalanceMax: cap,
monthlyCredits: dbUser.monthlyCredits,
creditsUsed: dbUser.videosUsed,
plan: dbUser.plan,
stripeConfigured: isStripeConfigured() || isBillingDevMock(),
room,
atExtraCap: room === 0,
purchases: purchases.map((p) => ({
id: p.id,
credits: p.credits,
amountCents: p.amountCents,
completedAt: p.completedAt?.toISOString() ?? null,
})),
});
}
/** Free tier: top up 115 extra credits (price = credits × €0.25). */
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.plan === "PREMIUM") {
return NextResponse.json(
{
error:
"Credit top-ups are for the Free plan. Pro includes 50 monthly credits; existing extras never expire.",
},
{ status: 403 },
);
}
let credits = 0;
try {
const body = await req.json();
credits = Math.floor(Number(body.credits));
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { extraCredits: true, email: true, monthlyCredits: true, videosUsed: true, bonusQuota: true },
});
const monthlyRemaining = Math.max(
0,
dbUser.monthlyCredits + dbUser.bonusQuota - dbUser.videosUsed,
);
const validation = validateFreeTopUpAmount(credits, dbUser.extraCredits, monthlyRemaining);
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { credits: amount, amountCents } = validation;
if (isBillingDevMock()) {
const purchase = await prisma.creditPurchase.create({
data: {
userId: user.id,
credits: amount,
amountCents,
status: "COMPLETED",
completedAt: new Date(),
stripeSessionId: `dev_topup_${Date.now()}`,
},
});
await grantExtraCredits(user.id, amount);
return NextResponse.json({
mocked: true,
granted: amount,
amountCents,
purchaseId: purchase.id,
});
}
if (!isStripeConfigured()) {
return NextResponse.json(
{ error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." },
{ status: 503 },
);
}
const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin;
const purchase = await prisma.creditPurchase.create({
data: {
userId: user.id,
credits: amount,
amountCents,
status: "PENDING",
},
});
try {
const stripe = getStripe();
const customerId = await ensureStripeCustomer(user.id, dbUser.email);
const session = await stripe.checkout.sessions.create({
mode: "payment",
customer: customerId,
line_items: [
{
quantity: amount,
price_data: {
currency: "eur",
unit_amount: CREDIT_PRICE_CENTS,
...priceDataTaxFields(),
product_data: productDataWithTaxCode(
"Songs2VID video credit",
`1 credit = 1 video upload (${formatCreditPrice(1)} each)`,
),
},
},
],
metadata: {
type: "free_top_up",
userId: user.id,
purchaseId: purchase.id,
credits: String(amount),
},
...checkoutTaxAndReferenceOptions(user.id),
success_url: `${origin}/dashboard/settings?credits=success`,
cancel_url: `${origin}/dashboard/settings?credits=cancelled`,
});
await prisma.creditPurchase.update({
where: { id: purchase.id },
data: { stripeSessionId: session.id },
});
return NextResponse.json({ url: session.url, sessionId: session.id });
} catch (err) {
await prisma.creditPurchase.update({
where: { id: purchase.id },
data: { status: "FAILED" },
});
const message = err instanceof Error ? err.message : "Checkout failed";
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -1,36 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { createQuotaExtensionRequest, getQuotaExtensionUsage } from "@/lib/quota-extensions";
import { getSessionUser } from "@/lib/session";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const usage = await getQuotaExtensionUsage(user.id);
return NextResponse.json(usage);
}
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let message = "";
try {
const body = await req.json();
if (typeof body.message === "string") message = body.message;
} catch {
// optional body
}
try {
const result = await createQuotaExtensionRequest(user.id, message);
return NextResponse.json(result);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to submit request";
return NextResponse.json({ error: msg }, { status: 400 });
}
}
-87
View File
@@ -1,87 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { activateProPlan } from "@/lib/billing";
import { PRO_PRICE_CENTS } from "@/lib/credits";
import { prisma } from "@/lib/db";
import { getSessionUser } from "@/lib/session";
import {
checkoutTaxAndReferenceOptions,
ensureStripeCustomer,
priceDataTaxFields,
productDataWithTaxCode,
} from "@/lib/stripe-checkout";
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
/** Start Pro subscription Checkout (€5/mo) or mock-upgrade in development. */
export async function POST(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.plan === "PREMIUM") {
return NextResponse.json({ error: "Already on Pro." }, { status: 400 });
}
const dbUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { email: true, stripeCustomerId: true },
});
if (isBillingDevMock()) {
await activateProPlan(user.id, {
stripeCustomerId: dbUser.stripeCustomerId,
stripeSubscriptionId: `dev_sub_${Date.now()}`,
cardLast4: "4242",
});
return NextResponse.json({ mocked: true, plan: "PREMIUM" });
}
if (!isStripeConfigured()) {
return NextResponse.json(
{ error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." },
{ status: 503 },
);
}
const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin;
const stripe = getStripe();
const customerId = await ensureStripeCustomer(user.id, dbUser.email);
const priceId = process.env.STRIPE_PRO_PRICE_ID;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: customerId,
line_items: priceId
? [{ price: priceId, quantity: 1 }]
: [
{
quantity: 1,
price_data: {
currency: "eur",
unit_amount: PRO_PRICE_CENTS,
recurring: { interval: "month" },
...priceDataTaxFields(),
product_data: productDataWithTaxCode(
"Songs2VID Pro",
"50 video credits / month · 1080p · API access · Includes Custom Branding, Watermark Positioning, and Bulk Image Matching",
),
},
},
],
metadata: {
type: "pro_subscription",
userId: user.id,
},
subscription_data: {
metadata: {
type: "pro_subscription",
userId: user.id,
},
},
...checkoutTaxAndReferenceOptions(user.id),
success_url: `${origin}/dashboard/settings?upgrade=success`,
cancel_url: `${origin}/dashboard/settings?upgrade=cancelled`,
});
return NextResponse.json({ url: session.url, sessionId: session.id });
}
-95
View File
@@ -1,95 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { prisma } from "@/lib/db";
import { normalizeBadgeFields, validateBadgeFields } from "@/lib/footer-badges";
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(req: NextRequest, ctx: Ctx) {
const denied = requireAdmin(req);
if (denied) return denied;
const { id } = await ctx.params;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const existing = await prisma.footerBadge.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "Badge not found" }, { status: 404 });
}
const input = (body ?? {}) as Record<string, unknown>;
const patch: {
name?: string;
embedHtml?: string | null;
imageUrl?: string | null;
linkUrl?: string | null;
isActive?: boolean;
sortOrder?: number;
} = {};
if ("name" in input) patch.name = typeof input.name === "string" ? input.name : existing.name;
if ("embedHtml" in input) {
patch.embedHtml = typeof input.embedHtml === "string" ? input.embedHtml : null;
}
if ("imageUrl" in input) {
patch.imageUrl = typeof input.imageUrl === "string" ? input.imageUrl : null;
}
if ("linkUrl" in input) {
patch.linkUrl = typeof input.linkUrl === "string" ? input.linkUrl : null;
}
if ("isActive" in input && typeof input.isActive === "boolean") {
patch.isActive = input.isActive;
}
if ("sortOrder" in input && typeof input.sortOrder === "number") {
patch.sortOrder = input.sortOrder;
}
const fields = normalizeBadgeFields({
name: patch.name ?? existing.name,
embedHtml: "embedHtml" in patch ? patch.embedHtml : existing.embedHtml,
imageUrl: "imageUrl" in patch ? patch.imageUrl : existing.imageUrl,
linkUrl: "linkUrl" in patch ? patch.linkUrl : existing.linkUrl,
isActive: patch.isActive ?? existing.isActive,
sortOrder: patch.sortOrder ?? existing.sortOrder,
});
// Toggle-only updates should not re-validate empty content
const contentChanging =
"name" in input || "embedHtml" in input || "imageUrl" in input || "linkUrl" in input;
if (contentChanging) {
const error = validateBadgeFields(fields);
if (error) return NextResponse.json({ error }, { status: 400 });
}
const badge = await prisma.footerBadge.update({
where: { id },
data: contentChanging
? fields
: {
...(patch.isActive !== undefined ? { isActive: patch.isActive } : {}),
...(patch.sortOrder !== undefined ? { sortOrder: fields.sortOrder } : {}),
},
});
return NextResponse.json({ badge });
}
export async function DELETE(req: NextRequest, ctx: Ctx) {
const denied = requireAdmin(req);
if (denied) return denied;
const { id } = await ctx.params;
const existing = await prisma.footerBadge.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "Badge not found" }, { status: 404 });
}
await prisma.footerBadge.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
-48
View File
@@ -1,48 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-auth";
import { prisma } from "@/lib/db";
import { normalizeBadgeFields, validateBadgeFields } from "@/lib/footer-badges";
export async function GET(req: NextRequest) {
const denied = requireAdmin(req);
if (denied) return denied;
const badges = await prisma.footerBadge.findMany({
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json({ badges });
}
export async function POST(req: NextRequest) {
const denied = requireAdmin(req);
if (denied) return denied;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const input = (body ?? {}) as Record<string, unknown>;
const fields = normalizeBadgeFields({
name: typeof input.name === "string" ? input.name : "",
embedHtml: typeof input.embedHtml === "string" ? input.embedHtml : null,
imageUrl: typeof input.imageUrl === "string" ? input.imageUrl : null,
linkUrl: typeof input.linkUrl === "string" ? input.linkUrl : null,
isActive: typeof input.isActive === "boolean" ? input.isActive : true,
sortOrder: typeof input.sortOrder === "number" ? input.sortOrder : undefined,
});
const error = validateBadgeFields(fields);
if (error) return NextResponse.json({ error }, { status: 400 });
if (fields.sortOrder === 0) {
const max = await prisma.footerBadge.aggregate({ _max: { sortOrder: true } });
fields.sortOrder = (max._max.sortOrder ?? -1) + 1;
}
const badge = await prisma.footerBadge.create({ data: fields });
return NextResponse.json({ badge }, { status: 201 });
}
@@ -1,53 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
approveQuotaExtensionRequest,
rejectQuotaExtensionRequest,
} from "@/lib/quota-extensions";
function isAuthorized(req: NextRequest) {
const key = process.env.ADMIN_API_KEY;
if (!key) return false;
const auth = req.headers.get("authorization");
return auth === `Bearer ${key}`;
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
let action: "approve" | "reject" = "approve";
let bonusQuota: number | undefined;
let bonusRateLimit: number | undefined;
let adminNote: string | undefined;
try {
const body = await req.json();
if (body.action === "reject") action = "reject";
if (typeof body.bonusQuota === "number" && Number.isFinite(body.bonusQuota)) {
bonusQuota = body.bonusQuota;
}
if (typeof body.bonusRateLimit === "number" && Number.isFinite(body.bonusRateLimit)) {
bonusRateLimit = body.bonusRateLimit;
}
if (typeof body.adminNote === "string") adminNote = body.adminNote;
} catch {
// defaults
}
try {
if (action === "reject") {
await rejectQuotaExtensionRequest(id, adminNote);
} else {
await approveQuotaExtensionRequest(id, { bonusQuota, bonusRateLimit, adminNote });
}
return NextResponse.json({ ok: true });
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to process request";
return NextResponse.json({ error: msg }, { status: 400 });
}
}
-69
View File
@@ -1,69 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { adminResetUserCredits } from "@/lib/billing";
import { prisma } from "@/lib/db";
/**
* Admin utility: reset / adjust a user's credits.
* Authorization: Bearer ${ADMIN_API_KEY}
*
* Body: { userId | email, plan?, monthlyCredits?, creditsUsed?, extraCredits?, clearFreeTopUp? }
*/
export async function POST(req: NextRequest) {
const adminKey = process.env.ADMIN_API_KEY;
if (!adminKey) {
return NextResponse.json({ error: "Admin API not configured" }, { status: 503 });
}
const auth = req.headers.get("authorization");
if (auth !== `Bearer ${adminKey}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: {
userId?: string;
email?: string;
plan?: "FREE" | "PREMIUM";
monthlyCredits?: number;
creditsUsed?: number;
extraCredits?: number;
clearFreeTopUp?: boolean;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
let userId = body.userId;
if (!userId && body.email) {
const found = await prisma.user.findUnique({ where: { email: body.email } });
if (!found) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
userId = found.id;
}
if (!userId) {
return NextResponse.json({ error: "Provide userId or email" }, { status: 400 });
}
const updated = await adminResetUserCredits(userId, {
plan: body.plan,
monthlyCredits: body.monthlyCredits,
creditsUsed: body.creditsUsed,
extraCredits: body.extraCredits,
clearFreeTopUp: body.clearFreeTopUp,
});
return NextResponse.json({
ok: true,
user: {
id: updated.id,
email: updated.email,
plan: updated.plan,
monthlyCredits: updated.monthlyCredits,
creditsUsed: updated.videosUsed,
extraCredits: updated.extraCredits,
freeTopUpPurchased: updated.freeTopUpPurchased,
},
});
}
-24
View File
@@ -1,24 +0,0 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
/** Public: active footer badges for the landing marquee. */
export async function GET() {
const badges = await prisma.footerBadge.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: {
id: true,
name: true,
embedHtml: true,
imageUrl: true,
linkUrl: true,
sortOrder: true,
},
});
return NextResponse.json({ badges }, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
},
});
}
-95
View File
@@ -1,95 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
activateProPlan,
adminResetUserCredits,
grantExtraCredits,
markFreeTopUpPurchased,
} from "@/lib/billing";
import { FREE_TOP_UP_CREDITS } from "@/lib/credits";
import { isBillingDevMock } from "@/lib/stripe";
import { getSessionUser } from "@/lib/session";
/**
* Local-only billing helpers (no Stripe CLI required).
* Enabled when NODE_ENV=development (unless BILLING_DEV_MOCK=false) or BILLING_DEV_MOCK=true.
*/
function assertDev() {
if (!isBillingDevMock()) {
return NextResponse.json({ error: "Not available outside development mock mode" }, { status: 404 });
}
return null;
}
export async function POST(req: NextRequest) {
const blocked = assertDev();
if (blocked) return blocked;
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: {
action?: string;
credits?: number;
plan?: "FREE" | "PREMIUM";
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const action = body.action ?? "grant-credits";
switch (action) {
case "grant-credits": {
const credits = Math.floor(Number(body.credits ?? FREE_TOP_UP_CREDITS));
if (!Number.isFinite(credits) || credits <= 0) {
return NextResponse.json({ error: "Invalid credits" }, { status: 400 });
}
await grantExtraCredits(user.id, credits);
return NextResponse.json({ ok: true, granted: credits });
}
case "free-top-up": {
await markFreeTopUpPurchased(user.id);
return NextResponse.json({ ok: true, granted: FREE_TOP_UP_CREDITS });
}
case "set-pro": {
await activateProPlan(user.id, {
stripeSubscriptionId: `dev_sub_${Date.now()}`,
cardLast4: "4242",
});
return NextResponse.json({ ok: true, plan: "PREMIUM" });
}
case "set-free": {
await adminResetUserCredits(user.id, { plan: "FREE", creditsUsed: 0 });
return NextResponse.json({ ok: true, plan: "FREE" });
}
case "reset-cycle": {
await adminResetUserCredits(user.id, {
plan: body.plan,
creditsUsed: 0,
});
return NextResponse.json({ ok: true, reset: true });
}
default:
return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 });
}
}
/** Convenience GET for quick browser testing: /api/dev/grant-credits?action=set-pro */
export async function GET(req: NextRequest) {
const blocked = assertDev();
if (blocked) return blocked;
const action = req.nextUrl.searchParams.get("action") ?? "grant-credits";
const credits = Number(req.nextUrl.searchParams.get("credits") ?? FREE_TOP_UP_CREDITS);
const fake = new NextRequest(req.url, {
method: "POST",
headers: { "content-type": "application/json", cookie: req.headers.get("cookie") ?? "" },
body: JSON.stringify({ action, credits }),
});
return POST(fake);
}
+1 -6
View File
@@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { createVideoJob } from "@/lib/jobs/create-job";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/session";
@@ -17,12 +16,8 @@ export async function POST(req: NextRequest) {
const job = await createVideoJob(user, body);
return NextResponse.json({ jobId: job.id });
} catch (err) {
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Failed to create job";
const status = message.includes("Quota exceeded") ? 403 : 400;
return NextResponse.json({ error: message }, { status });
return NextResponse.json({ error: message }, { status: 400 });
}
}
-255
View File
@@ -1,255 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import type Stripe from "stripe";
import {
activateProPlan,
applyMonthlyCreditRenewal,
downgradeToFreePlan,
grantExtraCredits,
} from "@/lib/billing";
import { monthlyCreditsForPlan } from "@/lib/credits";
import { prisma } from "@/lib/db";
import { getNextMonthlyQuotaReset } from "@/lib/plans";
import { getStripe } from "@/lib/stripe";
export const runtime = "nodejs";
/** Stripe API 2025+: subscription lives on parent.subscription_details, not invoice.subscription. */
function subscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
const legacy = (invoice as Stripe.Invoice & {
subscription?: string | Stripe.Subscription | null;
}).subscription;
if (typeof legacy === "string" && legacy) return legacy;
if (legacy && typeof legacy === "object" && "id" in legacy && legacy.id) {
return String(legacy.id);
}
const parent = (
invoice as Stripe.Invoice & {
parent?: {
type?: string | null;
subscription_details?: { subscription?: string | Stripe.Subscription | null } | null;
} | null;
}
).parent;
const fromParent = parent?.subscription_details?.subscription;
if (typeof fromParent === "string" && fromParent) return fromParent;
if (fromParent && typeof fromParent === "object" && "id" in fromParent && fromParent.id) {
return String(fromParent.id);
}
return null;
}
async function fulfillFreeTopUp(session: Stripe.Checkout.Session) {
const purchaseId = session.metadata?.purchaseId;
const userId = session.metadata?.userId;
if (!purchaseId || !userId) {
console.error("[stripe] free_top_up missing metadata", session.id);
return;
}
const purchase = await prisma.creditPurchase.findUnique({ where: { id: purchaseId } });
if (!purchase || purchase.userId !== userId) {
throw new Error(`Purchase not found: ${purchaseId}`);
}
if (purchase.status === "COMPLETED") return;
await grantExtraCredits(userId, purchase.credits);
await prisma.creditPurchase.update({
where: { id: purchaseId },
data: {
status: "COMPLETED",
completedAt: new Date(),
stripeSessionId: session.id,
stripePaymentIntentId:
typeof session.payment_intent === "string"
? session.payment_intent
: session.payment_intent?.id ?? null,
},
});
}
async function fulfillProCheckout(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
if (!userId) {
console.error("[stripe] pro_subscription missing userId", session.id);
return;
}
const subscriptionId =
typeof session.subscription === "string"
? session.subscription
: session.subscription?.id ?? null;
const customerId =
typeof session.customer === "string" ? session.customer : session.customer?.id ?? null;
await activateProPlan(userId, {
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
});
}
async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) {
const subscriptionId = subscriptionIdFromInvoice(invoice);
if (!subscriptionId) {
console.warn("[stripe] invoice.payment_succeeded without subscription id", invoice.id);
return;
}
// Skip the first invoice if checkout already activated Pro (billing_reason subscription_create)
const user = await prisma.user.findFirst({
where: { stripeSubscriptionId: subscriptionId },
});
if (!user) {
// Fallback: metadata on subscription via Stripe retrieve is optional; try customer
const customerId =
typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
if (!customerId) return;
const byCustomer = await prisma.user.findFirst({
where: { stripeCustomerId: customerId },
});
if (!byCustomer) return;
await applyMonthlyCreditRenewal(byCustomer.id, {
plan: "PREMIUM",
newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"),
quotaResetAt: getNextMonthlyQuotaReset(),
});
await prisma.user.update({
where: { id: byCustomer.id },
data: {
stripeSubscriptionId: subscriptionId,
subscribedAt: byCustomer.subscribedAt ?? new Date(),
},
});
return;
}
// Renewal: rollover unused + new monthly, trim to MAX_CREDIT_CAP (30)
await applyMonthlyCreditRenewal(user.id, {
plan: "PREMIUM",
newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"),
quotaResetAt: getNextMonthlyQuotaReset(),
});
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const user = await prisma.user.findFirst({
where: { stripeSubscriptionId: subscription.id },
});
if (!user) return;
await downgradeToFreePlan(user.id);
}
/** Stripe retries failed invoices; when status is unpaid/canceled, revoke Pro access. */
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const terminal = new Set(["canceled", "unpaid", "incomplete_expired"]);
if (!terminal.has(subscription.status)) return;
const user = await prisma.user.findFirst({
where: { stripeSubscriptionId: subscription.id },
});
if (!user || user.plan !== "PREMIUM") return;
await downgradeToFreePlan(user.id);
}
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
const subscriptionId = subscriptionIdFromInvoice(invoice);
if (!subscriptionId) return;
const user = await prisma.user.findFirst({
where: {
OR: [
{ stripeSubscriptionId: subscriptionId },
...(typeof invoice.customer === "string"
? [{ stripeCustomerId: invoice.customer }]
: invoice.customer?.id
? [{ stripeCustomerId: invoice.customer.id }]
: []),
],
},
});
if (!user) return;
// Soft signal only Stripe Smart Retries continue. Do not downgrade on first failure.
console.warn(
"[stripe] invoice.payment_failed",
invoice.id,
"user",
user.id,
"attempt",
invoice.attempt_count,
);
}
export async function POST(req: NextRequest) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
return NextResponse.json({ error: "Webhook not configured" }, { status: 503 });
}
const signature = req.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
const rawBody = await req.text();
let event: Stripe.Event;
try {
const stripe = getStripe();
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Invalid signature";
return NextResponse.json({ error: message }, { status: 400 });
}
try {
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
if (session.payment_status === "paid" || session.status === "complete") {
const type = session.metadata?.type;
if (type === "pro_subscription" || session.mode === "subscription") {
await fulfillProCheckout(session);
} else if (type === "free_top_up") {
await fulfillFreeTopUp(session);
} else if (session.metadata?.purchaseId) {
// Legacy PAYG purchases: grant metadata credits as extras
const userId = session.metadata.userId;
const credits = Number(session.metadata.credits);
const purchaseId = session.metadata.purchaseId;
if (userId && purchaseId && Number.isFinite(credits) && credits > 0) {
const purchase = await prisma.creditPurchase.findUnique({
where: { id: purchaseId },
});
if (purchase && purchase.status !== "COMPLETED") {
await grantExtraCredits(userId, credits);
await prisma.creditPurchase.update({
where: { id: purchaseId },
data: {
status: "COMPLETED",
completedAt: new Date(),
stripeSessionId: session.id,
},
});
}
}
}
}
} else if (event.type === "invoice.payment_succeeded") {
await handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice);
} else if (event.type === "invoice.payment_failed") {
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
} else if (event.type === "customer.subscription.updated") {
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
} else if (event.type === "customer.subscription.deleted") {
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
}
} catch (err) {
console.error("[stripe] webhook handler error", event.type, event.id, err);
return NextResponse.json({ error: "Handler failed" }, { status: 500 });
}
return NextResponse.json({ received: true });
}
+1 -5
View File
@@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
import { getSessionUser } from "@/lib/session";
@@ -28,14 +27,11 @@ export async function POST(req: NextRequest) {
}
try {
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan, {
const result = await saveUploadedFile(user.id, file, type as UploadFileType, {
sessionKey,
});
return NextResponse.json(result);
} catch (err) {
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Upload failed";
return NextResponse.json({ error: message }, { status: 400 });
}
+2 -2
View File
@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { requireApiUser } from "@/lib/api-auth";
import { prisma } from "@/lib/db";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { error, user } = await requirePaidApiUser(_req);
const { error, user } = await requireApiUser(_req);
if (error || !user) return error!;
const { id } = await params;
+7 -18
View File
@@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { Privacy } from "@prisma/client";
import { requirePaidApiUser } from "@/lib/api-auth";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { requireApiUser } from "@/lib/api-auth";
import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job";
import {
applyCreatePlaylistToItems,
@@ -41,7 +40,7 @@ function defaultMetadata(title: string): ItemMetadata {
export async function POST(req: NextRequest) {
try {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
const formData = await req.formData();
@@ -56,10 +55,10 @@ export async function POST(req: NextRequest) {
);
}
const limits = getPlanLimits(user.plan);
const limits = getPlanLimits();
if (audioFiles.length > limits.maxBatchSize) {
return NextResponse.json(
{ error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.` },
{ error: `Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.` },
{ status: 400 },
);
}
@@ -98,10 +97,10 @@ export async function POST(req: NextRequest) {
}
const sessionKey = Date.now().toString();
const imageUpload = await saveUploadedFile(user.id, image, "image", user.plan, { sessionKey });
const imageUpload = await saveUploadedFile(user.id, image, "image", { sessionKey });
const uploads = await mapWithConcurrency(audioFiles, 4, (audio) =>
saveUploadedFile(user.id, audio, "audio", user.plan, { sessionKey }),
saveUploadedFile(user.id, audio, "audio", { sessionKey }),
);
const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => {
@@ -142,18 +141,8 @@ export async function POST(req: NextRequest) {
});
} catch (err) {
console.error("Batch upload failed:", err);
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Batch upload failed";
const status =
message.includes("Batch limit exceeded")
? 400
: message.includes("Quota exceeded") ||
message.includes("Not enough credits") ||
message.includes("Payment Required")
? 402
: 500;
const status = message.includes("Batch limit exceeded") ? 400 : 500;
return NextResponse.json({ error: message }, { status });
}
}
+4 -14
View File
@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { requireApiUser } from "@/lib/api-auth";
import { createVideoJob } from "@/lib/jobs/create-job";
import {
applyCreatePlaylistToItems,
@@ -10,7 +9,7 @@ import { prisma } from "@/lib/db";
import type { CreateJobPayload } from "@/lib/types";
export async function POST(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown };
@@ -47,22 +46,13 @@ export async function POST(req: NextRequest) {
: null,
});
} catch (err) {
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Failed to create job";
const status =
message.includes("Quota exceeded") ||
message.includes("Not enough credits") ||
message.includes("Payment Required")
? 402
: 400;
return NextResponse.json({ error: message }, { status });
return NextResponse.json({ error: message }, { status: 400 });
}
}
export async function GET(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
+3 -3
View File
@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { requireApiUser } from "@/lib/api-auth";
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
export async function GET(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
try {
@@ -18,7 +18,7 @@ export async function GET(req: NextRequest) {
}
export async function POST(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
try {
+5 -5
View File
@@ -6,8 +6,8 @@ export async function GET() {
name: "Songs2VID API",
version: "1.0",
authentication: "Authorization: Bearer <api_key>",
requirements: ["Pro subscription", "YouTube account connected"],
rateLimit: "60 requests per minute per account (plus any approved bonus)",
requirements: ["YouTube account connected"],
rateLimit: "100,000 requests per minute per account",
guidance: {
recommended:
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
@@ -19,14 +19,14 @@ export async function GET() {
method: "POST",
path: "/api/v1/upload",
description:
"Upload image, audio, PNG logo, or .ttf/.otf font (Pro typography; font ≤10MB)",
"Upload image, audio, PNG logo, or .ttf/.otf font (font ≤10MB)",
body: "multipart/form-data: file, type (image|audio|logo|font)",
},
{
method: "POST",
path: "/api/v1/jobs",
description:
"Create a video job from uploaded file paths. Pro: layout templates, blur, watermark, per-item covers",
"Create a video job from uploaded file paths with layouts, watermark, and per-item covers",
body: "application/json: { imagePath, items[{ audioPath, audioFilename, metadata }] }",
},
{
@@ -39,7 +39,7 @@ export async function GET() {
{
method: "GET",
path: "/api/v1/playlists",
description: "List YouTube playlists for the authenticated Pro account",
description: "List YouTube playlists for the authenticated account",
},
{
method: "POST",
+3 -7
View File
@@ -1,12 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { requirePaidApiUser } from "@/lib/api-auth";
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
import { requireApiUser } from "@/lib/api-auth";
import { saveUploadedFile, type UploadFileType } from "@/lib/jobs/create-job";
export const maxDuration = 120;
export async function POST(req: NextRequest) {
const { error, user } = await requirePaidApiUser(req);
const { error, user } = await requireApiUser(req);
if (error || !user) return error!;
const formData = await req.formData();
@@ -25,12 +24,9 @@ export async function POST(req: NextRequest) {
}
try {
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan);
const result = await saveUploadedFile(user.id, file, type as UploadFileType);
return NextResponse.json(result);
} catch (err) {
if (err instanceof PremiumRequiredError) {
return NextResponse.json(premiumRequiredResponse(err.message), { status: 403 });
}
const message = err instanceof Error ? err.message : "Upload failed";
return NextResponse.json({ error: message }, { status: 400 });
}
-10
View File
@@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
import { hasProFeatures } from "@/lib/edition";
import { getSessionUser } from "@/lib/session";
import { parseCreatePlaylistInput } from "@/lib/jobs/resolve-playlist";
import { createYouTubePlaylist, listYouTubePlaylists } from "@/lib/youtube/upload";
@@ -9,15 +8,6 @@ async function requirePremiumYouTubeUser() {
if (!user) {
return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }), user: null };
}
if (!hasProFeatures(user.plan)) {
return {
error: NextResponse.json(
{ error: "Playlist features are available on the Pro plan only" },
{ status: 403 },
),
user: null,
};
}
if (!user.youtubeConnection) {
return {
error: NextResponse.json({ error: "YouTube account not connected" }, { status: 403 }),