152 lines
6.0 KiB
TypeScript
152 lines
6.0 KiB
TypeScript
import { PrismaClient } from "@prisma/client";
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
/**
|
|
* Seeds 90 days of mock metric snapshots, conversion events,
|
|
* and channel metrics for each brand.
|
|
*
|
|
* Run: npx tsx prisma/seed-metrics.ts
|
|
*/
|
|
async function main() {
|
|
console.log("Seeding metrics data...");
|
|
|
|
const brands = await prisma.brand.findMany();
|
|
if (brands.length === 0) {
|
|
console.log("No brands found. Run the main seed first: npx tsx prisma/seed.ts");
|
|
return;
|
|
}
|
|
|
|
const DAYS = 90;
|
|
const channels = ["organic", "ai_search", "social", "gbp", "direct"] as const;
|
|
const convTypes = ["phone", "form", "order", "booking"] as const;
|
|
const pages = ["/", "/pricing", "/features", "/blog/seo-guide", "/contact", "/about", "/products"];
|
|
const keywords = ["seo tool", "project management", "ai visibility", "website audit", "local seo", "content strategy"];
|
|
|
|
for (const brand of brands) {
|
|
console.log(`Seeding metrics for ${brand.name}...`);
|
|
|
|
// Clear existing metric data for this brand
|
|
await prisma.metricSnapshot.deleteMany({ where: { brandId: brand.id } });
|
|
await prisma.conversionEvent.deleteMany({ where: { brandId: brand.id } });
|
|
await prisma.channelMetric.deleteMany({ where: { brandId: brand.id } });
|
|
|
|
// Base values per brand (vary by brand)
|
|
const base = getBrandBase(brand.id);
|
|
|
|
for (let d = DAYS; d >= 0; d--) {
|
|
const date = new Date();
|
|
date.setDate(date.getDate() - d);
|
|
date.setHours(12, 0, 0, 0);
|
|
|
|
// Growth factor — slight upward trend over time
|
|
const growth = 1 + ((DAYS - d) / DAYS) * 0.15;
|
|
const noise = () => 0.85 + Math.random() * 0.3;
|
|
const weekday = date.getDay();
|
|
const weekendDip = weekday === 0 || weekday === 6 ? 0.65 : 1;
|
|
|
|
// Daily snapshot
|
|
const sessions = Math.round(base.sessions * growth * noise() * weekendDip);
|
|
const users = Math.round(sessions * 0.75);
|
|
const organicClicks = Math.round(base.clicks * growth * noise() * weekendDip);
|
|
const dayConversions = Math.round(base.conversions * growth * noise() * weekendDip);
|
|
const dayRevenue = Math.round(base.revenue * growth * noise() * weekendDip);
|
|
|
|
await prisma.metricSnapshot.create({
|
|
data: {
|
|
brandId: brand.id,
|
|
date,
|
|
sessions,
|
|
users,
|
|
pageviews: Math.round(sessions * 2.4),
|
|
bounceRate: Math.round((0.35 + Math.random() * 0.15) * 100) / 100,
|
|
avgSessionSec: Math.round(120 + Math.random() * 120),
|
|
organicClicks,
|
|
impressions: Math.round(organicClicks * (15 + Math.random() * 5)),
|
|
avgPosition: Math.round((10 + Math.random() * 8) * 10) / 10,
|
|
ctr: Math.round((0.04 + Math.random() * 0.03) * 1000) / 1000,
|
|
seoScore: Math.min(100, base.seoScore + Math.round((DAYS - d) / DAYS * 8)),
|
|
crawlHealth: Math.min(100, base.crawlHealth + Math.round((DAYS - d) / DAYS * 5)),
|
|
aiMentions: Math.round(base.aiMentions * noise()),
|
|
revenue: dayRevenue,
|
|
conversions: dayConversions,
|
|
},
|
|
});
|
|
|
|
// Channel metrics
|
|
for (const channel of channels) {
|
|
const channelShare = getChannelShare(channel);
|
|
await prisma.channelMetric.create({
|
|
data: {
|
|
brandId: brand.id,
|
|
date,
|
|
channel,
|
|
sessions: Math.round(sessions * channelShare),
|
|
users: Math.round(users * channelShare),
|
|
conversions: Math.round(dayConversions * channelShare * (channel === "organic" ? 1.3 : 0.8)),
|
|
revenue: Math.round(dayRevenue * channelShare * (channel === "organic" ? 1.3 : 0.8)),
|
|
bounceRate: Math.round((0.3 + Math.random() * 0.2) * 100) / 100,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Conversion events (random number per day)
|
|
const eventCount = Math.max(0, dayConversions + Math.round((Math.random() - 0.5) * 4));
|
|
for (let e = 0; e < eventCount; e++) {
|
|
const type = convTypes[Math.floor(Math.random() * convTypes.length)];
|
|
const channel = weightedChannel();
|
|
const value = type === "order" ? 50 + Math.round(Math.random() * 200) :
|
|
type === "booking" ? 100 + Math.round(Math.random() * 300) :
|
|
type === "form" ? 0 :
|
|
0;
|
|
|
|
await prisma.conversionEvent.create({
|
|
data: {
|
|
brandId: brand.id,
|
|
date,
|
|
type,
|
|
channel,
|
|
value,
|
|
landingPage: pages[Math.floor(Math.random() * pages.length)],
|
|
keyword: Math.random() > 0.3 ? keywords[Math.floor(Math.random() * keywords.length)] : null,
|
|
source: channel === "organic" ? "google" : channel === "social" ? "linkedin" : channel === "gbp" ? "google_maps" : null,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(` → ${DAYS + 1} days of snapshots, channel metrics, and conversion events`);
|
|
}
|
|
|
|
console.log("Metrics seeding complete!");
|
|
}
|
|
|
|
function getBrandBase(brandId: string) {
|
|
const bases: Record<string, { sessions: number; clicks: number; conversions: number; revenue: number; seoScore: number; crawlHealth: number; aiMentions: number }> = {
|
|
"acme-corp": { sessions: 280, clicks: 180, conversions: 8, revenue: 820, seoScore: 68, crawlHealth: 88, aiMentions: 12 },
|
|
"beacon-health": { sessions: 410, clicks: 260, conversions: 12, revenue: 1200, seoScore: 76, crawlHealth: 86, aiMentions: 18 },
|
|
"nova-digital": { sessions: 110, clicks: 60, conversions: 3, revenue: 280, seoScore: 55, crawlHealth: 72, aiMentions: 5 },
|
|
};
|
|
return bases[brandId] ?? bases["acme-corp"];
|
|
}
|
|
|
|
function getChannelShare(channel: string): number {
|
|
const shares: Record<string, number> = {
|
|
organic: 0.55, ai_search: 0.08, social: 0.12, gbp: 0.10, direct: 0.15,
|
|
};
|
|
return shares[channel] ?? 0.1;
|
|
}
|
|
|
|
function weightedChannel(): string {
|
|
const r = Math.random();
|
|
if (r < 0.55) return "organic";
|
|
if (r < 0.63) return "ai_search";
|
|
if (r < 0.75) return "social";
|
|
if (r < 0.85) return "gbp";
|
|
return "direct";
|
|
}
|
|
|
|
main()
|
|
.catch((e) => { console.error(e); process.exit(1); })
|
|
.finally(() => prisma.$disconnect());
|