feat: arastirma modulleri odeme ve operasyonlari tamamla

This commit is contained in:
2026-08-09 09:24:26 +03:00
parent 4b4e00c6ee
commit 2b75e97ad6
45 changed files with 1316 additions and 330 deletions
@@ -1,8 +0,0 @@
import { NextResponse } from "next/server";
import { getApiAdmin } from "@/lib/admin-api";
export async function POST() {
const admin = await getApiAdmin();
if (!admin) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
return NextResponse.json({ ok: true, message: "Demo seed komut satırından çalışır: npm run db:seed" });
}
+97
View File
@@ -0,0 +1,97 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { currentUser } from "@/lib/auth/current-user";
import { importApifyAds, runApifyActor } from "@/lib/apify";
import { prisma } from "@/lib/db";
import { planFromUser } from "@/lib/plans";
import { checkAndConsumeQuota, refundQuota } from "@/lib/quota";
import { hizSiniriAsimi } from "@/lib/rate-limit";
const schema = z.object({
searchTerm: z.string().trim().min(2).max(100),
maxResults: z.coerce.number().int().refine((value) => [10, 25, 50, 100].includes(value))
});
function planResultLimit(planCode: string | undefined, isAdmin: boolean) {
if (isAdmin || planCode === "PREMIUM") return 100;
if (planCode === "STANDARD") return 50;
if (planCode === "BASIC") return 25;
return 0;
}
export async function POST(request: Request) {
const limited = hizSiniriAsimi(request, "arama");
if (limited) return limited;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const plan = planFromUser(user as never);
const isAdmin = user.role === "ADMIN";
const resultLimit = planResultLimit(plan?.code, isAdmin);
if (resultLimit === 0) return NextResponse.json({ error: "UPGRADE_REQUIRED" }, { status: 403 });
if (parsed.data.maxResults > resultLimit) {
return NextResponse.json({ error: "PLAN_LIMIT_EXCEEDED", maxResults: resultLimit }, { status: 403 });
}
let dailyReserved = false;
let creditsReserved = false;
if (!isAdmin) {
const daily = await checkAndConsumeQuota({ userId: user.id, plan, metric: "ads_search_daily" });
if (!daily.allowed) return NextResponse.json({ error: "DAILY_QUOTA_EXCEEDED", usage: daily }, { status: 403 });
dailyReserved = true;
const credits = await checkAndConsumeQuota({ userId: user.id, plan, metric: "api_credits_monthly", amount: parsed.data.maxResults });
if (!credits.allowed) {
await refundQuota({ userId: user.id, metric: "ads_search_daily" });
return NextResponse.json({ error: "API_CREDITS_EXCEEDED", usage: credits }, { status: 403 });
}
creditsReserved = true;
}
const job = await prisma.ingestJob.create({
data: {
source: "apify",
type: "meta-ads-library",
status: "RUNNING",
startedAt: new Date(),
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, maxResults: parsed.data.maxResults }
}
});
try {
const records = await runApifyActor({
searchTerms: [parsed.data.searchTerm],
country: "ALL",
adActiveStatus: "ACTIVE",
mediaType: "ALL",
maxResults: parsed.data.maxResults,
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
scrapeAdDetails: true,
includeAboutPage: false
});
const result = await importApifyAds(records);
if (creditsReserved && records.length < parsed.data.maxResults) {
await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - records.length });
}
await prisma.ingestJob.update({
where: { id: job.id },
data: {
status: "COMPLETED",
finishedAt: new Date(),
recordsImported: result.imported,
recordsFailed: result.failed,
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, maxResults: parsed.data.maxResults, received: records.length }
}
});
return NextResponse.json({ ok: true, ...result, received: records.length });
} catch (error) {
if (dailyReserved) await refundQuota({ userId: user.id, metric: "ads_search_daily" });
if (creditsReserved) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults });
const code = error instanceof Error && /^APIFY_[A-Z0-9_]+$/.test(error.message) ? error.message : "APIFY_INGEST_FAILED";
await prisma.ingestJob.update({ where: { id: job.id }, data: { status: "FAILED", finishedAt: new Date(), errorMessage: code } });
return NextResponse.json({ error: code }, { status: code === "APIFY_NOT_CONFIGURED" ? 503 : 502 });
}
}
+4 -2
View File
@@ -5,10 +5,12 @@ import { prisma } from "@/lib/db";
import { createSession } from "@/lib/auth/session";
import { hizSiniriAsimi } from "@/lib/rate-limit";
const schema = z.object({ email: z.string().email(), password: z.string().min(1) });
const schema = z.object({ email: z.string().email(), password: z.string().min(1).max(72) });
export async function POST(req: Request) {
const body = schema.parse(await req.json());
const parsed = schema.safeParse(await req.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const body = parsed.data;
// Sayaç e-posta bazlı da tutulur: tek IP'den farklı hesaplara saldırı da yavaşlar.
const sinir = hizSiniriAsimi(req, "giris", body.email.trim().toLowerCase());
if (sinir) return sinir;
+4 -2
View File
@@ -9,7 +9,7 @@ import { hizSiniriAsimi } from "@/lib/rate-limit";
const schema = z.object({
email: z.string().email(),
password: z.string().min(6),
password: z.string().min(10).max(72),
name: z.string().optional()
});
@@ -18,7 +18,9 @@ export async function POST(req: Request) {
const sinir = hizSiniriAsimi(req, "kayit");
if (sinir) return sinir;
const body = schema.parse(await req.json());
const parsed = schema.safeParse(await req.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const body = parsed.data;
const email = body.email.trim().toLowerCase();
const isAdmin = isConfiguredAdminEmail(email);
const plan = await prisma.plan.findUnique({ where: { code: isAdmin ? "PREMIUM" : "FREE" } });
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { currentUser } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db";
import { stripeClient, stripePriceFor } from "@/lib/stripe";
import { hizSiniriAsimi } from "@/lib/rate-limit";
const schema = z.object({ planCode: z.enum(["BASIC", "STANDARD", "PREMIUM"]), interval: z.enum(["monthly", "yearly"]).default("monthly") });
export async function POST(request: Request) {
const limited = hizSiniriAsimi(request, "giris");
if (limited) return limited;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const plan = await prisma.plan.findUnique({ where: { code: parsed.data.planCode } });
if (!plan || !plan.isActive) return NextResponse.json({ error: "PLAN_NOT_FOUND" }, { status: 404 });
try {
const stripe = stripeClient();
const appUrl = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, "");
if (!appUrl || !appUrl.startsWith("https://")) throw new Error("APP_URL_INVALID");
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: stripePriceFor(plan.code, parsed.data.interval), quantity: 1 }],
customer: user.subscription?.providerCustomerId || undefined,
customer_email: user.subscription?.providerCustomerId ? undefined : user.email,
client_reference_id: user.id,
success_url: `${appUrl}/dashboard/account?checkout=success`,
cancel_url: `${appUrl}/pricing?checkout=canceled`,
allow_promotion_codes: true,
metadata: { userId: user.id, planCode: plan.code, billingInterval: parsed.data.interval },
subscription_data: { metadata: { userId: user.id, planCode: plan.code, billingInterval: parsed.data.interval } }
});
await prisma.payment.upsert({
where: { externalId: session.id },
create: { userId: user.id, amountCents: parsed.data.interval === "yearly" ? (plan.yearlyPriceEur || plan.monthlyPriceEur * 12) * 100 : plan.monthlyPriceEur * 100, currency: "EUR", status: "PENDING", provider: "STRIPE", externalId: session.id, description: `${plan.name} ${parsed.data.interval}` },
update: {}
});
return NextResponse.json({ url: session.url });
} catch (error) {
const code = error instanceof Error && /^(STRIPE_[A-Z0-9_]+|APP_URL_INVALID)$/.test(error.message) ? error.message : "CHECKOUT_FAILED";
return NextResponse.json({ error: code }, { status: code.includes("NOT_CONFIGURED") ? 503 : 502 });
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { PaymentStatus, SubscriptionStatus } from "@prisma/client";
import { prisma } from "@/lib/db";
import { stripeClient } from "@/lib/stripe";
function status(value: Stripe.Subscription.Status): SubscriptionStatus {
if (value === "active") return SubscriptionStatus.ACTIVE;
if (value === "trialing") return SubscriptionStatus.TRIALING;
if (value === "past_due" || value === "unpaid" || value === "incomplete") return SubscriptionStatus.PAST_DUE;
if (value === "canceled" || value === "incomplete_expired") return SubscriptionStatus.CANCELED;
return SubscriptionStatus.EXPIRED;
}
async function syncSubscription(subscription: Stripe.Subscription) {
const userId = subscription.metadata.userId;
const planCode = subscription.metadata.planCode as "BASIC" | "STANDARD" | "PREMIUM" | undefined;
if (!userId || !planCode) return;
const plan = await prisma.plan.findUnique({ where: { code: planCode } });
if (!plan) return;
const item = subscription.items.data[0];
const start = item?.current_period_start ? new Date(item.current_period_start * 1000) : null;
const end = item?.current_period_end ? new Date(item.current_period_end * 1000) : null;
await prisma.subscription.upsert({
where: { userId },
create: { userId, planId: plan.id, status: status(subscription.status), billingInterval: subscription.metadata.billingInterval || "monthly", currentPeriodStart: start, currentPeriodEnd: end, cancelAtPeriodEnd: subscription.cancel_at_period_end, provider: "STRIPE", providerCustomerId: String(subscription.customer), providerSubscriptionId: subscription.id },
update: { planId: plan.id, status: status(subscription.status), billingInterval: subscription.metadata.billingInterval || "monthly", currentPeriodStart: start, currentPeriodEnd: end, cancelAtPeriodEnd: subscription.cancel_at_period_end, provider: "STRIPE", providerCustomerId: String(subscription.customer), providerSubscriptionId: subscription.id }
});
}
export async function POST(request: Request) {
const secret = process.env.STRIPE_WEBHOOK_SECRET?.trim();
const signature = request.headers.get("stripe-signature");
if (!secret || !signature) return NextResponse.json({ error: "WEBHOOK_NOT_CONFIGURED" }, { status: 503 });
const raw = await request.text();
let event: Stripe.Event;
try { event = stripeClient().webhooks.constructEvent(raw, signature, secret, 300); }
catch { return NextResponse.json({ error: "INVALID_SIGNATURE" }, { status: 400 }); }
if (event.type === "checkout.session.completed") {
const session = event.data.object;
const userId = session.metadata?.userId || session.client_reference_id;
if (userId) {
await prisma.payment.upsert({
where: { externalId: session.id },
create: { userId, amountCents: session.amount_total || 0, currency: (session.currency || "eur").toUpperCase(), status: PaymentStatus.PAID, provider: "STRIPE", externalId: session.id, description: `Stripe ${session.metadata?.planCode || "subscription"}`, paidAt: new Date() },
update: { status: PaymentStatus.PAID, amountCents: session.amount_total || 0, currency: (session.currency || "eur").toUpperCase(), paidAt: new Date() }
});
if (typeof session.subscription === "string") await syncSubscription(await stripeClient().subscriptions.retrieve(session.subscription));
}
}
if (event.type === "customer.subscription.updated" || event.type === "customer.subscription.deleted" || event.type === "customer.subscription.created") await syncSubscription(event.data.object);
return NextResponse.json({ received: true });
}
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
import { getApiAdmin } from "@/lib/admin-api";
import { captureTrendSnapshot, createBrandAlerts } from "@/lib/trend-snapshots";
export async function POST(request: Request) {
const configured = process.env.CRON_SECRET?.trim();
const bearer = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
const authorizedBySecret = Boolean(configured && bearer && bearer.length === configured.length && timingSafeEqual(Buffer.from(bearer), Buffer.from(configured)));
if (!authorizedBySecret && !(await getApiAdmin())) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
const snapshot = await captureTrendSnapshot();
const alertsCreated = await createBrandAlerts();
return NextResponse.json({ ok: true, snapshotDate: snapshot.date, alertsCreated });
}
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
const started = Date.now();
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({ status: "ok", database: "ok", latencyMs: Date.now() - started }, { headers: { "cache-control": "no-store" } });
} catch {
return NextResponse.json({ status: "degraded", database: "unavailable" }, { status: 503, headers: { "cache-control": "no-store" } });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { currentUser } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db";
import { analyzeCreatives } from "@/lib/openai-analysis";
import { hizSiniriAsimi } from "@/lib/rate-limit";
const schema = z.object({ query: z.string().trim().max(100).optional() });
export async function POST(request: Request) {
const limited = hizSiniriAsimi(request, "arama");
if (limited) return limited;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const parsed = schema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const q = parsed.data.query;
const where: Prisma.AdWhereInput = q ? { OR: [{ headline: { contains: q, mode: "insensitive" } }, { primaryText: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }] } : {};
const ads = await prisma.ad.findMany({ where, include: { brandPage: true }, orderBy: [{ daysRunning: "desc" }, { updatedAt: "desc" }], take: 20 });
if (!ads.length) return NextResponse.json({ error: "NO_ADS_TO_ANALYZE" }, { status: 404 });
try {
const result = await analyzeCreatives(q, ads.map((ad) => ({ headline: ad.headline, primaryText: ad.primaryText, mediaType: ad.mediaType, daysRunning: ad.daysRunning, status: ad.status, brand: ad.brandPage?.name || null })));
const saved = await prisma.aiAnalysis.create({ data: { userId: user.id, query: q, provider: "openai", model: result.model, summary: result.summary, insights: result.insights } });
return NextResponse.json({ data: saved });
} catch (error) {
const code = error instanceof Error && /^(OPENAI_[A-Z0-9_]+)$/.test(error.message) ? error.message : "AI_ANALYSIS_FAILED";
return NextResponse.json({ error: code }, { status: code === "OPENAI_NOT_CONFIGURED" ? 503 : 502 });
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { currentUser } from "@/lib/auth/current-user";
import { importTikTokProducts, runTikTokShopActor } from "@/lib/apify-tiktok";
import { planFromUser } from "@/lib/plans";
import { checkAndConsumeQuota, refundQuota } from "@/lib/quota";
import { hizSiniriAsimi } from "@/lib/rate-limit";
const schema = z.object({ query: z.string().trim().min(2).max(100), region: z.enum(["US", "GB", "DE", "FR", "TR"]).default("US"), maxResults: z.coerce.number().int().refine((v) => [10, 25, 50].includes(v)) });
export async function POST(request: Request) {
const limited = hizSiniriAsimi(request, "arama");
if (limited) return limited;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const plan = planFromUser(user as never);
const isAdmin = user.role === "ADMIN";
if (!isAdmin && (!plan || plan.code === "FREE")) return NextResponse.json({ error: "UPGRADE_REQUIRED" }, { status: 403 });
const max = isAdmin || plan?.code === "PREMIUM" ? 50 : plan?.code === "STANDARD" ? 25 : 10;
if (parsed.data.maxResults > max) return NextResponse.json({ error: "PLAN_LIMIT_EXCEEDED", maxResults: max }, { status: 403 });
let daily = false;
let credits = false;
if (!isAdmin) {
const dailyQuota = await checkAndConsumeQuota({ userId: user.id, plan, metric: "tiktok_search_daily" });
if (!dailyQuota.allowed) return NextResponse.json({ error: "DAILY_QUOTA_EXCEEDED", usage: dailyQuota }, { status: 403 });
daily = true;
const apiQuota = await checkAndConsumeQuota({ userId: user.id, plan, metric: "api_credits_monthly", amount: parsed.data.maxResults });
if (!apiQuota.allowed) { await refundQuota({ userId: user.id, metric: "tiktok_search_daily" }); return NextResponse.json({ error: "API_CREDITS_EXCEEDED", usage: apiQuota }, { status: 403 }); }
credits = true;
}
try {
const rows = await runTikTokShopActor(parsed.data);
const result = await importTikTokProducts(rows, parsed.data.region);
if (credits && rows.length < parsed.data.maxResults) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - rows.length });
return NextResponse.json({ ok: true, ...result, received: rows.length });
} catch (error) {
if (daily) await refundQuota({ userId: user.id, metric: "tiktok_search_daily" });
if (credits) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults });
const code = error instanceof Error && /^APIFY_[A-Z0-9_]+$/.test(error.message) ? error.message : "APIFY_TIKTOK_FAILED";
return NextResponse.json({ error: code }, { status: code === "APIFY_NOT_CONFIGURED" ? 503 : 502 });
}
}
+10 -2
View File
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
import { getFeatureLimit, planFromUser } from "@/lib/plans";
import { z } from "zod";
import { hizSiniriAsimi } from "@/lib/rate-limit";
const schema = z.object({ domain: z.string().trim().min(3).max(253) });
export async function GET() {
const user = await currentUser();
@@ -11,14 +15,18 @@ export async function GET() {
}
export async function POST(req: Request) {
const rateLimit = hizSiniriAsimi(req, "arama");
if (rateLimit) return rateLimit;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const parsed = schema.safeParse(await req.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const plan = planFromUser(user as any);
const limit = getFeatureLimit(plan as any, "tracked_stores");
const count = await prisma.trackedStore.count({ where: { userId: user.id } });
if (limit !== null && count >= limit) return NextResponse.json({ error: "TRACKED_STORE_LIMIT_EXCEEDED", upgradeRequired: true }, { status: 403 });
const body = await req.json();
const domain = String(body.domain || "").replace(/^https?:\/\//, "").replace(/\/$/, "").toLowerCase();
let domain = parsed.data.domain.replace(/^https?:\/\//i, "").split("/")[0]!.toLowerCase().replace(/^www\./, "");
try { domain = new URL(`https://${domain}`).hostname; } catch { return NextResponse.json({ error: "INVALID_DOMAIN" }, { status: 400 }); }
const store = await prisma.store.findUnique({ where: { domain } });
if (!store) return NextResponse.json({ error: "STORE_NOT_FOUND" }, { status: 404 });
const row = await prisma.trackedStore.upsert({ where: { userId_storeId: { userId: user.id, storeId: store.id } }, update: {}, create: { userId: user.id, storeId: store.id } });