feat(brief): Sase 360° evening brief — page + worker job + Telegram@20:00
Daily Sase brief aggregating VIN decode health, catalog coverage gaps, revenue (Stripe/iyzico TRY), users/subscriptions, PostHog behavior + UTM source, Meta ads (env-gated), and the insight pipeline into one critical-points-first report. - Brief model (panel DB): one row/project/day (date, headline, summary, health score, structured body, raw 360 metrics). - Panel: getSaseBriefMetrics() (saseDb half) + /api/internal/sase-brief-data. - Worker: sase-evening-brief job collects panel metrics + PostHog (hogqlQuery) + Meta (lib/meta, env-gated) + pipeline stats -> DeepSeek structured brief -> upsert Brief -> Telegram. Scheduled 17:00 UTC (20:00 Europe/Istanbul). - UI: /projects/sase/briefs (table of days) + /briefs/[id] (detail: critical-first, sections by status, recommendations, raw metrics). Meta dormant until META_ACCESS_TOKEN/META_AD_ACCOUNT_ID set on the worker. Needs a `briefs` table migration on the panel DB before first run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -292,6 +292,30 @@ model Insight {
|
||||
@@map("insights")
|
||||
}
|
||||
|
||||
// Daily evening brief — one 360° report per project per day. Generated by the
|
||||
// worker (sase-evening-brief job) from Sase DB + PostHog + Meta + pipeline, then
|
||||
// rendered at /projects/sase/briefs and pushed to Telegram at 20:00.
|
||||
model Brief {
|
||||
id String @id @default(cuid())
|
||||
projectKey String @default("sase")
|
||||
briefDate DateTime @db.Date
|
||||
status String @default("generated") // generated | failed
|
||||
headline String @db.Text
|
||||
summary String @db.Text
|
||||
healthScore Int @default(0)
|
||||
body Json // structured: { critical[], sections[], recommendations[] }
|
||||
metrics Json // raw 360 aggregates that fed the LLM (audit + page render)
|
||||
telegramSent Boolean @default(false)
|
||||
sourceModel String @default("")
|
||||
sourceCostUsd Float @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([projectKey, briefDate])
|
||||
@@index([projectKey, briefDate(sort: Desc)])
|
||||
@@map("briefs")
|
||||
}
|
||||
|
||||
model CostLedger {
|
||||
id String @id @default(cuid())
|
||||
insightId String?
|
||||
|
||||
29
apps/web/src/app/api/internal/sase-brief-data/route.ts
Normal file
29
apps/web/src/app/api/internal/sase-brief-data/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { getSaseBriefMetrics } from "@/lib/sase/brief-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const INTERNAL_TOKEN = process.env.INTERNAL_WORKER_TOKEN ?? "";
|
||||
|
||||
function validToken(req: Request): boolean {
|
||||
if (!INTERNAL_TOKEN) return false;
|
||||
const provided = req.headers.get("x-internal-worker-token") ?? "";
|
||||
if (!provided) return false;
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(INTERNAL_TOKEN);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
if (!validToken(req)) {
|
||||
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
try {
|
||||
const data = await getSaseBriefMetrics();
|
||||
return NextResponse.json({ ok: true, data });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,12 @@ export async function SaseHealth() {
|
||||
>
|
||||
VIN Decode →
|
||||
</Link>
|
||||
<Link
|
||||
href="/projects/sase/briefs"
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
Briefs →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
120
apps/web/src/app/projects/sase/briefs/[id]/page.tsx
Normal file
120
apps/web/src/app/projects/sase/briefs/[id]/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Section = { key?: string; title?: string; status?: string; bullets?: string[] };
|
||||
type BriefBody = {
|
||||
headline?: string;
|
||||
summary?: string;
|
||||
health_score?: number;
|
||||
critical?: string[];
|
||||
sections?: Section[];
|
||||
recommendations?: string[];
|
||||
};
|
||||
|
||||
function statusDot(status?: string): string {
|
||||
if (status === "good") return "🟢";
|
||||
if (status === "bad") return "🔴";
|
||||
if (status === "warn") return "🟡";
|
||||
return "⚪";
|
||||
}
|
||||
|
||||
export default async function BriefDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const brief = await prisma.brief.findUnique({ where: { id } });
|
||||
if (!brief) notFound();
|
||||
|
||||
const body = brief.body as unknown as BriefBody;
|
||||
const healthVariant =
|
||||
brief.healthScore >= 70 ? "default" : brief.healthScore >= 40 ? "secondary" : "destructive";
|
||||
|
||||
return (
|
||||
<PanelShell title={`Sase Brief · ${brief.briefDate.toISOString().slice(0, 10)}`}>
|
||||
<Link href="/projects/sase/briefs" className="text-xs underline text-muted-foreground">
|
||||
← Tüm briefler
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={healthVariant as "default" | "secondary" | "destructive"}>
|
||||
Sağlık {brief.healthScore}/100
|
||||
</Badge>
|
||||
{brief.status === "failed" && <Badge variant="destructive">failed</Badge>}
|
||||
{brief.telegramSent && <Badge variant="outline">📨 Telegram</Badge>}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{brief.sourceModel || "—"} · ${brief.sourceCostUsd.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{body.headline && <h2 className="text-xl font-semibold">{body.headline}</h2>}
|
||||
{body.summary && <p className="text-sm text-muted-foreground">{body.summary}</p>}
|
||||
|
||||
{Array.isArray(body.critical) && body.critical.length > 0 && (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base text-destructive">🔴 Kritik noktalar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="ml-4 list-disc space-y-1 text-sm">
|
||||
{body.critical.map((c, i) => (
|
||||
<li key={i}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{(body.sections ?? []).map((s, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{statusDot(s.status)} {s.title ?? s.key ?? "Bölüm"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="ml-4 list-disc space-y-1 text-sm">
|
||||
{(s.bullets ?? []).map((b, j) => (
|
||||
<li key={j}>{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{Array.isArray(body.recommendations) && body.recommendations.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">✅ Öneriler (yarın)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="ml-4 list-decimal space-y-1 text-sm">
|
||||
{body.recommendations.map((r, i) => (
|
||||
<li key={i}>{r}</li>
|
||||
))}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Beslendiği ham veri (360°)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground">metrics JSON</summary>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap rounded-md bg-muted/30 p-3 font-mono">
|
||||
{JSON.stringify(brief.metrics, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
94
apps/web/src/app/projects/sase/briefs/page.tsx
Normal file
94
apps/web/src/app/projects/sase/briefs/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function HealthBadge({ score }: { score: number }) {
|
||||
const variant = score >= 70 ? "default" : score >= 40 ? "secondary" : "destructive";
|
||||
return <Badge variant={variant as "default" | "secondary" | "destructive"}>{score}</Badge>;
|
||||
}
|
||||
|
||||
export default async function BriefsPage() {
|
||||
const briefs = await prisma.brief.findMany({
|
||||
where: { projectKey: "sase" },
|
||||
orderBy: { briefDate: "desc" },
|
||||
take: 90,
|
||||
});
|
||||
|
||||
return (
|
||||
<PanelShell title="Sase · Briefs">
|
||||
<Link href="/projects/sase" className="text-xs underline text-muted-foreground">
|
||||
← Sase
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Her akşam 20:00'de üretilen 360° günlük brief — VIN decode → gelir → davranış → reklam →
|
||||
insight pipeline. Detay için güne tıkla.
|
||||
</p>
|
||||
|
||||
{briefs.length === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Henüz brief yok</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
İlk brief bu akşam 20:00'de (17:00 UTC) üretilecek ve Telegram'a düşecek.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-28">Tarih</TableHead>
|
||||
<TableHead className="w-16">Skor</TableHead>
|
||||
<TableHead>Başlık</TableHead>
|
||||
<TableHead className="w-28">Durum</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{briefs.map((b) => (
|
||||
<TableRow key={b.id}>
|
||||
<TableCell className="font-mono">
|
||||
<Link href={`/projects/sase/briefs/${b.id}`} className="underline">
|
||||
{b.briefDate.toISOString().slice(0, 10)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HealthBadge score={b.healthScore} />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[520px] truncate">
|
||||
{b.status === "failed" ? (
|
||||
<span className="text-destructive">{b.headline}</span>
|
||||
) : (
|
||||
b.headline
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{b.telegramSent ? (
|
||||
<Badge variant="outline">📨 gönderildi</Badge>
|
||||
) : b.status === "failed" ? (
|
||||
<Badge variant="destructive">failed</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">üretildi</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
77
apps/web/src/lib/sase/brief-data.ts
Normal file
77
apps/web/src/lib/sase/brief-data.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { saseDb } from "@/lib/db-sase";
|
||||
import { getOperationalHealth } from "./vin-decode";
|
||||
import { detectCatalogCoverageGaps } from "./catalog-coverage";
|
||||
import {
|
||||
getSaseHealthSnapshot,
|
||||
getSaseUserCounts,
|
||||
getInsightSummary,
|
||||
getRecentDeployStatuses,
|
||||
} from "./daily-overview";
|
||||
|
||||
// The Sase-DB half of the evening brief — everything that needs `saseDb`
|
||||
// (the panel is the only process with that connection). The worker fetches this
|
||||
// over the internal API and adds PostHog/Meta/pipeline before the LLM pass.
|
||||
export async function getSaseBriefMetrics() {
|
||||
const since24 = new Date(Date.now() - 24 * 3600_000);
|
||||
const since7 = new Date(Date.now() - 7 * 24 * 3600_000);
|
||||
|
||||
const [health, op, gaps, users, insights, deploys, rev24, rev7, pend7, byMethod7] =
|
||||
await Promise.all([
|
||||
getSaseHealthSnapshot(),
|
||||
getOperationalHealth("24h"),
|
||||
detectCatalogCoverageGaps({ windowDays: 7, minFailures: 3 }),
|
||||
getSaseUserCounts(),
|
||||
getInsightSummary(),
|
||||
getRecentDeployStatuses(5),
|
||||
saseDb.payment.aggregate({ where: { status: "completed", createdAt: { gte: since24 } }, _sum: { amount: true }, _count: true }),
|
||||
saseDb.payment.aggregate({ where: { status: "completed", createdAt: { gte: since7 } }, _sum: { amount: true }, _count: true }),
|
||||
saseDb.payment.aggregate({ where: { status: "pending", createdAt: { gte: since7 } }, _sum: { amount: true }, _count: true }),
|
||||
saseDb.payment.groupBy({ by: ["method"], where: { status: "completed", createdAt: { gte: since7 } }, _sum: { amount: true }, _count: true }),
|
||||
]);
|
||||
|
||||
// amount is stored in kuruş (TRY cents).
|
||||
const kurus = (v: number | null | undefined) => Number(v ?? 0) / 100;
|
||||
|
||||
return {
|
||||
decode: {
|
||||
total24h: health.total24h,
|
||||
successRate24h: round(health.successRate24h * 100, 1),
|
||||
successDeltaPp: round(health.successDeltaPp, 1),
|
||||
volumeDeltaPct: round(health.volumeDeltaPct * 100, 0),
|
||||
errorRatePct: round(op.errorRate * 100, 1),
|
||||
timeouts24h: op.timeoutCount,
|
||||
unknownVins24h: op.unknownVinCount,
|
||||
cacheHitRatePct: round(op.cacheHitRate * 100, 0),
|
||||
p95ResponseMs: op.p95ResponseMs,
|
||||
},
|
||||
catalogGaps: gaps.slice(0, 10).map((g) => ({
|
||||
brand: g.brand,
|
||||
failures: g.failures,
|
||||
users: g.uniqueUsers,
|
||||
years: g.years,
|
||||
})),
|
||||
revenue: {
|
||||
currency: "TRY",
|
||||
completed24h: kurus(rev24._sum.amount),
|
||||
completed24hCount: rev24._count,
|
||||
completed7d: kurus(rev7._sum.amount),
|
||||
completed7dCount: rev7._count,
|
||||
pending7d: kurus(pend7._sum.amount),
|
||||
pending7dCount: pend7._count,
|
||||
byMethod7d: byMethod7.map((m) => ({ method: m.method, amount: kurus(m._sum.amount), count: m._count })),
|
||||
},
|
||||
users,
|
||||
insights,
|
||||
deploys: deploys.map((d) => ({
|
||||
commit: d.commit?.slice(0, 8) ?? null,
|
||||
regressed: d.regressed,
|
||||
deltaPp: d.successRateDeltaPp,
|
||||
startedAt: d.startedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function round(n: number, d: number): number {
|
||||
const f = 10 ** d;
|
||||
return Math.round(n * f) / f;
|
||||
}
|
||||
Reference in New Issue
Block a user