Initial WinningHunter MVP

This commit is contained in:
2026-07-07 20:40:43 +03:00
commit 8dd43c57ca
57 changed files with 5295 additions and 0 deletions
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth/current-user";
export async function POST() {
await requireAdmin();
return NextResponse.json({ ok: true, message: "Demo seed komut satırından çalışır: npm run db:seed" });
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
import { maskAdForPlan } from "@/lib/locked-response";
import { planFromUser } from "@/lib/plans";
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const ad = await prisma.ad.findUnique({
where: { id: params.id },
include: { brandPage: true, creatives: true, aiTags: true, countryStats: true }
});
if (!ad) return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
const plan = planFromUser(user as any);
return NextResponse.json({ data: maskAdForPlan(ad as any, plan?.code) });
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function POST(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const ad = await prisma.ad.findUnique({ where: { id: params.id } });
if (!ad) return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
await prisma.savedAd.upsert({
where: { userId_adId: { userId: user.id, adId: params.id } },
update: {},
create: { userId: user.id, adId: params.id }
});
return NextResponse.json({ ok: true });
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
await prisma.savedAd.deleteMany({ where: { userId: user.id, adId: params.id } });
return NextResponse.json({ ok: true });
}
+87
View File
@@ -0,0 +1,87 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
import { checkAndConsumeQuota } from "@/lib/quota";
import { maskAdForPlan } from "@/lib/locked-response";
import { planFromUser } from "@/lib/plans";
export async function GET(req: Request) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const plan = planFromUser(user as any);
const quota = await checkAndConsumeQuota({ userId: user.id, plan, metric: "ads_search_daily" });
if (!quota.allowed) return NextResponse.json({ error: "QUOTA_EXCEEDED", usage: quota, upgradeRequired: true }, { status: 403 });
const url = new URL(req.url);
const q = url.searchParams.get("q")?.trim();
const page = Math.max(1, Number(url.searchParams.get("page") || 1));
const limit = Math.min(60, Math.max(1, Number(url.searchParams.get("limit") || 24)));
const status = url.searchParams.get("status");
const niche = url.searchParams.get("niche");
const mediaType = url.searchParams.get("mediaType");
const country = url.searchParams.get("country");
const sort = url.searchParams.get("sort") || "lastSeen_desc";
const where: Prisma.AdWhereInput = {};
if (status) where.status = status as any;
if (niche) where.niche = { contains: niche, mode: "insensitive" };
if (mediaType) where.mediaType = mediaType as any;
if (country) where.countries = { has: country };
if (q) {
where.OR = [
{ primaryText: { contains: q, mode: "insensitive" } },
{ headline: { contains: q, mode: "insensitive" } },
{ brandPage: { name: { contains: q, mode: "insensitive" } } }
];
}
const orderBy: Prisma.AdOrderByWithRelationInput =
sort === "rank_asc" ? { rankPercentile: "asc" } : sort === "days_desc" ? { daysRunning: "desc" } : { lastSeenAt: "desc" };
const [total, rows, saved] = await Promise.all([
prisma.ad.count({ where }),
prisma.ad.findMany({
where,
orderBy,
skip: (page - 1) * limit,
take: limit,
include: { brandPage: true, creatives: { take: 1 } }
}),
prisma.savedAd.findMany({ where: { userId: user.id }, select: { adId: true } })
]);
const savedIds = new Set(saved.map((s) => s.adId));
const data = rows.map((ad) =>
maskAdForPlan(
{
id: ad.id,
source: ad.source,
status: ad.status,
mediaType: ad.mediaType,
adScore: ad.adScore,
primaryText: ad.primaryText,
headline: ad.headline,
ctaText: ad.ctaText,
landingUrl: ad.landingUrl,
productUrl: ad.productUrl,
language: ad.language,
countries: ad.countries,
niche: ad.niche,
daysRunning: ad.daysRunning,
estimatedReachMin: ad.estimatedReachMin,
estimatedReachMax: ad.estimatedReachMax,
estimatedSpendMin: ad.estimatedSpendMin,
estimatedSpendMax: ad.estimatedSpendMax,
rankPercentile: ad.rankPercentile,
brand: ad.brandPage && { id: ad.brandPage.id, name: ad.brandPage.name, logoUrl: ad.brandPage.logoUrl },
thumbnailUrl: ad.creatives[0]?.thumbnailUrl || ad.creatives[0]?.url,
isSaved: savedIds.has(ad.id)
},
plan?.code
)
);
return NextResponse.json({ data, pagination: { page, limit, total }, usage: quota });
}
+18
View File
@@ -0,0 +1,18 @@
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { createSession } from "@/lib/auth/session";
const schema = z.object({ email: z.string().email(), password: z.string().min(1) });
export async function POST(req: Request) {
const body = schema.parse(await req.json());
const user = await prisma.user.findUnique({ where: { email: body.email } });
if (!user?.passwordHash) return NextResponse.json({ error: "INVALID_CREDENTIALS" }, { status: 401 });
const ok = await bcrypt.compare(body.password, user.passwordHash);
if (!ok) return NextResponse.json({ error: "INVALID_CREDENTIALS" }, { status: 401 });
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
await createSession(user.id);
return NextResponse.json({ ok: true });
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { destroySession } from "@/lib/auth/session";
export async function POST() {
await destroySession();
return NextResponse.json({ ok: true });
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { currentUser } from "@/lib/auth/current-user";
export async function GET() {
const user = await currentUser();
if (!user) return NextResponse.json({ user: null });
return NextResponse.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
plan: user.subscription?.plan.code
}
});
}
+31
View File
@@ -0,0 +1,31 @@
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { createSession } from "@/lib/auth/session";
const schema = z.object({
email: z.string().email(),
password: z.string().min(6),
name: z.string().optional()
});
export async function POST(req: Request) {
const body = schema.parse(await req.json());
const free = await prisma.plan.findUnique({ where: { code: "FREE" } });
if (!free) return NextResponse.json({ error: "PLAN_NOT_SEEDED" }, { status: 500 });
const exists = await prisma.user.findUnique({ where: { email: body.email } });
if (exists) return NextResponse.json({ error: "EMAIL_EXISTS" }, { status: 409 });
const user = await prisma.user.create({
data: {
email: body.email,
name: body.name || body.email.split("@")[0],
passwordHash: await bcrypt.hash(body.password, 10),
subscription: { create: { planId: free.id, status: "ACTIVE" } }
}
});
await createSession(user.id);
return NextResponse.json({ ok: true });
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
const plans = await prisma.plan.findMany({ where: { isActive: true }, orderBy: { sortOrder: "asc" } });
return NextResponse.json({ data: plans });
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const body = await req.json();
const saved = await prisma.savedAd.updateMany({ where: { id: params.id, userId: user.id }, data: { folderId: body.folderId || null, note: body.note } });
return NextResponse.json({ data: saved });
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
await prisma.savedAd.deleteMany({ where: { id: params.id, userId: user.id } });
return NextResponse.json({ ok: true });
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function GET(req: Request) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const folderId = new URL(req.url).searchParams.get("folderId");
const data = await prisma.savedAd.findMany({
where: { userId: user.id, ...(folderId ? { folderId } : {}) },
orderBy: { createdAt: "desc" },
include: { folder: true, ad: { include: { brandPage: true, creatives: { take: 1 } } } }
});
return NextResponse.json({ data });
}
export async function POST(req: Request) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const body = await req.json();
const saved = await prisma.savedAd.upsert({
where: { userId_adId: { userId: user.id, adId: body.adId } },
update: { folderId: body.folderId || null, note: body.note },
create: { userId: user.id, adId: body.adId, folderId: body.folderId || null, note: body.note }
});
return NextResponse.json({ data: saved });
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const body = await req.json();
await prisma.savedFolder.updateMany({ where: { id: params.id, userId: user.id }, data: { name: body.name, color: body.color } });
return NextResponse.json({ ok: true });
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
await prisma.savedFolder.deleteMany({ where: { id: params.id, userId: user.id } });
return NextResponse.json({ ok: true });
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function GET() {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
return NextResponse.json({ data: await prisma.savedFolder.findMany({ where: { userId: user.id }, orderBy: { sortOrder: "asc" } }) });
}
export async function POST(req: Request) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const body = await req.json();
const folder = await prisma.savedFolder.create({ data: { userId: user.id, name: body.name, color: body.color } });
return NextResponse.json({ data: folder });
}
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const store = await prisma.store.findUnique({ where: { id: params.id }, include: { products: true, snapshots: { orderBy: { date: "asc" } }, pixels: true, apps: true, brandPages: { include: { ads: { include: { creatives: { take: 1 } }, take: 12 } } } } });
if (!store) return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
return NextResponse.json({ data: store });
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const store = await prisma.store.findUnique({ where: { id: params.id } });
if (!store) return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
const data = await prisma.store.findMany({ where: { id: { not: params.id }, niche: store.niche }, take: 20, orderBy: { monthlyVisits: "desc" } });
return NextResponse.json({ data });
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function GET(req: Request) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const url = new URL(req.url);
const q = url.searchParams.get("q")?.trim();
const niche = url.searchParams.get("niche");
const country = url.searchParams.get("country");
const sort = url.searchParams.get("sort") || "revenue_desc";
const page = Math.max(1, Number(url.searchParams.get("page") || 1));
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit") || 25)));
const where: Prisma.StoreWhereInput = {};
if (niche) where.niche = { contains: niche, mode: "insensitive" };
if (country) where.country = country;
if (q) where.OR = [{ name: { contains: q, mode: "insensitive" } }, { domain: { contains: q, mode: "insensitive" } }, { niche: { contains: q, mode: "insensitive" } }];
const orderBy: Prisma.StoreOrderByWithRelationInput = sort === "traffic_desc" ? { monthlyVisits: "desc" } : sort === "growth_desc" ? { monthlyVisitGrowth: "desc" } : { estRevenue30dMax: "desc" };
const [total, rows] = await Promise.all([
prisma.store.count({ where }),
prisma.store.findMany({ where, orderBy, skip: (page - 1) * limit, take: limit, include: { products: { where: { isBestSeller: true }, take: 3 }, brandPages: { include: { _count: { select: { ads: true } } }, take: 1 } } })
]);
return NextResponse.json({ data: rows, pagination: { page, limit, total } });
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
await prisma.trackedStore.deleteMany({ where: { id: params.id, userId: user.id } });
return NextResponse.json({ ok: true });
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
import { getFeatureLimit, planFromUser } from "@/lib/plans";
export async function GET() {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const data = await prisma.trackedStore.findMany({ where: { userId: user.id }, include: { store: { include: { products: { where: { isBestSeller: true }, take: 3 } } } }, orderBy: { createdAt: "desc" } });
return NextResponse.json({ data });
}
export async function POST(req: Request) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
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();
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 } });
return NextResponse.json({ data: row });
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { currentUser } from "@/lib/auth/current-user";
import { usageSummary } from "@/lib/quota";
export async function GET() {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
return NextResponse.json({ data: await usageSummary(user.id) });
}