feat(sase): user management Phase 7a — listing + detail (RO)
Adım 1 — User listesi sayfası - Extend Sase RO Prisma schema: Plan, Brand, UserBrand, BankAccount, Payment, QueryLog - listUsers() + listPlans() repo with filter/sort/page in apps/web/src/lib/sase/users.ts - /projects/sase/users — TanStack-free server-rendered table with URL-param filters (plan multi-select, status, activity preset, search) + pager - Sase landing "Users →" link Adım 2 — User detay 360° - /projects/sase/users/[id] — header (masked email + audit-logged reveal button) + 5 tabs: Özet · Subscription & Billing · Kullanım · Aktivite · Audit - Usage stats: 30d/90d/lifetime queries, success rate, avg response, daily sparkline, brand/source distribution, top VINs - Timeline: merged signup + subscriptions + payments + recent queries - User-scoped audit trail (AuditLog endpoint LIKE filter) - POST /api/sase/users/[id]/reveal-email — audit-logged full email reveal teknikborc.md — Phase 7 PRD vs Sase.tr şeması gap'leri kaydedildi (EFT yok, B2B yok, API keys/webhooks yok, vb.) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -18,12 +18,35 @@ model User {
|
||||
email String @unique @db.VarChar(255)
|
||||
emailVerified Boolean @default(false) @map("email_verified")
|
||||
role String @default("user") @db.VarChar(20)
|
||||
image String?
|
||||
referralCode String? @map("referral_code") @db.VarChar(20)
|
||||
referredBy String? @map("referred_by") @db.Uuid
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
subscriptions UserSubscription[]
|
||||
payments Payment[]
|
||||
brands UserBrand[]
|
||||
queryLogs QueryLog[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Plan {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
brandCount Int @map("brand_count")
|
||||
priceMonthly Int @map("price_monthly")
|
||||
priceYearly Int @map("price_yearly")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
subscriptions UserSubscription[]
|
||||
|
||||
@@map("plans")
|
||||
}
|
||||
|
||||
model UserSubscription {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
@@ -36,7 +59,107 @@ model UserSubscription {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
plan Plan @relation(fields: [planId], references: [id])
|
||||
brands UserBrand[]
|
||||
payments Payment[]
|
||||
|
||||
@@index([status])
|
||||
@@index([userId])
|
||||
@@map("user_subscriptions")
|
||||
}
|
||||
|
||||
model Brand {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
slug String @unique @db.VarChar(100)
|
||||
logoUrl String? @map("logo_url")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
users UserBrand[]
|
||||
|
||||
@@map("brands")
|
||||
}
|
||||
|
||||
model UserBrand {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
subscriptionId String @map("subscription_id") @db.Uuid
|
||||
brandId String @map("brand_id") @db.Uuid
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
subscription UserSubscription @relation(fields: [subscriptionId], references: [id])
|
||||
brand Brand @relation(fields: [brandId], references: [id])
|
||||
|
||||
@@unique([userId, subscriptionId, brandId])
|
||||
@@index([userId])
|
||||
@@map("user_brands")
|
||||
}
|
||||
|
||||
model BankAccount {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
bankName String @map("bank_name") @db.VarChar(100)
|
||||
accountHolder String @map("account_holder") @db.VarChar(200)
|
||||
iban String @db.VarChar(34)
|
||||
kolayAdres String? @map("kolay_adres") @db.VarChar(100)
|
||||
kolayAdresType String? @map("kolay_adres_type") @db.VarChar(20)
|
||||
qrImageUrl String? @map("qr_image_url")
|
||||
descriptionTemplate String @default("SASE-{{paymentId}}") @map("description_template") @db.VarChar(200)
|
||||
isActive Boolean @default(false) @map("is_active")
|
||||
displayOrder Int @default(0) @map("display_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
payments Payment[]
|
||||
|
||||
@@map("bank_accounts")
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
subscriptionId String @map("subscription_id") @db.Uuid
|
||||
amount Int
|
||||
currency String @default("TRY") @db.VarChar(3)
|
||||
method String @db.VarChar(20)
|
||||
status String @default("pending") @db.VarChar(20)
|
||||
iyzicoPaymentId String? @map("iyzico_payment_id")
|
||||
stripeSessionId String? @map("stripe_session_id")
|
||||
stripePaymentIntentId String? @map("stripe_payment_intent_id")
|
||||
bankAccountId String? @map("bank_account_id") @db.Uuid
|
||||
eftReceiptUrl String? @map("eft_receipt_url")
|
||||
adminNote String? @map("admin_note")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
subscription UserSubscription @relation(fields: [subscriptionId], references: [id])
|
||||
bankAccount BankAccount? @relation(fields: [bankAccountId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([stripeSessionId])
|
||||
@@map("payments")
|
||||
}
|
||||
|
||||
model QueryLog {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
vin String @db.VarChar(17)
|
||||
brandId String? @map("brand_id") @db.Uuid
|
||||
source String? @db.VarChar(20)
|
||||
success Boolean @default(true)
|
||||
errorMessage String? @map("error_message")
|
||||
responseTimeMs Int? @map("response_time_ms")
|
||||
timings Json?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([vin])
|
||||
@@map("query_logs")
|
||||
}
|
||||
|
||||
41
apps/web/src/app/api/sase/users/[id]/reveal-email/route.ts
Normal file
41
apps/web/src/app/api/sase/users/[id]/reveal-email/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { saseDb } from "@/lib/db-sase";
|
||||
import { writeAudit } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, error: "unauthenticated" }, { status: 401 });
|
||||
}
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const user = await saseDb.user.findUnique({
|
||||
where: { id },
|
||||
select: { email: true },
|
||||
});
|
||||
if (!user) {
|
||||
await writeAudit({
|
||||
projectKey: "sase",
|
||||
endpoint: `/api/sase/users/${id}/reveal-email`,
|
||||
method: "POST",
|
||||
responseStatus: 404,
|
||||
});
|
||||
return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await writeAudit({
|
||||
projectKey: "sase",
|
||||
endpoint: `/api/sase/users/${id}/reveal-email`,
|
||||
method: "POST",
|
||||
responseStatus: 200,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, email: user.email });
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { saseDb } from "@/lib/db-sase";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { SASE_ADMIN_ENDPOINTS, saseAdminWired } from "@/lib/admin-sdk/sase";
|
||||
|
||||
async function getSaseStats() {
|
||||
@@ -42,6 +44,15 @@ export async function SaseHealth() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/projects/sase/users"
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
Users →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Stat label="Connection" value="healthy" badge="ok" />
|
||||
<Stat label="Total users" value={stats.users.toString()} hint="public.users" />
|
||||
|
||||
56
apps/web/src/app/projects/sase/users/[id]/_email-reveal.tsx
Normal file
56
apps/web/src/app/projects/sase/users/[id]/_email-reveal.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function EmailReveal({
|
||||
userId,
|
||||
masked,
|
||||
}: {
|
||||
userId: string;
|
||||
masked: string;
|
||||
}) {
|
||||
const [revealed, setRevealed] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function onReveal() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await fetch(`/api/sase/users/${userId}/reveal-email`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setError(data.error ?? `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { email: string };
|
||||
setRevealed(data.email);
|
||||
});
|
||||
}
|
||||
|
||||
if (revealed) {
|
||||
return (
|
||||
<span className="font-mono text-sm" title="Audit'li reveal">
|
||||
{revealed}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-muted-foreground">{masked}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={onReveal}
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? "..." : "Aç"}
|
||||
</Button>
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
458
apps/web/src/app/projects/sase/users/[id]/page.tsx
Normal file
458
apps/web/src/app/projects/sase/users/[id]/page.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { getUser, maskEmail } from "@/lib/sase/users";
|
||||
import {
|
||||
getUserUsageStats,
|
||||
getUserTimeline,
|
||||
getUserAuditTrail,
|
||||
} from "@/lib/sase/user-detail";
|
||||
import { EmailReveal } from "./_email-reveal";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function SaseUserDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const user = await getUser(id);
|
||||
if (!user) notFound();
|
||||
|
||||
const [usage, timeline, audit] = await Promise.all([
|
||||
getUserUsageStats(id),
|
||||
getUserTimeline(id, 80),
|
||||
getUserAuditTrail(id, 50),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PanelShell title={`Sase · ${user.name}`}>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Link href="/projects/sase/users" className="hover:underline">
|
||||
← Kullanıcılar
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl">{user.name}</CardTitle>
|
||||
<CardDescription className="flex flex-wrap items-center gap-2">
|
||||
<EmailReveal userId={user.id} masked={maskEmail(user.email)} />
|
||||
{user.emailVerified ? (
|
||||
<Badge variant="secondary">verified</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">unverified</Badge>
|
||||
)}
|
||||
</CardDescription>
|
||||
<p className="font-mono text-xs text-muted-foreground">{user.id}</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p>Kayıt: {user.createdAt.toISOString().slice(0, 10)}</p>
|
||||
{user.lastActivityAt && (
|
||||
<p>Son aktivite: {user.lastActivityAt.toISOString().slice(0, 16).replace("T", " ")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
|
||||
<Stat
|
||||
label="Plan"
|
||||
value={user.planName ?? "—"}
|
||||
hint={user.planTier ?? undefined}
|
||||
/>
|
||||
<Stat
|
||||
label="Durum"
|
||||
value={user.subscriptionStatus ?? "—"}
|
||||
hint={user.billingPeriod ?? undefined}
|
||||
/>
|
||||
<Stat label="Sorgu (30g)" value={user.queryCount30d.toLocaleString("tr-TR")} />
|
||||
<Stat
|
||||
label="Markalar"
|
||||
value={user.brands.length ? user.brands.join(", ") : "—"}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Özet</TabsTrigger>
|
||||
<TabsTrigger value="billing">Subscription & Billing</TabsTrigger>
|
||||
<TabsTrigger value="usage">Kullanım</TabsTrigger>
|
||||
<TabsTrigger value="timeline">Aktivite</TabsTrigger>
|
||||
<TabsTrigger value="audit">Audit</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-3 pt-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<Stat label="Toplam sorgu" value={usage.totalLifetime.toLocaleString("tr-TR")} />
|
||||
<Stat label="Son 90g" value={usage.total90d.toLocaleString("tr-TR")} />
|
||||
<Stat
|
||||
label="Başarı oranı (30g)"
|
||||
value={
|
||||
usage.total30d > 0
|
||||
? `${(usage.successRate30d * 100).toFixed(1)}%`
|
||||
: "—"
|
||||
}
|
||||
hint={
|
||||
usage.avgResponseMs != null
|
||||
? `Ø ${Math.round(usage.avgResponseMs)}ms`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Sparkline daily={usage.daily30d} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="billing" className="space-y-3 pt-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Aktif subscription</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<Stat label="Plan" value={user.planName ?? "—"} />
|
||||
<Stat label="Tier" value={user.planTier ?? "—"} />
|
||||
<Stat label="Durum" value={user.subscriptionStatus ?? "—"} />
|
||||
<Stat
|
||||
label="Bitiş"
|
||||
value={
|
||||
user.subscriptionEndsAt
|
||||
? user.subscriptionEndsAt.toISOString().slice(0, 10)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tarih</TableHead>
|
||||
<TableHead>Yöntem</TableHead>
|
||||
<TableHead className="text-right">Tutar</TableHead>
|
||||
<TableHead>Durum</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{user.payments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
Ödeme kaydı yok.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
user.payments.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="text-xs">
|
||||
{p.createdAt.toISOString().slice(0, 16).replace("T", " ")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{p.method}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{p.amount.toLocaleString("tr-TR")} {p.currency}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PaymentStatusBadge status={p.status} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="usage" className="space-y-3 pt-3">
|
||||
<Sparkline daily={usage.daily30d} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Marka dağılımı (30g)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DistList
|
||||
items={usage.byBrand.map((b) => ({
|
||||
label: b.brandName ?? b.brandSlug ?? "—",
|
||||
count: b.count,
|
||||
}))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Kaynak dağılımı (30g)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DistList
|
||||
items={usage.bySource.map((s) => ({ label: s.source ?? "—", count: s.count }))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Top VIN (30g, en çok sorgulanan 10)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{usage.topVins.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Sorgu yok.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>VIN</TableHead>
|
||||
<TableHead className="text-right">Sorgu</TableHead>
|
||||
<TableHead>Son</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{usage.topVins.map((v) => (
|
||||
<TableRow key={v.vin}>
|
||||
<TableCell className="font-mono text-xs">{v.vin}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{v.count}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{v.lastAt.toISOString().slice(0, 16).replace("T", " ")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="timeline" className="space-y-1 pt-3">
|
||||
{timeline.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Etkinlik yok.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{timeline.map((e, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-baseline gap-3 border-b py-1 text-sm last:border-0"
|
||||
>
|
||||
<span className="w-40 shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{e.at.toISOString().slice(0, 16).replace("T", " ")}
|
||||
</span>
|
||||
<TimelineEntry e={e} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="audit" className="pt-3">
|
||||
{audit.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bu kullanıcı üzerinde admin aksiyon yok.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tarih</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead>Endpoint</TableHead>
|
||||
<TableHead>Durum</TableHead>
|
||||
<TableHead>Kaynak IP</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{audit.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="text-xs">
|
||||
{a.createdAt.toISOString().slice(0, 16).replace("T", " ")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{a.method}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{a.endpoint}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{a.responseStatus ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{a.sourceIp ?? "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-sm font-medium tabular-nums">{value}</p>
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentStatusBadge({ status }: { status: string }) {
|
||||
const v =
|
||||
status === "succeeded" || status === "confirmed"
|
||||
? "default"
|
||||
: status === "pending"
|
||||
? "secondary"
|
||||
: status === "failed" || status === "rejected"
|
||||
? "destructive"
|
||||
: "outline";
|
||||
return (
|
||||
<Badge variant={v as "default" | "secondary" | "outline" | "destructive"}>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function Sparkline({ daily }: { daily: Array<{ date: string; count: number }> }) {
|
||||
const max = Math.max(1, ...daily.map((d) => d.count));
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Günlük sorgu (son 30 gün)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-20 items-end gap-px">
|
||||
{daily.map((d) => (
|
||||
<div
|
||||
key={d.date}
|
||||
title={`${d.date}: ${d.count}`}
|
||||
style={{ height: `${(d.count / max) * 100}%` }}
|
||||
className="flex-1 min-w-[2px] bg-primary/60 hover:bg-primary"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between pt-1 text-xs text-muted-foreground">
|
||||
<span>{daily[0]?.date}</span>
|
||||
<span>maks {max}/gün</span>
|
||||
<span>{daily[daily.length - 1]?.date}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DistList({ items }: { items: Array<{ label: string; count: number }> }) {
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Veri yok.</p>;
|
||||
}
|
||||
const max = Math.max(1, ...items.map((i) => i.count));
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{items.map((i) => (
|
||||
<li key={i.label} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-20 truncate text-xs">{i.label}</span>
|
||||
<div className="flex-1 overflow-hidden rounded bg-muted">
|
||||
<div
|
||||
className="h-2 bg-primary"
|
||||
style={{ width: `${(i.count / max) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-12 text-right tabular-nums text-xs">{i.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineEntry({
|
||||
e,
|
||||
}: {
|
||||
e:
|
||||
| { type: "signup"; at: Date }
|
||||
| { type: "subscription"; at: Date; planName: string | null; status: string }
|
||||
| {
|
||||
type: "payment";
|
||||
at: Date;
|
||||
amount: number;
|
||||
currency: string;
|
||||
method: string;
|
||||
status: string;
|
||||
}
|
||||
| { type: "query"; at: Date; vin: string; success: boolean };
|
||||
}) {
|
||||
if (e.type === "signup") {
|
||||
return (
|
||||
<span>
|
||||
<Badge variant="default">signup</Badge> <span>Kullanıcı oluşturuldu.</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (e.type === "subscription") {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<Badge variant="secondary">subscription</Badge>
|
||||
<span>
|
||||
{e.planName ?? "?"} ({e.status})
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (e.type === "payment") {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<Badge variant="outline">payment · {e.method}</Badge>
|
||||
<span className="tabular-nums">
|
||||
{e.amount.toLocaleString("tr-TR")} {e.currency}
|
||||
</span>
|
||||
<PaymentStatusBadge status={e.status} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<Badge variant="outline">query</Badge>
|
||||
<span className="font-mono text-xs">{e.vin}</span>
|
||||
{!e.success && <Badge variant="destructive">failed</Badge>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
149
apps/web/src/app/projects/sase/users/_filter-bar.tsx
Normal file
149
apps/web/src/app/projects/sase/users/_filter-bar.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buildHref, toggleArray, type UsersSearchParams } from "./_query";
|
||||
|
||||
type Plan = { id: string; name: string; brandCount: number };
|
||||
|
||||
const STATUSES = ["active", "trial", "pending", "cancelled", "expired"];
|
||||
|
||||
const ACTIVITY_PRESETS: Array<{ label: string; key: "activity" | "inactive"; days: string }> = [
|
||||
{ label: "Son 7g aktif", key: "activity", days: "7" },
|
||||
{ label: "Son 30g aktif", key: "activity", days: "30" },
|
||||
{ label: "14g+ inaktif", key: "inactive", days: "14" },
|
||||
{ label: "30g+ inaktif", key: "inactive", days: "30" },
|
||||
];
|
||||
|
||||
export function UsersFilterBar({
|
||||
plans,
|
||||
initial,
|
||||
}: {
|
||||
plans: Plan[];
|
||||
initial: UsersSearchParams;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const sp = useSearchParams();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const current: UsersSearchParams = {
|
||||
q: sp.get("q") ?? undefined,
|
||||
plan: sp.getAll("plan").length ? sp.getAll("plan") : undefined,
|
||||
status: sp.getAll("status").length ? sp.getAll("status") : undefined,
|
||||
activity: sp.get("activity") ?? undefined,
|
||||
inactive: sp.get("inactive") ?? undefined,
|
||||
sort: sp.get("sort") ?? undefined,
|
||||
page: sp.get("page") ?? undefined,
|
||||
signupFrom: sp.get("signupFrom") ?? undefined,
|
||||
signupTo: sp.get("signupTo") ?? undefined,
|
||||
};
|
||||
|
||||
function nav(patch: Partial<UsersSearchParams>) {
|
||||
const href = `${pathname}${buildHref(current, { page: undefined, ...patch })}`;
|
||||
startTransition(() => router.push(href));
|
||||
}
|
||||
|
||||
function isPlanActive(id: string) {
|
||||
const v = current.plan;
|
||||
if (!v) return false;
|
||||
return Array.isArray(v) ? v.includes(id) : v === id;
|
||||
}
|
||||
|
||||
function isStatusActive(s: string) {
|
||||
const v = current.status;
|
||||
if (!v) return false;
|
||||
return Array.isArray(v) ? v.includes(s) : v === s;
|
||||
}
|
||||
|
||||
function isPresetActive(key: "activity" | "inactive", days: string) {
|
||||
return current[key] === days;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<form
|
||||
action={(fd) => {
|
||||
const q = String(fd.get("q") ?? "").trim();
|
||||
nav({ q: q || undefined });
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
name="q"
|
||||
defaultValue={initial.q ?? ""}
|
||||
placeholder="Email, isim veya user ID ile ara…"
|
||||
className="max-w-md"
|
||||
/>
|
||||
<Button type="submit" disabled={pending} size="sm">
|
||||
Ara
|
||||
</Button>
|
||||
{Object.values(current).some((v) => v !== undefined && v !== "") && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(pathname)}
|
||||
>
|
||||
Temizle
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-xs text-muted-foreground">Plan:</span>
|
||||
{plans.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => nav({ plan: toggleArray(current.plan, p.id) })}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge variant={isPlanActive(p.id) ? "default" : "outline"}>
|
||||
{p.name}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-xs text-muted-foreground">Durum:</span>
|
||||
{STATUSES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => nav({ status: toggleArray(current.status, s) })}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge variant={isStatusActive(s) ? "default" : "outline"}>{s}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-xs text-muted-foreground">Aktivite:</span>
|
||||
{ACTIVITY_PRESETS.map((p) => {
|
||||
const active = isPresetActive(p.key, p.days);
|
||||
return (
|
||||
<button
|
||||
key={`${p.key}-${p.days}`}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
nav({
|
||||
activity: p.key === "activity" && !active ? p.days : undefined,
|
||||
inactive: p.key === "inactive" && !active ? p.days : undefined,
|
||||
})
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Badge variant={active ? "default" : "outline"}>{p.label}</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
apps/web/src/app/projects/sase/users/_pager.tsx
Normal file
67
apps/web/src/app/projects/sase/users/_pager.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams, usePathname } from "next/navigation";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { buildHref, type UsersSearchParams } from "./_query";
|
||||
|
||||
export function Pager({
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
}: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}) {
|
||||
const sp = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const current: UsersSearchParams = Object.fromEntries(sp.entries()) as UsersSearchParams;
|
||||
// Multi-value plan/status: re-derive
|
||||
const planAll = sp.getAll("plan");
|
||||
const statusAll = sp.getAll("status");
|
||||
if (planAll.length > 1) current.plan = planAll;
|
||||
if (statusAll.length > 1) current.status = statusAll;
|
||||
|
||||
const lastPage = Math.max(0, Math.ceil(total / pageSize) - 1);
|
||||
const prevPage = Math.max(0, page - 1);
|
||||
const nextPage = Math.min(lastPage, page + 1);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{total === 0
|
||||
? "Kayıt yok"
|
||||
: `${page * pageSize + 1}–${Math.min(total, (page + 1) * pageSize)} / ${total}`}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{page === 0 ? (
|
||||
<span className={buttonVariants({ variant: "outline", size: "sm" }) + " pointer-events-none opacity-50"}>
|
||||
← Önceki
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`${pathname}${buildHref(current, { page: prevPage === 0 ? undefined : String(prevPage) })}`}
|
||||
scroll={false}
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
← Önceki
|
||||
</Link>
|
||||
)}
|
||||
{page >= lastPage ? (
|
||||
<span className={buttonVariants({ variant: "outline", size: "sm" }) + " pointer-events-none opacity-50"}>
|
||||
Sonraki →
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`${pathname}${buildHref(current, { page: String(nextPage) })}`}
|
||||
scroll={false}
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
Sonraki →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/app/projects/sase/users/_query.ts
Normal file
43
apps/web/src/app/projects/sase/users/_query.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type UsersSearchParams = {
|
||||
q?: string;
|
||||
plan?: string | string[];
|
||||
status?: string | string[];
|
||||
signupFrom?: string;
|
||||
signupTo?: string;
|
||||
activity?: string;
|
||||
inactive?: string;
|
||||
sort?: string;
|
||||
page?: string;
|
||||
};
|
||||
|
||||
export function buildHref(
|
||||
current: UsersSearchParams,
|
||||
patch: Partial<UsersSearchParams>,
|
||||
): string {
|
||||
const merged: Record<string, string | string[] | undefined> = {
|
||||
...current,
|
||||
...patch,
|
||||
} as Record<string, string | string[] | undefined>;
|
||||
const usp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(merged)) {
|
||||
if (v === undefined || v === null || v === "") continue;
|
||||
if (Array.isArray(v)) {
|
||||
for (const item of v) if (item) usp.append(k, item);
|
||||
} else {
|
||||
usp.set(k, String(v));
|
||||
}
|
||||
}
|
||||
const qs = usp.toString();
|
||||
return qs ? `?${qs}` : "?";
|
||||
}
|
||||
|
||||
export function toggleArray(
|
||||
current: string | string[] | undefined,
|
||||
value: string,
|
||||
): string[] {
|
||||
const arr = !current ? [] : Array.isArray(current) ? [...current] : current.split(",");
|
||||
const idx = arr.indexOf(value);
|
||||
if (idx >= 0) arr.splice(idx, 1);
|
||||
else arr.push(value);
|
||||
return arr;
|
||||
}
|
||||
225
apps/web/src/app/projects/sase/users/page.tsx
Normal file
225
apps/web/src/app/projects/sase/users/page.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import Link from "next/link";
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { listUsers, listPlans, maskEmail } from "@/lib/sase/users";
|
||||
import { UsersFilterBar } from "./_filter-bar";
|
||||
import { Pager } from "./_pager";
|
||||
import { buildHref, type UsersSearchParams } from "./_query";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SearchParams = UsersSearchParams;
|
||||
|
||||
export default async function SaseUsersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
|
||||
const planIds = toArray(sp.plan);
|
||||
const statuses = toArray(sp.status);
|
||||
const signupFrom = sp.signupFrom ? new Date(sp.signupFrom) : undefined;
|
||||
const signupTo = sp.signupTo ? new Date(sp.signupTo) : undefined;
|
||||
const activitySince = sp.activity
|
||||
? new Date(Date.now() - Number(sp.activity) * 24 * 60 * 60 * 1000)
|
||||
: undefined;
|
||||
const inactiveSince = sp.inactive
|
||||
? new Date(Date.now() - Number(sp.inactive) * 24 * 60 * 60 * 1000)
|
||||
: undefined;
|
||||
|
||||
const [sortField, sortDir] = parseSort(sp.sort);
|
||||
const page = Math.max(0, Number(sp.page ?? "0") || 0);
|
||||
const pageSize = 50;
|
||||
|
||||
const [plans, result] = await Promise.all([
|
||||
listPlans(),
|
||||
listUsers(
|
||||
{
|
||||
search: sp.q,
|
||||
planIds: planIds.length ? planIds : undefined,
|
||||
statuses: statuses.length ? statuses : undefined,
|
||||
signupFrom,
|
||||
signupTo,
|
||||
activitySince,
|
||||
inactiveSince,
|
||||
},
|
||||
{ field: sortField, dir: sortDir },
|
||||
page,
|
||||
pageSize,
|
||||
),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PanelShell title="Sase · Users">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Kullanıcılar</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.total.toLocaleString("tr-TR")} kayıt · sayfa {page + 1}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UsersFilterBar plans={plans} initial={sp} />
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<SortableHeader label="Email" field="email" sp={sp} />
|
||||
</TableHead>
|
||||
<TableHead>İsim</TableHead>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead>Durum</TableHead>
|
||||
<TableHead className="text-right">Sorgu (30g)</TableHead>
|
||||
<TableHead>
|
||||
<SortableHeader label="Son aktivite" field="lastActivity" sp={sp} />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<SortableHeader label="Kayıt" field="createdAt" sp={sp} />
|
||||
</TableHead>
|
||||
<TableHead>Markalar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
Eşleşen kullanıcı yok.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
result.rows.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-mono text-xs" title={u.email}>
|
||||
<Link href={`/projects/sase/users/${u.id}`} className="hover:underline">
|
||||
{maskEmail(u.email)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{u.name}</TableCell>
|
||||
<TableCell>
|
||||
{u.planName ? (
|
||||
<Badge variant="outline">{u.planName}</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={u.subscriptionStatus} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{u.queryCount30d.toLocaleString("tr-TR")}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{u.lastActivityAt ? relativeDate(u.lastActivityAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{u.createdAt.toISOString().slice(0, 10)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{u.brands.length ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.brands.slice(0, 3).map((b) => (
|
||||
<Badge key={b} variant="secondary" className="font-mono">
|
||||
{b}
|
||||
</Badge>
|
||||
))}
|
||||
{u.brands.length > 3 && (
|
||||
<span className="text-muted-foreground">
|
||||
+{u.brands.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Pager page={page} pageSize={pageSize} total={result.total} />
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
function toArray(v: string | string[] | undefined): string[] {
|
||||
if (!v) return [];
|
||||
if (Array.isArray(v)) return v;
|
||||
return v.split(",").filter(Boolean);
|
||||
}
|
||||
|
||||
function parseSort(s: string | undefined): [
|
||||
"createdAt" | "lastActivity" | "email" | "name",
|
||||
"asc" | "desc",
|
||||
] {
|
||||
const [f, d] = (s ?? "createdAt:desc").split(":");
|
||||
const field =
|
||||
f === "lastActivity" || f === "email" || f === "name" || f === "createdAt"
|
||||
? (f as "createdAt" | "lastActivity" | "email" | "name")
|
||||
: "createdAt";
|
||||
const dir: "asc" | "desc" = d === "asc" ? "asc" : "desc";
|
||||
return [field, dir];
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
label,
|
||||
field,
|
||||
sp,
|
||||
}: {
|
||||
label: string;
|
||||
field: string;
|
||||
sp: SearchParams;
|
||||
}) {
|
||||
const [curField, curDir] = (sp.sort ?? "createdAt:desc").split(":");
|
||||
const nextDir = curField === field && curDir === "desc" ? "asc" : "desc";
|
||||
const indicator = curField === field ? (curDir === "asc" ? " ↑" : " ↓") : "";
|
||||
return (
|
||||
<Link
|
||||
href={buildHref(sp, { sort: `${field}:${nextDir}`, page: undefined })}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
scroll={false}
|
||||
>
|
||||
{label}
|
||||
{indicator}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function StatusBadge({ status }: { status: string | null }) {
|
||||
if (!status) return <span className="text-muted-foreground">—</span>;
|
||||
const v =
|
||||
status === "active"
|
||||
? "default"
|
||||
: status === "pending" || status === "trial"
|
||||
? "secondary"
|
||||
: status === "cancelled" || status === "expired"
|
||||
? "outline"
|
||||
: "destructive";
|
||||
return <Badge variant={v as "default" | "secondary" | "outline" | "destructive"}>{status}</Badge>;
|
||||
}
|
||||
|
||||
function relativeDate(d: Date): string {
|
||||
const ms = Date.now() - d.getTime();
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
if (minutes < 60) return `${minutes}dk önce`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}sa önce`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}g önce`;
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
228
apps/web/src/lib/sase/user-detail.ts
Normal file
228
apps/web/src/lib/sase/user-detail.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { saseDb } from "@/lib/db-sase";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export type UsageStats = {
|
||||
total30d: number;
|
||||
total90d: number;
|
||||
totalLifetime: number;
|
||||
successRate30d: number;
|
||||
avgResponseMs: number | null;
|
||||
daily30d: Array<{ date: string; count: number }>;
|
||||
byBrand: Array<{ brandSlug: string | null; brandName: string | null; count: number }>;
|
||||
bySource: Array<{ source: string | null; count: number }>;
|
||||
topVins: Array<{ vin: string; lastAt: Date; count: number }>;
|
||||
};
|
||||
|
||||
export async function getUserUsageStats(userId: string): Promise<UsageStats> {
|
||||
const now = Date.now();
|
||||
const cutoff30 = new Date(now - 30 * 24 * 60 * 60 * 1000);
|
||||
const cutoff90 = new Date(now - 90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [total30, total90, totalLife, success30, withTimings, recent] = await Promise.all([
|
||||
saseDb.queryLog.count({ where: { userId, createdAt: { gte: cutoff30 } } }),
|
||||
saseDb.queryLog.count({ where: { userId, createdAt: { gte: cutoff90 } } }),
|
||||
saseDb.queryLog.count({ where: { userId } }),
|
||||
saseDb.queryLog.count({
|
||||
where: { userId, createdAt: { gte: cutoff30 }, success: true },
|
||||
}),
|
||||
saseDb.queryLog.aggregate({
|
||||
where: { userId, createdAt: { gte: cutoff30 }, responseTimeMs: { not: null } },
|
||||
_avg: { responseTimeMs: true },
|
||||
}),
|
||||
saseDb.queryLog.findMany({
|
||||
where: { userId, createdAt: { gte: cutoff30 } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 2000,
|
||||
select: {
|
||||
createdAt: true,
|
||||
brandId: true,
|
||||
source: true,
|
||||
success: true,
|
||||
vin: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const daily = bucketDaily(recent.map((r) => r.createdAt), 30);
|
||||
|
||||
const brandIdsRaw = recent.map((r) => r.brandId).filter((b): b is string => !!b);
|
||||
const brandIds = Array.from(new Set(brandIdsRaw));
|
||||
const brands = brandIds.length
|
||||
? await saseDb.brand.findMany({
|
||||
where: { id: { in: brandIds } },
|
||||
select: { id: true, name: true, slug: true },
|
||||
})
|
||||
: [];
|
||||
const brandMap = new Map(brands.map((b) => [b.id, b]));
|
||||
|
||||
const brandCounts = new Map<string | null, number>();
|
||||
for (const r of recent) {
|
||||
const k = r.brandId ?? null;
|
||||
brandCounts.set(k, (brandCounts.get(k) ?? 0) + 1);
|
||||
}
|
||||
const byBrand = Array.from(brandCounts.entries())
|
||||
.map(([brandId, count]) => {
|
||||
const b = brandId ? brandMap.get(brandId) : null;
|
||||
return {
|
||||
brandSlug: b?.slug ?? null,
|
||||
brandName: b?.name ?? null,
|
||||
count,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
const sourceCounts = new Map<string | null, number>();
|
||||
for (const r of recent) {
|
||||
const k = r.source ?? null;
|
||||
sourceCounts.set(k, (sourceCounts.get(k) ?? 0) + 1);
|
||||
}
|
||||
const bySource = Array.from(sourceCounts.entries())
|
||||
.map(([source, count]) => ({ source, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Top VINs (last 30d)
|
||||
const vinCounts = new Map<string, { count: number; lastAt: Date }>();
|
||||
for (const r of recent) {
|
||||
const cur = vinCounts.get(r.vin);
|
||||
if (cur) {
|
||||
cur.count += 1;
|
||||
if (r.createdAt > cur.lastAt) cur.lastAt = r.createdAt;
|
||||
} else {
|
||||
vinCounts.set(r.vin, { count: 1, lastAt: r.createdAt });
|
||||
}
|
||||
}
|
||||
const topVins = Array.from(vinCounts.entries())
|
||||
.map(([vin, v]) => ({ vin, count: v.count, lastAt: v.lastAt }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
return {
|
||||
total30d: total30,
|
||||
total90d: total90,
|
||||
totalLifetime: totalLife,
|
||||
successRate30d: total30 > 0 ? success30 / total30 : 0,
|
||||
avgResponseMs: withTimings._avg.responseTimeMs,
|
||||
daily30d: daily,
|
||||
byBrand,
|
||||
bySource,
|
||||
topVins,
|
||||
};
|
||||
}
|
||||
|
||||
function bucketDaily(dates: Date[], days: number): Array<{ date: string; count: number }> {
|
||||
const buckets = new Map<string, number>();
|
||||
const today = new Date();
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
buckets.set(d.toISOString().slice(0, 10), 0);
|
||||
}
|
||||
for (const d of dates) {
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
if (buckets.has(key)) buckets.set(key, (buckets.get(key) ?? 0) + 1);
|
||||
}
|
||||
return Array.from(buckets.entries()).map(([date, count]) => ({ date, count }));
|
||||
}
|
||||
|
||||
export type TimelineEvent =
|
||||
| { type: "signup"; at: Date }
|
||||
| { type: "subscription"; at: Date; planName: string | null; status: string }
|
||||
| {
|
||||
type: "payment";
|
||||
at: Date;
|
||||
amount: number;
|
||||
currency: string;
|
||||
method: string;
|
||||
status: string;
|
||||
}
|
||||
| { type: "query"; at: Date; vin: string; success: boolean };
|
||||
|
||||
export async function getUserTimeline(
|
||||
userId: string,
|
||||
limit = 50,
|
||||
): Promise<TimelineEvent[]> {
|
||||
const [user, subs, payments, recentQueries] = await Promise.all([
|
||||
saseDb.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
saseDb.userSubscription.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
include: { plan: { select: { name: true } } },
|
||||
}),
|
||||
saseDb.payment.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
}),
|
||||
saseDb.queryLog.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
select: { createdAt: true, vin: true, success: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const events: TimelineEvent[] = [];
|
||||
|
||||
if (user) events.push({ type: "signup", at: user.createdAt });
|
||||
for (const s of subs) {
|
||||
events.push({
|
||||
type: "subscription",
|
||||
at: s.createdAt,
|
||||
planName: s.plan.name,
|
||||
status: s.status,
|
||||
});
|
||||
}
|
||||
for (const p of payments) {
|
||||
events.push({
|
||||
type: "payment",
|
||||
at: p.createdAt,
|
||||
amount: p.amount,
|
||||
currency: p.currency,
|
||||
method: p.method,
|
||||
status: p.status,
|
||||
});
|
||||
}
|
||||
for (const q of recentQueries) {
|
||||
events.push({ type: "query", at: q.createdAt, vin: q.vin, success: q.success });
|
||||
}
|
||||
|
||||
events.sort((a, b) => b.at.getTime() - a.at.getTime());
|
||||
return events.slice(0, limit);
|
||||
}
|
||||
|
||||
export type AdminAuditEntry = {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
actorUserId: string | null;
|
||||
responseStatus: number | null;
|
||||
sourceIp: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export async function getUserAuditTrail(
|
||||
saseUserId: string,
|
||||
limit = 50,
|
||||
): Promise<AdminAuditEntry[]> {
|
||||
// Audit entries we emit for actions on a sase user encode the id in the endpoint.
|
||||
const entries = await prisma.auditLog.findMany({
|
||||
where: {
|
||||
projectKey: "sase",
|
||||
endpoint: { contains: `/users/${saseUserId}` },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
return entries.map((e) => ({
|
||||
id: e.id,
|
||||
endpoint: e.endpoint,
|
||||
method: e.method,
|
||||
actorUserId: e.actorUserId,
|
||||
responseStatus: e.responseStatus,
|
||||
sourceIp: e.sourceIp,
|
||||
createdAt: e.createdAt,
|
||||
}));
|
||||
}
|
||||
277
apps/web/src/lib/sase/users.ts
Normal file
277
apps/web/src/lib/sase/users.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { saseDb } from "@/lib/db-sase";
|
||||
import type { Prisma } from ".prisma/client-sase";
|
||||
|
||||
export type UserListFilter = {
|
||||
search?: string;
|
||||
planIds?: string[];
|
||||
statuses?: string[];
|
||||
signupFrom?: Date;
|
||||
signupTo?: Date;
|
||||
activitySince?: Date;
|
||||
inactiveSince?: Date;
|
||||
};
|
||||
|
||||
export type UserListSort = {
|
||||
field: "createdAt" | "lastActivity" | "email" | "name";
|
||||
dir: "asc" | "desc";
|
||||
};
|
||||
|
||||
export type UserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
emailVerified: boolean;
|
||||
createdAt: Date;
|
||||
planName: string | null;
|
||||
planTier: string | null;
|
||||
subscriptionStatus: string | null;
|
||||
billingPeriod: string | null;
|
||||
subscriptionEndsAt: Date | null;
|
||||
brands: string[];
|
||||
lastActivityAt: Date | null;
|
||||
queryCount30d: number;
|
||||
};
|
||||
|
||||
export type UserListResult = {
|
||||
rows: UserRow[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
const PAGE_DEFAULT = 50;
|
||||
const PAGE_MAX = 200;
|
||||
|
||||
export async function listUsers(
|
||||
filter: UserListFilter,
|
||||
sort: UserListSort,
|
||||
page = 0,
|
||||
pageSize = PAGE_DEFAULT,
|
||||
): Promise<UserListResult> {
|
||||
const take = Math.min(Math.max(pageSize, 1), PAGE_MAX);
|
||||
const skip = Math.max(page, 0) * take;
|
||||
|
||||
const where: Prisma.UserWhereInput = {};
|
||||
|
||||
if (filter.search?.trim()) {
|
||||
const q = filter.search.trim();
|
||||
const or: Prisma.UserWhereInput[] = [
|
||||
{ email: { contains: q, mode: "insensitive" } },
|
||||
{ name: { contains: q, mode: "insensitive" } },
|
||||
];
|
||||
if (q.length === 36) or.push({ id: q });
|
||||
where.OR = or;
|
||||
}
|
||||
|
||||
if (filter.signupFrom || filter.signupTo) {
|
||||
where.createdAt = {
|
||||
...(filter.signupFrom ? { gte: filter.signupFrom } : {}),
|
||||
...(filter.signupTo ? { lte: filter.signupTo } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (filter.planIds?.length || filter.statuses?.length) {
|
||||
where.subscriptions = {
|
||||
some: {
|
||||
...(filter.planIds?.length ? { planId: { in: filter.planIds } } : {}),
|
||||
...(filter.statuses?.length ? { status: { in: filter.statuses } } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Sort: createdAt and email/name handled at DB level. lastActivity needs post-sort.
|
||||
const orderBy: Prisma.UserOrderByWithRelationInput | undefined =
|
||||
sort.field === "createdAt"
|
||||
? { createdAt: sort.dir }
|
||||
: sort.field === "email"
|
||||
? { email: sort.dir }
|
||||
: sort.field === "name"
|
||||
? { name: sort.dir }
|
||||
: { createdAt: "desc" }; // fallback for lastActivity, re-sorted below
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
saseDb.user.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip,
|
||||
take: sort.field === "lastActivity" ? take * 3 : take, // overfetch for re-sort
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
subscriptions: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
select: {
|
||||
status: true,
|
||||
billingPeriod: true,
|
||||
endDate: true,
|
||||
plan: { select: { name: true, brandCount: true } },
|
||||
brands: { select: { brand: { select: { slug: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
saseDb.user.count({ where }),
|
||||
]);
|
||||
|
||||
// Last activity from queryLogs (single grouped query for the candidate user IDs)
|
||||
const userIds = users.map((u) => u.id);
|
||||
const cutoff30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [lastActivities, recentCounts] = await Promise.all([
|
||||
userIds.length
|
||||
? saseDb.queryLog.groupBy({
|
||||
by: ["userId"],
|
||||
where: { userId: { in: userIds } },
|
||||
_max: { createdAt: true },
|
||||
})
|
||||
: Promise.resolve([] as Array<{ userId: string; _max: { createdAt: Date | null } }>),
|
||||
userIds.length
|
||||
? saseDb.queryLog.groupBy({
|
||||
by: ["userId"],
|
||||
where: { userId: { in: userIds }, createdAt: { gte: cutoff30d } },
|
||||
_count: { _all: true },
|
||||
})
|
||||
: Promise.resolve([] as Array<{ userId: string; _count: { _all: number } }>),
|
||||
]);
|
||||
|
||||
const lastActivityMap = new Map(
|
||||
lastActivities.map((r) => [r.userId, r._max.createdAt as Date | null]),
|
||||
);
|
||||
const countMap = new Map(recentCounts.map((r) => [r.userId, r._count._all]));
|
||||
|
||||
let rows: UserRow[] = users.map((u) => {
|
||||
const sub = u.subscriptions[0];
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
emailVerified: u.emailVerified,
|
||||
createdAt: u.createdAt,
|
||||
planName: sub?.plan.name ?? null,
|
||||
planTier: tierFromPlan(sub?.plan.name ?? null, sub?.plan.brandCount ?? null),
|
||||
subscriptionStatus: sub?.status ?? null,
|
||||
billingPeriod: sub?.billingPeriod ?? null,
|
||||
subscriptionEndsAt: sub?.endDate ?? null,
|
||||
brands: sub?.brands.map((b) => b.brand.slug) ?? [],
|
||||
lastActivityAt: lastActivityMap.get(u.id) ?? null,
|
||||
queryCount30d: countMap.get(u.id) ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
if (filter.activitySince) {
|
||||
rows = rows.filter((r) => r.lastActivityAt && r.lastActivityAt >= filter.activitySince!);
|
||||
}
|
||||
if (filter.inactiveSince) {
|
||||
rows = rows.filter(
|
||||
(r) => !r.lastActivityAt || r.lastActivityAt < filter.inactiveSince!,
|
||||
);
|
||||
}
|
||||
|
||||
if (sort.field === "lastActivity") {
|
||||
rows.sort((a, b) => {
|
||||
const av = a.lastActivityAt?.getTime() ?? 0;
|
||||
const bv = b.lastActivityAt?.getTime() ?? 0;
|
||||
return sort.dir === "asc" ? av - bv : bv - av;
|
||||
});
|
||||
rows = rows.slice(0, take);
|
||||
}
|
||||
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
export type UserDetail = UserRow & {
|
||||
referralCode: string | null;
|
||||
payments: Array<{
|
||||
id: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
method: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function getUser(id: string): Promise<UserDetail | null> {
|
||||
const u = await saseDb.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
subscriptions: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
include: {
|
||||
plan: { select: { name: true, brandCount: true } },
|
||||
brands: { include: { brand: { select: { slug: true } } } },
|
||||
},
|
||||
},
|
||||
payments: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!u) return null;
|
||||
|
||||
const [lastQuery, count30d] = await Promise.all([
|
||||
saseDb.queryLog.findFirst({
|
||||
where: { userId: id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
saseDb.queryLog.count({
|
||||
where: { userId: id, createdAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const sub = u.subscriptions[0];
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
emailVerified: u.emailVerified,
|
||||
createdAt: u.createdAt,
|
||||
referralCode: u.referralCode,
|
||||
planName: sub?.plan.name ?? null,
|
||||
planTier: tierFromPlan(sub?.plan.name ?? null, sub?.plan.brandCount ?? null),
|
||||
subscriptionStatus: sub?.status ?? null,
|
||||
billingPeriod: sub?.billingPeriod ?? null,
|
||||
subscriptionEndsAt: sub?.endDate ?? null,
|
||||
brands: sub?.brands.map((b) => b.brand.slug) ?? [],
|
||||
lastActivityAt: lastQuery?.createdAt ?? null,
|
||||
queryCount30d: count30d,
|
||||
payments: u.payments.map((p) => ({
|
||||
id: p.id,
|
||||
amount: p.amount,
|
||||
currency: p.currency,
|
||||
method: p.method,
|
||||
status: p.status,
|
||||
createdAt: p.createdAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPlans() {
|
||||
return saseDb.plan.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { brandCount: "asc" },
|
||||
select: { id: true, name: true, brandCount: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function maskEmail(email: string): string {
|
||||
const [local, domain] = email.split("@");
|
||||
if (!local || !domain) return email;
|
||||
if (local.length <= 1) return `${local}***@${domain}`;
|
||||
return `${local[0]}${"*".repeat(Math.min(local.length - 1, 4))}@${domain}`;
|
||||
}
|
||||
|
||||
function tierFromPlan(name: string | null, brandCount: number | null): string | null {
|
||||
if (!name) return null;
|
||||
const n = name.toLowerCase();
|
||||
if (n.includes("full") || (brandCount && brandCount >= 4)) return "full";
|
||||
if (n.includes("brand")) return "brand_specific";
|
||||
if (n.includes("starter")) return "starter";
|
||||
if (n.includes("free") || (brandCount ?? 0) === 0) return "free";
|
||||
return name;
|
||||
}
|
||||
Reference in New Issue
Block a user