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