Initial OSS scaffold from Songs2VID (pre-strip)
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
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() {
|
||||
const user = await getSessionUser();
|
||||
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 };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const { error, user } = await requireApiKeyAccess();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const status = await getUserApiKeyStatus(user.id);
|
||||
return NextResponse.json(status);
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
const { error, user } = await requireApiKeyAccess();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const { token, prefix } = await createUserApiKey(user.id);
|
||||
return NextResponse.json({
|
||||
apiKey: token,
|
||||
prefix,
|
||||
message: "Copy this key now. It will not be shown again.",
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const { error, user } = await requireApiKeyAccess();
|
||||
if (error || !user) return error!;
|
||||
|
||||
await revokeUserApiKey(user.id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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() {
|
||||
const user = await getSessionUser();
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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 1–15 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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export async function POST() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -0,0 +1,24 @@
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import fs from "fs/promises";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getWatermarkPath } from "@/lib/storage";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const buf = await fs.readFile(getWatermarkPath());
|
||||
return new NextResponse(buf, {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=86400, immutable",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Watermark asset not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs/promises";
|
||||
import { CURATED_FONTS, isCuratedFontKey } from "@/lib/fonts";
|
||||
import { resolveCuratedFontPath } from "@/lib/fonts-server";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ key: string }> },
|
||||
) {
|
||||
const { key } = await params;
|
||||
if (!isCuratedFontKey(key)) {
|
||||
return NextResponse.json({ error: "Unknown font" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fontPath = resolveCuratedFontPath(key);
|
||||
try {
|
||||
const buf = await fs.readFile(fontPath);
|
||||
const meta = CURATED_FONTS.find((f) => f.key === key)!;
|
||||
return new NextResponse(buf, {
|
||||
headers: {
|
||||
"Content-Type": "font/ttf",
|
||||
"Content-Disposition": `inline; filename="${meta.file}"`,
|
||||
"Cache-Control": "public, max-age=86400, immutable",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Font file not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/session";
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { error, user } = await requireAuth();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const job = await prisma.job.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
include: {
|
||||
items: {
|
||||
orderBy: { audioFilename: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
audioFilename: true,
|
||||
title: true,
|
||||
description: true,
|
||||
tags: true,
|
||||
privacy: true,
|
||||
categoryId: true,
|
||||
resolution: true,
|
||||
status: true,
|
||||
youtubeVideoId: true,
|
||||
error: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
items: job.items,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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";
|
||||
import type { CreateJobPayload } from "@/lib/types";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requireAuth();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const body = (await req.json()) as CreateJobPayload;
|
||||
|
||||
try {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { error, user } = await requireAuth();
|
||||
if (error || !user) return error!;
|
||||
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
|
||||
|
||||
const jobs = await prisma.job.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
include: {
|
||||
items: {
|
||||
select: {
|
||||
id: true,
|
||||
audioFilename: true,
|
||||
title: true,
|
||||
status: true,
|
||||
youtubeVideoId: true,
|
||||
error: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
jobs: jobs.map((job) => ({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
items: job.items,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getQuotaInfo } from "@/lib/quota";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const quota = await getQuotaInfo(user.id);
|
||||
return NextResponse.json(quota);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const type = formData.get("type") as string | null;
|
||||
const sessionKey = (formData.get("session") as string | null)?.trim() || undefined;
|
||||
|
||||
if (
|
||||
!file ||
|
||||
!type ||
|
||||
(type !== "image" && type !== "audio" && type !== "logo" && type !== "font")
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing file or type (image | audio | logo | font)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan, {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } 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);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const job = await prisma.job.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
include: {
|
||||
items: {
|
||||
orderBy: { audioFilename: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
audioFilename: true,
|
||||
title: true,
|
||||
description: true,
|
||||
tags: true,
|
||||
privacy: true,
|
||||
categoryId: true,
|
||||
resolution: true,
|
||||
status: true,
|
||||
youtubeVideoId: true,
|
||||
error: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
items: job.items,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { Privacy } from "@prisma/client";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
import { createVideoJob, saveUploadedFile } from "@/lib/jobs/create-job";
|
||||
import {
|
||||
applyCreatePlaylistToItems,
|
||||
parseCreatePlaylistInput,
|
||||
} from "@/lib/jobs/resolve-playlist";
|
||||
import { filenameWithoutExtension } from "@/lib/constants";
|
||||
import { mapWithConcurrency } from "@/lib/fs-utils";
|
||||
import { getPlanLimits } from "@/lib/plans";
|
||||
import { checkQuota } from "@/lib/quota";
|
||||
import type { CreateJobPayload, CreatePlaylistRequest, ItemMetadata } from "@/lib/types";
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
type BatchItemInput = Partial<ItemMetadata> & {
|
||||
filename?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
function defaultMetadata(title: string): ItemMetadata {
|
||||
return {
|
||||
title,
|
||||
songTitle: null,
|
||||
description: "",
|
||||
tags: "",
|
||||
privacy: "PUBLIC",
|
||||
categoryId: "10",
|
||||
resolution: "1920x1080",
|
||||
notifySubscribers: true,
|
||||
madeForKids: false,
|
||||
embeddable: true,
|
||||
creativeCommons: false,
|
||||
includeWatermark: false,
|
||||
playlistId: null,
|
||||
artist: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const formData = await req.formData();
|
||||
const image = formData.get("image") as File | null;
|
||||
const audioFiles = formData.getAll("audio").filter((f): f is File => f instanceof File);
|
||||
const metadataRaw = formData.get("metadata");
|
||||
|
||||
if (!image || audioFiles.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide multipart fields: image (file), audio (one or more files)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const limits = getPlanLimits(user.plan);
|
||||
if (audioFiles.length > limits.maxBatchSize) {
|
||||
return NextResponse.json(
|
||||
{ error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const quotaCheck = await checkQuota(user.id, audioFiles.length);
|
||||
if (!quotaCheck.ok) {
|
||||
return NextResponse.json({ error: quotaCheck.error }, { status: 403 });
|
||||
}
|
||||
|
||||
let itemMeta: BatchItemInput[] = [];
|
||||
let defaults: Partial<ItemMetadata> = {};
|
||||
let createPlaylist: CreatePlaylistRequest | null = null;
|
||||
|
||||
if (metadataRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(metadataRaw)) as {
|
||||
items?: BatchItemInput[];
|
||||
defaults?: Partial<ItemMetadata>;
|
||||
createPlaylist?: unknown;
|
||||
};
|
||||
itemMeta = parsed.items ?? [];
|
||||
defaults = parsed.defaults ?? {};
|
||||
createPlaylist = parseCreatePlaylistInput(parsed.createPlaylist);
|
||||
if (parsed.createPlaylist && !createPlaylist) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"createPlaylist requires a title. Optional: description, privacy (public|unlisted|private)",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: "metadata must be valid JSON" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const sessionKey = Date.now().toString();
|
||||
const imageUpload = await saveUploadedFile(user.id, image, "image", user.plan, { sessionKey });
|
||||
|
||||
const uploads = await mapWithConcurrency(audioFiles, 4, (audio) =>
|
||||
saveUploadedFile(user.id, audio, "audio", user.plan, { sessionKey }),
|
||||
);
|
||||
|
||||
const builtItems: CreateJobPayload["items"] = uploads.map((upload, i) => {
|
||||
const audio = audioFiles[i];
|
||||
const metaInput = itemMeta[i] ?? itemMeta.find((m) => m.filename === audio.name) ?? {};
|
||||
const base = defaultMetadata(
|
||||
metaInput.title?.trim() || filenameWithoutExtension(audio.name),
|
||||
);
|
||||
const metadata: ItemMetadata = {
|
||||
...base,
|
||||
...defaults,
|
||||
...metaInput,
|
||||
title: (metaInput.title ?? defaults.title ?? base.title).trim(),
|
||||
privacy: (metaInput.privacy ?? defaults.privacy ?? base.privacy) as Privacy,
|
||||
};
|
||||
|
||||
return {
|
||||
audioPath: upload.path,
|
||||
audioFilename: upload.filename,
|
||||
metadata,
|
||||
};
|
||||
});
|
||||
|
||||
const { items, playlist } = await applyCreatePlaylistToItems(user, builtItems, createPlaylist);
|
||||
|
||||
const job = await createVideoJob(user, {
|
||||
imagePath: imageUpload.path,
|
||||
items,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
jobId: job.id,
|
||||
itemCount: job.items.length,
|
||||
status: job.status,
|
||||
playlist: playlist
|
||||
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
|
||||
: null,
|
||||
});
|
||||
} 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;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
import { createVideoJob } from "@/lib/jobs/create-job";
|
||||
import {
|
||||
applyCreatePlaylistToItems,
|
||||
parseCreatePlaylistInput,
|
||||
} from "@/lib/jobs/resolve-playlist";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { CreateJobPayload } from "@/lib/types";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const body = (await req.json()) as CreateJobPayload & { createPlaylist?: unknown };
|
||||
|
||||
try {
|
||||
const createPlaylist = parseCreatePlaylistInput(body.createPlaylist);
|
||||
if (body.createPlaylist && !createPlaylist) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"createPlaylist requires a title. Optional: description, privacy (public|unlisted|private)",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { items, playlist } = await applyCreatePlaylistToItems(
|
||||
user,
|
||||
body.items,
|
||||
createPlaylist,
|
||||
);
|
||||
|
||||
const job = await createVideoJob(user, {
|
||||
imagePath: body.imagePath,
|
||||
items,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
jobId: job.id,
|
||||
itemCount: job.items.length,
|
||||
status: job.status,
|
||||
playlist: playlist
|
||||
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
|
||||
: 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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 20, 100);
|
||||
|
||||
const jobs = await prisma.job.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
include: {
|
||||
items: {
|
||||
select: {
|
||||
id: true,
|
||||
audioFilename: true,
|
||||
title: true,
|
||||
status: true,
|
||||
youtubeVideoId: true,
|
||||
error: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
jobs: jobs.map((job) => ({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString() ?? null,
|
||||
items: job.items,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } 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);
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
const playlists = await listYouTubePlaylists(user.id);
|
||||
return NextResponse.json({ playlists });
|
||||
} catch (err) {
|
||||
console.error("List playlists failed:", err);
|
||||
const message = err instanceof Error ? err.message : "Failed to list playlists";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePaidApiUser(req);
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const input = parseCreatePlaylistInput(body);
|
||||
if (!input) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Provide JSON body: { title, description?, privacy? } where privacy is public|unlisted|private",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const playlist = await createYouTubePlaylist(user.id, input);
|
||||
return NextResponse.json({ playlist });
|
||||
} catch (err) {
|
||||
console.error("Create playlist failed:", err);
|
||||
const message = err instanceof Error ? err.message : "Failed to create playlist";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { API_DOCS_URL } from "@/lib/plans";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
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)",
|
||||
guidance: {
|
||||
recommended:
|
||||
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
|
||||
batch:
|
||||
"POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'",
|
||||
},
|
||||
endpoints: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/upload",
|
||||
description:
|
||||
"Upload image, audio, PNG logo, or .ttf/.otf font (Pro typography; 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",
|
||||
body: "application/json: { imagePath, items[{ audioPath, audioFilename, metadata }] }",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/jobs/batch",
|
||||
description:
|
||||
"One-shot batch for small packs only; large uploads may fail FormData parsing; prefer upload + jobs",
|
||||
body: "multipart/form-data: image, audio[] (repeatable), metadata? (JSON string)",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/playlists",
|
||||
description: "List YouTube playlists for the authenticated Pro account",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/playlists",
|
||||
description: "Create a YouTube playlist",
|
||||
body: "application/json: { title, description?, privacy? (public|unlisted|private) }",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/jobs",
|
||||
description: "List recent jobs",
|
||||
query: "limit (default 20, max 100)",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/jobs/:id",
|
||||
description: "Get job status and item details",
|
||||
},
|
||||
],
|
||||
docs: API_DOCS_URL,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requirePaidApiUser } from "@/lib/api-auth";
|
||||
import { PremiumRequiredError, premiumRequiredResponse } from "@/lib/entitlements";
|
||||
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);
|
||||
if (error || !user) return error!;
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const type = formData.get("type") as string | null;
|
||||
|
||||
if (
|
||||
!file ||
|
||||
!type ||
|
||||
(type !== "image" && type !== "audio" && type !== "logo" && type !== "font")
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide multipart fields: file, type (image|audio|logo|font)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await saveUploadedFile(user.id, file, type as UploadFileType, user.plan);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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";
|
||||
|
||||
async function requirePremiumYouTubeUser() {
|
||||
const user = await getSessionUser();
|
||||
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 }),
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
return { error: null, user };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const { error, user } = await requirePremiumYouTubeUser();
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
const playlists = await listYouTubePlaylists(user.id);
|
||||
return NextResponse.json({ playlists });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to list playlists";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { error, user } = await requirePremiumYouTubeUser();
|
||||
if (error || !user) return error!;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const input = parseCreatePlaylistInput(body);
|
||||
if (!input) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide title, and optionally description and privacy (public|unlisted|private)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const playlist = await createYouTubePlaylist(user.id, input);
|
||||
return NextResponse.json({ playlist });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create playlist";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user