3466 lines
136 KiB
Plaintext
3466 lines
136 KiB
Plaintext
generator client {
|
||
provider = "prisma-client-js"
|
||
}
|
||
|
||
datasource db {
|
||
provider = "postgresql"
|
||
url = env("DATABASE_URL")
|
||
directUrl = env("DIRECT_URL")
|
||
}
|
||
|
||
// ─── User (synced from Clerk) ────────────────────────────────────────────────
|
||
|
||
model User {
|
||
id String @id
|
||
name String?
|
||
email String @unique
|
||
image String?
|
||
// IANA timezone string (e.g. "America/Chicago"). Used by scheduled
|
||
// reports + cron timing so runs fire at the user's local hour
|
||
// rather than platform UTC. Falls back to "America/New_York" in
|
||
// the email report generator when null.
|
||
timezone String?
|
||
// Email notification preferences. JSON blob so new toggles can be
|
||
// added without a migration. Shape:
|
||
// { signalAlerts: true, weeklyDigest: true, auditCompletion: true,
|
||
// reportReady: true, teamActivity: false }
|
||
// Read by the alert / report senders before dispatch; a missing
|
||
// key defaults to true for the core channels (signal/audit/report)
|
||
// so new users don't go dark on important events by default.
|
||
emailPrefs Json?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
memberships OrgMembership[]
|
||
brandAccess BrandMembership[]
|
||
assignedTasks Task[]
|
||
contentBriefs ContentBrief[]
|
||
avatarConversations AvatarConversation[]
|
||
}
|
||
|
||
// ─── Organization ────────────────────────────────────────────────────────────
|
||
|
||
model Organization {
|
||
id String @id @default(cuid())
|
||
name String
|
||
slug String @unique
|
||
plan String @default("free")
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
memberships OrgMembership[]
|
||
brands Brand[]
|
||
trackedSites TrackedSite[]
|
||
subscription Subscription?
|
||
planUsage OrgPlanUsage?
|
||
whiteLabelConfig WhiteLabelConfig?
|
||
customDomains CustomDomain[]
|
||
}
|
||
|
||
model OrgMembership {
|
||
id String @id @default(cuid())
|
||
role String @default("member")
|
||
createdAt DateTime @default(now())
|
||
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
orgId String
|
||
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([userId, orgId])
|
||
}
|
||
|
||
// Per-brand access control. OrgMembership says "this user is in
|
||
// the org" (and owners/admins can see every brand in the org by
|
||
// virtue of that). BrandMembership is the finer-grained cut:
|
||
// non-owner members only see brands they've been explicitly added
|
||
// to. Resolving access order:
|
||
// 1. Owner role on the org → sees every brand unconditionally
|
||
// 2. BrandMembership row → sees that specific brand
|
||
// 3. neither → no access (sidebar hides brand,
|
||
// API returns 403)
|
||
//
|
||
// Kept as a join table rather than a String[] on User so the
|
||
// per-brand role can diverge from the org role (e.g. Admin on the
|
||
// org but Member on one specific brand) without contortions.
|
||
model BrandMembership {
|
||
id String @id @default(cuid())
|
||
role String @default("member") // owner | admin | member | viewer
|
||
createdAt DateTime @default(now())
|
||
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([userId, brandId])
|
||
@@index([brandId])
|
||
@@index([userId])
|
||
}
|
||
|
||
// ─── Brand / Website ─────────────────────────────────────────────────────────
|
||
|
||
model Brand {
|
||
id String @id @default(cuid())
|
||
name String
|
||
domain String
|
||
industry String?
|
||
// Legacy single-string fields. Kept populated alongside the array
|
||
// fields below (location = locations[0], serviceType =
|
||
// primaryServices.join(", ")) so consumers that haven't migrated to
|
||
// the helpers in @/lib/brand-helpers continue to read sensible
|
||
// values during the rollout. Slated for removal once every consumer
|
||
// reads via getBrandLocations() / getBrandServices() —
|
||
// tracked as BACKLOG-002.
|
||
serviceType String?
|
||
location String?
|
||
// Multi-location and categorized service support.
|
||
// String[] @default([]) is non-destructive on `prisma db push`:
|
||
// adds the columns with empty-array defaults so existing rows
|
||
// backfill to {} automatically. The /api/admin/backfill-brand-arrays
|
||
// endpoint (idempotent, dryRun-aware) populates these from the
|
||
// legacy fields above for brands that pre-date the multi-array UX.
|
||
locations String[] @default([])
|
||
primaryServices String[] @default([])
|
||
secondaryServices String[] @default([])
|
||
servicesExtractedAt DateTime?
|
||
initials String
|
||
// ── Plan / billing ────────────────────────────────────────────
|
||
// Plan the brand is currently on. "trial" grants Professional-
|
||
// level access for 14 days (enforced via planExpiresAt); after
|
||
// that it downgrades to Growth features but data is retained.
|
||
// Stripe + webhook wiring for real billing lives outside this
|
||
// model — this is purely the entitlement state feature gates
|
||
// read from.
|
||
plan String @default("trial") // trial | growth | professional | agency | agency_pro | enterprise
|
||
billingCycle String @default("monthly") // monthly | annual
|
||
planStartedAt DateTime @default(now())
|
||
planExpiresAt DateTime?
|
||
// AI credit bucket. Reset monthly by a cron (or on subscription
|
||
// renewal). Feature handlers increment aiCreditsUsed; the
|
||
// upgrade modal fires when usage >= 80% of the plan's quota.
|
||
aiCreditsUsed Int @default(0)
|
||
aiCreditsResetAt DateTime?
|
||
// ── Compliance & privacy ──────────────────────────────────────
|
||
// When healthcareMode = true, the Site Tag strips query params,
|
||
// form field names, and referrer search queries so PHI never
|
||
// crosses the wire. Set per-brand by admin or auto-suggested
|
||
// when industry detection flags healthcare.
|
||
healthcareMode Boolean @default(false)
|
||
// Per-brand data retention override in days. Null = use the
|
||
// platform default (13 months / 395 days, matching GA4). The
|
||
// data-retention cron honours both the platform default and
|
||
// the per-brand override before purging.
|
||
dataRetentionDays Int?
|
||
// DPA signed timestamp — null when not yet signed. Null doesn't
|
||
// necessarily mean missing; the Compliance dashboard treats
|
||
// brands flagged as "Not Required" separately.
|
||
dpaSignedAt DateTime?
|
||
dpaStatus String @default("not_required") // not_required | pending | signed
|
||
color String @default("bg-brand-500")
|
||
status String @default("active") // active | inactive | suspended | deleted
|
||
healthScore Int @default(0)
|
||
deactivatedAt DateTime?
|
||
deactivatedBy String?
|
||
deactivationReason String?
|
||
statusChangedAt DateTime?
|
||
statusChangedBy String?
|
||
// ── Data quality verification ─────────────────────────────────
|
||
verifiedAt DateTime?
|
||
lastVerificationScore Float?
|
||
lastVerificationIssues Json?
|
||
// External converter domains registered per-brand. Stored as JSON array
|
||
// of { domain, nickname, isActive, registeredAt } objects so no new
|
||
// table is needed. Outbound clicks to registered domains fire an
|
||
// additional external_conversion_initiated event server-side.
|
||
externalConverters Json @default("[]")
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
orgId String
|
||
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||
|
||
memberships BrandMembership[]
|
||
dashboardData DashboardData?
|
||
websites Website[]
|
||
profile BrandProfile?
|
||
integrations BrandIntegration[]
|
||
metricSnapshots MetricSnapshot[]
|
||
conversions ConversionEvent[]
|
||
channelMetrics ChannelMetric[]
|
||
tasks Task[]
|
||
pageLaunches PageLaunch[]
|
||
contentBriefs ContentBrief[]
|
||
technicalAudits TechnicalAudit[]
|
||
deadPages DeadPage[]
|
||
deadPageScans DeadPageScan[]
|
||
perfAudits PerformanceAudit[]
|
||
aiVisibility AiVisibilitySnapshot[]
|
||
competitors Competitor[]
|
||
competitorSnapshots CompetitorSnapshot[]
|
||
analysisJobs AnalysisJob[]
|
||
crmDeals CrmDeal[]
|
||
attributionRecords RevenueAttribution[]
|
||
journeys UserJourney[]
|
||
avatarConversations AvatarConversation[]
|
||
trackedSite TrackedSite?
|
||
gscQueries GscQuery[]
|
||
gscPages GscPage[]
|
||
gbpLocations GbpLocation[]
|
||
gbpReviews GbpReview[]
|
||
gbpMetrics GbpMetric[]
|
||
pageRecords PageRecord[]
|
||
redirectRecords RedirectRecord[]
|
||
navSnapshots NavSnapshot[]
|
||
siteConversions SiteConversion[]
|
||
heatmapEvents HeatmapEvent[]
|
||
realUserMetrics RealUserMetric[]
|
||
siteEvents SiteEvent[]
|
||
siteTagDailyRollups SiteTagDailyRollup[]
|
||
pageSeoSnapshots PageSEOSnapshot[]
|
||
studioPages StudioPage[]
|
||
brandAssets BrandAsset[]
|
||
trackedKeywords TrackedKeyword[]
|
||
emailReportSchedules EmailReportSchedule[]
|
||
clientPortal ClientPortal?
|
||
keywordResearches KeywordResearch[]
|
||
savedKeywords SavedKeyword[]
|
||
savedLayouts SavedLayout[]
|
||
industryBriefings IndustryBriefing[]
|
||
backlinks Backlink[]
|
||
backlinkSnapshots BacklinkSnapshot[]
|
||
alertRules AlertRule[]
|
||
notifications Notification[]
|
||
predictions Prediction[]
|
||
recommendationOutcomes RecommendationOutcome[]
|
||
clientMemories ClientMemory[]
|
||
schemaMarkups SchemaMarkup[]
|
||
writingDocuments WritingDocument[]
|
||
contentAuditResults ContentAuditResult[]
|
||
auditScoreHistory AuditScoreHistory[]
|
||
aiVisibilityChecks AiVisibilityCheck[]
|
||
aiVisibilityQueries AiVisibilityQuery[]
|
||
enrichmentFiles EnrichmentFile[]
|
||
strategistSessions StrategistSession[]
|
||
// ── Paid Media Attribution ─────────────────────────────────
|
||
visitorProfiles VisitorProfile[]
|
||
householdClusters HouseholdCluster[]
|
||
ottImpressions OttImpression[]
|
||
aiAttributions AiAttribution[]
|
||
aiCitations AiCitation[]
|
||
aiRecommendations AiRecommendation[]
|
||
dashboardSnapshots DashboardSnapshot[]
|
||
adPlatformIntegrations AdPlatformIntegration[]
|
||
adCampaignData AdCampaignData[]
|
||
adClickEvents AdClickEvent[]
|
||
attributionPaths AttributionPath[]
|
||
discoveryJobs CompetitorDiscoveryJob[]
|
||
snippetDetectedAt DateTime?
|
||
snippetInstallationMethod String?
|
||
snippetCheckedAt DateTime?
|
||
// Demo brands are created by admins for showcase/sales purposes.
|
||
// Guards on cron jobs + aggregate admin queries exclude demo brands
|
||
// so their synthetic data never skews platform metrics.
|
||
isDemo Boolean @default(false)
|
||
renderTier String @default("base") // "base" | "js"
|
||
healthAlerts BrandHealthAlert[]
|
||
audits Audit[]
|
||
detectedIntegrations DetectedIntegration[]
|
||
conversionConfigs BrandConversionConfig[]
|
||
marketPositionSnapshots MarketPositionSnapshot[]
|
||
siteAuditRuns SiteAuditRun[]
|
||
}
|
||
|
||
// ─── Detected Integrations ───────────────────────────────────────────────────
|
||
|
||
model DetectedIntegration {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
name String
|
||
category String
|
||
vendor String?
|
||
version String?
|
||
firstDetectedAt DateTime @default(now())
|
||
lastDetectedAt DateTime @default(now())
|
||
detectionSources String[] @default([])
|
||
pageCount Int @default(1)
|
||
samplePages String[] @default([])
|
||
evidence Json?
|
||
status String @default("active")
|
||
isActive Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([brandId, name])
|
||
@@index([brandId, category])
|
||
@@index([brandId, lastDetectedAt(sort: Desc)])
|
||
@@index([brandId, status])
|
||
}
|
||
|
||
// ─── Per-brand conversion counting configuration ─────────────────────────────
|
||
|
||
model BrandConversionConfig {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
conversionType String
|
||
isCounted Boolean @default(true)
|
||
tierOverride String?
|
||
displayLabel String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([brandId, conversionType])
|
||
@@index([brandId])
|
||
}
|
||
|
||
// ─── Competitor Discovery Queue ──────────────────────────────────────────────
|
||
|
||
model CompetitorDiscoveryJob {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
status String @default("pending") // pending | running | completed | failed
|
||
focus String // local | national
|
||
locations Json // string[] — location strings to iterate
|
||
limit Int @default(20)
|
||
|
||
totalLocations Int @default(0)
|
||
processedLocations Int @default(0)
|
||
competitorsFound Int @default(0)
|
||
|
||
startedAt DateTime?
|
||
completedAt DateTime?
|
||
error String?
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([status, createdAt])
|
||
@@index([brandId])
|
||
}
|
||
|
||
// ─── Brand Profile (AI Intake Data) ──────────────────────────────────────────
|
||
|
||
model BrandProfile {
|
||
id String @id @default(cuid())
|
||
companyName String?
|
||
logoUrl String?
|
||
brandColors String[] @default([])
|
||
primaryColor String? // hex: "#FF0000"
|
||
secondaryColor String?
|
||
accentColor String?
|
||
fontFamily String? // e.g. "Inter, sans-serif"
|
||
services String[] @default([])
|
||
locations String[] @default([])
|
||
businessType String?
|
||
industry String?
|
||
targetAudience String?
|
||
description String?
|
||
shortDescription String? // AI-generated one-liner
|
||
brandVoice String? // "professional" | "medical" | "friendly" etc.
|
||
toneHints String? // longer description of brand communication style
|
||
lightBackground String? // derived: light theme bg hex
|
||
darkBackground String? // derived: dark theme bg hex
|
||
textColor String? // derived: preferred text color hex
|
||
extractedAt DateTime? // when auto-extraction last ran
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
brandId String @unique
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
}
|
||
|
||
// ─── Website / Domain ────────────────────────────────────────────────────────
|
||
|
||
model Website {
|
||
id String @id @default(cuid())
|
||
url String
|
||
isVerified Boolean @default(false)
|
||
isPrimary Boolean @default(false)
|
||
lastCrawled DateTime?
|
||
pageCount Int @default(0)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
}
|
||
|
||
// ─── Brand Integrations ──────────────────────────────────────────────────────
|
||
|
||
model BrandIntegration {
|
||
id String @id @default(cuid())
|
||
integrationId String // "gsc" | "ga4" | "gbp" | "bing"
|
||
connected Boolean @default(false)
|
||
status String @default("idle") // "idle"|"connected"|"syncing"|"error"|"expired"
|
||
lastSynced DateTime?
|
||
syncError String?
|
||
syncRetryCount Int @default(0) // how many consecutive failures
|
||
nextRetryAt DateTime? // when to retry after failure
|
||
|
||
// Encrypted credentials (tokens, API keys, refresh tokens)
|
||
// Encrypted with AES-256-GCM before storage — never stored in plain text.
|
||
credentialsEnc String? // encrypted JSON blob
|
||
credentialsMeta Json @default("{}") // non-secret metadata: scopes, account name, expiry
|
||
tokenExpiresAt DateTime? // when the access token expires
|
||
// Consecutive token-refresh failures. We tolerate 2 blips (network
|
||
// hiccup, transient Google 5xx) before marking status="expired" — a
|
||
// single refresh-endpoint outage shouldn't force the user to reconnect.
|
||
refreshFailCount Int @default(0)
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([brandId, integrationId])
|
||
}
|
||
|
||
// ─── Google Search Console Data ──────────────────────────────────────────────
|
||
// Granular GSC data stored per brand per sync.
|
||
// GscQuery: per-keyword performance (clicks, impressions, CTR, position)
|
||
// GscPage: per-landing-page performance
|
||
|
||
model GscQuery {
|
||
id String @id @default(cuid())
|
||
query String // the search keyword
|
||
clicks Int @default(0)
|
||
impressions Int @default(0)
|
||
ctr Float @default(0) // 0.0 to 1.0
|
||
position Float @default(0) // average position
|
||
date DateTime // which day this row covers
|
||
createdAt DateTime @default(now())
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([brandId, date])
|
||
@@index([brandId, query])
|
||
}
|
||
|
||
model GscPage {
|
||
id String @id @default(cuid())
|
||
pageUrl String // the landing page URL
|
||
clicks Int @default(0)
|
||
impressions Int @default(0)
|
||
ctr Float @default(0)
|
||
position Float @default(0)
|
||
date DateTime
|
||
createdAt DateTime @default(now())
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([brandId, date])
|
||
@@index([brandId, pageUrl])
|
||
}
|
||
|
||
// ─── Google Business Profile Data ────────────────────────────────────────────
|
||
// GbpLocation: the business location linked to a brand
|
||
// GbpReview: individual reviews from the GBP listing
|
||
// GbpMetric: periodic performance metrics (views, searches, actions)
|
||
|
||
model GbpLocation {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
googleLocationId String
|
||
locationName String
|
||
address String?
|
||
city String?
|
||
state String?
|
||
zip String?
|
||
phone String?
|
||
website String?
|
||
category String?
|
||
placeId String?
|
||
latitude Float?
|
||
longitude Float?
|
||
isActive Boolean @default(true)
|
||
groupName String?
|
||
tags String[] @default([])
|
||
avgRating Float @default(0)
|
||
totalReviews Int @default(0)
|
||
connectedAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
metrics GbpMetric[]
|
||
reviews GbpReview[]
|
||
|
||
@@unique([brandId, googleLocationId])
|
||
@@index([brandId, isActive])
|
||
@@index([brandId, groupName])
|
||
}
|
||
|
||
model GbpReview {
|
||
id String @id @default(cuid())
|
||
locationId String
|
||
location GbpLocation @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||
googleReviewId String?
|
||
reviewerName String?
|
||
rating Int
|
||
comment String?
|
||
replyText String?
|
||
repliedAt DateTime?
|
||
publishedAt DateTime
|
||
sentiment String?
|
||
topics String[] @default([])
|
||
createdAt DateTime @default(now())
|
||
|
||
// Legacy brand-level fields — kept for backward compat
|
||
brandId String?
|
||
brand Brand? @relation(fields: [brandId], references: [id])
|
||
|
||
@@unique([locationId, googleReviewId])
|
||
@@index([locationId, publishedAt])
|
||
@@index([locationId, rating])
|
||
}
|
||
|
||
model GbpMetric {
|
||
id String @id @default(cuid())
|
||
locationId String
|
||
location GbpLocation @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||
date DateTime
|
||
searchesTotal Int?
|
||
searchesDirect Int?
|
||
searchesDiscovery Int?
|
||
searchesBranded Int?
|
||
viewsTotal Int?
|
||
viewsMaps Int?
|
||
viewsSearch Int?
|
||
websiteClicks Int?
|
||
phoneClicks Int?
|
||
directionClicks Int?
|
||
messageCount Int?
|
||
bookingCount Int?
|
||
createdAt DateTime @default(now())
|
||
|
||
// Legacy brand-level fields
|
||
brandId String?
|
||
brand Brand? @relation(fields: [brandId], references: [id])
|
||
|
||
@@unique([locationId, date])
|
||
@@index([locationId, date])
|
||
}
|
||
|
||
// ─── Dashboard Data ──────────────────────────────────────────────────────────
|
||
|
||
model DashboardData {
|
||
id String @id @default(cuid())
|
||
seoScore Int @default(0)
|
||
aiVisibility Int @default(0)
|
||
crawlHealth Int @default(0)
|
||
monthlyTraffic Int @default(0)
|
||
revenue Int @default(0)
|
||
revenueChange Float @default(0)
|
||
alertCount Int @default(0)
|
||
|
||
brandId String @unique
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
}
|
||
|
||
// ─── Daily Metric Snapshots ──────────────────────────────────────────────────
|
||
// One row per brand per day. Stores aggregated daily metrics.
|
||
|
||
model MetricSnapshot {
|
||
id String @id @default(cuid())
|
||
date DateTime
|
||
source String @default("manual") // "ga4" | "gsc" | "site_tag" | "daily" | "manual" | "seed"
|
||
// GA4 metrics
|
||
sessions Int @default(0)
|
||
users Int @default(0)
|
||
pageviews Int @default(0)
|
||
bounceRate Float @default(0)
|
||
avgSessionSec Int @default(0)
|
||
conversions Int @default(0)
|
||
revenue Int @default(0)
|
||
// GSC metrics
|
||
organicClicks Int @default(0)
|
||
impressions Int @default(0)
|
||
avgPosition Float @default(0)
|
||
ctr Float @default(0)
|
||
// Audit / AI
|
||
seoScore Int @default(0)
|
||
crawlHealth Int @default(0)
|
||
aiMentions Int @default(0)
|
||
// ── Site Tag metrics (added 2026-04) ──
|
||
// Captured by the daily snapshot cron from TrackedSession +
|
||
// SiteConversion + TrackedEvent. Preserved permanently so the
|
||
// platform retains a historical record even after per-event rows
|
||
// age out of the data-retention window.
|
||
siteTagSessions Int?
|
||
siteTagPageViews Int?
|
||
siteTagConversions Int?
|
||
siteTagFormSubmits Int?
|
||
siteTagPhoneCalls Int?
|
||
siteTagBookings Int?
|
||
siteTagEvents Int?
|
||
siteTagOutboundClicks Int?
|
||
// ── Platform metrics ──
|
||
aiCalls Int?
|
||
aiCost Float?
|
||
// ── Paid media (when ad platform connected) ──
|
||
adSpend Float?
|
||
adClicks Int?
|
||
adImpressions Int?
|
||
adConversions Int?
|
||
|
||
updatedAt DateTime @default(now()) @updatedAt
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([brandId, date, source])
|
||
@@index([brandId, date])
|
||
}
|
||
|
||
// ─── Conversion Events ───────────────────────────────────────────────────────
|
||
// Individual conversion records. type: phone | form | order | booking
|
||
|
||
model ConversionEvent {
|
||
id String @id @default(cuid())
|
||
date DateTime
|
||
type String
|
||
channel String
|
||
value Float @default(0)
|
||
source String?
|
||
landingPage String?
|
||
keyword String?
|
||
createdAt DateTime @default(now())
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
crmDeals CrmDeal[]
|
||
journeys UserJourney[]
|
||
|
||
@@index([brandId, date])
|
||
@@index([brandId, type])
|
||
@@index([brandId, channel])
|
||
}
|
||
|
||
// ─── Channel Metrics ─────────────────────────────────────────────────────────
|
||
// Daily per-channel breakdown. channel: organic | ai_search | social | gbp | direct
|
||
|
||
model ChannelMetric {
|
||
id String @id @default(cuid())
|
||
date DateTime
|
||
channel String
|
||
source String @default("manual") // "ga4" | "gsc" | "manual" | "seed"
|
||
sessions Int @default(0)
|
||
users Int @default(0)
|
||
conversions Int @default(0)
|
||
revenue Int @default(0)
|
||
bounceRate Float @default(0)
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([brandId, date, channel, source])
|
||
@@index([brandId, date])
|
||
}
|
||
|
||
// ─── Execution Hub: Tasks ────────────────────────────────────────────────────
|
||
// category: seo | llmo | gbp | content | technical
|
||
// status: todo | in_progress | completed
|
||
// priority: low | medium | high | critical
|
||
// effort: low | medium | high
|
||
|
||
model Task {
|
||
id String @id @default(cuid())
|
||
title String
|
||
description String?
|
||
category String
|
||
status String @default("todo")
|
||
priority String @default("medium")
|
||
effort String @default("medium")
|
||
pageUrl String?
|
||
recommendation String?
|
||
source String? // "technical_audit" | "dead_pages" | "performance_audit" | "signals" | "recommendations" | "manual"
|
||
sourceId String? // ID of the source record (issue ID, signal ID, etc.)
|
||
sourceRunAt DateTime? // timestamp of the source run that produced this task
|
||
dueDate DateTime?
|
||
completedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
assignedTo String?
|
||
assignee User? @relation(fields: [assignedTo], references: [id], onDelete: SetNull)
|
||
|
||
// Content pipeline fields
|
||
contentType String? // blog_post | landing_page | service_page | case_study | social_post
|
||
pipelineStage String? // idea | brief_created | writing | editing | review | approved | published
|
||
briefId String? // ContentBrief reference
|
||
studioPageId String? // StudioPage reference
|
||
publishedUrl String?
|
||
publishedAt DateTime?
|
||
wordCount Int?
|
||
targetKeyword String?
|
||
|
||
pageLaunch PageLaunch?
|
||
|
||
@@index([brandId, status])
|
||
@@index([brandId, category])
|
||
@@index([brandId, pipelineStage])
|
||
@@index([assignedTo])
|
||
@@index([brandId, dueDate])
|
||
}
|
||
|
||
// ─── Performance Since Launch: Page Tracking ─────────────────────────────────
|
||
// Tracks a page from launch through performance monitoring.
|
||
// Links to the task that triggered the launch.
|
||
|
||
model PageLaunch {
|
||
id String @id @default(cuid())
|
||
pageUrl String
|
||
pageType String
|
||
launchDate DateTime
|
||
optimizedDate DateTime?
|
||
notes String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
// Baseline metrics at launch
|
||
baselineSessions Int @default(0)
|
||
baselineClicks Int @default(0)
|
||
baselineImpressions Int @default(0)
|
||
baselinePosition Float @default(0)
|
||
baselineConversions Int @default(0)
|
||
baselineRevenue Int @default(0)
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
// Optional link to the task that triggered this launch
|
||
taskId String? @unique
|
||
task Task? @relation(fields: [taskId], references: [id], onDelete: SetNull)
|
||
|
||
// Post-launch metric snapshots
|
||
snapshots PageLaunchSnapshot[]
|
||
|
||
@@index([brandId])
|
||
@@index([brandId, launchDate])
|
||
}
|
||
|
||
// Post-launch performance snapshots (weekly or periodic)
|
||
model PageLaunchSnapshot {
|
||
id String @id @default(cuid())
|
||
date DateTime
|
||
sessions Int @default(0)
|
||
clicks Int @default(0)
|
||
impressions Int @default(0)
|
||
position Float @default(0)
|
||
conversions Int @default(0)
|
||
revenue Int @default(0)
|
||
|
||
pageLaunchId String
|
||
pageLaunch PageLaunch @relation(fields: [pageLaunchId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([pageLaunchId, date])
|
||
@@index([pageLaunchId, date])
|
||
}
|
||
|
||
// ─── Content Briefs (SEO + LLMO) ─────────────────────────────────────────────
|
||
// type: "seo" | "llmo"
|
||
// status: "draft" | "review" | "approved" | "published"
|
||
|
||
model ContentBrief {
|
||
id String @id @default(cuid())
|
||
type String
|
||
status String @default("draft")
|
||
title String
|
||
|
||
// ── Input fields (what the user provides) ──
|
||
primaryKeyword String?
|
||
secondaryKeywords String[] @default([])
|
||
city String?
|
||
state String?
|
||
service String?
|
||
industry String?
|
||
brandName String?
|
||
ctaOffer String?
|
||
serviceAreaNotes String?
|
||
uniqueSellingPoints String[] @default([])
|
||
|
||
// LLMO-specific inputs
|
||
primaryTopic String?
|
||
longTailKeywords String[] @default([])
|
||
conversationalPhrases String[] @default([])
|
||
relatedQuestions String[] @default([])
|
||
|
||
// ── Settings ──
|
||
tone String @default("professional")
|
||
humanizationMode Boolean @default(false)
|
||
contentScore Int @default(0)
|
||
|
||
// ── Sub-scores (JSON object) ──
|
||
// SEO: { keywordCoverage, internalLinkingStrength, contentStructure }
|
||
// LLMO: { conversationalDepth, longTailCoverage, answerReadyFormat }
|
||
subScores Json @default("{}")
|
||
|
||
// ── Generated output (stored as JSON) ──
|
||
sections Json @default("[]")
|
||
internalLinks Json @default("[]")
|
||
writerNotes String?
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
createdById String?
|
||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||
|
||
@@index([brandId, type])
|
||
@@index([brandId, status])
|
||
}
|
||
|
||
// ─── Technical Audit ─────────────────────────────────────────────────────────
|
||
|
||
model TechnicalAudit {
|
||
id String @id @default(cuid())
|
||
status String @default("pending") // "pending" | "running" | "completed" | "failed"
|
||
crawlHealth Int @default(0)
|
||
pagesScanned Int @default(0)
|
||
errorCount Int @default(0)
|
||
warningCount Int @default(0)
|
||
noticeCount Int @default(0)
|
||
passedCount Int @default(0)
|
||
duration Int @default(0)
|
||
errorMessage String?
|
||
createdAt DateTime @default(now())
|
||
completedAt DateTime?
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
issues TechnicalIssue[]
|
||
|
||
@@index([brandId])
|
||
}
|
||
|
||
model TechnicalIssue {
|
||
id String @id @default(cuid())
|
||
category String
|
||
severity String
|
||
title String
|
||
detail String?
|
||
pageUrl String?
|
||
affectedUrls Json @default("[]") // JSON array of affected URL strings
|
||
evidence Json @default("[]") // JSON array of structured evidence objects
|
||
affected Int @default(1)
|
||
status String @default("open")
|
||
|
||
auditId String
|
||
audit TechnicalAudit @relation(fields: [auditId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([auditId, category])
|
||
@@index([auditId, severity])
|
||
}
|
||
|
||
// ─── Dead 404 Pages ──────────────────────────────────────────────────────────
|
||
|
||
model DeadPageScan {
|
||
id String @id @default(cuid())
|
||
status String @default("pending") // "pending" | "running" | "completed" | "failed"
|
||
pagesChecked Int @default(0)
|
||
deadFound Int @default(0)
|
||
errorMessage String?
|
||
completedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([brandId])
|
||
}
|
||
|
||
model DeadPage {
|
||
id String @id @default(cuid())
|
||
deadUrl String
|
||
priority String @default("medium")
|
||
suggestedFix String?
|
||
status String @default("open")
|
||
discoveredAt DateTime @default(now())
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
sources DeadPageSource[]
|
||
|
||
@@index([brandId, status])
|
||
}
|
||
|
||
model DeadPageSource {
|
||
id String @id @default(cuid())
|
||
sourceUrl String
|
||
anchorText String?
|
||
|
||
deadPageId String
|
||
deadPage DeadPage @relation(fields: [deadPageId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([deadPageId])
|
||
}
|
||
|
||
// ─── Performance Audit ───────────────────────────────────────────────────────
|
||
|
||
model PerformanceAudit {
|
||
id String @id @default(cuid())
|
||
device String @default("mobile")
|
||
status String @default("pending") // "pending" | "running" | "completed" | "failed"
|
||
errorMessage String?
|
||
completedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
pages PerformancePageScore[]
|
||
|
||
@@index([brandId])
|
||
}
|
||
|
||
model PerformancePageScore {
|
||
id String @id @default(cuid())
|
||
pageUrl String
|
||
device String @default("mobile")
|
||
performance Int @default(0)
|
||
accessibility Int @default(0)
|
||
bestPractices Int @default(0)
|
||
seo Int @default(0)
|
||
lcpMs Int @default(0)
|
||
inpMs Int @default(0)
|
||
clsVal Float @default(0)
|
||
fcpMs Int @default(0)
|
||
ttfbMs Int @default(0)
|
||
status String @default("average")
|
||
|
||
auditId String
|
||
audit PerformanceAudit @relation(fields: [auditId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([auditId])
|
||
@@index([auditId, pageUrl])
|
||
}
|
||
|
||
// ─── AI Visibility ───────────────────────────────────────────────────────────
|
||
// platform: chatgpt | claude | perplexity | gemini | google_aio
|
||
|
||
model AiVisibilitySnapshot {
|
||
id String @id @default(cuid())
|
||
date DateTime
|
||
platform String
|
||
topic String
|
||
mentions Int @default(0)
|
||
citations Int @default(0)
|
||
visibilityScore Int @default(0)
|
||
sourceUrl String?
|
||
change Int @default(0)
|
||
createdAt DateTime @default(now())
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([brandId, date])
|
||
@@index([brandId, platform])
|
||
@@index([brandId, topic])
|
||
}
|
||
|
||
// ─── Competitors ─────────────────────────────────────────────────────────────
|
||
// type: national | local | emerging
|
||
|
||
model Competitor {
|
||
id String @id @default(cuid())
|
||
name String
|
||
domain String
|
||
type String @default("national")
|
||
logoUrl String?
|
||
strongestPage String?
|
||
whyWinning String?
|
||
watchlist Boolean @default(false)
|
||
isActive Boolean @default(true)
|
||
// Cached sitemap URLs discovered during the last crawl so subsequent
|
||
// crawls can skip the discovery phase and go direct.
|
||
sitemapUrls Json? // string[] of absolute sitemap URLs
|
||
discoveredForLocations Json? // string[] — locations where this competitor was discovered. Null for manual adds and pre-migration rows.
|
||
lastCrawledAt DateTime?
|
||
detectedCms String? // wordpress | shopify | wix | squarespace | etc.
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
snapshots CompetitorSnapshot[]
|
||
rankings CompetitorRanking[]
|
||
pages CompetitorPage[]
|
||
|
||
@@unique([brandId, domain])
|
||
@@index([brandId, isActive])
|
||
}
|
||
|
||
model CompetitorSnapshot {
|
||
id String @id @default(cuid())
|
||
date DateTime @default(now())
|
||
movement String?
|
||
rankChange Int @default(0)
|
||
aiMentions Int @default(0)
|
||
aiMentionChange Int @default(0)
|
||
trafficEstimate Int @default(0)
|
||
topKeyword String?
|
||
threat String @default("medium")
|
||
// Sitemap monitoring
|
||
totalPages Int?
|
||
newPages Json?
|
||
removedPages Json?
|
||
changedPages Json?
|
||
sitemapUrls Json?
|
||
title String?
|
||
metaDescription String?
|
||
techStack Json?
|
||
crawledAt DateTime @default(now())
|
||
createdAt DateTime @default(now())
|
||
|
||
competitorId String
|
||
competitor Competitor @relation(fields: [competitorId], references: [id], onDelete: Cascade)
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([competitorId, crawledAt])
|
||
@@index([brandId, date])
|
||
}
|
||
|
||
model CompetitorRanking {
|
||
id String @id @default(cuid())
|
||
competitorId String
|
||
competitor Competitor @relation(fields: [competitorId], references: [id], onDelete: Cascade)
|
||
keyword String
|
||
position Int?
|
||
url String?
|
||
checkedAt DateTime @default(now())
|
||
|
||
@@index([competitorId, keyword, checkedAt])
|
||
}
|
||
|
||
// Per-page data from sitemap crawling. Each row = one URL found in a
|
||
// competitor's sitemap. Status is updated on every crawl to track new
|
||
// content, updates, and removals over time.
|
||
model CompetitorPage {
|
||
id String @id @default(cuid())
|
||
competitorId String
|
||
competitor Competitor @relation(fields: [competitorId], references: [id], onDelete: Cascade)
|
||
url String
|
||
title String?
|
||
metaDescription String?
|
||
h1 String?
|
||
wordCount Int?
|
||
lastmod DateTime?
|
||
changefreq String? // always | hourly | daily | weekly | monthly | yearly | never
|
||
priority Float? // 0.0 – 1.0 from sitemap
|
||
firstSeenAt DateTime @default(now())
|
||
lastCheckedAt DateTime @default(now())
|
||
status String @default("new") // new | updated | unchanged | removed
|
||
|
||
@@unique([competitorId, url])
|
||
@@index([competitorId, status])
|
||
@@index([competitorId, firstSeenAt])
|
||
}
|
||
|
||
// ─── CRM Deals ───────────────────────────────────────────────────────────────
|
||
// source: hubspot | salesforce | highlevel | manual
|
||
// stage: lead | qualified | proposal | negotiation | closed_won | closed_lost
|
||
|
||
model CrmDeal {
|
||
id String @id @default(cuid())
|
||
externalId String?
|
||
source String @default("manual")
|
||
contactName String?
|
||
contactEmail String?
|
||
dealName String
|
||
value Float @default(0)
|
||
stage String @default("lead")
|
||
status String @default("open")
|
||
closeDate DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
// Link to conversion that created this deal
|
||
conversionId String?
|
||
conversion ConversionEvent? @relation(fields: [conversionId], references: [id], onDelete: SetNull)
|
||
|
||
attributions RevenueAttribution[]
|
||
journeys UserJourney[]
|
||
|
||
@@index([brandId, status])
|
||
@@index([brandId, stage])
|
||
@@index([source])
|
||
}
|
||
|
||
// ─── Revenue Attribution ─────────────────────────────────────────────────────
|
||
// model: first_touch | last_touch | assisted
|
||
|
||
model RevenueAttribution {
|
||
id String @id @default(cuid())
|
||
attributionModel String @default("last_touch")
|
||
channel String
|
||
landingPage String?
|
||
conversionType String?
|
||
revenue Float @default(0)
|
||
creditPct Float @default(100)
|
||
date DateTime
|
||
createdAt DateTime @default(now())
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
dealId String?
|
||
deal CrmDeal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
|
||
|
||
@@index([brandId, date])
|
||
@@index([brandId, channel])
|
||
@@index([brandId, attributionModel])
|
||
}
|
||
|
||
// ─── User Journeys (Closed-Loop Tracking) ────────────────────────────────────
|
||
|
||
model UserJourney {
|
||
id String @id @default(cuid())
|
||
entryChannel String
|
||
pagesVisited String[] @default([])
|
||
conversionType String?
|
||
revenue Float @default(0)
|
||
date DateTime
|
||
createdAt DateTime @default(now())
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
conversionId String?
|
||
conversion ConversionEvent? @relation(fields: [conversionId], references: [id], onDelete: SetNull)
|
||
|
||
dealId String?
|
||
deal CrmDeal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
|
||
|
||
@@index([brandId, date])
|
||
@@index([brandId, entryChannel])
|
||
}
|
||
|
||
// ─── Notifications (System/Account Events) ───────────────────────────────────
|
||
// category: login | system | security | integration | task | billing | workspace
|
||
// status: unread | read | dismissed
|
||
|
||
model Notification {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
alertRuleId String?
|
||
alertRule AlertRule? @relation(fields: [alertRuleId], references: [id])
|
||
userId String?
|
||
title String
|
||
message String
|
||
type String @default("info")
|
||
severity String @default("medium")
|
||
channel String @default("in_app")
|
||
isRead Boolean @default(false)
|
||
readAt DateTime?
|
||
emailSent Boolean @default(false)
|
||
smsSent Boolean @default(false)
|
||
smsError String?
|
||
metadata Json?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([brandId, isRead, createdAt])
|
||
@@index([userId, isRead])
|
||
@@index([brandId, channel])
|
||
}
|
||
|
||
// ─── AI Avatar Conversations ─────────────────────────────────────────────────
|
||
// Each conversation belongs to a user + brand and contains messages.
|
||
|
||
model AvatarConversation {
|
||
id String @id @default(cuid())
|
||
title String @default("New conversation")
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
messages AvatarMessage[]
|
||
|
||
@@index([userId, brandId, updatedAt])
|
||
}
|
||
|
||
model AvatarMessage {
|
||
id String @id @default(cuid())
|
||
role String // "user" or "assistant"
|
||
content String
|
||
module String? // which module was active (e.g. "Technical Audit")
|
||
metadata Json @default("{}") // actions, followUps, context
|
||
createdAt DateTime @default(now())
|
||
|
||
conversationId String
|
||
conversation AvatarConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([conversationId, createdAt])
|
||
}
|
||
|
||
// ─── meSEO Site Tag (Machine Enhancing Tracking ID) ─────────────────────────
|
||
//
|
||
// TrackedSite: one per brand — holds the unique site_id and tracking_key
|
||
// TrackedSession: one per visitor session (30-min inactivity timeout)
|
||
// TrackedEvent: each page view, click, or custom event
|
||
//
|
||
// How IDs work:
|
||
// siteId = "ms_" + cuid (public, embedded in the snippet)
|
||
// trackingKey = random 32-char hex string (secret, used to validate)
|
||
|
||
model TrackedSite {
|
||
id String @id @default(cuid())
|
||
siteId String @unique // public ID: "ms_clxyz..."
|
||
trackingKey String @unique // secret key for validation
|
||
status String @default("active") // active | paused | disabled
|
||
allowedDomains String[] @default([]) // e.g. ["acme.com", "www.acme.com"]
|
||
lastEventAt DateTime? // timestamp of most recent event
|
||
// When the Site Tag actually started capturing data — the
|
||
// timestamp of the *first* TrackedEvent or SiteEvent for this
|
||
// site. Distinct from createdAt (which is just when the row
|
||
// was inserted; the snippet may sit uninstalled for days or
|
||
// weeks before the customer actually deploys it). Written
|
||
// once, lazily — the first /api/collect hit after this column
|
||
// is null backfills it via an idempotent updateMany. Null when
|
||
// the site exists but has never phoned home.
|
||
firstEventAt DateTime?
|
||
// Custom events configured via the /site-tag UI. Each entry:
|
||
// { id, name, triggerType, triggerConfig, properties, isActive }
|
||
customEvents Json @default("[]")
|
||
sitePlatform String?
|
||
sitePlatformVersion String?
|
||
sitePlatformConfidence String?
|
||
sitePlatformCategory String?
|
||
sitePlatformDetectedAt DateTime?
|
||
siteHosting String?
|
||
siteHostingDetectedAt DateTime?
|
||
// When true, "load_after_engage" iframe heuristic signals are promoted
|
||
// to completed-tier form_submit for this brand. Default off -- the
|
||
// heuristic is unreliable enough that it should only be enabled after
|
||
// confirming in prod that load-after-engage correlates with real leads.
|
||
countInferredIframeSubmits Boolean @default(false)
|
||
// When true, only SiteConversion rows with successConfirmed=true count
|
||
// toward this brand's conversion total. Default false -- all rows count,
|
||
// preserving existing behaviour for every brand.
|
||
requireSuccessConfirmed Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
orgId String
|
||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||
|
||
brandId String @unique // one tracked site per brand
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
sessions TrackedSession[]
|
||
events TrackedEvent[]
|
||
canonicalEvents CanonicalEvent[]
|
||
sourceEvents SourceEvent[]
|
||
|
||
@@index([siteId])
|
||
@@index([orgId])
|
||
}
|
||
|
||
model TrackedSession {
|
||
id String @id @default(cuid())
|
||
sessionId String @unique // random ID set by the tracker JS
|
||
startedAt DateTime @default(now())
|
||
lastSeenAt DateTime @default(now())
|
||
pageCount Int @default(1)
|
||
entryPage String // first page URL in the session
|
||
referrer String? // external referrer if any
|
||
utmSource String?
|
||
utmMedium String?
|
||
utmCampaign String?
|
||
userAgent String?
|
||
country String?
|
||
device String? // "mobile" | "desktop" | "tablet"
|
||
// Additional client context captured on session_start / first
|
||
// page_view. Populated from the incoming request's headers
|
||
// (User-Agent parse + x-vercel-ip-* geo headers) — never from
|
||
// the client payload, so a spoofed t.js can't pollute these
|
||
// columns. All optional: historical sessions and non-Vercel
|
||
// deploys keep rendering with nulls where values aren't
|
||
// available.
|
||
browser String?
|
||
os String?
|
||
city String?
|
||
region String?
|
||
ipAddress String? // admin-only surface — never returned to brand users
|
||
// ── Extended UTM + ad-click attribution (Phase 1, 2026-04) ──
|
||
// utmSource / utmMedium / utmCampaign already exist above. The
|
||
// three fields below complete the full UTM spec so the attribution
|
||
// model can segment by keyword (utm_term) and creative variant
|
||
// (utm_content) in addition to source/medium/campaign.
|
||
utmTerm String?
|
||
utmContent String?
|
||
// Ad-platform click IDs. Present when the visitor landed from a
|
||
// paid ad. Matched against AdClickEvent rows server-side so the
|
||
// attribution path can trace campaign → ad group → ad → session →
|
||
// conversion. Only the ID is stored (no PII). Nullable: organic /
|
||
// direct / referral sessions have no click ID.
|
||
gclid String? // Google Ads
|
||
fbclid String? // Meta (Facebook / Instagram)
|
||
msclkid String? // Microsoft Ads (Bing)
|
||
ttclid String? // TikTok Ads
|
||
li_fat_id String? // LinkedIn Ads
|
||
dclid String? // Google Display & Video 360
|
||
wbraid String? // Google Ads (iOS web-to-app)
|
||
gbraid String? // Google Ads (iOS app-to-web)
|
||
// Server-computed channel group. Derived from the combination of
|
||
// referrer + UTM params + click IDs at session-start time. Values
|
||
// follow GA4's Default Channel Group taxonomy so cross-source
|
||
// comparisons read naturally.
|
||
channelGroup String? // Paid Search | Paid Social | Organic Search | Direct | Referral | Email | Display | OTT/CTV | Other
|
||
// ── Predictive Conversion Scoring (Phase 9) ──
|
||
predictedConversionScore Float? @default(0)
|
||
scoreLastComputedAt DateTime?
|
||
scoreContributingFactors Json?
|
||
|
||
trackedSiteId String
|
||
trackedSite TrackedSite @relation(fields: [trackedSiteId], references: [id], onDelete: Cascade)
|
||
|
||
events TrackedEvent[]
|
||
|
||
@@index([trackedSiteId, startedAt])
|
||
@@index([sessionId])
|
||
}
|
||
|
||
model TrackedEvent {
|
||
id String @id @default(cuid())
|
||
eventType String // "page_view" | "session_start" | custom
|
||
pageUrl String
|
||
referrer String?
|
||
timestamp DateTime @default(now())
|
||
metadata Json @default("{}") // custom key-value pairs
|
||
synthetic Boolean @default(false)
|
||
|
||
trackedSiteId String
|
||
trackedSite TrackedSite @relation(fields: [trackedSiteId], references: [id], onDelete: Cascade)
|
||
|
||
sessionId String?
|
||
session TrackedSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
|
||
|
||
@@index([trackedSiteId, timestamp])
|
||
@@index([trackedSiteId, eventType])
|
||
// Composite index for the common "this site + this event type + recent
|
||
// window" query pattern (session_start counts, page_view groupBys, etc.)
|
||
// — without it Postgres picks one of the two-column indexes and filters
|
||
// the remaining predicate in-row, which gets expensive as TrackedEvent
|
||
// grows into the millions.
|
||
@@index([trackedSiteId, eventType, timestamp])
|
||
// Supports domain-status queries: WHERE trackedSiteId = $1 AND pageUrl LIKE 'https://domain/%'
|
||
// Sargable prefix scan after the contains->startsWith fix for M18.
|
||
// Production deploy: run manually with CONCURRENTLY to avoid table lock.
|
||
@@index([trackedSiteId, pageUrl, timestamp])
|
||
@@index([sessionId])
|
||
// Cross-brand timestamp range queries (admin overview groupBy, retention
|
||
// cron) need this standalone index — without it the WHERE timestamp >= ?
|
||
// query cannot use any index and does a full sequential scan.
|
||
@@index([timestamp])
|
||
}
|
||
|
||
// ─── Data Reconciliation ─────────────────────────────────────────────────────
|
||
//
|
||
// How it works:
|
||
// 1. SourceEvent — a raw event from ANY source (Site Tag, GA4, GTM, GSC, CRM)
|
||
// 2. CanonicalEvent — the deduplicated, reconciled "truth" event
|
||
// 3. Each CanonicalEvent links to 1+ SourceEvents that describe the same action
|
||
//
|
||
// Example:
|
||
// A form_submit might arrive from both Site Tag and GA4.
|
||
// → 2 SourceEvent rows (one per source)
|
||
// → 1 CanonicalEvent row (the reconciled truth, confidence 94%)
|
||
|
||
model CanonicalEvent {
|
||
id String @id @default(cuid())
|
||
eventType String // "page_view", "form_submit", etc.
|
||
pageUrl String
|
||
timestamp DateTime
|
||
confidence Int @default(100) // 0-100, how sure we are this is correct
|
||
status String @default("verified") // "verified" | "inferred" | "unmatched"
|
||
journeyNote String? // e.g. "Session → Form → CRM lead"
|
||
metadata Json @default("{}")
|
||
createdAt DateTime @default(now())
|
||
|
||
trackedSiteId String
|
||
trackedSite TrackedSite @relation(fields: [trackedSiteId], references: [id], onDelete: Cascade)
|
||
|
||
sourceEvents SourceEvent[]
|
||
|
||
@@index([trackedSiteId, eventType])
|
||
@@index([trackedSiteId, timestamp])
|
||
}
|
||
|
||
model SourceEvent {
|
||
id String @id @default(cuid())
|
||
source String // "site_tag" | "ga4" | "gtm" | "gsc" | "crm"
|
||
eventType String
|
||
pageUrl String
|
||
timestamp DateTime
|
||
isDuplicate Boolean @default(false) // true if this was identified as a dupe
|
||
rawData Json @default("{}") // original payload from the source
|
||
createdAt DateTime @default(now())
|
||
|
||
trackedSiteId String
|
||
trackedSite TrackedSite @relation(fields: [trackedSiteId], references: [id], onDelete: Cascade)
|
||
|
||
canonicalEventId String?
|
||
canonicalEvent CanonicalEvent? @relation(fields: [canonicalEventId], references: [id], onDelete: SetNull)
|
||
|
||
@@index([trackedSiteId, source])
|
||
@@index([trackedSiteId, isDuplicate])
|
||
@@index([trackedSiteId, timestamp])
|
||
@@index([trackedSiteId, createdAt])
|
||
@@index([canonicalEventId])
|
||
}
|
||
|
||
// ─── Billing & Subscriptions ─────────────────────────────────────────────────
|
||
//
|
||
// Plan: defines what a subscription includes (limits, features, price)
|
||
// Subscription: links an Organization to a Plan with status and dates
|
||
// OrgPlanUsage: tracks current usage against plan limits
|
||
//
|
||
// No Stripe integration yet — this is the internal billing model.
|
||
|
||
model Plan {
|
||
id String @id @default(cuid())
|
||
name String @unique // "Free", "Starter", "Pro", "Enterprise"
|
||
slug String @unique // "free", "starter", "pro", "enterprise"
|
||
priceMonthly Int @default(0) // cents (e.g. 4900 = $49.00)
|
||
billingInterval String @default("monthly") // "monthly" | "yearly"
|
||
features Json @default("{}") // { "ai_avatar": true, "site_tag": true, ... }
|
||
limitBrands Int @default(1) // max brands allowed
|
||
limitWebsites Int @default(1) // max websites per brand
|
||
limitEvents Int @default(1000) // max tracked events per month
|
||
limitUsers Int @default(1) // max org members
|
||
isActive Boolean @default(true) // false = plan no longer offered
|
||
sortOrder Int @default(0) // display order on pricing page
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
subscriptions Subscription[]
|
||
}
|
||
|
||
model Subscription {
|
||
id String @id @default(cuid())
|
||
status String @default("trial") // "active" | "trial" | "canceled" | "past_due"
|
||
startDate DateTime @default(now())
|
||
endDate DateTime? // null = ongoing
|
||
trialEnd DateTime? // when the trial expires
|
||
canceledAt DateTime? // when the user canceled
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
orgId String @unique // one subscription per org
|
||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||
|
||
planId String
|
||
plan Plan @relation(fields: [planId], references: [id])
|
||
|
||
@@index([orgId])
|
||
@@index([status])
|
||
}
|
||
|
||
model OrgPlanUsage {
|
||
id String @id @default(cuid())
|
||
brandsUsed Int @default(0)
|
||
websitesUsed Int @default(0)
|
||
eventsUsed Int @default(0) // tracked events this billing period
|
||
usersUsed Int @default(0)
|
||
periodStart DateTime @default(now()) // start of current billing period
|
||
periodEnd DateTime? // end of current billing period
|
||
lastCalculatedAt DateTime @default(now())
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
orgId String @unique // one usage record per org
|
||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([orgId])
|
||
}
|
||
|
||
// ─── Application Logs ────────────────────────────────────────────────────────
|
||
// Simple log table for API requests, errors, and integration events.
|
||
// Kept small — auto-prune old entries periodically.
|
||
|
||
model AppLog {
|
||
id String @id @default(cuid())
|
||
level String // "info" | "warn" | "error"
|
||
source String // "api" | "collect" | "integration" | "billing" | "avatar"
|
||
message String
|
||
path String? // API route path
|
||
userId String? // who triggered it (if known)
|
||
metadata Json @default("{}") // extra context (request body, error stack, etc.)
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([level, createdAt])
|
||
@@index([source, createdAt])
|
||
}
|
||
|
||
// ─── AI Strategist Analysis Jobs ────────────────────────────────────────────
|
||
|
||
model AnalysisJob {
|
||
id String @id @default(cuid())
|
||
type String @default("seasonal_performance")
|
||
status String @default("queued") // "queued" | "running" | "completed" | "failed"
|
||
prompt String?
|
||
manualCaveats Json @default("[]")
|
||
uploadedNotes Json @default("[]")
|
||
selectedSources Json @default("[]")
|
||
result Json? // Full Analysis object
|
||
branding Json? // AnalysisBranding object
|
||
errorMessage String?
|
||
shareToken String? @unique // for shareable links
|
||
shareEnabled Boolean @default(false) // must be explicitly enabled
|
||
clientVersion Json? // polished client-facing snapshot
|
||
clientVersionAt DateTime? // when client version was saved
|
||
hiddenSections Json @default("[]") // sections hidden from client view
|
||
createdAt DateTime @default(now())
|
||
completedAt DateTime?
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
userId String?
|
||
|
||
@@index([brandId, status])
|
||
@@index([shareToken])
|
||
@@index([brandId, createdAt])
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// PAGE LIFECYCLE INTELLIGENCE
|
||
// Real-time page tracking driven by the Site Tag. Every URL a visitor lands
|
||
// on gets upserted into PageRecord, with status changes recorded in
|
||
// PageStatusChange, redirect chains in RedirectRecord, and day-over-day
|
||
// performance (joined from GSC data) in PageDailyMetric.
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
model PageRecord {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
// Identity
|
||
url String // relative path: /about, /services/seo
|
||
fullUrl String // absolute: https://example.com/about
|
||
title String?
|
||
|
||
// Lifecycle
|
||
firstSeenAt DateTime @default(now())
|
||
lastSeenAt DateTime @default(now())
|
||
currentStatus Int @default(200)
|
||
isInNav Boolean @default(false)
|
||
addedToNavAt DateTime?
|
||
removedFromNavAt DateTime?
|
||
|
||
// Aggregated performance (denormalised from PageDailyMetric for speed)
|
||
totalClicks Int @default(0)
|
||
totalImpressions Int @default(0)
|
||
currentCtr Float @default(0)
|
||
currentPosition Float @default(0)
|
||
totalVisits Int @default(0) // from Site Tag page_view events
|
||
|
||
// Metadata scraped by the Site Tag
|
||
metaDescription String?
|
||
h1 String?
|
||
canonicalUrl String?
|
||
wordCount Int?
|
||
// Internal links discovered on this page (JSON array of { href, anchorText })
|
||
internalLinks Json?
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
statusHistory PageStatusChange[]
|
||
redirectsToThis RedirectRecord[] @relation("redirectTarget")
|
||
redirectsFrom RedirectRecord[] @relation("redirectSource")
|
||
dailyMetrics PageDailyMetric[]
|
||
|
||
@@unique([brandId, url])
|
||
@@index([brandId, currentStatus])
|
||
@@index([brandId, firstSeenAt])
|
||
@@index([brandId, isInNav])
|
||
}
|
||
|
||
model PageStatusChange {
|
||
id String @id @default(cuid())
|
||
pageId String
|
||
page PageRecord @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
||
fromStatus Int
|
||
toStatus Int
|
||
detectedAt DateTime @default(now())
|
||
note String?
|
||
|
||
@@index([pageId, detectedAt])
|
||
}
|
||
|
||
model RedirectRecord {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
sourceUrl String
|
||
targetUrl String
|
||
statusCode Int @default(301)
|
||
|
||
sourcePageId String?
|
||
sourcePage PageRecord? @relation("redirectSource", fields: [sourcePageId], references: [id], onDelete: SetNull)
|
||
targetPageId String?
|
||
targetPage PageRecord? @relation("redirectTarget", fields: [targetPageId], references: [id], onDelete: SetNull)
|
||
|
||
firstSeenAt DateTime @default(now())
|
||
lastSeenAt DateTime @default(now())
|
||
isActive Boolean @default(true)
|
||
|
||
@@unique([brandId, sourceUrl, targetUrl])
|
||
@@index([brandId, isActive])
|
||
@@index([targetPageId])
|
||
}
|
||
|
||
model PageDailyMetric {
|
||
id String @id @default(cuid())
|
||
pageId String
|
||
page PageRecord @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
||
date DateTime
|
||
clicks Int @default(0)
|
||
impressions Int @default(0)
|
||
ctr Float @default(0)
|
||
avgPosition Float @default(0)
|
||
sessions Int?
|
||
conversions Int?
|
||
statusCode Int @default(200)
|
||
|
||
@@unique([pageId, date])
|
||
@@index([pageId, date])
|
||
}
|
||
|
||
// Snapshot of the site's main navigation at a point in time. Used to detect
|
||
// links being added to or removed from the nav. One row per ingest; the
|
||
// endpoint keeps only the most recent N snapshots per brand.
|
||
model NavSnapshot {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
capturedAt DateTime @default(now())
|
||
// JSON array of { href, anchorText, section? }
|
||
links Json
|
||
|
||
@@index([brandId, capturedAt])
|
||
}
|
||
|
||
// ─── Site Tag Intelligence ────────────────────────────────────────────────────
|
||
// Machine Enhancing analytics captured by public/t.js: attribution, form/call
|
||
// conversions, heatmaps, RUM/Core Web Vitals, UX signals, SEO snapshots.
|
||
|
||
// Conversions detected by the Site Tag: form submits (native, HubSpot,
|
||
// Typeform, Calendly, etc.), phone clicks (tel: links or phone-pattern
|
||
// elements), and bookings. Includes multi-touch attribution rolled up
|
||
// from the visitor's touchpoint cookie.
|
||
model SiteConversion {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
visitorId String
|
||
sessionId String
|
||
conversionType String // form_submit | phone_call | phone_number_click | calendly | booking
|
||
pageUrl String
|
||
formId String?
|
||
formProvider String? // native | hubspot | typeform | jotform | calendly | custom
|
||
formFields Json? // array of field names only (never values)
|
||
detectionMethod String? // submit_event | fetch_intercept | xhr_intercept | postmessage | mutation_observer | tel_link | phone_pattern
|
||
phoneNumber String?
|
||
elementLocation String? // header | footer | sidebar | nav | main_content
|
||
firstTouchChannel String?
|
||
firstTouchSource String?
|
||
lastTouchChannel String?
|
||
lastTouchSource String?
|
||
touchpoints Json? // ordered array of touchpoint objects
|
||
assistedChannels Json? // list of channels that appeared in the journey
|
||
// Revenue attribution. Populated for e-commerce conversions
|
||
// (purchase, purchase_completed, and any other type that carries
|
||
// a money amount in metadata). SUM(conversionValue) drives the
|
||
// ROI calculation on the Cost Center + attribution dashboards,
|
||
// so these live in typed columns rather than the formFields JSON
|
||
// for cheap aggregation. Nullable: non-money conversions
|
||
// (form_submission, phone_call, etc.) leave these blank.
|
||
conversionValue Float?
|
||
currency String? // ISO 4217 code (USD, EUR, GBP, ...)
|
||
orderId String? // e-commerce order/transaction ID from purchase_completed
|
||
// Shopify line items: [{title, quantity, price, variantId}]
|
||
lineItems Json?
|
||
timestamp DateTime @default(now())
|
||
// Dedup architecture: when this row is identified as a duplicate
|
||
// of another SiteConversion, this field is set to the id of the
|
||
// surviving (canonical) row. Null = this row is canonical or has
|
||
// not been processed yet. Analytics queries filter WHERE
|
||
// isDuplicateOf IS NULL to get deduped counts.
|
||
isDuplicateOf String?
|
||
// When a detection method is deprecated (e.g. phone_pattern), rows
|
||
// are marked with the reason rather than deleted. Analytics queries
|
||
// filter WHERE invalidatedReason IS NULL.
|
||
invalidatedReason String?
|
||
// Channel classification from the rule-based classifier.
|
||
// Populated on write + backfilled for historical rows.
|
||
classifiedChannel String?
|
||
classificationReason String?
|
||
classificationEvidence Json?
|
||
|
||
// Provider category — coarse grouping used by the Embedded Conversions
|
||
// dashboard (booking, form, sms, chat, checkout, donation, civic, survey).
|
||
// Derived from formProvider at write time; null for pre-categorisation rows.
|
||
providerCategory String?
|
||
|
||
// SMS-specific attribution fields. Never contain personal data — only
|
||
// keyword text, shortcode, and consent method classification.
|
||
smsKeyword String? // e.g. 'JOIN', 'START', 'YES'
|
||
smsShortcode String? // e.g. '55555', '12345'
|
||
smsConsentMethod String? // 'explicit_checkbox' | 'keyword_optin' | 'two_step_followup' | 'implicit_form'
|
||
smsFollowupOfConversionId String? // links two-step opt-in to the preceding email conversion
|
||
detectionLatencyMs Int? // ms from page load to detection — diagnostic
|
||
synthetic Boolean @default(false) // true when fired by the test harness (_test=true)
|
||
// Client-generated token tying every re-fire of the same real form
|
||
// submission together. Set by getFormSubmissionId() in t.js using a 30s
|
||
// fixed-from-first sessionStorage window. Nullable so old rows are
|
||
// unaffected. No unique constraint -- dedup is handled in the resolver.
|
||
submissionId String?
|
||
// Set to true when a framework-specific success signal (CF7 wpcf7mailsent,
|
||
// gform_confirmation_loaded, WPForms confirmation container, Elementor
|
||
// submit_success, etc.) corroborates the submit. Default false preserves
|
||
// all historical rows as unchecked. Used by the requireSuccessConfirmed
|
||
// per-brand gate to tighten which rows count.
|
||
successConfirmed Boolean @default(false)
|
||
|
||
@@index([brandId, timestamp])
|
||
@@index([brandId, conversionType])
|
||
@@index([brandId, isDuplicateOf])
|
||
@@index([brandId, submissionId])
|
||
// Covers the server-side rapid-fire dedup query in /api/site-tag/conversion:
|
||
// WHERE brandId = ? AND sessionId = ? AND pageUrl = ? AND conversionType = ? AND timestamp >= ?
|
||
@@index([brandId, sessionId, pageUrl, conversionType, timestamp])
|
||
// Embedded Conversions dashboard query patterns
|
||
@@index([brandId, providerCategory, timestamp])
|
||
@@index([brandId, pageUrl, providerCategory])
|
||
@@index([brandId, firstTouchChannel, timestamp])
|
||
}
|
||
|
||
// Heatmap / engagement payloads from public/t.js. One row per flush
|
||
// (clicks batch, scroll summary, or attention summary).
|
||
model HeatmapEvent {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
visitorId String?
|
||
sessionId String?
|
||
pageUrl String
|
||
eventType String // clicks | scroll | attention
|
||
events Json? // array of click points or section entries
|
||
maxScrollPercent Int?
|
||
milestonesReached Json? // [25, 50, 75, 90, 100]
|
||
timeOnPage Int? // seconds
|
||
sectionTimes Json? // { selector → seconds visible }
|
||
pageWidth Int?
|
||
pageHeight Int?
|
||
viewportWidth Int?
|
||
viewportHeight Int?
|
||
timestamp DateTime @default(now())
|
||
|
||
@@index([brandId, pageUrl, eventType])
|
||
@@index([brandId, eventType, timestamp])
|
||
}
|
||
|
||
// Real-user Core Web Vitals sampled per page load.
|
||
model RealUserMetric {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
pageUrl String
|
||
lcp Float?
|
||
cls Float?
|
||
inp Float?
|
||
ttfb Float?
|
||
fcp Float? // First Contentful Paint (ms) — lab parity with
|
||
// the CrUX CWV bundle. Added so RUM vs lab-score
|
||
// comparisons on the Core Web Intelligence page
|
||
// can render the same five metrics side by side.
|
||
device String? // mobile | tablet | desktop
|
||
connection String? // 4g | 3g | slow-2g | wifi
|
||
|
||
deviceMemory Float? // navigator.deviceMemory (GB) when exposed
|
||
viewportW Int?
|
||
viewportH Int?
|
||
timestamp DateTime @default(now())
|
||
|
||
@@index([brandId, pageUrl, timestamp])
|
||
// Cross-brand timestamp range queries (admin overview count) need this
|
||
// — the compound index starting with brandId can't serve a bare
|
||
// WHERE timestamp >= ? predicate.
|
||
@@index([timestamp])
|
||
}
|
||
|
||
// Generic UX / intelligence signal bucket: rage clicks, dead clicks,
|
||
// hesitation, exit intent, JS errors, SEO changes, outbound clicks,
|
||
// internal search queries, copy events, slow third-party resources.
|
||
model SiteEvent {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
visitorId String?
|
||
sessionId String?
|
||
pageUrl String
|
||
eventType String // rage_click | dead_click | hesitation | exit_intent | js_error | seo_change | outbound_click | search_query | copy_event | slow_resource | third_party_impact
|
||
eventData Json
|
||
timestamp DateTime @default(now())
|
||
|
||
@@index([brandId, eventType, timestamp])
|
||
@@index([eventType, timestamp])
|
||
// Hot query in writeConversionSideEffect: WHERE sessionId = ? ORDER BY timestamp DESC.
|
||
// This index went missing from schema (and was therefore dropped by db push), causing a
|
||
// full scan of the ~22GB table. Declared here so future pushes treat it as already-present.
|
||
// Name matches the production index exactly so db push is a no-op against it.
|
||
@@index([sessionId, timestamp], map: "SiteEvent_sessionId_timestamp_idx")
|
||
}
|
||
|
||
// ─── Site Tag Daily Rollup ────────────────────────────────────────────────────
|
||
// Pre-aggregated daily totals per brand, written by the site-tag-rollup cron.
|
||
// Turns 30-day groupBy scans on TrackedEvent (1M+ rows) into 30-row lookups.
|
||
|
||
model SiteTagDailyRollup {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
// Calendar date this row covers (UTC midnight, one row per brand per day)
|
||
date DateTime
|
||
pageViews Int @default(0)
|
||
sessions Int @default(0)
|
||
conversions Int @default(0)
|
||
// JSON blobs for top-N breakdowns; small enough to keep in the row
|
||
topPages Json @default("[]") // [{ url, views }] top 10
|
||
byReferrer Json @default("[]") // [{ source, views }] top 10
|
||
// Extended columns added in Phase 19A.25 for fast sub-summary reads
|
||
conversionsByType Json? // { "form_submit": 142, "click_to_call": 89, ... }
|
||
conversionsByTier Json? // { "completed": 200, "intent": 47, "signal": 0 }
|
||
eventCountsByType Json? // { "page_view": 3520, "session_start": 164, ... }
|
||
outboundClickCount Int @default(0)
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([brandId, date])
|
||
@@index([brandId, date])
|
||
}
|
||
|
||
// ─── Page Studio ──────────────────────────────────────────────────────────────
|
||
// AI-assisted landing page builder. Pages are stored as JSON section arrays
|
||
// so the editor can round-trip them and the renderer can hydrate from the
|
||
// section registry without any schema migrations when new block types ship.
|
||
|
||
model StudioPage {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
title String
|
||
slug String
|
||
purpose String // lead_generation | service | product | event | coming_soon
|
||
sections Json // array of { id, type, content }
|
||
branding Json // { primaryColor, secondaryColor, accentColor, fontFamily, logoUrl }
|
||
settings Json // { metaTitle, metaDescription, ogImage, pageWidth, customScripts }
|
||
status String @default("draft") // draft | published | archived
|
||
thumbnail String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([brandId, slug])
|
||
@@index([brandId])
|
||
}
|
||
|
||
// Uploaded brand media (logos, photography, icons, backgrounds) that the
|
||
// Page Studio editor can insert into sections. Strictly PNG/JPEG — no SVG
|
||
// or other formats to keep the upload path simple and safe.
|
||
model BrandAsset {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
filename String
|
||
url String
|
||
type String // logo | photo | icon | background
|
||
mimeType String // image/png | image/jpeg
|
||
fileSize Int
|
||
width Int?
|
||
height Int?
|
||
altText String?
|
||
tags String[] @default([])
|
||
uploadedAt DateTime @default(now())
|
||
|
||
@@index([brandId])
|
||
}
|
||
|
||
// Per-page SEO metadata snapshot from the Site Tag. Upserted on every
|
||
// page view — enables detection of accidental noindex, missing meta,
|
||
// schema changes, and mixed-content regressions.
|
||
model PageSEOSnapshot {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
pageUrl String
|
||
title String?
|
||
metaDescription String?
|
||
canonical String?
|
||
robots String?
|
||
h1Count Int?
|
||
hasSchema Boolean?
|
||
schemaTypes Json?
|
||
noindexed Boolean?
|
||
mixedContent Boolean?
|
||
detectedAt DateTime @default(now())
|
||
|
||
@@unique([brandId, pageUrl])
|
||
}
|
||
|
||
// ─── Rank Tracking ──────────────────────────────────────────────────────────
|
||
// Daily keyword position monitoring. TrackedKeyword holds the target;
|
||
// KeywordRanking stores each check result so the UI can chart position
|
||
// over time and alert on significant changes.
|
||
|
||
model TrackedKeyword {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
keyword String
|
||
location String @default("United States")
|
||
device String @default("desktop") // desktop | mobile
|
||
targetUrl String?
|
||
tags String[] @default([])
|
||
isActive Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
|
||
rankings KeywordRanking[]
|
||
|
||
@@unique([brandId, keyword, location, device])
|
||
@@index([brandId, isActive])
|
||
}
|
||
|
||
model KeywordRanking {
|
||
id String @id @default(cuid())
|
||
trackedKeywordId String
|
||
trackedKeyword TrackedKeyword @relation(fields: [trackedKeywordId], references: [id], onDelete: Cascade)
|
||
position Int? // null → not found in top results
|
||
previousPosition Int?
|
||
url String? // actual URL that ranks
|
||
snippet String? // SERP snippet text
|
||
searchVolume Int?
|
||
// SERP features detected on the result page for this query, e.g.
|
||
// ["local_pack","reviews","people_also_ask"]. Populated by DataForSEO
|
||
// checks; empty for GSC backfill rows (GSC doesn't expose features).
|
||
serpFeatures String[] @default([])
|
||
// Top 5 organic competitors on the same SERP — captured at check time
|
||
// so the UI can render "who outranks you" without a second API call.
|
||
// Stored as JSON: [{ domain, position, url, title }].
|
||
competitors Json @default("[]")
|
||
// Where the row came from:
|
||
// "dataforseo" — live daily rank check (default for new rows)
|
||
// "google_cse" — Google Custom Search fallback when DataForSEO isn't configured
|
||
// "gsc_backfill" — inferred from GscQuery historical data on keyword add
|
||
// UI renders backfill rows with a dashed line to signal they're from a
|
||
// different source with different accuracy characteristics (GSC's
|
||
// avgPosition is impression-weighted; DataForSEO is a raw SERP lookup).
|
||
source String @default("dataforseo")
|
||
checkedAt DateTime @default(now())
|
||
|
||
@@index([trackedKeywordId, checkedAt])
|
||
@@index([trackedKeywordId, position])
|
||
}
|
||
|
||
// ─── Automated Email Reports ────────────────────────────────────────────────
|
||
// Schedule-driven intelligence reports assembled from all platform data
|
||
// sources, optionally enriched with Claude AI analysis + competitor scans.
|
||
|
||
model EmailReportSchedule {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
name String
|
||
frequency String // daily | weekly | biweekly | monthly
|
||
dayOfWeek Int? // 0-6 (0=Sunday) for weekly/biweekly
|
||
dayOfMonth Int? // 1-28 for monthly
|
||
timeOfDay String @default("08:00") // HH:mm UTC
|
||
recipients String[]
|
||
sections String[]
|
||
includeAiAnalysis Boolean @default(true)
|
||
includeCompetitorScan Boolean @default(false)
|
||
includeMarketInsights Boolean @default(false)
|
||
isActive Boolean @default(true)
|
||
lastSentAt DateTime?
|
||
nextSendAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
sentReports SentEmailReport[]
|
||
|
||
@@index([brandId])
|
||
@@index([nextSendAt, isActive])
|
||
}
|
||
|
||
model SentEmailReport {
|
||
id String @id @default(cuid())
|
||
scheduleId String
|
||
schedule EmailReportSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
|
||
brandId String
|
||
recipients String[]
|
||
subject String
|
||
htmlContent String // @db.Text — large content
|
||
reportData Json
|
||
status String // sent | failed | bounced
|
||
error String?
|
||
sentAt DateTime @default(now())
|
||
|
||
@@index([scheduleId, sentAt])
|
||
@@index([brandId, sentAt])
|
||
}
|
||
|
||
// ─── White-Label Client Portal ──────────────────────────────────────────────
|
||
// Per-brand portal that exposes a limited, agency-branded view of
|
||
// the platform to clients. Authenticated via magic links, not Clerk.
|
||
|
||
model ClientPortal {
|
||
id String @id @default(cuid())
|
||
brandId String @unique
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
isEnabled Boolean @default(false)
|
||
agencyName String?
|
||
agencyLogo String?
|
||
agencyFavicon String?
|
||
primaryColor String?
|
||
accentColor String?
|
||
customDomain String?
|
||
// Custom-domain verification state. Populated when the agency
|
||
// starts the custom-domain add flow; middleware host-routing only
|
||
// activates once customDomainStatus === "verified". DnsTarget is
|
||
// the CNAME the agency points their domain at.
|
||
customDomainStatus String? // pending | verified | failed | null
|
||
customDomainVerifiedAt DateTime?
|
||
customDomainDnsTarget String?
|
||
// White-label outgoing email. When replyToEmail is set, invite and
|
||
// scheduled-report emails use the agency name as From and route
|
||
// replies to this inbox. supportEmail is shown in email footers.
|
||
replyToEmail String?
|
||
supportEmail String?
|
||
allowedPages String[] @default(["dashboard", "report", "rankings", "conversions", "audit_summary"])
|
||
portalConfig Json? // granular { pages: { dashboard: { enabled, sections }, ... } }
|
||
hideSourceBranding Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
clientUsers ClientUser[]
|
||
}
|
||
|
||
model ClientUser {
|
||
id String @id @default(cuid())
|
||
portalId String
|
||
portal ClientPortal @relation(fields: [portalId], references: [id], onDelete: Cascade)
|
||
email String
|
||
name String?
|
||
role String @default("viewer") // viewer | editor | admin
|
||
permissions Json? // granular overrides: { canExportReports: true, ... }
|
||
// Per-client page-visibility overrides. When null, the portal's
|
||
// portalConfig (default for all clients) applies. When set, the
|
||
// shape is the same as ClientPortal.portalConfig and fully
|
||
// replaces the default for this user.
|
||
pageOverrides Json?
|
||
// Lifecycle status beyond isActive. "pending" means invited but
|
||
// hasn't signed in yet; flips to "active" on first magic-link
|
||
// verification. "disabled" / "revoked" hide the user from the
|
||
// portal without deleting the row (preserves audit log FKs).
|
||
status String @default("pending") // pending | active | disabled | revoked
|
||
invitedAt DateTime? // when the first invite was sent
|
||
lastLoginAt DateTime?
|
||
isActive Boolean @default(true)
|
||
accessToken String? @unique
|
||
tokenExpiresAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([portalId, email])
|
||
@@index([accessToken])
|
||
@@index([portalId, status])
|
||
}
|
||
|
||
model PortalAuditLog {
|
||
id String @id @default(cuid())
|
||
portalId String
|
||
clientUserId String
|
||
action String // login | view_dashboard | export_report | reply_review | share_report | request_analysis
|
||
details Json?
|
||
ipAddress String?
|
||
timestamp DateTime @default(now())
|
||
|
||
@@index([portalId, timestamp])
|
||
@@index([clientUserId, timestamp])
|
||
}
|
||
|
||
// ─── Platform Admin ─────────────────────────────────────────────────────────
|
||
|
||
model PlatformLog {
|
||
id String @id @default(cuid())
|
||
level String // error | warn | info | debug
|
||
source String // api_route | cron | sync | ai_call | site_tag
|
||
message String
|
||
details Json?
|
||
userId String?
|
||
brandId String?
|
||
endpoint String?
|
||
duration Int?
|
||
statusCode Int?
|
||
timestamp DateTime @default(now())
|
||
|
||
@@index([level, timestamp])
|
||
@@index([source, timestamp])
|
||
@@index([brandId, timestamp])
|
||
}
|
||
|
||
model AiUsageLog {
|
||
id String @id @default(cuid())
|
||
brandId String?
|
||
userId String?
|
||
provider String // anthropic | openai
|
||
model String
|
||
feature String // ai_strategist | content_brief | keyword_research | email_report | page_studio | competitor_analysis | industry_intel | humanization | rewrite
|
||
inputTokens Int
|
||
outputTokens Int
|
||
estimatedCost Float
|
||
duration Int?
|
||
timestamp DateTime @default(now())
|
||
|
||
@@index([brandId, timestamp])
|
||
@@index([feature, timestamp])
|
||
@@index([timestamp])
|
||
}
|
||
|
||
// ─── Compliance Event Log ────────────────────────────────────────────────────
|
||
// Immutable audit trail for every compliance-relevant action:
|
||
// data deletions (right-to-deletion / GDPR Article 17), data
|
||
// exports (Article 15), retention-policy changes, healthcare-mode
|
||
// toggles, DPA sign events, scheduled-purge runs.
|
||
//
|
||
// Rows are append-only — no UI delete button, no admin
|
||
// "edit" path. Exportable as CSV for regulator review.
|
||
|
||
model ComplianceEvent {
|
||
id String @id @default(cuid())
|
||
// Action key. Stable string — the Compliance dashboard groups by
|
||
// this. Examples:
|
||
// right_to_deletion | data_export | data_purge_run |
|
||
// retention_policy_change | healthcare_mode_change |
|
||
// dpa_signed | dpa_revoked | consent_mode_change
|
||
action String
|
||
// Optional brand scope. Platform-wide actions (e.g. retention
|
||
// policy default change) leave this null.
|
||
brandId String?
|
||
// Optional user identifier in the action (email or sessionId for
|
||
// right-to-deletion / data-export). Stored verbatim — already PII
|
||
// by definition; this is the audit trail FOR PII handling.
|
||
identifier String?
|
||
// Counts produced by the action: { events, sessions, conversions,
|
||
// siteEvents, ... }. Free-form so new action types can include
|
||
// their own breakdowns without a schema change.
|
||
details Json?
|
||
// Who triggered it (admin Clerk userId, or "cron" / "system").
|
||
requestedBy String
|
||
ipAddress String?
|
||
timestamp DateTime @default(now())
|
||
|
||
@@index([brandId, timestamp])
|
||
@@index([action, timestamp])
|
||
@@index([timestamp])
|
||
}
|
||
|
||
// ─── Platform Telemetry ─────────────────────────────────────────────────────
|
||
// Every significant user action across the platform lands here. Admin
|
||
// analytics (engagement, stickiness, feature adoption) and the real-
|
||
// time activity feed read from this table. trackEvent() in
|
||
// src/lib/services/platform-telemetry.ts is the fire-and-forget write
|
||
// path; it's non-blocking so it never slows a user request.
|
||
|
||
model PlatformEvent {
|
||
id String @id @default(cuid())
|
||
userId String? // Clerk user id, null for anonymous/system events
|
||
brandId String? // brand context when applicable
|
||
// Dotted action key, e.g. "audit_started", "report_generated",
|
||
// "page_view". Stable over time — analytics key off this.
|
||
action String
|
||
// High-level bucket for grouping in the admin UI:
|
||
// navigation | search | audit | report | integration | content |
|
||
// ai | export | settings | auth | admin
|
||
category String
|
||
// Arbitrary JSON payload: { path, status, elapsedMs, ... }
|
||
metadata Json?
|
||
ipAddress String?
|
||
userAgent String?
|
||
timestamp DateTime @default(now())
|
||
|
||
@@index([userId, timestamp])
|
||
@@index([brandId, timestamp])
|
||
@@index([category, timestamp])
|
||
@@index([action, timestamp])
|
||
@@index([timestamp])
|
||
}
|
||
|
||
// ─── AI Interaction Log (guardrail monitoring) ──────────────────────────────
|
||
// Richer than AiUsageLog: stores the full prompt + response + any
|
||
// guardrail flags so the /admin/ai-monitor surface can audit what
|
||
// the models said, flag risky outputs, and enforce per-user /
|
||
// per-brand usage caps. AiUsageLog stays the source of truth for
|
||
// token/cost accounting; this table is the audit surface.
|
||
|
||
model AiInteraction {
|
||
id String @id @default(cuid())
|
||
userId String? // Clerk user id, null for system calls (crons)
|
||
brandId String?
|
||
// Feature dotted key: ai_strategist | content_brief | llmo_brief |
|
||
// schema_gen | industry_intel | competitor_discovery |
|
||
// content_recommendations | report_studio | email_report | topic_map
|
||
feature String
|
||
// User's typed prompt (or composed system+user for non-chat
|
||
// features like content brief generation). Truncated at 4KB.
|
||
userPrompt String @db.Text
|
||
// Full system prompt sent to the model. Stored for audit.
|
||
systemPrompt String? @db.Text
|
||
// Full model response text. Truncated at 16KB.
|
||
aiResponse String @db.Text
|
||
model String // claude-opus-4-6 | gpt-4o | etc.
|
||
tokensUsed Int?
|
||
costEstimate Float?
|
||
// Flat list of guardrail flag keys that matched:
|
||
// ["pii_detected", "medical_advice", "prompt_injection"].
|
||
guardrailFlags String[] @default([])
|
||
// Worst-severity of the above: none | warning | critical | blocked.
|
||
flagSeverity String @default("none")
|
||
timestamp DateTime @default(now())
|
||
|
||
@@index([userId, timestamp])
|
||
@@index([brandId, timestamp])
|
||
@@index([feature, timestamp])
|
||
@@index([flagSeverity, timestamp])
|
||
@@index([timestamp])
|
||
}
|
||
|
||
// ─── Platform Snapshot (monthly metrics archive) ────────────────────────────
|
||
// Monthly snapshot of platform-wide metrics, written by a cron on
|
||
// the 1st of each month. Powers the exit-ready metrics dashboard +
|
||
// month-over-month comparisons in /admin/analytics.
|
||
|
||
model PlatformSnapshot {
|
||
id String @id @default(cuid())
|
||
// YYYY-MM-01 UTC — the first day of the snapshotted month.
|
||
snapshotDate DateTime @unique
|
||
// Full metric payload: DAU, WAU, MAU, churn, feature adoption,
|
||
// total events, API spend, DB size, etc. Shape documented in
|
||
// src/lib/services/platform-snapshot.ts.
|
||
metrics Json
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([snapshotDate])
|
||
}
|
||
|
||
// ─── Keyword Research ───────────────────────────────────────────────────────
|
||
// Cached keyword research results from DataForSEO or AI fallback.
|
||
|
||
model KeywordResearch {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
seedKeyword String
|
||
location String @default("United States")
|
||
language String @default("en")
|
||
results Json
|
||
aiAnalysis Json?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([brandId, seedKeyword])
|
||
@@index([brandId, createdAt])
|
||
}
|
||
|
||
model SavedKeyword {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
keyword String
|
||
searchVolume Int?
|
||
difficulty Int?
|
||
cpc Float?
|
||
competition String?
|
||
trend Json?
|
||
intent String?
|
||
source String // dataforseo | ai_estimated
|
||
tags String[] @default([])
|
||
group String? // AI-assigned topic cluster
|
||
priority String? // high | medium | low — AI-assigned
|
||
savedAt DateTime @default(now())
|
||
|
||
@@unique([brandId, keyword])
|
||
@@index([brandId])
|
||
}
|
||
|
||
// ─── Customizable Layouts ───────────────────────────────────────────────────
|
||
// Per-brand, per-page saved widget configurations. Users can create
|
||
// named layouts ("CEO View", "Client Review") that persist the widget
|
||
// order, visibility, and sizing.
|
||
|
||
model SavedLayout {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
page String // dashboard | report_studio | conversion_intelligence
|
||
name String
|
||
widgets Json // WidgetConfig[]
|
||
isDefault Boolean @default(false)
|
||
createdBy String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([brandId, page])
|
||
}
|
||
|
||
// ─── Industry Intelligence ─────────────────────────────────────────���────────
|
||
// AI-generated daily market briefings correlating external events
|
||
// (weather, regulations, seasonal patterns, competitor moves, algorithm
|
||
// updates) with the brand's actual performance data.
|
||
|
||
model IndustryBriefing {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
industry String
|
||
locations Json
|
||
period String // daily | weekly
|
||
summary String?
|
||
marketTrends Json?
|
||
externalEvents Json?
|
||
seasonalInsights Json?
|
||
competitorActivity Json?
|
||
searchTrends Json?
|
||
performanceCorrelation Json?
|
||
predictions Json?
|
||
sources Json?
|
||
generatedAt DateTime @default(now())
|
||
|
||
@@index([brandId, generatedAt])
|
||
@@index([industry, generatedAt])
|
||
}
|
||
|
||
model IndustryBenchmark {
|
||
id String @id @default(cuid())
|
||
industry String
|
||
location String?
|
||
metric String
|
||
value Float
|
||
sampleSize Int
|
||
period String
|
||
computedAt DateTime @default(now())
|
||
|
||
@@unique([industry, location, metric, period])
|
||
@@index([industry, metric])
|
||
}
|
||
|
||
// ─── Backlink Monitoring ────────────────────────────────────────────────────
|
||
|
||
model Backlink {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
sourceDomain String
|
||
sourceUrl String
|
||
targetUrl String
|
||
anchorText String?
|
||
linkType String? // dofollow | nofollow | ugc | sponsored
|
||
context String?
|
||
position String? // header | footer | content | sidebar | navigation
|
||
domainAuthority Int?
|
||
relevanceScore Int?
|
||
toxicityScore Int?
|
||
// Bucket label mirroring calculateToxicityScore's 5-tier output:
|
||
// "Healthy" | "Low Risk" | "Moderate Risk" | "High Risk" | "Toxic".
|
||
// Legacy AI-scored rows may still carry excellent/good/neutral/poor/toxic —
|
||
// the UI normalises both shapes via toxicityBadge().
|
||
qualityTier String?
|
||
// Human-readable heuristics that fired for this link — surfaced in the
|
||
// Toxic Panel's expandable "Why is this toxic?" section and embedded
|
||
// as comments in the generated Google disavow file.
|
||
toxicityReasons String[] @default([])
|
||
status String @default("active") // active | lost | new | disavowed
|
||
firstSeenAt DateTime @default(now())
|
||
lastSeenAt DateTime
|
||
lostAt DateTime?
|
||
|
||
@@unique([brandId, sourceUrl, targetUrl])
|
||
@@index([brandId, status])
|
||
@@index([brandId, sourceDomain])
|
||
@@index([brandId, toxicityScore])
|
||
}
|
||
|
||
model BacklinkSnapshot {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
totalBacklinks Int
|
||
totalDomains Int
|
||
newBacklinks Int
|
||
lostBacklinks Int
|
||
avgDomainAuthority Float?
|
||
toxicCount Int?
|
||
snapshotDate DateTime @default(now())
|
||
|
||
@@index([brandId, snapshotDate])
|
||
}
|
||
|
||
// ─── Automated Alerts + Notifications ───────────────────────────────────────
|
||
|
||
model AlertRule {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
name String
|
||
type String // traffic_drop | rank_change | new_review | site_error | competitor_change | conversion_drop | page_404 | audit_health | backlink_alert | custom
|
||
condition Json
|
||
channels String[] // email | in_app | sms
|
||
recipients String[]
|
||
smsRecipients String[] @default([])
|
||
severity String @default("medium")
|
||
isActive Boolean @default(true)
|
||
cooldownMinutes Int @default(60)
|
||
lastFiredAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
notifications Notification[]
|
||
|
||
@@index([brandId, isActive])
|
||
@@index([type])
|
||
}
|
||
|
||
model SmsOptIn {
|
||
id String @id @default(cuid())
|
||
userId String @unique
|
||
phone String
|
||
isVerified Boolean @default(false)
|
||
verifyCode String?
|
||
codeExpiresAt DateTime?
|
||
optedInAt DateTime?
|
||
optedOutAt DateTime?
|
||
isActive Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([phone])
|
||
}
|
||
|
||
// ─── Predictive Modeling ────────────────────────────────────────────────────
|
||
|
||
model Prediction {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
type String // traffic_forecast | conversion_forecast | rank_forecast | scenario
|
||
metric String // clicks | sessions | conversions | position
|
||
currentValue Float
|
||
predictedValue Float
|
||
confidence String // high | medium | low
|
||
timeframe Int // days forward
|
||
assumptions Json
|
||
scenarioName String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([brandId, type])
|
||
}
|
||
|
||
model EmailUnsubscribe {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
email String
|
||
reason String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([brandId, email])
|
||
}
|
||
|
||
// ─── First-Party Data Engine ────────────────────────────────────────────────
|
||
|
||
model RecommendationOutcome {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
recommendationId String?
|
||
recommendationType String
|
||
industry String
|
||
description String
|
||
targetMetric String
|
||
metricBefore Float?
|
||
measuredAt DateTime
|
||
metricAfter Float?
|
||
measuredAfterAt DateTime?
|
||
implemented Boolean @default(false)
|
||
implementedAt DateTime?
|
||
outcome String? // improved | declined | unchanged | unknown
|
||
impactPercent Float?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([industry, recommendationType, outcome])
|
||
@@index([brandId])
|
||
}
|
||
|
||
model ClientMemory {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
userId String?
|
||
category String // analysis | chat | report | content_brief | audit | rank_check | page_build | keyword_research | competitor_analysis | settings_change
|
||
action String // generated | viewed | exported | created | deleted | updated
|
||
summary String
|
||
details Json?
|
||
aiContext String?
|
||
timestamp DateTime @default(now())
|
||
|
||
@@index([brandId, category, timestamp])
|
||
@@index([brandId, timestamp])
|
||
}
|
||
|
||
// ─── Schema Markup Generator ────────────────────────────────────────────────
|
||
|
||
model SchemaMarkup {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
pageUrl String
|
||
schemaType String
|
||
jsonLd Json
|
||
status String @default("draft") // draft | deployed | verified
|
||
autoInject Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([brandId, pageUrl])
|
||
@@index([brandId, schemaType])
|
||
}
|
||
|
||
// ─── Writing Assistant ──────────────────────────────────────────────────────
|
||
|
||
model WritingDocument {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
title String
|
||
content String @db.Text
|
||
plainText String? @db.Text
|
||
targetKeyword String?
|
||
secondaryKeywords String[]
|
||
briefId String?
|
||
seoScore Int?
|
||
llmoScore Int?
|
||
humanScore Int?
|
||
grade String?
|
||
wordCount Int?
|
||
status String @default("draft")
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([brandId, updatedAt])
|
||
}
|
||
|
||
// ─── Content Audit ──────────────────────────────────────────────────────────
|
||
|
||
model ContentAuditResult {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
totalPages Int
|
||
results Json
|
||
aiSummary String? @db.Text
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([brandId, createdAt])
|
||
}
|
||
|
||
// ─── Audit Score History ────────────────────────────────────────────────────
|
||
|
||
model AuditScoreHistory {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
healthScore Int
|
||
errorCount Int
|
||
warningCount Int
|
||
infoCount Int
|
||
totalIssues Int
|
||
auditedAt DateTime @default(now())
|
||
|
||
@@index([brandId, auditedAt])
|
||
}
|
||
|
||
// ─── AI Visibility (LLMO / AEO Monitoring) ──────────────────────────────────
|
||
|
||
model AiVisibilityCheck {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
query String
|
||
platform String // chatgpt, perplexity, gemini, claude
|
||
mentioned Boolean @default(false)
|
||
position Int?
|
||
context String? @db.Text
|
||
fullResponse String? @db.Text
|
||
sentiment String? // positive, neutral, negative
|
||
competitors Json?
|
||
checkedAt DateTime @default(now())
|
||
|
||
@@index([brandId, query, platform])
|
||
@@index([brandId, checkedAt])
|
||
}
|
||
|
||
model AiVisibilityQuery {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
query String
|
||
category String? // service, comparison, location, general
|
||
intent String? // informational, commercial, local, transactional
|
||
targetLocation String? // city, neighborhood, or state anchor used in the query
|
||
isActive Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([brandId, query])
|
||
@@index([brandId, isActive])
|
||
}
|
||
|
||
// ─── Enrichment Files (uploaded brand documents) ────────────────────────────
|
||
|
||
model EnrichmentFile {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
fileName String
|
||
fileType String // document, whitepaper, pdf
|
||
mimeType String
|
||
fileSize Int
|
||
fileUrl String
|
||
extractedText String? @db.Text
|
||
processingStatus String @default("pending") // pending, processing, ready, failed
|
||
processingError String?
|
||
uploadedAt DateTime @default(now())
|
||
|
||
@@index([brandId, processingStatus])
|
||
}
|
||
|
||
// ─── AI Strategist Chat Sessions ───────────────────────────────────────────
|
||
|
||
model StrategistSession {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
userId String
|
||
title String @default("New Chat")
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
messages StrategistMessage[]
|
||
|
||
@@index([brandId, userId, updatedAt])
|
||
}
|
||
|
||
model StrategistMessage {
|
||
id String @id @default(cuid())
|
||
sessionId String
|
||
session StrategistSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||
role String // "user" | "assistant"
|
||
content String
|
||
artifacts Json? // Array of { title, type, code } — null for user messages
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([sessionId, createdAt])
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// PAID MEDIA ATTRIBUTION — Phase 1 (2026-04)
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
//
|
||
// Visitor identity graph, household clustering, ad-platform
|
||
// integration, campaign data sync, click matching, and multi-touch
|
||
// attribution paths. Together these let the platform correlate paid
|
||
// media spend → Site Tag behavioural events → conversions at a level
|
||
// of detail that dedicated attribution vendors (AiTRK, LiveRamp,
|
||
// TripleWhale) can't match because they don't see what happens on
|
||
// the site.
|
||
|
||
// ─── Visitor Profile ────────────────────────────────────────────
|
||
// Aggregated per-visitor record built from TrackedSession +
|
||
// SiteConversion data. The visitorId is the localStorage UUID that
|
||
// t.js sets on first visit — it persists across sessions (legal,
|
||
// first-party, no cookies). This model is a READ CACHE: a cron or
|
||
// on-demand rebuild re-aggregates from the source tables. The raw
|
||
// data in TrackedSession + SiteConversion stays the source of truth.
|
||
|
||
model VisitorProfile {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
visitorId String // localStorage UUID from t.js
|
||
firstSeenAt DateTime
|
||
lastSeenAt DateTime
|
||
totalSessions Int @default(0)
|
||
totalPageViews Int @default(0)
|
||
totalConversions Int @default(0)
|
||
devices Json @default("[]") // array of { device, browser, os } combos
|
||
ipAddresses Json @default("[]") // array of IPs seen (for household inference)
|
||
channelHistory Json @default("[]") // array of { channel, source, medium, date }
|
||
conversionHistory Json @default("[]") // array of { type, pageUrl, date, value? }
|
||
topPages Json @default("[]") // most-visited pages
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([brandId, visitorId])
|
||
@@index([brandId, lastSeenAt])
|
||
@@index([brandId, totalConversions])
|
||
}
|
||
|
||
// ─── Household Cluster ──────────────────────────────────────────
|
||
// Probabilistic household grouping using IP subnet (/24 network) +
|
||
// city. NOT an address lookup — the same inference every DSP and
|
||
// LiveRamp use. Legal under CCPA with disclosure. Used to answer
|
||
// "how many unique households convert?" and "what's the cross-device
|
||
// journey within a household?"
|
||
|
||
model HouseholdCluster {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
ipSubnet String // first 3 octets, e.g. "192.168.1"
|
||
city String?
|
||
region String?
|
||
visitorIds Json @default("[]") // array of visitorId strings
|
||
deviceCount Int @default(0)
|
||
sessionCount Int @default(0)
|
||
conversionCount Int @default(0)
|
||
confidence String @default("low") // low | medium | high
|
||
firstSeenAt DateTime @default(now())
|
||
lastSeenAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([brandId, ipSubnet, city])
|
||
@@index([brandId, confidence])
|
||
@@index([brandId, conversionCount])
|
||
}
|
||
|
||
// ─── Ad Platform Integration ────────────────────────────────────
|
||
// OAuth credentials + sync state for each connected ad platform.
|
||
// One row per (brand, platform). Credentials are encrypted at rest
|
||
// via the same vault pattern BrandIntegration uses for GA4/GSC.
|
||
|
||
model AdPlatformIntegration {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
platform String // google_ads | meta | linkedin | tiktok | microsoft_ads | the_trade_desk
|
||
credentials Json? // encrypted OAuth tokens (access, refresh, expiry)
|
||
accountId String? // platform-specific account/customer ID
|
||
status String @default("pending") // pending | connected | error | expired
|
||
lastSyncAt DateTime?
|
||
syncError String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([brandId, platform])
|
||
@@index([brandId, status])
|
||
}
|
||
|
||
// ─── Ad Campaign Data ───────────────────────────────────────────
|
||
// Daily campaign-level performance pulled from connected ad
|
||
// platforms. One row per (brand, platform, campaign, adGroup, ad,
|
||
// date). Drives the "Campaign Performance" tab and the spend-side
|
||
// of ROAS calculations.
|
||
|
||
model AdCampaignData {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
platform String // google_ads | meta | linkedin | tiktok | microsoft_ads | the_trade_desk
|
||
campaignId String
|
||
campaignName String
|
||
adGroupId String?
|
||
adGroupName String?
|
||
adId String?
|
||
adName String?
|
||
date DateTime @db.Date
|
||
impressions Int @default(0)
|
||
clicks Int @default(0)
|
||
spend Float @default(0) // in the brand's billing currency
|
||
platformConversions Int @default(0) // what the ad platform claims
|
||
currency String @default("USD")
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([brandId, platform, campaignId, date])
|
||
@@index([brandId, platform, date])
|
||
@@index([brandId, date])
|
||
}
|
||
|
||
// ─── Ad Click Event ─────────────────────────────────────────────
|
||
// One row per ad-click landing. Created when t.js fires a
|
||
// paid_click_landing event (gclid/fbclid/etc present in the URL).
|
||
// The server-side matcher runs async to link the click to the
|
||
// TrackedSession + VisitorProfile.
|
||
|
||
model AdClickEvent {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
clickId String // gclid | fbclid | msclkid | ttclid | li_fat_id | dclid | wbraid | gbraid
|
||
platform String // google_ads | meta | linkedin | tiktok | microsoft_ads | google_dv360
|
||
campaignId String? // populated when campaign data is synced
|
||
adGroupId String?
|
||
adId String?
|
||
landingPage String
|
||
timestamp DateTime @default(now())
|
||
matched Boolean @default(false)
|
||
matchedSessionId String? // TrackedSession.sessionId when matched
|
||
matchedVisitorId String? // VisitorProfile.visitorId when matched
|
||
|
||
@@unique([brandId, clickId])
|
||
@@index([brandId, platform, timestamp])
|
||
@@index([brandId, matched])
|
||
}
|
||
|
||
// ─── Attribution Path ───────────────────────────────────────────
|
||
// One row per conversion. Records the full multi-touch journey
|
||
// from first interaction to conversion. Built by the attribution
|
||
// service from TrackedSession + SiteConversion + AdClickEvent data.
|
||
// Powers the Attribution tab's path analysis, assisted-conversion
|
||
// counts, and channel-comparison visualisations.
|
||
|
||
model AttributionPath {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
conversionId String // SiteConversion.id
|
||
visitorId String // VisitorProfile.visitorId
|
||
conversionType String // canonical conversion type
|
||
conversionValue Float? // revenue if available
|
||
touchpoints Json // ordered array of { sessionId, channel, source, medium, campaign, adClickId?, timestamp, eventsInSession[] }
|
||
touchpointCount Int @default(1)
|
||
firstTouchChannel String?
|
||
lastTouchChannel String?
|
||
convertingChannel String? // channel of the session where the conversion happened
|
||
daysBetweenFirstAndConversion Int @default(0)
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([brandId, conversionId])
|
||
@@index([brandId, createdAt])
|
||
@@index([brandId, firstTouchChannel])
|
||
@@index([brandId, convertingChannel])
|
||
}
|
||
|
||
// ─── OTT / CTV Impressions ──────────────────────────────────────
|
||
// Fed from ad platform integrations (The Trade Desk, Roku, etc.).
|
||
// Each row is one OTT ad impression delivered to a smart TV.
|
||
// The matching service connects the impression to a web visit via
|
||
// IP subnet (/24) + city — the same probabilistic household
|
||
// inference the HouseholdCluster model uses.
|
||
|
||
model OttImpression {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
campaignId String?
|
||
campaignName String?
|
||
platform String // roku | hulu | samsung_tv | peacock | tubi | the_trade_desk
|
||
deviceId String? // OTT device identifier from the platform
|
||
ipAddress String? // household IP at time of impression
|
||
ipSubnet String? // /24 subnet for matching
|
||
city String?
|
||
region String?
|
||
impressionAt DateTime
|
||
matched Boolean @default(false)
|
||
matchedVisitorId String?
|
||
matchedSessionId String?
|
||
|
||
@@index([brandId, ipSubnet, impressionAt])
|
||
@@index([brandId, matched])
|
||
@@index([brandId, platform, impressionAt])
|
||
}
|
||
|
||
// ─── LLMO / GEO / AEO Attribution ────────────────────────────────────────────
|
||
// Patent-pending AI attribution system. Tracks the full journey from AI
|
||
// platform citation → click → engagement → conversion → outcome.
|
||
|
||
model AiAttribution {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
sessionId String
|
||
channel String // "llmo" | "geo" | "aeo"
|
||
aiPlatform String? // "chatgpt" | "perplexity" | "gemini" | "copilot" | "claude" | "google_ai_overview" | etc.
|
||
referrerUrl String?
|
||
queryContext String?
|
||
landingPage String
|
||
pagesViewed Int @default(1)
|
||
sessionDuration Int?
|
||
maxScrollDepth Float?
|
||
engagementScore Float?
|
||
converted Boolean @default(false)
|
||
conversionType String?
|
||
conversionPage String?
|
||
conversionId String?
|
||
conversionTimestamp DateTime?
|
||
leadType String?
|
||
dealValue Float?
|
||
dealStatus String?
|
||
outcomeUpdatedAt DateTime?
|
||
timestamp DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([brandId, timestamp])
|
||
@@index([brandId, channel])
|
||
@@index([brandId, aiPlatform])
|
||
@@index([brandId, converted])
|
||
@@index([sessionId])
|
||
}
|
||
|
||
model AiCitation {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
keyword String
|
||
searchEngine String @default("google")
|
||
citationType String // "ai_overview" | "featured_snippet" | "people_also_ask" | "llm_citation"
|
||
channel String // "llmo" | "geo" | "aeo"
|
||
brandCited Boolean @default(false)
|
||
citedUrl String?
|
||
citedPosition Int?
|
||
citedSnippet String?
|
||
competitorsCited Json?
|
||
checkedAt DateTime
|
||
|
||
@@unique([brandId, keyword, searchEngine, checkedAt])
|
||
@@index([brandId, keyword])
|
||
@@index([brandId, checkedAt])
|
||
@@index([brandId, channel])
|
||
}
|
||
|
||
// ─── AI Recommendations (Phase 4) ──────────────────────────────────────────
|
||
// Stores prioritised, data-backed recommendations generated by the
|
||
// AI Strategy Engine. Each recommendation is auditable — the raw data
|
||
// that produced it is captured in dataPoints.
|
||
|
||
model AiRecommendation {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
type String // format_replication | citation_gap | competitive_alert | conversion_optimization | content_expansion | schema_fix | query_expansion | content_brief_trigger
|
||
priority String // critical | high | medium | low
|
||
status String @default("pending") // pending | in_progress | completed | dismissed
|
||
title String
|
||
insight String
|
||
action String
|
||
impact String?
|
||
dataPoints Json
|
||
relatedPages Json?
|
||
relatedKeywords Json?
|
||
contentBriefId String?
|
||
completedAt DateTime?
|
||
dismissedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([brandId, status])
|
||
@@index([brandId, type])
|
||
@@index([brandId, priority, createdAt])
|
||
}
|
||
|
||
// ─── Admin: Cron + Error Logs ──────────────────────────────────────────────
|
||
|
||
model CronLog {
|
||
id String @id @default(cuid())
|
||
route String
|
||
status String // success | error
|
||
duration Int // ms
|
||
details String?
|
||
startedAt DateTime @default(now())
|
||
|
||
@@index([route, startedAt])
|
||
}
|
||
|
||
model ErrorLog {
|
||
id String @id @default(cuid())
|
||
route String
|
||
method String
|
||
status Int
|
||
message String
|
||
stack String?
|
||
brandId String?
|
||
userId String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([createdAt])
|
||
@@index([route])
|
||
}
|
||
|
||
// ─── Dashboard Snapshot ────────────────────────────────────────────────────
|
||
// Precomputed dashboard KPI payloads. The cron writes one row per
|
||
// brand per range every 30 minutes so the dashboard page can load
|
||
// instantly from the snapshot and refresh in the background.
|
||
|
||
model DashboardSnapshot {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
range String // "today" | "7d" | "28d" | "90d"
|
||
payload Json
|
||
computedAt DateTime @default(now())
|
||
|
||
@@unique([brandId, range])
|
||
@@index([brandId, range])
|
||
}
|
||
|
||
// ─── Brand Health Alerting ────────────────────────────────────────────────────
|
||
|
||
model BrandHealthAlert {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
status String // "degraded" | "down" | "resolved"
|
||
firstDetectedAt DateTime @default(now())
|
||
alertSentAt DateTime?
|
||
resolvedAt DateTime?
|
||
expectedRate Float
|
||
actualRate Float
|
||
dropPct Float
|
||
consecutiveHoursDegraded Int @default(1)
|
||
lastCheckedAt DateTime @default(now())
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([brandId, status])
|
||
@@index([status, firstDetectedAt])
|
||
}
|
||
|
||
// ─── Audit Engine ─────────────────────────────────────────────────────────────
|
||
// Powers both 3rd-party lead-gen audits and 1st-party onboarding audits.
|
||
// A single record tracks the full lifecycle from trigger to report.
|
||
|
||
model Audit {
|
||
id String @id @default(cuid())
|
||
brandId String?
|
||
brand Brand? @relation(fields: [brandId], references: [id])
|
||
|
||
// Requester context (for 3rd-party / anonymous audits)
|
||
requesterEmail String?
|
||
requestedBrandName String?
|
||
requestedWebsite String?
|
||
requestedCompetitors String[] @default([])
|
||
requestedLocations String[] @default([])
|
||
requestedServices String[] @default([])
|
||
isLocal Boolean @default(false)
|
||
|
||
auditType String // "third-party" | "first-party"
|
||
source String @default("admin") // "admin" | "public" | "embed"
|
||
auditMode String @default("auto") // "auto" | "first-party" | "third-party"
|
||
status String // "queued" | "running" | "completed" | "failed"
|
||
|
||
// Tiered progress
|
||
currentTier Int @default(0)
|
||
totalTiers Int @default(7)
|
||
currentTierLabel String?
|
||
tiersCompleted Json?
|
||
estimatedSecondsRemaining Int?
|
||
|
||
// Granular progress (step-level, written by runner)
|
||
progressStep Int @default(0)
|
||
progressLabel String?
|
||
progressDetail String?
|
||
siteTagDetected Boolean?
|
||
|
||
// Report sections (populated as they complete)
|
||
seoSection Json?
|
||
llmoSection Json?
|
||
geoSection Json?
|
||
aeoSection Json?
|
||
socialSection Json?
|
||
brandSection Json?
|
||
|
||
rawData Json?
|
||
errorLog String?
|
||
userFacingError String?
|
||
errorReason String?
|
||
errorDetail String?
|
||
degradedMode Boolean @default(false)
|
||
|
||
startedAt DateTime?
|
||
completedAt DateTime?
|
||
publicSlug String? @unique
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([brandId])
|
||
@@index([requesterEmail])
|
||
@@index([status])
|
||
@@index([createdAt])
|
||
}
|
||
|
||
// ─── Lead Capture ─────────────────────────────────────────────────────────────
|
||
// Stores lead data captured from public audit forms before an audit runs.
|
||
|
||
model Lead {
|
||
id String @id @default(cuid())
|
||
firstName String
|
||
lastName String
|
||
email String
|
||
companyName String
|
||
phoneNumber String
|
||
websiteUrl String
|
||
source String @default("public-audit")
|
||
auditId String?
|
||
ipAddress String?
|
||
userAgent String?
|
||
normalizedEmail String @default("")
|
||
normalizedPhone String @default("")
|
||
emailVerified Boolean @default(false)
|
||
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([email])
|
||
@@index([createdAt])
|
||
@@index([normalizedEmail])
|
||
@@index([normalizedPhone])
|
||
}
|
||
|
||
// ─── Email Verification (OTP) ─────────────────────────────────────────────────
|
||
|
||
model EmailVerification {
|
||
id String @id @default(cuid())
|
||
email String
|
||
codeHash String
|
||
attempts Int @default(0)
|
||
expiresAt DateTime
|
||
verifiedAt DateTime?
|
||
ipAddress String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([email])
|
||
@@index([expiresAt])
|
||
}
|
||
|
||
// ─── Cron Cursor (keyset pagination state) ────────────────────────────────────
|
||
|
||
model CronCursor {
|
||
name String @id
|
||
brandId String?
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
// ─── White-Label (Phase 20A) ──────────────────────────────────────────────────
|
||
|
||
enum DomainStatus {
|
||
PENDING
|
||
VERIFYING
|
||
ACTIVE
|
||
FAILED
|
||
}
|
||
|
||
// One white-label config per organization. Controls branding overrides applied
|
||
// to client-facing surfaces when the agency has the feature enabled.
|
||
model WhiteLabelConfig {
|
||
id String @id @default(cuid())
|
||
enabled Boolean @default(false)
|
||
agencyName String?
|
||
logoUrl String?
|
||
logoDarkUrl String?
|
||
faviconUrl String?
|
||
primaryColor String?
|
||
secondaryColor String?
|
||
accentColor String?
|
||
fontFamily String?
|
||
supportEmail String?
|
||
removeMeseoBranding Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
organizationId String @unique
|
||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||
}
|
||
|
||
// Custom domains an organization has registered for white-label delivery.
|
||
// One org may have multiple domains (e.g. client1.agency.com, client2.agency.com).
|
||
model CustomDomain {
|
||
id String @id @default(cuid())
|
||
hostname String @unique
|
||
status DomainStatus @default(PENDING)
|
||
verificationToken String?
|
||
sslStatus String?
|
||
vercelDomainId String?
|
||
createdAt DateTime @default(now())
|
||
verifiedAt DateTime?
|
||
updatedAt DateTime @updatedAt
|
||
|
||
organizationId String
|
||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([organizationId])
|
||
}
|
||
|
||
// ─── AI Market Position (Block 1) ────────────────────────────────────────────
|
||
//
|
||
// These models support the AI Market Position feature: population-level
|
||
// probing of LLM engines across a query corpus, competitor-panel construction,
|
||
// calibration against first-party attribution data, and per-brand scored output.
|
||
//
|
||
// Conceptual layering:
|
||
// ProbeRun + VisibilityObservation -- raw collection (Block 2)
|
||
// CompetitorPanel + QueryCorpusEntry -- corpus and panel management (Block 3)
|
||
// CalibrationRun -- vertical-level model coefficients (Block 4)
|
||
// MarketPositionSnapshot -- scored, ranked output per brand (Block 4/5)
|
||
|
||
enum ProbeRunStatus {
|
||
PENDING
|
||
RUNNING
|
||
COMPLETED
|
||
FAILED
|
||
}
|
||
|
||
enum QuerySource {
|
||
FRAGMENT_SEEDED // seeded from real captured AI-referral text fragments
|
||
CATEGORY_COVERAGE // synthetic coverage queries for the vertical category
|
||
CURATED // manually added by an agency user
|
||
}
|
||
|
||
// A single scheduled probing run for one (vertical, locale, engine) cell.
|
||
// Not scoped to a brand; probes the entire vertical query corpus and records
|
||
// raw observations for all competitor-panel entities simultaneously.
|
||
model ProbeRun {
|
||
id String @id @default(cuid())
|
||
vertical String
|
||
locale String
|
||
engine String
|
||
status ProbeRunStatus @default(PENDING)
|
||
sampleCount Int
|
||
startedAt DateTime?
|
||
completedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
observations VisibilityObservation[]
|
||
|
||
@@index([vertical, locale, engine, createdAt])
|
||
}
|
||
|
||
// One parsed engine response for a single (query, entity, sample) tuple.
|
||
// brandKey is a freeform entity identifier (brand name, competitor slug, etc.)
|
||
// and is intentionally not a FK to Brand because competitor entities may not
|
||
// have Brand records in the database.
|
||
model VisibilityObservation {
|
||
id String @id @default(cuid())
|
||
probeRunId String
|
||
probeRun ProbeRun @relation(fields: [probeRunId], references: [id], onDelete: Cascade)
|
||
query String
|
||
engine String
|
||
brandKey String
|
||
mentioned Boolean @default(false)
|
||
position Int?
|
||
cited Boolean @default(false)
|
||
sentiment String?
|
||
sourceUrls String[]
|
||
sampleIndex Int
|
||
|
||
@@index([probeRunId])
|
||
@@index([query, brandKey])
|
||
}
|
||
|
||
// The set of named competitors for a (vertical, locale) market cell.
|
||
// members holds freeform entity keys matching the brandKey values in
|
||
// VisibilityObservation rows for this cell. One panel per cell (unique).
|
||
model CompetitorPanel {
|
||
id String @id @default(cuid())
|
||
vertical String
|
||
locale String
|
||
members String[]
|
||
curatedByUserId String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([vertical, locale])
|
||
}
|
||
|
||
// One query in the probing corpus for a (vertical, locale) cell.
|
||
// FRAGMENT_SEEDED rows come from real captured AI-referral text fragments
|
||
// (AiAttribution.queryContext) so conversion weighting reflects actual demand.
|
||
// A unique constraint on (vertical, locale, query) makes Block 2 upserts
|
||
// idempotent: seeding and probing can re-run without duplicating rows.
|
||
model QueryCorpusEntry {
|
||
id String @id @default(cuid())
|
||
vertical String
|
||
locale String
|
||
query String
|
||
source QuerySource
|
||
conversionWeight Float @default(0)
|
||
lastProbedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([vertical, locale, query])
|
||
@@index([vertical, locale])
|
||
}
|
||
|
||
// Fitted calibration coefficients for one vertical, versioned so that
|
||
// Block 4 can roll back to an earlier fit if a new one degrades coverage.
|
||
// Scoped to vertical only (not to a brand or org) because calibration
|
||
// transfers cross-tenant within a vertical.
|
||
model CalibrationRun {
|
||
id String @id @default(cuid())
|
||
vertical String
|
||
version Int
|
||
coefficients Json
|
||
fittedAt DateTime
|
||
sampleSize Int
|
||
createdAt DateTime @default(now())
|
||
|
||
snapshots MarketPositionSnapshot[]
|
||
|
||
@@index([vertical, version])
|
||
}
|
||
|
||
// Calibrated, conversion-weighted, ranked market position for one brand
|
||
// within a (vertical, locale) cell. Written by the Block 4 cron and read
|
||
// by the Block 5 UI. Never computed on demand.
|
||
model MarketPositionSnapshot {
|
||
id String @id @default(cuid())
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
vertical String
|
||
locale String
|
||
calibratedScore Float
|
||
conversionWeightedSov Float
|
||
rawSov Float
|
||
rankInPanel Int
|
||
panelSize Int
|
||
divergence Float
|
||
stabilityBand Float?
|
||
calibrationRunId String?
|
||
calibrationRun CalibrationRun? @relation(fields: [calibrationRunId], references: [id], onDelete: SetNull)
|
||
computedAt DateTime
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([brandId, locale, computedAt])
|
||
}
|
||
|
||
// -- Site Audit ----------------------------------------------------------
|
||
// On-demand crawl audit: SiteAuditRun (header) -> SiteAuditPage[] (inventory)
|
||
// -> SiteAuditIssue[] (findings). Resumable: taskId + pagesProcessed cursor.
|
||
|
||
model SiteAuditRun {
|
||
id String @id @default(cuid())
|
||
status String @default("pending")
|
||
// status: pending | crawling | processing | completed | failed
|
||
taskId String? // DataForSEO OnPage task ID, stored on submit
|
||
pagesProcessed Int @default(0) // cursor for resumable page writes
|
||
score Int? // 0-100 composite meSEO Site Health Score
|
||
subScores Json? // { technical: Int, aiReadiness: Int, aeoFaq: Int }
|
||
categoryCounts Json? // { error: Int, warning: Int, notice: Int, passed: Int }
|
||
pagesScanned Int @default(0)
|
||
issueCount Int @default(0)
|
||
errorMessage String?
|
||
crawlError String? // crawl_blocked | crawl_timeout | site_unreachable | ...
|
||
llmsTxt Boolean?
|
||
robotsTxt Boolean?
|
||
robotsAiBlocks Json? // { gptbot: Boolean, claudebot: Boolean, perplexitybot: Boolean, googleExtended: Boolean, ccbot: Boolean }
|
||
sitemapFound Boolean?
|
||
sitemapUrl String?
|
||
startedAt DateTime?
|
||
completedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
brandId String
|
||
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
||
|
||
issues SiteAuditIssue[]
|
||
pages SiteAuditPage[]
|
||
|
||
@@index([brandId, createdAt])
|
||
@@index([status, updatedAt])
|
||
}
|
||
|
||
model SiteAuditIssue {
|
||
id String @id @default(cuid())
|
||
// category: technical | ai_readiness | aeo_faq
|
||
category String
|
||
// severity: error | warning | notice | passed
|
||
severity String
|
||
// stable token, e.g. missing_title | duplicate_meta | noindex_page | redirect_chain | ...
|
||
issueType String
|
||
title String
|
||
detail String?
|
||
affectedUrls Json @default("[]") // String[]
|
||
affected Int @default(1)
|
||
|
||
runId String
|
||
run SiteAuditRun @relation(fields: [runId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([runId, severity])
|
||
@@index([runId, category])
|
||
@@index([runId, issueType])
|
||
}
|
||
|
||
model SiteAuditPage {
|
||
id String @id @default(cuid())
|
||
url String
|
||
// page type from URL rules or Haiku classification
|
||
pageType String? // homepage | service | product | blog | location | category | contact | legacy-slug | other
|
||
pageTypeSource String? // rule | ai
|
||
statusCode Int
|
||
title String?
|
||
h1 String?
|
||
metaDescription String?
|
||
wordCount Int?
|
||
thinContent Boolean @default(false) // wordCount < 150 or empty body (JS-required signal)
|
||
isIndexable Boolean @default(true)
|
||
inSitemap Boolean @default(false)
|
||
isOrphan Boolean @default(false) // no inbound internal links from non-orphan pages
|
||
canonical String?
|
||
redirectTarget String?
|
||
hasSchema Boolean @default(false)
|
||
schemaTypes String[] @default([])
|
||
hasFaq Boolean @default(false)
|
||
hasHowTo Boolean @default(false)
|
||
severity String? // worst severity on this page
|
||
issueTypes String[] @default([]) // issueType tokens affecting this URL
|
||
// GSC 30-day aggregates (set-based join, populated post-crawl)
|
||
gscClicks Int?
|
||
gscImpressions Int?
|
||
gscPosition Float?
|
||
// Site Tag 30-day aggregates (set-based join)
|
||
stSessions Int?
|
||
stConversions Int?
|
||
|
||
runId String
|
||
run SiteAuditRun @relation(fields: [runId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([runId, statusCode])
|
||
@@index([runId, pageType])
|
||
@@index([runId, severity])
|
||
@@index([runId, isIndexable])
|
||
@@index([runId, isOrphan])
|
||
}
|