diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 30330b6..8860963 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -33,6 +33,23 @@ enum UsagePeriod {
LIFETIME
}
+enum CreditTransactionType {
+ ADMIN_CREDIT
+ ADMIN_DEBIT
+ PAYMENT
+ REFUND
+ USAGE
+ ADJUSTMENT
+}
+
+enum PaymentStatus {
+ PENDING
+ PAID
+ FAILED
+ REFUNDED
+ CANCELED
+}
+
enum AdSource {
META
ADSPY
@@ -83,29 +100,35 @@ enum IngestStatus {
}
model User {
- id String @id @default(cuid())
- email String @unique
- passwordHash String?
- googleId String? @unique
- name String?
- avatarUrl String?
- role UserRole @default(USER)
- locale String @default("tr")
- currency String @default("EUR")
- theme String @default("system")
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- lastLoginAt DateTime?
+ id String @id @default(cuid())
+ email String @unique
+ passwordHash String?
+ googleId String? @unique
+ name String?
+ avatarUrl String?
+ role UserRole @default(USER)
+ locale String @default("tr")
+ currency String @default("EUR")
+ theme String @default("system")
+ creditBalance Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ lastLoginAt DateTime?
- sessions AuthSession[]
- subscription Subscription?
- usageCounters UsageCounter[]
- savedFolders SavedFolder[]
- savedAds SavedAd[]
- filterPresets FilterPreset[]
- trackedStores TrackedStore[]
- exportJobs ExportJob[]
- apiKeys ApiKey[]
+ sessions AuthSession[]
+ subscription Subscription?
+ usageCounters UsageCounter[]
+ savedFolders SavedFolder[]
+ savedAds SavedAd[]
+ filterPresets FilterPreset[]
+ trackedStores TrackedStore[]
+ exportJobs ExportJob[]
+ apiKeys ApiKey[]
+ creditTransactions CreditTransaction[] @relation("CreditOwner")
+ creditActions CreditTransaction[] @relation("CreditActor")
+ payments Payment[] @relation("PaymentOwner")
+ manualPayments Payment[] @relation("PaymentActor")
+ adminAuditLogs AdminAuditLog[] @relation("AuditActor")
@@index([email])
}
@@ -241,7 +264,7 @@ model Ad {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- brandPage BrandPage? @relation(fields: [brandPageId], references: [id], onDelete: SetNull)
+ brandPage BrandPage? @relation(fields: [brandPageId], references: [id], onDelete: SetNull)
creatives AdCreative[]
metricsDaily AdMetricDaily[]
countryStats AdCountryStat[]
@@ -395,7 +418,7 @@ model StoreSnapshot {
}
model StoreProduct {
- id String @id @default(cuid())
+ id String @id @default(cuid())
storeId String
externalProductId String?
title String
@@ -406,11 +429,11 @@ model StoreProduct {
compareAtPrice Float?
currency String?
variantCount Int?
- isBestSeller Boolean @default(false)
+ isBestSeller Boolean @default(false)
firstSeenAt DateTime?
raw Json?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
@@ -420,7 +443,7 @@ model StoreProduct {
}
model StorePixel {
- id String @id @default(cuid())
+ id String @id @default(cuid())
storeId String
type String
value String?
@@ -562,3 +585,66 @@ model ApiKey {
@@index([userId])
@@index([prefix])
}
+
+model CreditTransaction {
+ id String @id @default(cuid())
+ userId String
+ actorId String?
+ type CreditTransactionType
+ amount Int
+ balanceAfter Int
+ reason String
+ metadata Json?
+ createdAt DateTime @default(now())
+
+ user User @relation("CreditOwner", fields: [userId], references: [id], onDelete: Cascade)
+ actor User? @relation("CreditActor", fields: [actorId], references: [id], onDelete: SetNull)
+
+ @@index([userId, createdAt])
+ @@index([actorId, createdAt])
+ @@index([type, createdAt])
+}
+
+model Payment {
+ id String @id @default(cuid())
+ userId String
+ actorId String?
+ amountCents Int
+ currency String @default("EUR")
+ status PaymentStatus @default(PAID)
+ provider String @default("MANUAL")
+ externalId String? @unique
+ description String?
+ creditGranted Int @default(0)
+ paidAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ user User @relation("PaymentOwner", fields: [userId], references: [id], onDelete: Cascade)
+ actor User? @relation("PaymentActor", fields: [actorId], references: [id], onDelete: SetNull)
+
+ @@index([userId, createdAt])
+ @@index([status, createdAt])
+ @@index([provider, createdAt])
+}
+
+model AdminAuditLog {
+ id String @id @default(cuid())
+ actorId String?
+ actorEmail String
+ action String
+ targetType String
+ targetId String?
+ summary String
+ details Json?
+ ipAddress String?
+ userAgent String?
+ createdAt DateTime @default(now())
+
+ actor User? @relation("AuditActor", fields: [actorId], references: [id], onDelete: SetNull)
+
+ @@index([actorId, createdAt])
+ @@index([action, createdAt])
+ @@index([targetType, targetId])
+ @@index([createdAt])
+}
diff --git a/src/app/api/admin/ingest/seed-demo/route.ts b/src/app/api/admin/ingest/seed-demo/route.ts
index 46910a1..833c40e 100644
--- a/src/app/api/admin/ingest/seed-demo/route.ts
+++ b/src/app/api/admin/ingest/seed-demo/route.ts
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server";
-import { requireAdmin } from "@/lib/auth/current-user";
+import { getApiAdmin } from "@/lib/admin-api";
export async function POST() {
- await requireAdmin();
+ 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" });
}
diff --git a/src/app/api/admin/payments/[id]/status/route.ts b/src/app/api/admin/payments/[id]/status/route.ts
new file mode 100644
index 0000000..17375a8
--- /dev/null
+++ b/src/app/api/admin/payments/[id]/status/route.ts
@@ -0,0 +1,29 @@
+import { PaymentStatus } from "@prisma/client";
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { getApiAdmin, requestAuditContext } from "@/lib/admin-api";
+import { prisma } from "@/lib/db";
+
+const schema = z.object({ status: z.nativeEnum(PaymentStatus) });
+
+export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
+ const actor = await getApiAdmin();
+ if (!actor) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
+ const parsed = schema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
+ const { id } = await params;
+ const payment = await prisma.payment.findUnique({ where: { id }, include: { user: { select: { email: true } } } });
+ if (!payment) return NextResponse.json({ error: "PAYMENT_NOT_FOUND" }, { status: 404 });
+ const auditContext = requestAuditContext(request);
+ await prisma.$transaction([
+ prisma.payment.update({ where: { id }, data: { status: parsed.data.status, paidAt: parsed.data.status === PaymentStatus.PAID ? payment.paidAt || new Date() : payment.paidAt } }),
+ prisma.adminAuditLog.create({
+ data: {
+ actorId: actor.id, actorEmail: actor.email, action: "PAYMENT_STATUS_CHANGED", targetType: "Payment", targetId: id,
+ summary: `${payment.user.email} ödeme durumu ${payment.status} → ${parsed.data.status} olarak değiştirildi.`,
+ details: { before: payment.status, after: parsed.data.status }, ...auditContext
+ }
+ })
+ ]);
+ return NextResponse.json({ ok: true, status: parsed.data.status });
+}
diff --git a/src/app/api/admin/payments/route.ts b/src/app/api/admin/payments/route.ts
new file mode 100644
index 0000000..bdbd369
--- /dev/null
+++ b/src/app/api/admin/payments/route.ts
@@ -0,0 +1,47 @@
+import { CreditTransactionType, PaymentStatus, Prisma } from "@prisma/client";
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { getApiAdmin, requestAuditContext } from "@/lib/admin-api";
+import { prisma } from "@/lib/db";
+
+const schema = z.object({
+ userId: z.string().min(1), amountCents: z.coerce.number().int().positive().max(100_000_000),
+ currency: z.string().trim().length(3).transform((value) => value.toUpperCase()),
+ description: z.string().trim().max(240).optional().default("Manuel ödeme"),
+ externalId: z.string().trim().max(120).optional().nullable(), creditGranted: z.coerce.number().int().min(0).max(1_000_000).default(0)
+});
+
+export async function POST(request: Request) {
+ const actor = await getApiAdmin();
+ if (!actor) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
+ const parsed = schema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
+ const auditContext = requestAuditContext(request);
+ try {
+ const payment = await prisma.$transaction(async (tx) => {
+ const target = await tx.user.findUnique({ where: { id: parsed.data.userId }, select: { email: true, creditBalance: true } });
+ if (!target) throw new Error("USER_NOT_FOUND");
+ const created = await tx.payment.create({
+ data: { userId: parsed.data.userId, actorId: actor.id, amountCents: parsed.data.amountCents, currency: parsed.data.currency, status: PaymentStatus.PAID, provider: "MANUAL", externalId: parsed.data.externalId || null, description: parsed.data.description, creditGranted: parsed.data.creditGranted, paidAt: new Date() }
+ });
+ let balanceAfter = target.creditBalance;
+ if (parsed.data.creditGranted > 0) {
+ const updated = await tx.user.update({ where: { id: parsed.data.userId }, data: { creditBalance: { increment: parsed.data.creditGranted } }, select: { creditBalance: true } });
+ balanceAfter = updated.creditBalance;
+ await tx.creditTransaction.create({ data: { userId: parsed.data.userId, actorId: actor.id, type: CreditTransactionType.PAYMENT, amount: parsed.data.creditGranted, balanceAfter, reason: `Manuel ödeme ${created.id}`, metadata: { paymentId: created.id } } });
+ }
+ await tx.adminAuditLog.create({
+ data: {
+ actorId: actor.id, actorEmail: actor.email, action: "PAYMENT_RECORDED", targetType: "Payment", targetId: created.id,
+ summary: `${target.email} için ${(parsed.data.amountCents / 100).toFixed(2)} ${parsed.data.currency} manuel ödeme kaydedildi.`,
+ details: { amountCents: parsed.data.amountCents, currency: parsed.data.currency, creditGranted: parsed.data.creditGranted, balanceAfter }, ...auditContext
+ }
+ });
+ return created;
+ }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
+ return NextResponse.json({ ok: true, payment });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "UNKNOWN";
+ return NextResponse.json({ error: message }, { status: message === "USER_NOT_FOUND" ? 404 : 500 });
+ }
+}
diff --git a/src/app/api/admin/users/[id]/credits/route.ts b/src/app/api/admin/users/[id]/credits/route.ts
new file mode 100644
index 0000000..f473017
--- /dev/null
+++ b/src/app/api/admin/users/[id]/credits/route.ts
@@ -0,0 +1,60 @@
+import { CreditTransactionType, Prisma } from "@prisma/client";
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { getApiAdmin, requestAuditContext } from "@/lib/admin-api";
+import { prisma } from "@/lib/db";
+
+const schema = z.object({
+ operation: z.enum(["add", "remove"]),
+ amount: z.coerce.number().int().positive().max(1_000_000),
+ reason: z.string().trim().min(3).max(240)
+});
+
+export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
+ const actor = await getApiAdmin();
+ if (!actor) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
+ const parsed = schema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
+
+ const { id } = await params;
+ const { operation, amount, reason } = parsed.data;
+ const signedAmount = operation === "add" ? amount : -amount;
+ const auditContext = requestAuditContext(request);
+
+ try {
+ const result = await prisma.$transaction(async (tx) => {
+ const target = await tx.user.findUnique({ where: { id }, select: { id: true, email: true, creditBalance: true } });
+ if (!target) throw new Error("USER_NOT_FOUND");
+ if (target.creditBalance + signedAmount < 0) throw new Error("INSUFFICIENT_CREDIT");
+ const updated = await tx.user.update({ where: { id }, data: { creditBalance: { increment: signedAmount } }, select: { creditBalance: true } });
+ const transaction = await tx.creditTransaction.create({
+ data: {
+ userId: id,
+ actorId: actor.id,
+ type: operation === "add" ? CreditTransactionType.ADMIN_CREDIT : CreditTransactionType.ADMIN_DEBIT,
+ amount: signedAmount,
+ balanceAfter: updated.creditBalance,
+ reason
+ }
+ });
+ await tx.adminAuditLog.create({
+ data: {
+ actorId: actor.id,
+ actorEmail: actor.email,
+ action: operation === "add" ? "CREDIT_ADDED" : "CREDIT_REMOVED",
+ targetType: "User",
+ targetId: id,
+ summary: `${target.email} için ${Math.abs(signedAmount)} kredi ${operation === "add" ? "eklendi" : "çıkarıldı"}.`,
+ details: { amount: signedAmount, balanceAfter: updated.creditBalance, reason },
+ ...auditContext
+ }
+ });
+ return { transaction, balance: updated.creditBalance };
+ }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
+ return NextResponse.json({ ok: true, ...result });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "UNKNOWN";
+ const status = message === "USER_NOT_FOUND" ? 404 : message === "INSUFFICIENT_CREDIT" ? 409 : 500;
+ return NextResponse.json({ error: message }, { status });
+ }
+}
diff --git a/src/app/api/admin/users/[id]/plan/route.ts b/src/app/api/admin/users/[id]/plan/route.ts
new file mode 100644
index 0000000..19fb156
--- /dev/null
+++ b/src/app/api/admin/users/[id]/plan/route.ts
@@ -0,0 +1,33 @@
+import { PlanCode, SubscriptionStatus } from "@prisma/client";
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { getApiAdmin, requestAuditContext } from "@/lib/admin-api";
+import { prisma } from "@/lib/db";
+
+const schema = z.object({ planCode: z.nativeEnum(PlanCode), status: z.nativeEnum(SubscriptionStatus).default(SubscriptionStatus.ACTIVE) });
+
+export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
+ const actor = await getApiAdmin();
+ if (!actor) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
+ const parsed = schema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
+ const { id } = await params;
+ const [target, plan] = await Promise.all([
+ prisma.user.findUnique({ where: { id }, include: { subscription: { include: { plan: true } } } }),
+ prisma.plan.findUnique({ where: { code: parsed.data.planCode } })
+ ]);
+ if (!target) return NextResponse.json({ error: "USER_NOT_FOUND" }, { status: 404 });
+ if (!plan) return NextResponse.json({ error: "PLAN_NOT_FOUND" }, { status: 404 });
+ const auditContext = requestAuditContext(request);
+ await prisma.$transaction([
+ prisma.subscription.upsert({ where: { userId: id }, update: { planId: plan.id, status: parsed.data.status, cancelAtPeriodEnd: false }, create: { userId: id, planId: plan.id, status: parsed.data.status } }),
+ prisma.adminAuditLog.create({
+ data: {
+ actorId: actor.id, actorEmail: actor.email, action: "PLAN_CHANGED", targetType: "User", targetId: id,
+ summary: `${target.email} planı ${target.subscription?.plan.code || "YOK"} → ${plan.code} olarak değiştirildi.`,
+ details: { before: target.subscription?.plan.code || null, after: plan.code, status: parsed.data.status }, ...auditContext
+ }
+ })
+ ]);
+ return NextResponse.json({ ok: true, plan: plan.code, status: parsed.data.status });
+}
diff --git a/src/app/api/admin/users/[id]/role/route.ts b/src/app/api/admin/users/[id]/role/route.ts
new file mode 100644
index 0000000..8ff8677
--- /dev/null
+++ b/src/app/api/admin/users/[id]/role/route.ts
@@ -0,0 +1,33 @@
+import { UserRole } from "@prisma/client";
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { getApiAdmin, requestAuditContext } from "@/lib/admin-api";
+import { isConfiguredAdminEmail } from "@/lib/admin-emails";
+import { prisma } from "@/lib/db";
+
+const schema = z.object({ role: z.nativeEnum(UserRole) });
+
+export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
+ const actor = await getApiAdmin();
+ if (!actor) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
+ const parsed = schema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
+ const { id } = await params;
+ const target = await prisma.user.findUnique({ where: { id }, select: { id: true, email: true, role: true } });
+ if (!target) return NextResponse.json({ error: "USER_NOT_FOUND" }, { status: 404 });
+ if (parsed.data.role === UserRole.USER && (target.id === actor.id || isConfiguredAdminEmail(target.email))) {
+ return NextResponse.json({ error: "PROTECTED_ADMIN" }, { status: 409 });
+ }
+ const auditContext = requestAuditContext(request);
+ await prisma.$transaction([
+ prisma.user.update({ where: { id }, data: { role: parsed.data.role } }),
+ prisma.adminAuditLog.create({
+ data: {
+ actorId: actor.id, actorEmail: actor.email, action: "ROLE_CHANGED", targetType: "User", targetId: id,
+ summary: `${target.email} yetkisi ${target.role} → ${parsed.data.role} olarak değiştirildi.`,
+ details: { before: target.role, after: parsed.data.role }, ...auditContext
+ }
+ })
+ ]);
+ return NextResponse.json({ ok: true, role: parsed.data.role });
+}
diff --git a/src/app/api/auth/google/callback/route.ts b/src/app/api/auth/google/callback/route.ts
index 39c3293..b00f263 100644
--- a/src/app/api/auth/google/callback/route.ts
+++ b/src/app/api/auth/google/callback/route.ts
@@ -67,6 +67,7 @@ export async function GET(req: Request) {
avatarUrl: profile.picture || null,
locale: profile.locale || "tr",
role: isAdmin ? UserRole.ADMIN : UserRole.USER,
+ lastLoginAt: new Date(),
subscription: { create: { planId: plan.id, status: "ACTIVE" } }
}
});
@@ -77,9 +78,19 @@ export async function GET(req: Request) {
googleId: user.googleId || profile.sub,
name: user.name || profile.name || null,
avatarUrl: user.avatarUrl || profile.picture || null,
+ role: isAdmin ? UserRole.ADMIN : user.role,
lastLoginAt: new Date()
}
});
+
+ if (isAdmin) {
+ if (!plan) return redirectToLogin("PLAN_NOT_SEEDED");
+ await prisma.subscription.upsert({
+ where: { userId: user.id },
+ update: { planId: plan.id, status: "ACTIVE", cancelAtPeriodEnd: false },
+ create: { userId: user.id, planId: plan.id, status: "ACTIVE" }
+ });
+ }
}
await createSession(user.id);
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index 03e4e32..35101a1 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -10,7 +10,8 @@ export async function GET() {
email: user.email,
name: user.name,
role: user.role,
- plan: user.subscription?.plan.code
+ plan: user.subscription?.plan.code,
+ creditBalance: user.creditBalance
}
});
}
diff --git a/src/app/dashboard/account/page.tsx b/src/app/dashboard/account/page.tsx
index 7831db4..e0d1fd7 100644
--- a/src/app/dashboard/account/page.tsx
+++ b/src/app/dashboard/account/page.tsx
@@ -3,5 +3,5 @@ import { Card } from "@/components/ui/card";
export default async function AccountPage() {
const user = await requireUser();
- return {user.email} · Plan: {user.subscription?.plan.name}My Account
Reklam, mağaza ve ingest operasyonları.
Admin audit, kredi hareketleri, veri işleri ve oturum kayıtları.
{log.summary}
Audit kaydı yok.
}| Kullanıcı | Tür | Miktar | Bakiye | Neden | Tarih |
|---|---|---|---|---|---|
| {tx.user.email} | {tx.type} | = 0 ? "font-bold text-emerald-600" : "font-bold text-rose-600"}>{tx.amount > 0 ? "+" : ""}{tx.amount} | {tx.balanceAfter} | {tx.reason} | {tx.createdAt.toLocaleString("tr-TR")} |
Kullanıcı, gelir, kredi ve operasyon görünümü.
{log.summary}
Manuel tahsilat, referans ve ödeme durumlarını yönetin.
Ödeme ile verilen kredi atomik olarak bakiyeye eklenir. İade durumunda kredi otomatik düşmez; kullanıcı ekranından gerekçeli işlem yapın.
| Kullanıcı | Tutar | Sağlayıcı | Kredi | Referans | Tarih | Durum |
|---|---|---|---|---|---|---|
| {payment.user.email} Admin: {payment.actor?.email || "Sistem"} | {money(payment.amountCents, payment.currency)} | {payment.provider} | {payment.creditGranted} | {payment.externalId || "—"} | {payment.createdAt.toLocaleString("tr-TR")} |
Yetki, plan ve kredi bakiyelerini tek yerden yönetin.
Eşleşen kullanıcı bulunamadı.
{message}
; +} + +export function CreditAdjustForm({ userId, currentBalance }: { userId: string; currentBalance: number }) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + const [isError, setIsError] = useState(false); + + async function submit(event: FormEvent