feat: kapsamli admin dashboard ekle

This commit is contained in:
2026-08-02 18:10:56 +03:00
parent bf2fc272f2
commit 3af61c82a2
19 changed files with 653 additions and 34 deletions
+3 -2
View File
@@ -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" });
}
@@ -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 });
}
+47
View File
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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 });
}
@@ -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 });
}
+11
View File
@@ -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);
+2 -1
View File
@@ -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
}
});
}