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]) }