From 3af61c82a22d3448fa234492b49dc9f74a80fc73 Mon Sep 17 00:00:00 2001 From: Hikmet Date: Sun, 2 Aug 2026 18:10:56 +0300 Subject: [PATCH] feat: kapsamli admin dashboard ekle --- prisma/schema.prisma | 142 ++++++++++++++---- src/app/api/admin/ingest/seed-demo/route.ts | 5 +- .../api/admin/payments/[id]/status/route.ts | 29 ++++ src/app/api/admin/payments/route.ts | 47 ++++++ src/app/api/admin/users/[id]/credits/route.ts | 60 ++++++++ src/app/api/admin/users/[id]/plan/route.ts | 33 ++++ src/app/api/admin/users/[id]/role/route.ts | 33 ++++ src/app/api/auth/google/callback/route.ts | 11 ++ src/app/api/auth/me/route.ts | 3 +- src/app/dashboard/account/page.tsx | 2 +- src/app/dashboard/admin/data/page.tsx | 3 +- src/app/dashboard/admin/logs/page.tsx | 27 ++++ src/app/dashboard/admin/page.tsx | 52 +++++++ src/app/dashboard/admin/payments/page.tsx | 26 ++++ src/app/dashboard/admin/users/page.tsx | 48 ++++++ src/app/dashboard/layout.tsx | 4 +- src/components/admin/admin-actions.tsx | 126 ++++++++++++++++ src/components/admin/admin-nav.tsx | 21 +++ src/lib/admin-api.ts | 15 ++ 19 files changed, 653 insertions(+), 34 deletions(-) create mode 100644 src/app/api/admin/payments/[id]/status/route.ts create mode 100644 src/app/api/admin/payments/route.ts create mode 100644 src/app/api/admin/users/[id]/credits/route.ts create mode 100644 src/app/api/admin/users/[id]/plan/route.ts create mode 100644 src/app/api/admin/users/[id]/role/route.ts create mode 100644 src/app/dashboard/admin/logs/page.tsx create mode 100644 src/app/dashboard/admin/page.tsx create mode 100644 src/app/dashboard/admin/payments/page.tsx create mode 100644 src/app/dashboard/admin/users/page.tsx create mode 100644 src/components/admin/admin-actions.tsx create mode 100644 src/components/admin/admin-nav.tsx create mode 100644 src/lib/admin-api.ts 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

My Account

{user.email} · Plan: {user.subscription?.plan.name}

; + return

Hesabım

