/** * Phase 1 — Metric Ground-Truth Audit * ─────────────────────────────────────── * * Run against a live DB to capture the *actual* counts per metric * table for a single brand. Output is written to stdout AND to * scripts/audit-results.md so the ground truth lives alongside the * code-side audit. * * Usage: * DATABASE_URL=postgres://... npx tsx scripts/audit-phase1.ts * # or pick a different brand * BRAND_NAME="Modern Heart" npx tsx scripts/audit-phase1.ts * * This script is READ-ONLY — it only runs aggregate() / groupBy() * / count() queries. Safe to run in production. * * Scoping note: some metric tables are keyed by brandId directly * (SiteConversion, SiteEvent, RealUserMetric, AiInteraction, * AiUsageLog, PlatformEvent, MetricSnapshot, GscPage, TechnicalAudit) * while TrackedEvent and TrackedSession are scoped via TrackedSite. * The script resolves the brand's TrackedSite once and uses * trackedSiteId for those two tables. */ import { PrismaClient } from "@prisma/client"; import { writeFileSync, mkdirSync } from "fs"; import { dirname } from "path"; const prisma = new PrismaClient(); // Accumulator for the markdown report. Every log() call appends to // both stdout and this buffer; the buffer is flushed to // scripts/audit-results.md at the end of the run. const report: string[] = []; function log(line: string) { log(line); report.push(line); } // JSON.stringify replacer that handles BigInt + Date cleanly. function replacer(_key: string, value: unknown): unknown { if (typeof value === "bigint") return value.toString(); if (value instanceof Date) return value.toISOString(); return value; } function j(v: unknown): string { return JSON.stringify(v, replacer); } async function main() { const brandNeedle = process.env.BRAND_NAME ?? "Modern Heart"; const brand = await prisma.brand.findFirst({ where: { name: { contains: brandNeedle, mode: "insensitive" } }, select: { id: true, name: true, domain: true, createdAt: true }, }); if (!brand) { console.error(`No brand matching "${brandNeedle}" found.`); process.exit(1); } const site = await prisma.trackedSite.findUnique({ where: { brandId: brand.id }, select: { id: true, status: true, firstEventAt: true, lastEventAt: true, createdAt: true }, }); log("=== Brand ==="); log(j({ brand })); log("=== TrackedSite ==="); log(j({ site })); // ─── Direct-brandId tables ────────────────────────────────── // // Each aggregate runs independently so a single failing query // doesn't blackhole the rest of the audit. Empty / errored rows // render as ERROR in the output. const brandTables: Array<{ name: string; run: () => Promise }> = [ { name: "SiteConversion", run: () => prisma.siteConversion.aggregate({ where: { brandId: brand.id }, _count: true, _min: { timestamp: true }, _max: { timestamp: true }, }), }, { name: "SiteEvent", run: () => prisma.siteEvent.aggregate({ where: { brandId: brand.id }, _count: true, _min: { timestamp: true }, _max: { timestamp: true }, }), }, { name: "RealUserMetric", run: () => prisma.realUserMetric.aggregate({ where: { brandId: brand.id }, _count: true, _min: { timestamp: true }, _max: { timestamp: true }, }), }, { name: "AiInteraction", run: () => prisma.aiInteraction.aggregate({ where: { brandId: brand.id }, _count: true, _min: { timestamp: true }, _max: { timestamp: true }, }), }, { name: "AiUsageLog", run: () => prisma.aiUsageLog.aggregate({ where: { brandId: brand.id }, _count: true, _min: { timestamp: true }, _max: { timestamp: true }, }), }, { name: "PlatformEvent", run: () => prisma.platformEvent.aggregate({ where: { brandId: brand.id }, _count: true, _min: { timestamp: true }, _max: { timestamp: true }, }), }, { name: "MetricSnapshot", run: () => prisma.metricSnapshot.aggregate({ where: { brandId: brand.id }, _count: true, _min: { date: true }, _max: { date: true }, }), }, { name: "GscPage", run: () => prisma.gscPage.aggregate({ where: { brandId: brand.id }, _count: true, _min: { date: true }, _max: { date: true }, }), }, { name: "TechnicalAudit", run: () => prisma.technicalAudit.aggregate({ where: { brandId: brand.id }, _count: true, _min: { createdAt: true }, _max: { createdAt: true }, }), }, ]; log("\n=== Brand-scoped tables ==="); for (const t of brandTables) { try { const result = await t.run(); log(`${t.name}: ${j(result)}`); } catch (err) { log(`${t.name}: ERROR ${err instanceof Error ? err.message : String(err)}`); } } // ─── TrackedSite-scoped tables ────────────────────────────── log("\n=== TrackedSite-scoped tables ==="); if (!site) { log("TrackedEvent / TrackedSession: SKIPPED (no TrackedSite for brand)"); } else { try { const te = await prisma.trackedEvent.aggregate({ where: { trackedSiteId: site.id }, _count: true, _min: { timestamp: true }, _max: { timestamp: true }, }); log(`TrackedEvent: ${j(te)}`); } catch (err) { log(`TrackedEvent: ERROR ${err instanceof Error ? err.message : String(err)}`); } try { const ts = await prisma.trackedSession.aggregate({ where: { trackedSiteId: site.id }, _count: true, _min: { startedAt: true }, _max: { startedAt: true }, }); log(`TrackedSession: ${j(ts)}`); } catch (err) { log(`TrackedSession: ERROR ${err instanceof Error ? err.message : String(err)}`); } } // ─── Type breakdowns (the numbers the UI actually renders) ── log("\n=== Distinct event-type counts ==="); if (site) { try { const trackedTypes = await prisma.trackedEvent.groupBy({ by: ["eventType"], where: { trackedSiteId: site.id }, _count: true, }); trackedTypes.sort((a, b) => (b._count as unknown as number) - (a._count as unknown as number)); // Top 30 only — longer lists hide the signal. log(`TrackedEvent types (top 30): ${j(trackedTypes.slice(0, 30))}`); log(`TrackedEvent distinct type count: ${trackedTypes.length}`); } catch (err) { log(`TrackedEvent types: ERROR ${err instanceof Error ? err.message : String(err)}`); } } try { const siteConvTypes = await prisma.siteConversion.groupBy({ by: ["conversionType"], where: { brandId: brand.id }, _count: true, }); siteConvTypes.sort((a, b) => (b._count as unknown as number) - (a._count as unknown as number)); // All SiteConversion types — there are rarely more than ~20 // distinct values but capped at 30 for safety. log(`SiteConversion types (top 30): ${j(siteConvTypes.slice(0, 30))}`); log(`SiteConversion distinct type count: ${siteConvTypes.length}`); } catch (err) { log(`SiteConversion types: ERROR ${err instanceof Error ? err.message : String(err)}`); } try { const siteEventTypes = await prisma.siteEvent.groupBy({ by: ["eventType"], where: { brandId: brand.id }, _count: true, }); siteEventTypes.sort((a, b) => (b._count as unknown as number) - (a._count as unknown as number)); log(`SiteEvent types (top 20): ${j(siteEventTypes.slice(0, 20))}`); log(`SiteEvent distinct type count: ${siteEventTypes.length}`); } catch (err) { log(`SiteEvent types: ERROR ${err instanceof Error ? err.message : String(err)}`); } // ─── 30-day windowed ground truth ────────────────────────── // The UI's default range is 30 days. Logging both all-time // and 30-day counts side by side so "why does the dashboard // show 528 conversions when the DB has 3000+?" is obvious. log("\n=== 30-day windowed counts ==="); const since30d = new Date(Date.now() - 30 * 86_400_000); try { const conv30 = await prisma.siteConversion.count({ where: { brandId: brand.id, timestamp: { gte: since30d } }, }); log(`SiteConversion (30d): ${conv30}`); } catch (err) { log(`SiteConversion (30d): ERROR ${err instanceof Error ? err.message : String(err)}`); } if (site) { try { const te30 = await prisma.trackedEvent.count({ where: { trackedSiteId: site.id, timestamp: { gte: since30d } }, }); log(`TrackedEvent (30d): ${te30}`); const sessStart30 = await prisma.trackedEvent.count({ where: { trackedSiteId: site.id, eventType: "session_start", timestamp: { gte: since30d } }, }); log(`TrackedEvent session_start (30d): ${sessStart30}`); const pv30 = await prisma.trackedEvent.count({ where: { trackedSiteId: site.id, eventType: "page_view", timestamp: { gte: since30d } }, }); log(`TrackedEvent page_view (30d): ${pv30}`); const ts30 = await prisma.trackedSession.count({ where: { trackedSiteId: site.id, startedAt: { gte: since30d } }, }); log(`TrackedSession (30d): ${ts30}`); } catch (err) { log(`TrackedEvent/TrackedSession (30d): ERROR ${err instanceof Error ? err.message : String(err)}`); } } try { const gsc30 = await prisma.gscPage.aggregate({ where: { brandId: brand.id, date: { gte: since30d } }, _sum: { clicks: true, impressions: true }, _avg: { position: true, ctr: true }, }); log(`GscPage (30d) aggregate: ${j(gsc30)}`); } catch (err) { log(`GscPage (30d): ERROR ${err instanceof Error ? err.message : String(err)}`); } try { const ai30 = await prisma.aiUsageLog.aggregate({ where: { brandId: brand.id, timestamp: { gte: since30d } }, _count: true, _sum: { estimatedCost: true, inputTokens: true, outputTokens: true }, }); log(`AiUsageLog (30d): ${j(ai30)}`); } catch (err) { log(`AiUsageLog (30d): ERROR ${err instanceof Error ? err.message : String(err)}`); } log("\n=== Audit complete ==="); // Flush the full run to scripts/audit-results.md so the result is // checked in alongside the code-side audit. Wrapping in ``` keeps // the markdown file renderable even for large JSON payloads. const outPath = "scripts/audit-results.md"; const now = new Date().toISOString(); const body = [ "# Phase 1 — Metric Ground-Truth Audit", "", `**Generated:** ${now}`, `**Brand:** ${brand.name} (${brand.id})`, `**Domain:** ${brand.domain}`, "", "This file is produced by `scripts/audit-phase1.ts` and captures", "the raw per-table counts + event-type breakdowns for a single", "brand. These are the **ground-truth numbers** the UI surfaces", "must agree with.", "", "Re-run with `npx tsx scripts/audit-phase1.ts` — the file is", "overwritten in place.", "", "## Full output", "", "```text", ...report, "```", "", ].join("\n"); mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, body, "utf8"); console.log(`\n[audit-phase1] wrote ${outPath} (${report.length} lines)`); } main() .catch((err) => { console.error("[audit-phase1] FATAL", err); process.exit(1); }) .finally(() => prisma.$disconnect());