feat(sase): VIN business-impact dashboard — Faz 3 (no-quota model)
Sase.tr has no monthly VIN quota — access is gated by plan.brandCount + user_brands assignments. The PRD's Faz 3 quota-centric metrics don't apply; this commit ships the 8 business signals that DO apply to that model. All queries are RO joins of query_logs to subscription/plan/brand. 1. Tier × decode volume (30d) — unique users, decode count, decodes/user, and success rate per plan. Plan derived from each user's most-recent active/trial subscription. 2. Brand-tier alignment — for plans with brandCount > 0, the fraction of decodes that landed on a brand the user actually has access to via user_brands. Low alignment = wrong tier or upsell signal. 3. Dormant payers — active subscription + no decode in the last 14 days. Sorted by lastDecodeAt ASC (most dormant first), 30 rows. 4. Empty-handed payers — active subscription + zero lifetime decodes. Onboarding-broken signal, sorted oldest start_date first. 5. Trial → paid funnel — bucket trial users by decodes-during-trial (0, 1–2, 3–5, 6–10, 11–25, 26+) and show the conversion rate (any subsequent active/cancelled subscription) per bucket. Answers "how many decodes is the aha moment". 6. Retention cohort — weekly signup cohorts (mature ≥30d), split by "had a successful decode in first 7 days" vs not; columns show 30d active rate per bucket and the retention lift (pp difference). 7. Power users — top 20 by 30d decode count: plan, decode count, brand diversity, top brand + occurrences, success rate. 8. Tier mismatch — brand-spesifik plan kullanıcıları whose 30d decodes include ≥3 hits on brands they don't have access to. Upsell candidates (or refund/downgrade conversation). All tables link emails to /projects/sase/users/[id] (existing detail page), so the dashboard is a "find users to talk to" tool, not just numbers. VIN dashboard header now has a "Business →" pill next to the time range picker. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
517
apps/web/src/app/projects/sase/vin-decode/business/page.tsx
Normal file
517
apps/web/src/app/projects/sase/vin-decode/business/page.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
import Link from "next/link";
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
getTierUsage,
|
||||
getBrandTierAlignment,
|
||||
getDormantPayers,
|
||||
getEmptyHandedPayers,
|
||||
getTrialFunnel,
|
||||
getRetentionCohorts,
|
||||
getPowerUsers,
|
||||
getTierMismatchUsers,
|
||||
} from "@/lib/sase/business-impact";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BusinessImpactPage() {
|
||||
const [tiers, alignment, dormant, empty, funnel, cohorts, powerUsers, mismatch] =
|
||||
await Promise.all([
|
||||
getTierUsage(30),
|
||||
getBrandTierAlignment(30),
|
||||
getDormantPayers(14, 30),
|
||||
getEmptyHandedPayers(30),
|
||||
getTrialFunnel(90),
|
||||
getRetentionCohorts(8),
|
||||
getPowerUsers(20, 30),
|
||||
getTierMismatchUsers(30, 3, 20),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PanelShell title="Sase · VIN Business Impact">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Link href="/projects/sase/vin-decode" className="hover:underline">
|
||||
← VIN Decode dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Business Impact</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Decode davranışı ile subscription/plan/brand verisinin kesişimi.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── 1. Tier × decode ───────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>1. Tier × decode hacmi (son 30g)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{tiers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Veri yok.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead className="text-right">Brand sayısı</TableHead>
|
||||
<TableHead className="text-right">Kullanıcı</TableHead>
|
||||
<TableHead className="text-right">Decode</TableHead>
|
||||
<TableHead className="text-right">User başına</TableHead>
|
||||
<TableHead className="text-right">Başarı</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tiers.map((t) => (
|
||||
<TableRow key={t.planId ?? "none"}>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{t.planName ?? "—"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{t.planBrandCount === 0
|
||||
? "tüm"
|
||||
: t.planBrandCount ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{t.uniqueUsers.toLocaleString("tr-TR")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{t.totalDecodes.toLocaleString("tr-TR")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{t.decodesPerUser.toFixed(1)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{pct(t.successRate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── 2. Brand-tier alignment ─────────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
2. Brand-tier uyumu (brand-spesifik plan kullanıcıları, son 30g)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{alignment.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Brand-spesifik plan kullanımı yok.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead className="text-right">Brand sayısı</TableHead>
|
||||
<TableHead className="text-right">Toplam</TableHead>
|
||||
<TableHead className="text-right">Atanmış brand</TableHead>
|
||||
<TableHead className="text-right">Off-brand</TableHead>
|
||||
<TableHead className="text-right">Uyum oranı</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{alignment.map((a) => (
|
||||
<TableRow key={a.planName}>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{a.planName}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{a.planBrandCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{a.totalDecodes.toLocaleString("tr-TR")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-emerald-600">
|
||||
{a.onAssignedBrand.toLocaleString("tr-TR")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-destructive">
|
||||
{a.offAssignedBrand.toLocaleString("tr-TR")}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right tabular-nums ${
|
||||
a.alignmentRate >= 0.9
|
||||
? "text-emerald-600"
|
||||
: a.alignmentRate >= 0.75
|
||||
? ""
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{pct(a.alignmentRate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Düşük uyum = ya yanlış tier'da kullanıcı, ya da farklı brand'leri görmek isteyen
|
||||
upsell adayı. Tier-mismatch tablosunda kişi başına ayrıntı var.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── 3. Dormant payers ───────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
3. Dormant payers — active sub var, son 14+ gün decode yok
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{dormant.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Yok — herkes aktif.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>İsim</TableHead>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead className="text-right">Son decode</TableHead>
|
||||
<TableHead className="text-right">Lifetime</TableHead>
|
||||
<TableHead className="text-right">Sub. bitiş</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dormant.map((u) => (
|
||||
<TableRow key={u.userId}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<Link
|
||||
href={`/projects/sase/users/${u.userId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{u.email}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{u.name ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{u.planName ?? "—"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs">
|
||||
{u.daysSinceLastDecode}g önce
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-xs">
|
||||
{u.lifetimeDecodes}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs text-muted-foreground">
|
||||
{u.subscriptionEndsAt
|
||||
? u.subscriptionEndsAt.toISOString().slice(0, 10)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── 4. Empty-handed payers ──────────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
4. Empty-handed payers — active sub, 0 lifetime decode (onboarding kırık)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{empty.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Yok — paying herkes en az bir sorgu yapmış.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>İsim</TableHead>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead className="text-right">Sub. başlangıç</TableHead>
|
||||
<TableHead className="text-right">Süre</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{empty.map((u) => (
|
||||
<TableRow key={u.userId}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<Link
|
||||
href={`/projects/sase/users/${u.userId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{u.email}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{u.name ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{u.planName ?? "—"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs">
|
||||
{u.subscriptionStartedAt
|
||||
? u.subscriptionStartedAt.toISOString().slice(0, 10)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs">
|
||||
{u.daysSinceSubscriptionStart != null
|
||||
? `${u.daysSinceSubscriptionStart}g`
|
||||
: "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── 5. Trial → paid funnel ──────────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
5. Trial → paid funnel — trial dönemi decode sayısı vs conversion (son 90g)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{funnel.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Trial veri yok.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Decode bucket</TableHead>
|
||||
<TableHead className="text-right">Trial user</TableHead>
|
||||
<TableHead className="text-right">Converted</TableHead>
|
||||
<TableHead className="text-right">Conversion oranı</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{funnel.map((b) => (
|
||||
<TableRow key={b.decodeBucket}>
|
||||
<TableCell className="font-mono text-xs">{b.decodeBucket}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{b.trialUsers.toLocaleString("tr-TR")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{b.converted.toLocaleString("tr-TR")}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right tabular-nums ${
|
||||
b.conversionRate >= 0.3
|
||||
? "text-emerald-600"
|
||||
: b.conversionRate >= 0.1
|
||||
? ""
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{pct(b.conversionRate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
"X decode yapan %Y dönüyor" — activation metric. Trial'da hangi sayı "aha moment".
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── 6. Retention cohort ─────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
6. Decode başarısı → retention (ilk hafta success var/yok vs 30g sonrası aktiflik)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{cohorts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Mature olmuş cohort yok.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Cohort</TableHead>
|
||||
<TableHead className="text-right">Toplam</TableHead>
|
||||
<TableHead className="text-right">İlk hafta success</TableHead>
|
||||
<TableHead className="text-right">İlk hafta success'siz</TableHead>
|
||||
<TableHead className="text-right">Retention lift</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cohorts.map((c) => {
|
||||
const hadRate =
|
||||
c.hadFirstWeekSuccess > 0
|
||||
? c.hadFirstWeekSuccessActive30d / c.hadFirstWeekSuccess
|
||||
: 0;
|
||||
const nohRate =
|
||||
c.noFirstWeekSuccess > 0
|
||||
? c.noFirstWeekSuccessActive30d / c.noFirstWeekSuccess
|
||||
: 0;
|
||||
return (
|
||||
<TableRow key={c.cohort}>
|
||||
<TableCell className="font-mono text-xs">{c.cohort}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{c.signupCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs">
|
||||
{c.hadFirstWeekSuccessActive30d}/{c.hadFirstWeekSuccess}{" "}
|
||||
<span className="text-emerald-600">({pct(hadRate)})</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs">
|
||||
{c.noFirstWeekSuccessActive30d}/{c.noFirstWeekSuccess}{" "}
|
||||
<span className="text-muted-foreground">({pct(nohRate)})</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right tabular-nums ${
|
||||
c.retentionLift > 0.1 ? "text-emerald-600" : ""
|
||||
}`}
|
||||
>
|
||||
{c.retentionLift > 0 ? "+" : ""}
|
||||
{(c.retentionLift * 100).toFixed(1)}pp
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── 7. Power users ──────────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>7. Top 20 power user (son 30g decode hacmi)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{powerUsers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Veri yok.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead className="text-right">Decode</TableHead>
|
||||
<TableHead className="text-right">Brand çeşitliliği</TableHead>
|
||||
<TableHead>Top brand</TableHead>
|
||||
<TableHead className="text-right">Başarı</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{powerUsers.map((u) => (
|
||||
<TableRow key={u.userId}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<Link
|
||||
href={`/projects/sase/users/${u.userId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{u.email}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{u.planName ?? "—"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{u.decodeCount.toLocaleString("tr-TR")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{u.uniqueBrands}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{u.topBrand ?? "—"}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
({u.topBrandCount})
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{pct(u.successRate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── 8. Tier mismatch ────────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
8. Tier mismatch — brand-spesifik tier ama atanmış olmayan brand'lerde 3+ sorgu (upsell adayı)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mismatch.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Mismatch yok.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Plan</TableHead>
|
||||
<TableHead>Atanmış brand'ler</TableHead>
|
||||
<TableHead className="text-right">Off-brand</TableHead>
|
||||
<TableHead className="text-right">Off oranı</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mismatch.map((u) => (
|
||||
<TableRow key={u.userId}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<Link
|
||||
href={`/projects/sase/users/${u.userId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{u.email}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{u.planName}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.assignedBrandSlugs.map((b) => (
|
||||
<Badge key={b} variant="secondary" className="font-mono">
|
||||
{b}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-destructive">
|
||||
{u.offBrandDecodes}/{u.totalDecodes}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{pct(u.offBrandRate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
function pct(v: number): string {
|
||||
return `${(v * 100).toFixed(1)}%`;
|
||||
}
|
||||
@@ -87,20 +87,28 @@ export default async function VinDecodePage({
|
||||
{RANGE_LABEL[range]} · {health.totalCount.toLocaleString("tr-TR")} sorgu
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{ALL_RANGES.map((r) => (
|
||||
<Link
|
||||
key={r}
|
||||
href={`?range=${r}`}
|
||||
className={buttonVariants({
|
||||
variant: r === range ? "default" : "outline",
|
||||
size: "sm",
|
||||
})}
|
||||
scroll={false}
|
||||
>
|
||||
{r}
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
{ALL_RANGES.map((r) => (
|
||||
<Link
|
||||
key={r}
|
||||
href={`?range=${r}`}
|
||||
className={buttonVariants({
|
||||
variant: r === range ? "default" : "outline",
|
||||
size: "sm",
|
||||
})}
|
||||
scroll={false}
|
||||
>
|
||||
{r}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href="/projects/sase/vin-decode/business"
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
Business →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
630
apps/web/src/lib/sase/business-impact.ts
Normal file
630
apps/web/src/lib/sase/business-impact.ts
Normal file
@@ -0,0 +1,630 @@
|
||||
import { saseDb } from "@/lib/db-sase";
|
||||
|
||||
// All metrics here join query_logs to subscription/plan/brand to answer
|
||||
// "what is decode behaviour by business segment?" — these are RO Sase queries.
|
||||
|
||||
// ─── 1. Tier × decode volume ──────────────────────────────────────────────
|
||||
export type TierRow = {
|
||||
planId: string | null;
|
||||
planName: string | null;
|
||||
planBrandCount: number | null;
|
||||
uniqueUsers: number;
|
||||
totalDecodes: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
successRate: number;
|
||||
decodesPerUser: number;
|
||||
};
|
||||
|
||||
export async function getTierUsage(days = 30): Promise<TierRow[]> {
|
||||
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
plan_id: string | null;
|
||||
plan_name: string | null;
|
||||
plan_brand_count: number | null;
|
||||
unique_users: bigint;
|
||||
total: bigint;
|
||||
succeeded: bigint;
|
||||
failed: bigint;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
p.id AS plan_id,
|
||||
p.name AS plan_name,
|
||||
p.brand_count AS plan_brand_count,
|
||||
count(DISTINCT q.user_id) AS unique_users,
|
||||
count(*) AS total,
|
||||
count(*) FILTER (WHERE q.success = true) AS succeeded,
|
||||
count(*) FILTER (WHERE q.success = false) AS failed
|
||||
FROM query_logs q
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT us.plan_id
|
||||
FROM user_subscriptions us
|
||||
WHERE us.user_id = q.user_id
|
||||
AND us.status IN ('active', 'trial')
|
||||
ORDER BY us.created_at DESC
|
||||
LIMIT 1
|
||||
) sub ON TRUE
|
||||
LEFT JOIN plans p ON p.id = sub.plan_id
|
||||
WHERE q.created_at >= ${start}
|
||||
GROUP BY p.id, p.name, p.brand_count
|
||||
ORDER BY count(*) DESC
|
||||
`;
|
||||
return rows.map((r) => {
|
||||
const total = Number(r.total);
|
||||
const users = Number(r.unique_users);
|
||||
const succeeded = Number(r.succeeded);
|
||||
return {
|
||||
planId: r.plan_id,
|
||||
planName: r.plan_name,
|
||||
planBrandCount: r.plan_brand_count,
|
||||
uniqueUsers: users,
|
||||
totalDecodes: total,
|
||||
successCount: succeeded,
|
||||
failureCount: Number(r.failed),
|
||||
successRate: total > 0 ? succeeded / total : 0,
|
||||
decodesPerUser: users > 0 ? total / users : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 2. Brand-tier alignment ───────────────────────────────────────────────
|
||||
// For users on brand-specific plans (brandCount > 0), what % of their decodes
|
||||
// match brands they actually have access to (assigned via user_brands)?
|
||||
export type BrandTierAlignmentRow = {
|
||||
planName: string;
|
||||
planBrandCount: number;
|
||||
totalDecodes: number;
|
||||
onAssignedBrand: number;
|
||||
offAssignedBrand: number;
|
||||
alignmentRate: number;
|
||||
};
|
||||
|
||||
export async function getBrandTierAlignment(days = 30): Promise<BrandTierAlignmentRow[]> {
|
||||
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
plan_name: string;
|
||||
plan_brand_count: number;
|
||||
total: bigint;
|
||||
on_brand: bigint;
|
||||
off_brand: bigint;
|
||||
}>
|
||||
>`
|
||||
WITH active_sub AS (
|
||||
SELECT DISTINCT ON (us.user_id)
|
||||
us.user_id, us.id AS sub_id, us.plan_id
|
||||
FROM user_subscriptions us
|
||||
WHERE us.status IN ('active', 'trial')
|
||||
ORDER BY us.user_id, us.created_at DESC
|
||||
)
|
||||
SELECT
|
||||
p.name AS plan_name,
|
||||
p.brand_count AS plan_brand_count,
|
||||
count(*) AS total,
|
||||
count(*) FILTER (
|
||||
WHERE q.brand_id IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM user_brands ub
|
||||
WHERE ub.subscription_id = s.sub_id AND ub.brand_id = q.brand_id
|
||||
)
|
||||
) AS on_brand,
|
||||
count(*) FILTER (
|
||||
WHERE q.brand_id IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM user_brands ub
|
||||
WHERE ub.subscription_id = s.sub_id AND ub.brand_id = q.brand_id
|
||||
)
|
||||
) AS off_brand
|
||||
FROM query_logs q
|
||||
JOIN active_sub s ON s.user_id = q.user_id
|
||||
JOIN plans p ON p.id = s.plan_id
|
||||
WHERE q.created_at >= ${start}
|
||||
AND p.brand_count > 0 -- skip Full plan (no brand restriction)
|
||||
GROUP BY p.name, p.brand_count
|
||||
ORDER BY count(*) DESC
|
||||
`;
|
||||
return rows.map((r) => {
|
||||
const total = Number(r.total);
|
||||
const on = Number(r.on_brand);
|
||||
const off = Number(r.off_brand);
|
||||
const matched = on + off;
|
||||
return {
|
||||
planName: r.plan_name,
|
||||
planBrandCount: r.plan_brand_count,
|
||||
totalDecodes: total,
|
||||
onAssignedBrand: on,
|
||||
offAssignedBrand: off,
|
||||
alignmentRate: matched > 0 ? on / matched : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 3. Dormant payers (active sub + no decode in N days) ─────────────────
|
||||
export type DormantPayer = {
|
||||
userId: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
planName: string | null;
|
||||
subscriptionEndsAt: Date | null;
|
||||
lastDecodeAt: Date | null;
|
||||
daysSinceLastDecode: number | null;
|
||||
lifetimeDecodes: number;
|
||||
};
|
||||
|
||||
export async function getDormantPayers(
|
||||
inactiveDays = 14,
|
||||
limit = 30,
|
||||
): Promise<DormantPayer[]> {
|
||||
const cutoff = new Date(Date.now() - inactiveDays * 24 * 60 * 60_000);
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
user_id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
plan_name: string | null;
|
||||
end_date: Date | null;
|
||||
last_decode_at: Date | null;
|
||||
lifetime_decodes: bigint;
|
||||
}>
|
||||
>`
|
||||
WITH active_sub AS (
|
||||
SELECT DISTINCT ON (us.user_id)
|
||||
us.user_id, us.plan_id, us.end_date
|
||||
FROM user_subscriptions us
|
||||
WHERE us.status = 'active'
|
||||
ORDER BY us.user_id, us.created_at DESC
|
||||
),
|
||||
user_decode AS (
|
||||
SELECT user_id, max(created_at) AS last_decode_at, count(*) AS lifetime
|
||||
FROM query_logs
|
||||
GROUP BY user_id
|
||||
)
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.name,
|
||||
u.email,
|
||||
p.name AS plan_name,
|
||||
s.end_date,
|
||||
ud.last_decode_at,
|
||||
coalesce(ud.lifetime, 0) AS lifetime_decodes
|
||||
FROM users u
|
||||
JOIN active_sub s ON s.user_id = u.id
|
||||
LEFT JOIN plans p ON p.id = s.plan_id
|
||||
LEFT JOIN user_decode ud ON ud.user_id = u.id
|
||||
WHERE ud.last_decode_at IS NOT NULL
|
||||
AND ud.last_decode_at < ${cutoff}
|
||||
ORDER BY ud.last_decode_at ASC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
const now = Date.now();
|
||||
return rows.map((r) => ({
|
||||
userId: r.user_id,
|
||||
name: r.name,
|
||||
email: r.email,
|
||||
planName: r.plan_name,
|
||||
subscriptionEndsAt: r.end_date,
|
||||
lastDecodeAt: r.last_decode_at,
|
||||
daysSinceLastDecode: r.last_decode_at
|
||||
? Math.floor((now - r.last_decode_at.getTime()) / (24 * 60 * 60_000))
|
||||
: null,
|
||||
lifetimeDecodes: Number(r.lifetime_decodes),
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── 4. Empty-handed payers (active sub + 0 lifetime decode) ──────────────
|
||||
export type EmptyHandedPayer = {
|
||||
userId: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
planName: string | null;
|
||||
subscriptionStartedAt: Date | null;
|
||||
daysSinceSubscriptionStart: number | null;
|
||||
};
|
||||
|
||||
export async function getEmptyHandedPayers(limit = 30): Promise<EmptyHandedPayer[]> {
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
user_id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
plan_name: string | null;
|
||||
start_date: Date | null;
|
||||
}>
|
||||
>`
|
||||
WITH active_sub AS (
|
||||
SELECT DISTINCT ON (us.user_id)
|
||||
us.user_id, us.plan_id, us.start_date
|
||||
FROM user_subscriptions us
|
||||
WHERE us.status = 'active'
|
||||
ORDER BY us.user_id, us.created_at DESC
|
||||
)
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.name,
|
||||
u.email,
|
||||
p.name AS plan_name,
|
||||
s.start_date
|
||||
FROM users u
|
||||
JOIN active_sub s ON s.user_id = u.id
|
||||
LEFT JOIN plans p ON p.id = s.plan_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM query_logs q WHERE q.user_id = u.id
|
||||
)
|
||||
ORDER BY s.start_date ASC NULLS LAST
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
const now = Date.now();
|
||||
return rows.map((r) => ({
|
||||
userId: r.user_id,
|
||||
name: r.name,
|
||||
email: r.email,
|
||||
planName: r.plan_name,
|
||||
subscriptionStartedAt: r.start_date,
|
||||
daysSinceSubscriptionStart: r.start_date
|
||||
? Math.floor((now - r.start_date.getTime()) / (24 * 60 * 60_000))
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── 5. Trial → paid funnel (decode count vs conversion) ──────────────────
|
||||
// Bucket trial users by how many decodes they made during their trial period;
|
||||
// see what fraction of each bucket eventually had an 'active' subscription.
|
||||
export type TrialFunnelRow = {
|
||||
decodeBucket: string;
|
||||
trialUsers: number;
|
||||
converted: number;
|
||||
conversionRate: number;
|
||||
};
|
||||
|
||||
export async function getTrialFunnel(days = 90): Promise<TrialFunnelRow[]> {
|
||||
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
bucket: string;
|
||||
trial_users: bigint;
|
||||
converted: bigint;
|
||||
}>
|
||||
>`
|
||||
WITH trial_users AS (
|
||||
SELECT
|
||||
us.user_id,
|
||||
us.start_date AS trial_start,
|
||||
us.end_date AS trial_end,
|
||||
EXISTS (
|
||||
SELECT 1 FROM user_subscriptions us2
|
||||
WHERE us2.user_id = us.user_id
|
||||
AND us2.status IN ('active', 'cancelled')
|
||||
AND us2.created_at > us.created_at
|
||||
) AS converted
|
||||
FROM user_subscriptions us
|
||||
WHERE us.status IN ('trial', 'expired')
|
||||
AND us.created_at >= ${start}
|
||||
),
|
||||
trial_decode_counts AS (
|
||||
SELECT
|
||||
t.user_id,
|
||||
t.converted,
|
||||
count(q.id) AS decode_count
|
||||
FROM trial_users t
|
||||
LEFT JOIN query_logs q
|
||||
ON q.user_id = t.user_id
|
||||
AND q.created_at >= t.trial_start
|
||||
AND q.created_at <= coalesce(t.trial_end, now())
|
||||
GROUP BY t.user_id, t.converted
|
||||
)
|
||||
SELECT
|
||||
CASE
|
||||
WHEN decode_count = 0 THEN '0'
|
||||
WHEN decode_count BETWEEN 1 AND 2 THEN '1-2'
|
||||
WHEN decode_count BETWEEN 3 AND 5 THEN '3-5'
|
||||
WHEN decode_count BETWEEN 6 AND 10 THEN '6-10'
|
||||
WHEN decode_count BETWEEN 11 AND 25 THEN '11-25'
|
||||
ELSE '26+'
|
||||
END AS bucket,
|
||||
count(*) AS trial_users,
|
||||
count(*) FILTER (WHERE converted = true) AS converted
|
||||
FROM trial_decode_counts
|
||||
GROUP BY bucket
|
||||
ORDER BY
|
||||
CASE bucket
|
||||
WHEN '0' THEN 0
|
||||
WHEN '1-2' THEN 1
|
||||
WHEN '3-5' THEN 2
|
||||
WHEN '6-10' THEN 3
|
||||
WHEN '11-25' THEN 4
|
||||
ELSE 5
|
||||
END
|
||||
`;
|
||||
return rows.map((r) => {
|
||||
const trial = Number(r.trial_users);
|
||||
const conv = Number(r.converted);
|
||||
return {
|
||||
decodeBucket: r.bucket,
|
||||
trialUsers: trial,
|
||||
converted: conv,
|
||||
conversionRate: trial > 0 ? conv / trial : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 6. Decode success → retention cohort ─────────────────────────────────
|
||||
// Compare 30-day "still active" rate between users whose first-week had ≥1
|
||||
// successful decode vs users whose first-week had 0 success.
|
||||
export type RetentionCohort = {
|
||||
cohort: string;
|
||||
signupCount: number;
|
||||
hadFirstWeekSuccess: number;
|
||||
hadFirstWeekSuccessActive30d: number;
|
||||
noFirstWeekSuccess: number;
|
||||
noFirstWeekSuccessActive30d: number;
|
||||
retentionLift: number;
|
||||
};
|
||||
|
||||
export async function getRetentionCohorts(weeks = 8): Promise<RetentionCohort[]> {
|
||||
const start = new Date(Date.now() - weeks * 7 * 24 * 60 * 60_000);
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
cohort: string;
|
||||
signup_count: bigint;
|
||||
had_success: bigint;
|
||||
had_success_active: bigint;
|
||||
no_success: bigint;
|
||||
no_success_active: bigint;
|
||||
}>
|
||||
>`
|
||||
WITH cohort_users AS (
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.created_at,
|
||||
date_trunc('week', u.created_at) AS cohort_week,
|
||||
EXISTS (
|
||||
SELECT 1 FROM query_logs q
|
||||
WHERE q.user_id = u.id
|
||||
AND q.success = true
|
||||
AND q.created_at >= u.created_at
|
||||
AND q.created_at < u.created_at + interval '7 days'
|
||||
) AS had_first_week_success
|
||||
FROM users u
|
||||
WHERE u.created_at >= ${start}
|
||||
AND u.created_at < now() - interval '30 days' -- cohort must have had 30d to mature
|
||||
),
|
||||
enriched AS (
|
||||
SELECT
|
||||
cu.cohort_week,
|
||||
cu.had_first_week_success,
|
||||
EXISTS (
|
||||
SELECT 1 FROM user_subscriptions us
|
||||
WHERE us.user_id = cu.user_id
|
||||
AND us.status IN ('active', 'trial')
|
||||
AND (us.end_date IS NULL OR us.end_date >= cu.created_at + interval '30 days')
|
||||
) AS still_active_at_30d
|
||||
FROM cohort_users cu
|
||||
)
|
||||
SELECT
|
||||
to_char(cohort_week, 'YYYY-MM-DD') AS cohort,
|
||||
count(*) AS signup_count,
|
||||
count(*) FILTER (WHERE had_first_week_success = true) AS had_success,
|
||||
count(*) FILTER (WHERE had_first_week_success = true AND still_active_at_30d = true) AS had_success_active,
|
||||
count(*) FILTER (WHERE had_first_week_success = false) AS no_success,
|
||||
count(*) FILTER (WHERE had_first_week_success = false AND still_active_at_30d = true) AS no_success_active
|
||||
FROM enriched
|
||||
GROUP BY cohort_week
|
||||
ORDER BY cohort_week DESC
|
||||
`;
|
||||
return rows.map((r) => {
|
||||
const had = Number(r.had_success);
|
||||
const hadActive = Number(r.had_success_active);
|
||||
const noh = Number(r.no_success);
|
||||
const nohActive = Number(r.no_success_active);
|
||||
const hadRate = had > 0 ? hadActive / had : 0;
|
||||
const nohRate = noh > 0 ? nohActive / noh : 0;
|
||||
return {
|
||||
cohort: r.cohort,
|
||||
signupCount: Number(r.signup_count),
|
||||
hadFirstWeekSuccess: had,
|
||||
hadFirstWeekSuccessActive30d: hadActive,
|
||||
noFirstWeekSuccess: noh,
|
||||
noFirstWeekSuccessActive30d: nohActive,
|
||||
retentionLift: hadRate - nohRate,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 7. Power users (top decode users last 30d) ────────────────────────────
|
||||
export type PowerUser = {
|
||||
userId: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
planName: string | null;
|
||||
planBrandCount: number | null;
|
||||
decodeCount: number;
|
||||
uniqueBrands: number;
|
||||
successRate: number;
|
||||
topBrand: string | null;
|
||||
topBrandCount: number;
|
||||
};
|
||||
|
||||
export async function getPowerUsers(limit = 20, days = 30): Promise<PowerUser[]> {
|
||||
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
user_id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
plan_name: string | null;
|
||||
plan_brand_count: number | null;
|
||||
decode_count: bigint;
|
||||
unique_brands: bigint;
|
||||
succeeded: bigint;
|
||||
top_brand_slug: string | null;
|
||||
top_brand_count: bigint;
|
||||
}>
|
||||
>`
|
||||
WITH user_decode_stats AS (
|
||||
SELECT
|
||||
q.user_id,
|
||||
count(*) AS decode_count,
|
||||
count(DISTINCT q.brand_id) FILTER (WHERE q.brand_id IS NOT NULL) AS unique_brands,
|
||||
count(*) FILTER (WHERE q.success = true) AS succeeded
|
||||
FROM query_logs q
|
||||
WHERE q.created_at >= ${start}
|
||||
GROUP BY q.user_id
|
||||
),
|
||||
top_users AS (
|
||||
SELECT user_id, decode_count, unique_brands, succeeded
|
||||
FROM user_decode_stats
|
||||
ORDER BY decode_count DESC
|
||||
LIMIT ${limit}
|
||||
),
|
||||
top_brand_per_user AS (
|
||||
SELECT DISTINCT ON (q.user_id)
|
||||
q.user_id, b.slug AS top_brand_slug, count(*) OVER (PARTITION BY q.user_id, q.brand_id) AS top_brand_count
|
||||
FROM query_logs q
|
||||
LEFT JOIN brands b ON b.id = q.brand_id
|
||||
WHERE q.created_at >= ${start}
|
||||
AND q.user_id IN (SELECT user_id FROM top_users)
|
||||
ORDER BY q.user_id, count(*) OVER (PARTITION BY q.user_id, q.brand_id) DESC NULLS LAST
|
||||
),
|
||||
active_sub AS (
|
||||
SELECT DISTINCT ON (us.user_id) us.user_id, us.plan_id
|
||||
FROM user_subscriptions us
|
||||
WHERE us.status IN ('active', 'trial')
|
||||
ORDER BY us.user_id, us.created_at DESC
|
||||
)
|
||||
SELECT
|
||||
t.user_id,
|
||||
u.name,
|
||||
u.email,
|
||||
p.name AS plan_name,
|
||||
p.brand_count AS plan_brand_count,
|
||||
t.decode_count,
|
||||
t.unique_brands,
|
||||
t.succeeded,
|
||||
tb.top_brand_slug,
|
||||
tb.top_brand_count
|
||||
FROM top_users t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
LEFT JOIN active_sub s ON s.user_id = t.user_id
|
||||
LEFT JOIN plans p ON p.id = s.plan_id
|
||||
LEFT JOIN top_brand_per_user tb ON tb.user_id = t.user_id
|
||||
ORDER BY t.decode_count DESC
|
||||
`;
|
||||
return rows.map((r) => {
|
||||
const total = Number(r.decode_count);
|
||||
const succ = Number(r.succeeded);
|
||||
return {
|
||||
userId: r.user_id,
|
||||
name: r.name,
|
||||
email: r.email,
|
||||
planName: r.plan_name,
|
||||
planBrandCount: r.plan_brand_count,
|
||||
decodeCount: total,
|
||||
uniqueBrands: Number(r.unique_brands),
|
||||
successRate: total > 0 ? succ / total : 0,
|
||||
topBrand: r.top_brand_slug,
|
||||
topBrandCount: Number(r.top_brand_count),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 8. Tier mismatch (brand-spesifik tier, çok off-brand fail) ────────────
|
||||
// Users on a brand-specific plan who have a lot of decodes on brands they
|
||||
// don't have access to → upsell candidates (or refund/downgrade tartışması).
|
||||
export type TierMismatchUser = {
|
||||
userId: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
planName: string | null;
|
||||
planBrandCount: number;
|
||||
totalDecodes: number;
|
||||
offBrandDecodes: number;
|
||||
offBrandRate: number;
|
||||
assignedBrandSlugs: string[];
|
||||
};
|
||||
|
||||
export async function getTierMismatchUsers(
|
||||
days = 30,
|
||||
minOffBrand = 3,
|
||||
limit = 20,
|
||||
): Promise<TierMismatchUser[]> {
|
||||
const start = new Date(Date.now() - days * 24 * 60 * 60_000);
|
||||
const rows = await saseDb.$queryRaw<
|
||||
Array<{
|
||||
user_id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
plan_name: string;
|
||||
plan_brand_count: number;
|
||||
total: bigint;
|
||||
off_brand: bigint;
|
||||
assigned_brands: string[];
|
||||
}>
|
||||
>`
|
||||
WITH active_sub AS (
|
||||
SELECT DISTINCT ON (us.user_id)
|
||||
us.user_id, us.id AS sub_id, us.plan_id
|
||||
FROM user_subscriptions us
|
||||
WHERE us.status IN ('active', 'trial')
|
||||
ORDER BY us.user_id, us.created_at DESC
|
||||
),
|
||||
user_brand_decode AS (
|
||||
SELECT
|
||||
s.user_id,
|
||||
s.sub_id,
|
||||
p.name AS plan_name,
|
||||
p.brand_count AS plan_brand_count,
|
||||
count(*) AS total,
|
||||
count(*) FILTER (
|
||||
WHERE q.brand_id IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM user_brands ub
|
||||
WHERE ub.subscription_id = s.sub_id AND ub.brand_id = q.brand_id
|
||||
)
|
||||
) AS off_brand
|
||||
FROM query_logs q
|
||||
JOIN active_sub s ON s.user_id = q.user_id
|
||||
JOIN plans p ON p.id = s.plan_id
|
||||
WHERE q.created_at >= ${start}
|
||||
AND p.brand_count > 0
|
||||
GROUP BY s.user_id, s.sub_id, p.name, p.brand_count
|
||||
)
|
||||
SELECT
|
||||
ubd.user_id,
|
||||
u.name,
|
||||
u.email,
|
||||
ubd.plan_name,
|
||||
ubd.plan_brand_count,
|
||||
ubd.total,
|
||||
ubd.off_brand,
|
||||
coalesce(
|
||||
array_agg(b.slug ORDER BY b.slug) FILTER (WHERE b.slug IS NOT NULL),
|
||||
ARRAY[]::text[]
|
||||
) AS assigned_brands
|
||||
FROM user_brand_decode ubd
|
||||
JOIN users u ON u.id = ubd.user_id
|
||||
LEFT JOIN user_brands ub ON ub.subscription_id = (
|
||||
SELECT sub_id FROM active_sub WHERE user_id = ubd.user_id LIMIT 1
|
||||
)
|
||||
LEFT JOIN brands b ON b.id = ub.brand_id
|
||||
WHERE ubd.off_brand >= ${minOffBrand}
|
||||
GROUP BY
|
||||
ubd.user_id, u.name, u.email, ubd.plan_name, ubd.plan_brand_count,
|
||||
ubd.total, ubd.off_brand
|
||||
ORDER BY ubd.off_brand DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
return rows.map((r) => {
|
||||
const total = Number(r.total);
|
||||
const off = Number(r.off_brand);
|
||||
return {
|
||||
userId: r.user_id,
|
||||
name: r.name,
|
||||
email: r.email,
|
||||
planName: r.plan_name,
|
||||
planBrandCount: r.plan_brand_count,
|
||||
totalDecodes: total,
|
||||
offBrandDecodes: off,
|
||||
offBrandRate: total > 0 ? off / total : 0,
|
||||
assignedBrandSlugs: r.assigned_brands ?? [],
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user