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:
Semih
2026-05-18 00:42:59 +03:00
parent bc67c387cd
commit 0b31685e95
12 changed files with 1707 additions and 0 deletions

View File

@@ -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")
}

View 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 });
}

View File

@@ -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" />

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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);
}

View 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,
}));
}

View 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;
}