Files
Andres Resiri 70daeb214a
CI / Quality gate (push) Has been cancelled
Initial project commit for Coolify staging
2026-08-03 10:45:41 -04:00

3466 lines
136 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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])
}