Hesap
{user.email}
Yetki: {user.role}
Plan
{user.subscription?.plan.name || "—"}
{user.subscription?.status || "Abonelik yok"}
Kredi bakiyesi
{user.creditBalance.toLocaleString("tr-TR")}
; } diff --git a/src/app/dashboard/admin/data/page.tsx b/src/app/dashboard/admin/data/page.tsx index 9b5cf97..c6d647a 100644 --- a/src/app/dashboard/admin/data/page.tsx +++ b/src/app/dashboard/admin/data/page.tsx @@ -1,9 +1,10 @@ import { requireAdmin } from "@/lib/auth/current-user"; import { prisma } from "@/lib/db"; import { Card } from "@/components/ui/card"; +import { AdminNav } from "@/components/admin/admin-nav"; export default async function AdminDataPage() { await requireAdmin(); const [ads, stores, users, jobs] = await Promise.all([prisma.ad.count(), prisma.store.count(), prisma.user.count(), prisma.ingestJob.findMany({ orderBy: { createdAt: "desc" }, take: 10 })]); - return

Admin Data

{ads}
Ads
{stores}
Stores
{users}
Users

Ingest Jobs

{jobs.map((j) =>
{j.source} · {j.type} · {j.status} · {j.recordsImported} records
)}
; + return

Veri İşleri

Reklam, mağaza ve ingest operasyonları.

{ads}
Ads
{stores}
Stores
{users}
Users

Ingest Jobs

{jobs.map((j) =>
{j.source} · {j.type} · {j.status} · {j.recordsImported} records
)}
; } diff --git a/src/app/dashboard/admin/logs/page.tsx b/src/app/dashboard/admin/logs/page.tsx new file mode 100644 index 0000000..4b2fdc9 --- /dev/null +++ b/src/app/dashboard/admin/logs/page.tsx @@ -0,0 +1,27 @@ +import { AdminNav } from "@/components/admin/admin-nav"; +import { Card } from "@/components/ui/card"; +import { requireAdmin } from "@/lib/auth/current-user"; +import { prisma } from "@/lib/db"; + +export default async function AdminLogsPage() { + await requireAdmin(); + const [audits, credits, ingestJobs, exportJobs, sessions] = await Promise.all([ + prisma.adminAuditLog.findMany({ orderBy: { createdAt: "desc" }, take: 100 }), + prisma.creditTransaction.findMany({ include: { user: { select: { email: true } }, actor: { select: { email: true } } }, orderBy: { createdAt: "desc" }, take: 100 }), + prisma.ingestJob.findMany({ orderBy: { createdAt: "desc" }, take: 50 }), + prisma.exportJob.findMany({ include: { user: { select: { email: true } } }, orderBy: { createdAt: "desc" }, take: 50 }), + prisma.authSession.findMany({ include: { user: { select: { email: true } } }, orderBy: { createdAt: "desc" }, take: 50 }) + ]); + return ( +
+

Log Merkezi

Admin audit, kredi hareketleri, veri işleri ve oturum kayıtları.

+ +
+

Admin audit logları

{audits.map((log) =>
{log.action}{log.createdAt.toLocaleString("tr-TR")}

{log.summary}

{log.actorEmail} · {log.ipAddress || "IP yok"}
)}{!audits.length &&

Audit kaydı yok.

}
+

Kredi hareketleri

{credits.map((tx) => )}
KullanıcıTürMiktarBakiyeNedenTarih
{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")}
+

Ingest işleri

{ingestJobs.map((job) =>
{job.source} · {job.type}
{job.status} · {job.recordsImported} başarılı · {job.recordsFailed} hatalı
{job.errorMessage &&
{job.errorMessage}
}
)}

Export işleri

{exportJobs.map((job) =>
{job.user.email} · {job.module}
{job.status} · {job.createdAt.toLocaleString("tr-TR")}
{job.errorMessage &&
{job.errorMessage}
}
)}
+

Son oturumlar

{sessions.map((session) =>
{session.user.email}
Açılış: {session.createdAt.toLocaleString("tr-TR")} · Bitiş: {session.expiresAt.toLocaleString("tr-TR")}
)}
+
+
+ ); +} diff --git a/src/app/dashboard/admin/page.tsx b/src/app/dashboard/admin/page.tsx new file mode 100644 index 0000000..b12fcd2 --- /dev/null +++ b/src/app/dashboard/admin/page.tsx @@ -0,0 +1,52 @@ +import Link from "next/link"; +import { PaymentStatus, SubscriptionStatus } from "@prisma/client"; +import { AdminNav } from "@/components/admin/admin-nav"; +import { Card } from "@/components/ui/card"; +import { requireAdmin } from "@/lib/auth/current-user"; +import { prisma } from "@/lib/db"; + +function money(cents: number | null | undefined, currency = "EUR") { + return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format((cents || 0) / 100); +} + +export default async function AdminOverviewPage() { + await requireAdmin(); + const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const [users, admins, activeSubscriptions, paid, totalCredits, recentPayments, recentAudits] = await Promise.all([ + prisma.user.count(), + prisma.user.count({ where: { role: "ADMIN" } }), + prisma.subscription.count({ where: { status: { in: [SubscriptionStatus.ACTIVE, SubscriptionStatus.TRIALING] } } }), + prisma.payment.aggregate({ where: { status: PaymentStatus.PAID, createdAt: { gte: since } }, _sum: { amountCents: true }, _count: true }), + prisma.user.aggregate({ _sum: { creditBalance: true } }), + prisma.payment.findMany({ include: { user: { select: { email: true } } }, orderBy: { createdAt: "desc" }, take: 6 }), + prisma.adminAuditLog.findMany({ orderBy: { createdAt: "desc" }, take: 8 }) + ]); + + return ( +
+
+
Kontrol Merkezi

WinningHunter Admin

Kullanıcı, gelir, kredi ve operasyon görünümü.

+
Sistem aktif
+
+ +
+
Toplam kullanıcı
{users}
{admins} admin
+
Aktif abonelik
{activeSubscriptions}
+
30 gün gelir
{money(paid._sum.amountCents)}
{paid._count} ödeme
+
Dağıtılan kredi
{(totalCredits._sum.creditBalance || 0).toLocaleString("tr-TR")}
+
Hızlı işlem
Kullanıcı yönet →
+
+ +
+ +

Son ödemeler

Tümü
+
{recentPayments.map((payment) =>
{payment.user.email}
{payment.provider} · {payment.status}
{money(payment.amountCents, payment.currency)}
)}{!recentPayments.length &&
Henüz ödeme kaydı yok.
}
+
+ +

Son admin işlemleri

Log merkezi
+
{recentAudits.map((log) =>
{log.action}{log.createdAt.toLocaleString("tr-TR")}

{log.summary}

{log.actorEmail}
)}{!recentAudits.length &&
Henüz audit kaydı yok.
}
+
+
+
+ ); +} diff --git a/src/app/dashboard/admin/payments/page.tsx b/src/app/dashboard/admin/payments/page.tsx new file mode 100644 index 0000000..dd03b64 --- /dev/null +++ b/src/app/dashboard/admin/payments/page.tsx @@ -0,0 +1,26 @@ +import { AdminNav } from "@/components/admin/admin-nav"; +import { ManualPaymentForm, PaymentStatusForm } from "@/components/admin/admin-actions"; +import { Card } from "@/components/ui/card"; +import { requireAdmin } from "@/lib/auth/current-user"; +import { prisma } from "@/lib/db"; + +function money(cents: number, currency: string) { return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(cents / 100); } + +export default async function AdminPaymentsPage() { + await requireAdmin(); + const [users, payments] = await Promise.all([ + prisma.user.findMany({ select: { id: true, email: true }, orderBy: { email: "asc" }, take: 500 }), + prisma.payment.findMany({ include: { user: { select: { email: true } }, actor: { select: { email: true } } }, orderBy: { createdAt: "desc" }, take: 100 }) + ]); + return ( +
+

Ödemeler

Manuel tahsilat, referans ve ödeme durumlarını yönetin.

+ +

Manuel ödeme ekle

Ödeme ile verilen kredi atomik olarak bakiyeye eklenir. İade durumunda kredi otomatik düşmez; kullanıcı ekranından gerekçeli işlem yapın.

+ +
{payments.map((payment) => )}
KullanıcıTutarSağlayıcıKrediReferansTarihDurum
{payment.user.email}
Admin: {payment.actor?.email || "Sistem"}
{money(payment.amountCents, payment.currency)}{payment.provider}{payment.creditGranted}{payment.externalId || "—"}{payment.createdAt.toLocaleString("tr-TR")}
+ {!payments.length &&
Henüz ödeme kaydı yok.
} +
+
+ ); +} diff --git a/src/app/dashboard/admin/users/page.tsx b/src/app/dashboard/admin/users/page.tsx new file mode 100644 index 0000000..14c5890 --- /dev/null +++ b/src/app/dashboard/admin/users/page.tsx @@ -0,0 +1,48 @@ +import { Prisma, UserRole } from "@prisma/client"; +import { AdminNav } from "@/components/admin/admin-nav"; +import { CreditAdjustForm, PlanForm, RoleForm } from "@/components/admin/admin-actions"; +import { Card } from "@/components/ui/card"; +import { isConfiguredAdminEmail } from "@/lib/admin-emails"; +import { requireAdmin } from "@/lib/auth/current-user"; +import { prisma } from "@/lib/db"; + +export default async function AdminUsersPage({ searchParams }: { searchParams: Promise> }) { + await requireAdmin(); + const query = await searchParams; + const q = query.q?.trim(); + const role = query.role === "ADMIN" || query.role === "USER" ? query.role as UserRole : undefined; + const where: Prisma.UserWhereInput = { role }; + if (q) where.OR = [{ email: { contains: q, mode: "insensitive" } }, { name: { contains: q, mode: "insensitive" } }]; + const [users, plans] = await Promise.all([ + prisma.user.findMany({ where, include: { subscription: { include: { plan: true } }, creditTransactions: { orderBy: { createdAt: "desc" }, take: 3 } }, orderBy: { createdAt: "desc" }, take: 100 }), + prisma.plan.findMany({ where: { isActive: true }, orderBy: { sortOrder: "asc" } }) + ]); + + return ( +
+

Kullanıcı Yönetimi

Yetki, plan ve kredi bakiyelerini tek yerden yönetin.

+ +
+ + + +
+
+ {users.map((user) => ( + +
+

{user.name || user.email}

{user.role}
{user.email} · {user.googleId ? "Google bağlı" : "E-posta hesabı"}
Son giriş: {user.lastLoginAt?.toLocaleString("tr-TR") || "—"} · Kayıt: {user.createdAt.toLocaleDateString("tr-TR")}
+
Plan
{user.subscription?.plan.code || "YOK"}
+
+
+
Yetki ve plan
plan.code)} />
+ +
+ {!!user.creditTransactions.length &&
{user.creditTransactions.map((tx) => {tx.amount > 0 ? "+" : ""}{tx.amount} · {tx.reason})}
} +
+ ))} + {!users.length &&

Eşleşen kullanıcı bulunamadı.

} +
+
+ ); +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 6b4216b..39fb8fb 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -6,6 +6,7 @@ const nav = [ ["Stores", "/dashboard/stores"], ["Store Tracker", "/dashboard/store-tracker"], ["Saved Ads", "/dashboard/saved-ads"], + ["Account", "/dashboard/account"], ["TikTok Shop", "#"], ["Magic AI", "#"], ["Trends", "#"], @@ -25,9 +26,10 @@ export default async function DashboardLayout({ children }: { children: React.Re {label} ))} + {user.role === "ADMIN" && Admin}
- Upgrade + {user.role === "ADMIN" ? Admin : Upgrade}
{user.name || user.email}
{user.subscription?.plan.name}
diff --git a/src/components/admin/admin-actions.tsx b/src/components/admin/admin-actions.tsx new file mode 100644 index 0000000..598383f --- /dev/null +++ b/src/components/admin/admin-actions.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useRouter } from "next/navigation"; + +async function postJson(url: string, body: unknown) { + const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || "İşlem başarısız"); + return data; +} + +function Feedback({ message, error }: { message: string; error: boolean }) { + if (!message) return null; + return

{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) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const operation = String(form.get("operation")); + const amount = Number(form.get("amount")); + if (operation === "remove" && !window.confirm(`${amount} kredi bakiyeden çıkarılsın mı?`)) return; + setBusy(true); setMessage(""); + try { + const result = await postJson(`/api/admin/users/${userId}/credits`, { operation, amount, reason: form.get("reason") }); + setIsError(false); setMessage(`Yeni bakiye: ${result.balance}`); event.currentTarget.reset(); router.refresh(); + } catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); } + finally { setBusy(false); } + } + + return ( +
+
Kredi bakiyesi: {currentBalance.toLocaleString("tr-TR")}
+
+ + + + +
+ + + ); +} + +export function RoleForm({ userId, currentRole, protectedAdmin }: { userId: string; currentRole: "USER" | "ADMIN"; protectedAdmin: boolean }) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + const [isError, setIsError] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); const form = new FormData(event.currentTarget); const role = String(form.get("role")); + if (role === "USER" && !window.confirm("Bu kullanıcının admin yetkisi kaldırılsın mı?")) return; + setBusy(true); setMessage(""); + try { await postJson(`/api/admin/users/${userId}/role`, { role }); setIsError(false); setMessage("Yetki güncellendi"); router.refresh(); } + catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); } + finally { setBusy(false); } + } + + return ( +
+ + + {protectedAdmin && Korumalı admin} + + + ); +} + +export function PlanForm({ userId, currentPlan, plans }: { userId: string; currentPlan: string; plans: string[] }) { + const router = useRouter(); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [isError, setIsError] = useState(false); + async function submit(event: FormEvent) { + event.preventDefault(); const form = new FormData(event.currentTarget); setBusy(true); setMessage(""); + try { await postJson(`/api/admin/users/${userId}/plan`, { planCode: form.get("planCode"), status: form.get("status") }); setIsError(false); setMessage("Plan güncellendi"); router.refresh(); } + catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); } + finally { setBusy(false); } + } + return ( +
+ + + + + + ); +} + +export function ManualPaymentForm({ users }: { users: Array<{ id: string; email: string }> }) { + const router = useRouter(); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [isError, setIsError] = useState(false); + async function submit(event: FormEvent) { + event.preventDefault(); const form = new FormData(event.currentTarget); setBusy(true); setMessage(""); + try { + await postJson("/api/admin/payments", { userId: form.get("userId"), amountCents: Math.round(Number(form.get("amount")) * 100), currency: form.get("currency"), creditGranted: Number(form.get("creditGranted") || 0), description: form.get("description"), externalId: form.get("externalId") || null }); + setIsError(false); setMessage("Ödeme kaydedildi"); event.currentTarget.reset(); router.refresh(); + } catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); } + finally { setBusy(false); } + } + return ( +
+ +
+ + + + +
+
+ ); +} + +export function PaymentStatusForm({ paymentId, status }: { paymentId: string; status: string }) { + const router = useRouter(); const [busy, setBusy] = useState(false); + async function submit(event: FormEvent) { + event.preventDefault(); const form = new FormData(event.currentTarget); const nextStatus = String(form.get("status")); + if ((nextStatus === "REFUNDED" || nextStatus === "CANCELED") && !window.confirm(`Ödeme ${nextStatus} yapılsın mı? Kredi bakiyesi otomatik değişmez.`)) return; + setBusy(true); try { await postJson(`/api/admin/payments/${paymentId}/status`, { status: nextStatus }); router.refresh(); } finally { setBusy(false); } + } + return
; +} diff --git a/src/components/admin/admin-nav.tsx b/src/components/admin/admin-nav.tsx new file mode 100644 index 0000000..fdca403 --- /dev/null +++ b/src/components/admin/admin-nav.tsx @@ -0,0 +1,21 @@ +import Link from "next/link"; + +const links = [ + ["Genel Bakış", "/dashboard/admin"], + ["Kullanıcılar", "/dashboard/admin/users"], + ["Ödemeler", "/dashboard/admin/payments"], + ["Log Merkezi", "/dashboard/admin/logs"], + ["Veri İşleri", "/dashboard/admin/data"] +]; + +export function AdminNav() { + return ( +
+ {links.map(([label, href]) => ( + + {label} + + ))} +
+ ); +} diff --git a/src/lib/admin-api.ts b/src/lib/admin-api.ts new file mode 100644 index 0000000..5e54abd --- /dev/null +++ b/src/lib/admin-api.ts @@ -0,0 +1,15 @@ +import { getSessionUser } from "@/lib/auth/session"; + +export async function getApiAdmin() { + const user = await getSessionUser(); + if (!user || user.role !== "ADMIN") return null; + return user; +} + +export function requestAuditContext(request: Request) { + const forwardedFor = request.headers.get("x-forwarded-for"); + return { + ipAddress: forwardedFor?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || null, + userAgent: request.headers.get("user-agent")?.slice(0, 500) || null + }; +}