122 lines
6.2 KiB
TypeScript
122 lines
6.2 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { z } from "zod";
|
||
import { currentUser } from "@/lib/auth/current-user";
|
||
import { importApifyAds, normalizeApifyAd, relevantApifyRecords, runApifyActor } from "@/lib/apify";
|
||
import { searchMetaAds } from "@/lib/meta-ads";
|
||
import { prisma } from "@/lib/db";
|
||
import { planFromUser } from "@/lib/plans";
|
||
import { checkAndConsumeQuota, refundQuota } from "@/lib/quota";
|
||
import { hizSiniriAsimi } from "@/lib/rate-limit";
|
||
import { AD_COUNTRY_CODES } from "@/lib/countries";
|
||
|
||
const schema = z.object({
|
||
searchTerm: z.string().trim().min(2).max(100),
|
||
country: z.string().trim().toUpperCase().refine((value) => AD_COUNTRY_CODES.has(value)),
|
||
mediaType: z.enum(["ALL", "IMAGE", "VIDEO", "MEME"]),
|
||
matchMode: z.enum(["ALL_WORDS", "EXACT_PHRASE"]).default("ALL_WORDS"),
|
||
status: z.enum(["ACTIVE", "INACTIVE", "ALL"]).default("ACTIVE"),
|
||
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: "meta",
|
||
type: "meta-ads-library",
|
||
status: "RUNNING",
|
||
startedAt: new Date(),
|
||
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, country: parsed.data.country, mediaType: parsed.data.mediaType, matchMode: parsed.data.matchMode, status: parsed.data.status, maxResults: parsed.data.maxResults }
|
||
}
|
||
});
|
||
|
||
try {
|
||
const meta = await searchMetaAds({
|
||
searchTerm: parsed.data.searchTerm,
|
||
country: parsed.data.country,
|
||
adActiveStatus: parsed.data.status,
|
||
matchMode: parsed.data.matchMode,
|
||
maxResults: parsed.data.maxResults
|
||
});
|
||
// Meta Ad Library ham medya dosyası vermez. Doğrulanan reklamların görsel/video
|
||
// dosyaları mevcut medya katmanından tamamlanır; Meta token'ı istemciye çıkmaz.
|
||
const candidateLimit = Math.min(parsed.data.maxResults * 3, 300);
|
||
const mediaRecords = await runApifyActor({
|
||
searchTerms: [parsed.data.searchTerm],
|
||
country: parsed.data.country,
|
||
adActiveStatus: parsed.data.status,
|
||
mediaType: parsed.data.mediaType,
|
||
maxResults: candidateLimit,
|
||
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
|
||
scrapeAdDetails: true,
|
||
includeAboutPage: false
|
||
});
|
||
const officialIds = new Set(meta.relevant.map((record) => String(record.adArchiveID || "")));
|
||
const relevantMediaRecords = relevantApifyRecords(mediaRecords, parsed.data.searchTerm, parsed.data.matchMode, parsed.data.maxResults)
|
||
.filter((record) => {
|
||
const normalized = normalizeApifyAd(record);
|
||
return normalized ? officialIds.has(normalized.externalAdId) : false;
|
||
});
|
||
const officialResult = await importApifyAds(meta.relevant);
|
||
const mediaResult = await importApifyAds(relevantMediaRecords);
|
||
const delivered = Math.min(meta.relevant.length, relevantMediaRecords.length);
|
||
if (creditsReserved && delivered < parsed.data.maxResults) {
|
||
await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - delivered });
|
||
}
|
||
await prisma.ingestJob.update({
|
||
where: { id: job.id },
|
||
data: {
|
||
status: "COMPLETED",
|
||
finishedAt: new Date(),
|
||
recordsImported: mediaResult.imported,
|
||
recordsFailed: officialResult.failed + mediaResult.failed,
|
||
metadata: { userId: user.id, provider: "meta", searchTerm: parsed.data.searchTerm, country: parsed.data.country, mediaType: parsed.data.mediaType, matchMode: parsed.data.matchMode, status: parsed.data.status, maxResults: parsed.data.maxResults, received: meta.received, relevant: meta.relevant.length, mediaEnriched: mediaResult.imported }
|
||
}
|
||
});
|
||
return NextResponse.json({ ok: true, provider: "meta", imported: mediaResult.imported, failed: officialResult.failed + mediaResult.failed, received: meta.received, relevant: meta.relevant.length, mediaEnriched: mediaResult.imported });
|
||
} 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 && /^(META|APIFY)_[A-Z0-9_]+$/.test(error.message) ? error.message : "META_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.endsWith("NOT_CONFIGURED") ? 503 : 502 });
|
||
}
|
||
}
|