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:
Semih
2026-05-18 12:49:14 +03:00
parent 717e4ae369
commit e583f6b6be
3 changed files with 1169 additions and 14 deletions

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

View File

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

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