Browse documentation

Research

RankFlow AI — Technical Specification

The schema follows these core principles:

docs/research_technical.md
On this page

Local SEO Automation Platform for Indian Medical Professionals#

Version: 1.0.0
Date: 2025
Status: Production Architecture Blueprint


1. Detailed Database Schema#

1.1 Schema Design Philosophy#

The schema follows these core principles:

  • Domain-driven design: Models grouped by bounded context (Auth, Practice, SEO, Content, Billing)
  • Audit compliance: Every mutable table includes createdAt/updatedAt and ties to auditLog
  • JSON flexibility: Json fields for schema markup, platform-specific metadata, and extensible config
  • Soft deletes: deletedAt pattern for all core business entities
  • Row-level tenancy: Isolation via practiceId foreign key (single-DB, multi-tenant)

1.2 Enum Definitions#

// prisma/schema.prisma

enum UserRole {
  OWNER
  ADMIN
  EDITOR
  VIEWER
}

enum SubscriptionTier {
  FREE
  STARTER      // ₹2,999/mo — 1 practice, 10 citations
  PROFESSIONAL // ₹5,999/mo — 3 practices, 50 citations, GBP
  ENTERPRISE   // ₹14,999/mo — unlimited, white-label, API
}

enum SubscriptionStatus {
  TRIAL
  ACTIVE
  PAST_DUE
  CANCELLED
  EXPIRED
}

enum PracticeType {
  CLINIC
  HOSPITAL
  DIAGNOSTIC_CENTER
  DENTAL_CLINIC
  PHYSIOTHERAPY
  AYURVEDIC_CENTER
  HOMEOPATHY_CLINIC
}

enum JobStatus {
  PENDING
  QUEUED
  PROCESSING
  COMPLETED
  FAILED
  CANCELLED
  RETRYING
}

enum JobType {
  GBP_POST_PUBLISH
  GBP_REVIEW_REPLY
  GBP_PHOTO_UPLOAD
  GBP_QA_SYNC
  CITATION_SUBMIT
  CITATION_NAP_SCAN
  CITATION_VERIFY
  CITATION_BLOG_POST
  SOCIAL_POST_PUBLISH
  SOCIAL_POST_SCHEDULE
  SOCIAL_POST_RESCHEDULE
  LANDING_PAGE_GENERATE
  LANDING_PAGE_DEPLOY
  REPORT_GENERATE
  REPORT_EMAIL_SEND
  AI_CONTENT_GENERATE
  AI_SEO_AUDIT
  AI_COMPETITOR_ANALYSIS
  RANK_TRACKING_FETCH
  BACKLINK_MONITOR
}

enum ContentType {
  GBP_POST
  GBP_REPLY
  SOCIAL_POST
  BLOG_POST
  LANDING_PAGE
  SCHEMA_MARKUP
  META_DESCRIPTION
  FAQ_CONTENT
  REVIEW_TEMPLATE
}

enum ContentStatus {
  DRAFT
  PENDING_REVIEW
  APPROVED
  PUBLISHED
  REJECTED
  ARCHIVED
}

enum PlatformType {
  GBP
  FACEBOOK
  INSTAGRAM
  LINKEDIN
  TWITTER
  WHATSAPP
  YOUTUBE
  JUSTDIAL
  PRACTO
  LYBRATE
  GOOGLE_ADS
}

enum CitationStatus {
  PENDING
  SUBMITTING
  SUBMITTED
  VERIFIED
  FAILED
  NEEDS_UPDATE
  REMOVED
}

enum NAPMatchStatus {
  MATCHED
  MISMATCH_NAME
  MISMATCH_ADDRESS
  MISMATCH_PHONE
  MISMATCH_ALL
  NOT_FOUND
}

enum ReportFrequency {
  WEEKLY
  BIWEEKLY
  MONTHLY
  QUARTERLY
}

enum PaymentProvider {
  STRIPE
  RAZORPAY
}

enum AIProvider {
  CLAUDE_SONNET
  GPT_4
  GPT_4_TURBO
  GPT_3_5
  LLAMA_3_70B
  MISTRAL_7B
  MISTRAL_Large
  GEMINI_PRO
}

enum AuditAction {
  CREATE
  UPDATE
  DELETE
  LOGIN
  LOGOUT
  EXPORT
  IMPORT
  PUBLISH
  SCHEDULE
  CANCEL
  BILLING
}

1.3 Core Models#

1.3.1 Authentication & Authorization#

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
  @@index([userId])
  @@map("accounts")
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@map("sessions")
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
  @@map("verification_tokens")
}

model User {
  id                    String    @id @default(cuid())
  email                 String    @unique
  emailVerified         DateTime?
  phone                 String?
  name                  String?
  firstName             String?
  lastName              String?
  image                 String?
  password              String?   // bcrypt hashed — null for OAuth-only users
  role                  UserRole  @default(EDITOR)
  isSuperAdmin          Boolean   @default(false)
  onboardingCompleted   Boolean   @default(false)
  onboardingStep        Int       @default(0)
  timezone              String    @default("Asia/Kolkata")
  locale                String    @default("en-IN")
  createdAt             DateTime  @default(now())
  updatedAt             DateTime  @updatedAt
  deletedAt             DateTime?

  // Relations
  accounts              Account[]
  sessions              Session[]
  practiceMemberships   PracticeMember[]
  ownedPractices        Practice[]       @relation("PracticeOwner")
  jobsCreated           Job[]            @relation("JobCreator")
  auditLogs             AuditLog[]
  apiKeys               ApiKey[]
  notifications         Notification[]

  @@map("users")
}

1.3.2 Practice (Tenant Core)#

model Practice {
  id                    String        @id @default(cuid())
  name                  String
  slug                  String        @unique
  type                  PracticeType  @default(CLINIC)
  status                String        @default("active") // active, suspended, trial
  ownerId               String
  subscriptionTier      SubscriptionTier  @default(FREE)
  subscriptionStatus    SubscriptionStatus @default(TRIAL)
  trialEndsAt           DateTime?
  subscriptionExpiresAt DateTime?
  stripeCustomerId      String?
  stripeSubscriptionId  String?
  razorpayCustomerId    String?
  razorpaySubscriptionId String?
  customDomain          String?       @unique
  wildcardSubdomain     String        @unique @default(cuid())
  isWhiteLabel          Boolean       @default(false)
  whiteLabelBrandName   String?
  whiteLabelLogoUrl     String?
  whiteLabelPrimaryColor String?
  whiteLabelFaviconUrl  String?
  settings              Json          @default("{}")
  createdAt             DateTime      @default(now())
  updatedAt             DateTime      @updatedAt
  deletedAt             DateTime?

  // Relations
  owner                 User          @relation("PracticeOwner", fields: [ownerId], references: [id])
  members               PracticeMember[]
  locations             Location[]
  gbpAccounts           GbpAccount[]
  socialAccounts        SocialAccount[]
  citations             Citation[]
  landingPages          LandingPage[]
  contentPieces         ContentPiece[]
  jobs                  Job[]
  reports               Report[]
  reportSchedules       ReportSchedule[]
  rankTrackingKeywords  RankTrackingKeyword[]
  backlinkMonitors      BacklinkMonitor[]
  auditLogs             AuditLog[]
  usageMetrics          UsageMetric[]
  invoices              Invoice[]
  notificationSettings  PracticeNotificationSettings?
  competitorPractices   CompetitorPractice[]

  @@index([ownerId])
  @@index([slug])
  @@index([subscriptionStatus])
  @@map("practices")
}

model PracticeMember {
  id          String    @id @default(cuid())
  practiceId  String
  userId      String
  role        UserRole  @default(EDITOR)
  invitedBy   String?
  invitedAt   DateTime?
  acceptedAt  DateTime?
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt

  practice    Practice  @relation(fields: [practiceId], references: [id], onDelete: Cascade)
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([practiceId, userId])
  @@index([practiceId])
  @@index([userId])
  @@map("practice_members")
}

model PracticeNotificationSettings {
  id                            String   @id @default(cuid())
  practiceId                    String   @unique
  emailWeeklySummary            Boolean  @default(true)
  emailNewReviewAlert           Boolean  @default(true)
  emailCitationUpdates          Boolean  @default(true)
  emailReportReady              Boolean  @default(true)
  emailBillingAlerts            Boolean  @default(true)
  smsCriticalAlerts             Boolean  @default(false)
  slackWebhookUrl               String?
  webhookUrl                    String?  // Generic webhook for integrations
  createdAt                     DateTime @default(now())
  updatedAt                     DateTime @updatedAt

  practice Practice @relation(fields: [practiceId], references: [id], onDelete: Cascade)

  @@map("practice_notification_settings")
}

1.3.3 Location (GBP Entity)#

model Location {
  id                    String   @id @default(cuid())
  practiceId            String
  name                  String
  primaryCategory       String   @default("Medical Clinic")
  additionalCategories  String[] @default([])
  description           String?  @db.Text

  // NAP — Name, Address, Phone
  businessName          String
  addressLine1          String
  addressLine2          String?
  city                  String
  state                 String   // e.g., "Maharashtra"
  postalCode            String
  country               String   @default("IN")
  latitude              Decimal? @db.Decimal(10, 8)
  longitude             Decimal? @db.Decimal(11, 8)
  phone                 String
  phoneSecondary        String?
  email                 String?
  website               String?

  // Business Hours (JSON for flexibility)
  businessHours         Json     @default("{}")
  specialHours          Json?    // Holidays, etc.

  // Media
  logoUrl               String?
  coverPhotoUrl         String?
  photos                LocationPhoto[]

  // Services & Attributes
  services              String[] @default([])
  attributes            Json     @default("{}") // Accessibility, amenities, etc.

  // Local SEO Fields
  targetKeywords        String[] @default([])
  serviceAreas          String[] @default([]) // City names for service-area businesses
  languages             String[] @default(["English", "Hindi"])

  // Meta
  isPrimary             Boolean  @default(false)
  isActive              Boolean  @default(true)
  settings              Json     @default("{}")
  createdAt             DateTime @default(now())
  updatedAt             DateTime @updatedAt
  deletedAt             DateTime?

  // Relations
  practice              Practice @relation(fields: [practiceId], references: [id], onDelete: Cascade)
  gbpLocation           GbpLocation?
  contentPieces         ContentPiece[]
  rankTrackingKeywords  RankTrackingKeyword[]
  reviews               Review[]

  @@index([practiceId])
  @@index([city, state])
  @@index([postalCode])
  @@map("locations")
}

model LocationPhoto {
  id           String   @id @default(cuid())
  locationId   String
  url          String
  thumbnailUrl String?
  category     String   @default("EXTERIOR") // EXTERIOR, INTERIOR, TEAM, LOGO, COVER, AT_WORK
  caption      String?
  isPrimary    Boolean  @default(false)
  fileSize     Int?     // bytes
  mimeType     String?
  storageKey   String?  // R2 key
  createdAt    DateTime @default(now())

  location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)

  @@index([locationId])
  @@map("location_photos")
}

1.3.4 GBP Integration#

model GbpAccount {
  id                    String   @id @default(cuid())
  practiceId            String
  accountEmail          String
  googleAccountId       String?   // Google Account ID
  oauthState            String?   @unique
  accessToken           String   @db.Text
  refreshToken          String   @db.Text
  tokenExpiresAt        DateTime
  scope                 String[] @default([])
  isActive              Boolean  @default(true)
  lastSyncedAt          DateTime?
  syncFrequencyMinutes  Int      @default(60)
  createdAt             DateTime @default(now())
  updatedAt             DateTime @updatedAt

  practice    Practice       @relation(fields: [practiceId], references: [id], onDelete: Cascade)
  locations   GbpLocation[]

  @@unique([practiceId, accountEmail])
  @@index([practiceId])
  @@map("gbp_accounts")
}

model GbpLocation {
  id              String   @id @default(cuid())
  gbpAccountId    String
  locationId      String   // The actual internal location ID
  gbpLocationId   String   // Google Places ID / GBP location identifier
  name            String
  status          String   @default("ACTIVE") // ACTIVE, SUSPENDED, DISABLED
  primaryPhone    String?
  websiteUrl      String?
  mapUrl          String?
  profilePhotoUrl String?
  coverPhotoUrl   String?
  metadata        Json     @default("{}") // Full GBP API response cache
  lastApiSyncAt   DateTime?
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  gbpAccount GbpAccount @relation(fields: [gbpAccountId], references: [id], onDelete: Cascade)
  location   Location   @relation(fields: [locationId], references: [id], onDelete: Cascade)
  posts      GbpPost[]
  reviews    Review[]
  qaEntries   GbpQA[]
  insights   GbpInsight[]
  photos     GbpPhoto[]

  @@unique([gbpLocationId])
  @@index([gbpAccountId])
  @@index([locationId])
  @@map("gbp_locations")
}

model GbpPost {
  id              String   @id @default(cuid())
  gbpLocationId   String
  contentPieceId  String?
  gbpPostId       String?  // Google's post ID after publish
  topicType       String   @default("STANDARD") // STANDARD, OFFER, EVENT
  summary         String   @db.Text
  actionType      String?  // BOOK, ORDER, SHOP, LEARN_MORE, SIGN_UP, CALL
  actionUrl       String?
  mediaUrls       String[] @default([])
  offerTitle      String?
  offerCouponCode String?
  offerTerms      String?
  eventTitle      String?
  eventStartTime  DateTime?
  eventEndTime    DateTime?
  searchTerms     String[] @default([])
  status          String   @default("SCHEDULED") // SCHEDULED, PUBLISHED, FAILED, REMOVED
  scheduledFor    DateTime?
  publishedAt     DateTime?
  failedReason    String?
  engagementStats Json?    // views, clicks
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  gbpLocation  GbpLocation  @relation(fields: [gbpLocationId], references: [id], onDelete: Cascade)
  contentPiece ContentPiece? @relation(fields: [contentPieceId], references: [id])

  @@index([gbpLocationId])
  @@index([status])
  @@index([scheduledFor])
  @@map("gbp_posts")
}

model GbpPhoto {
  id            String   @id @default(cuid())
  gbpLocationId String
  gbpPhotoName  String?  // Google's photo resource name
  url           String
  thumbnailUrl  String?
  category      String   @default("INTERIOR")
  caption       String?
  width         Int?
  height        Int?
  fileSize      Int?
  storageKey    String?  // R2 key
  status        String   @default("PENDING") // PENDING, UPLOADED, FAILED
  uploadedAt    DateTime?
  failedReason  String?
  createdAt     DateTime @default(now())

  gbpLocation GbpLocation @relation(fields: [gbpLocationId], references: [id], onDelete: Cascade)

  @@index([gbpLocationId])
  @@map("gbp_photos")
}

model GbpQA {
  id            String    @id @default(cuid())
  gbpLocationId String
  gbpQuestionId String?   // Google's question ID
  questionText  String    @db.Text
  questionerName String   @default("Anonymous")
  questionDate  DateTime
  answerText    String?   @db.Text
  answerBy      String?   // "Owner" or staff name
  answerDate    DateTime?
  upvoteCount   Int       @default(0)
  isAnswered    Boolean   @default(false)
  answerGeneratedByAI Boolean @default(false)
  status        String    @default("PENDING") // PENDING, ANSWERED, FLAGGED
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  gbpLocation GbpLocation @relation(fields: [gbpLocationId], references: [id], onDelete: Cascade)

  @@index([gbpLocationId])
  @@map("gbp_qa")
}

model GbpInsight {
  id                    String   @id @default(cuid())
  gbpLocationId         String
  date                  DateTime @db.Date // The date this insight represents

  // Discovery metrics
  viewsSearch           Int      @default(0)
  viewsMaps             Int      @default(0)
  totalViews            Int      @default(0)

  // Action metrics
  websiteClicks         Int      @default(0)
  phoneClicks           Int      @default(0)
  drivingDirections     Int      @default(0)
  bookingClicks         Int      @default(0)

  // Query breakdown (top 10 stored as JSON)
  searchQueries         Json?    @default("[]")

  // Photo metrics
  photosViews           Int      @default(0)
  photosCount           Int      @default(0)

  // Post metrics
  postsCount            Int      @default(0)
  postsViews            Int      @default(0)

  // Comparison
  viewsSearchPrevPeriod Int      @default(0)
  viewsMapsPrevPeriod   Int      @default(0)

  createdAt             DateTime @default(now())

  gbpLocation GbpLocation @relation(fields: [gbpLocationId], references: [id], onDelete: Cascade)

  @@unique([gbpLocationId, date])
  @@index([gbpLocationId])
  @@index([date])
  @@map("gbp_insights")
}

model Review {
  id              String   @id @default(cuid())
  locationId      String
  gbpLocationId   String?
  gbpReviewId     String?  @unique // Google's review ID
  reviewerName    String?
  reviewerPhoto   String?
  reviewerProfileUrl String?
  rating          Int      @db.SmallInt // 1-5
  comment         String?  @db.Text
  replyText       String?  @db.Text
  replyBy         String?  // AI or user name
  replyGeneratedByAI Boolean @default(false)
  replyPublished  Boolean  @default(false)
  repliedAt       DateTime?
  reviewDate      DateTime
  photos          String[] @default([]) // URLs of review photos
  isVerified      Boolean  @default(false)
  sentiment       String?  // POSITIVE, NEUTRAL, NEGATIVE
  keywords        String[] @default([])
  status          String   @default("NEW") // NEW, REPLIED, FLAGGED, ARCHIVED
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  location    Location    @relation(fields: [locationId], references: [id], onDelete: Cascade)
  gbpLocation GbpLocation? @relation(fields: [gbpLocationId], references: [id])

  @@index([locationId])
  @@index([gbpLocationId])
  @@index([status])
  @@index([rating])
  @@index([reviewDate])
  @@map("reviews")
}

1.3.5 Social Media Integration#

model SocialAccount {
  id              String   @id @default(cuid())
  practiceId      String
  platform        PlatformType
  accountName     String
  accountId       String?   // Platform's account ID
  profileUrl      String?
  accessToken     String   @db.Text
  refreshToken    String?  @db.Text
  tokenExpiresAt  DateTime?
  tokenScope      String[] @default([])
  avatarUrl       String?
  followerCount   Int?
  isActive        Boolean  @default(true)
  lastSyncedAt    DateTime?
  metadata        Json     @default("{}")
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  practice Practice    @relation(fields: [practiceId], references: [id], onDelete: Cascade)
  posts    SocialPost[]

  @@unique([practiceId, platform, accountName])
  @@index([practiceId])
  @@index([platform])
  @@map("social_accounts")
}

model SocialPost {
  id              String   @id @default(cuid())
  socialAccountId String
  contentPieceId  String?
  externalPostId  String?  // Platform's post ID after publish
  content         String   @db.Text
  mediaUrls       String[] @default([])
  mediaType       String   @default("NONE") // NONE, IMAGE, VIDEO, CAROUSEL, REEL
  hashtags        String[] @default([])
  mentions        String[] @default([])
  linkUrl         String?
  scheduledFor    DateTime?
  publishedAt     DateTime?
  status          String   @default("SCHEDULED") // DRAFT, SCHEDULED, PUBLISHED, FAILED, CANCELLED
  failedReason    String?
  engagementStats Json?    // likes, comments, shares, reach
  timezone        String   @default("Asia/Kolkata")
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  socialAccount SocialAccount @relation(fields: [socialAccountId], references: [id], onDelete: Cascade)
  contentPiece  ContentPiece?  @relation(fields: [contentPieceId], references: [id])

  @@index([socialAccountId])
  @@index([status])
  @@index([scheduledFor])
  @@map("social_posts")
}

1.3.6 Content Management (AI-Generated)#

model ContentPiece {
  id              String        @id @default(cuid())
  practiceId      String
  locationId      String?
  type            ContentType
  status          ContentStatus @default(DRAFT)
  title           String?
  content         String        @db.Text
  excerpt         String?       @db.Text
  seoTitle        String?
  seoDescription  String?
  focusKeywords   String[]      @default([])
  readabilityScore Int?         @default(0) // 0-100
  seoScore        Int?          @default(0) // 0-100
  aiProvider      AIProvider?
  aiModel         String?
  aiPrompt        String?       @db.Text
  aiTokensUsed    Int?
  aiCostUSD       Decimal?      @db.Decimal(10, 6)
  generationTimeMs Int?
  humanEdited     Boolean       @default(false)
  editedBy        String?
  editedAt        DateTime?
  sourceUrl       String?       // For scraped/competitor content
  metadata        Json          @default("{}") // Extra type-specific data
  createdAt       DateTime      @default(now())
  updatedAt       DateTime      @updatedAt

  practice     Practice      @relation(fields: [practiceId], references: [id], onDelete: Cascade)
  location     Location?     @relation(fields: [locationId], references: [id])
  gbpPosts     GbpPost[]
  socialPosts  SocialPost[]
  landingPages LandingPageContent[]

  @@index([practiceId])
  @@index([locationId])
  @@index([type])
  @@index([status])
  @@map("content_pieces")
}

1.3.7 Citation Engine#

model Citation {
  id                  String        @id @default(cuid())
  practiceId          String
  locationId          String
  directoryName       String        // e.g., "justdial", "lybrate", "practo", "sulekha"
  directoryDisplayName String
  directoryUrl        String?       // The listing URL
  submissionUrl       String?       // Where we submitted
  category            String?       // Category on the directory
  status              CitationStatus @default(PENDING)
  napSnapshot         Json          // { name, address, phone } at time of submission
  username            String?       // Credentials for the directory
  passwordEncrypted   String?       // Encrypted credentials
  submittedAt         DateTime?
  verifiedAt          DateTime?
  lastScannedAt       DateTime?
  scanResult          Json?         // Last NAP scan result
  matchStatus         NAPMatchStatus?
  errorMessage        String?
  retryCount          Int           @default(0)
  maxRetries          Int           @default(3)
  priority            Int           @default(5) // 1-10, higher = more important
  metadata            Json          @default("{}")
  createdAt           DateTime      @default(now())
  updatedAt           DateTime      @updatedAt

  practice Practice @relation(fields: [practiceId], references: [id], onDelete: Cascade)
  location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
  jobs     Job[]

  @@unique([locationId, directoryName])
  @@index([practiceId])
  @@index([locationId])
  @@index([status])
  @@index([directoryName])
  @@map("citations")
}

model CitationDirectory {
  id              String   @id @default(cuid())
  name            String   @unique // machine name: "justdial"
  displayName     String   // "Justdial"
  domain          String   // "justdial.com"
  category        String   // "medical", "general", "local"
  authorityScore  Int?     // Domain authority 0-100
  isActive        Boolean  @default(true)
  requiresCaptcha Boolean  @default(false)
  requiresPhoneVerify Boolean @default(false)
  signupFlow      Json     // JSON schema of the signup flow steps
  submissionFields Json    // Required fields for submission
  rateLimit       Json     @default("{}") // requests per minute, etc.
  successPatterns String[] @default([]) // Regex patterns to detect successful submission
  failurePatterns String[] @default([]) // Regex patterns to detect failures
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  @@map("citation_directories")
}

model OwnedBlogSite {
  id              String   @id @default(cuid())
  practiceId      String
  name            String
  domain          String   @unique
  subdomain       String?  // If hosted as subdomain of rankflow
  platform        String   @default("wordpress") // wordpress, ghost, custom
  cmsUrl          String?
  cmsUsername     String?
  cmsPasswordEncrypted String?
  apiEndpoint     String?
  apiKeyEncrypted String?
  isActive        Boolean  @default(true)
  autoPublish     Boolean  @default(true)
  lastPublishedAt DateTime?
  postCount       Int      @default(0)
  settings        Json     @default("{}")
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  practice Practice @relation(fields: [practiceId], references: [id], onDelete: Cascade)
  posts    BlogSitePost[]

  @@index([practiceId])
  @@map("owned_blog_sites")
}

model BlogSitePost {
  id              String   @id @default(cuid())
  blogSiteId      String
  contentPieceId  String?
  title           String
  slug            String
  content         String   @db.Text
  excerpt         String?
  status          String   @default("DRAFT") // DRAFT, PUBLISHED, FAILED
  publishedUrl    String?
  seoTitle        String?
  seoDescription  String?
  focusKeywords   String[] @default([])
  publishedAt     DateTime?
  failedReason    String?
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  blogSite OwnedBlogSite @relation(fields: [blogSiteId], references: [id], onDelete: Cascade)

  @@unique([blogSiteId, slug])
  @@index([blogSiteId])
  @@map("blog_site_posts")
}

1.3.8 Landing Page System#

model LandingPage {
  id              String   @id @default(cuid())
  practiceId      String
  locationId      String?
  slug            String   // URL slug: "dr-sharma-cardiology-mumbai"
  title           String
  isPublished     Boolean  @default(false)
  publishedAt     DateTime?
  customDomain    String?  @unique
  subdomain       String   @unique @default(cuid())
  seoTitle        String?
  seoDescription  String?
  canonicalUrl    String?
  schemaMarkup    Json?    // JSON-LD structured data
  templateId      String   @default("default-medical")
  themeConfig     Json     @default("{}") // Colors, fonts, layout preferences
  settings        Json     @default("{}")
  analyticsData   Json?    @default("{}") // Page views, conversions
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt
  deletedAt       DateTime?

  practice Practice           @relation(fields: [practiceId], references: [id], onDelete: Cascade)
  contents LandingPageContent[]
  deployments LandingPageDeployment[]

  @@unique([practiceId, slug])
  @@index([practiceId])
  @@index([subdomain])
  @@map("landing_pages")
}

model LandingPageContent {
  id              String   @id @default(cuid())
  landingPageId   String
  contentPieceId  String?
  sectionKey      String   // "hero", "about", "services", "testimonials", "faq", "contact", "cta"
  sortOrder       Int      @default(0)
  content         String   @db.Text
  mediaUrls       String[] @default([])
  config          Json     @default("{}") // Section-specific config
  isVisible       Boolean  @default(true)
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  landingPage  LandingPage  @relation(fields: [landingPageId], references: [id], onDelete: Cascade)
  contentPiece ContentPiece? @relation(fields: [contentPieceId], references: [id])

  @@unique([landingPageId, sectionKey])
  @@index([landingPageId])
  @@map("landing_page_contents")
}

model LandingPageDeployment {
  id            String   @id @default(cuid())
  landingPageId String
  version       Int      @default(1)
  deployedAt    DateTime @default(now())
  deployedBy    String?
  buildLog      String?  @db.Text
  isActive      Boolean  @default(true)
  rollbackFrom  String?  // Previous deployment ID
  createdAt     DateTime @default(now())

  landingPage LandingPage @relation(fields: [landingPageId], references: [id], onDelete: Cascade)

  @@index([landingPageId])
  @@map("landing_page_deployments")
}
model RankTrackingKeyword {
  id              String   @id @default(cuid())
  practiceId      String
  locationId      String?
  keyword         String
  searchEngine    String   @default("google") // google, google-maps, bing
  device          String   @default("desktop") // desktop, mobile
  language        String   @default("en-IN")
  locationTarget  String   @default("India") // Geo target
  currentRank     Int?
  previousRank    Int?
  bestRank        Int?
  searchVolume    Int?     // Monthly
  difficulty      Int?     // 0-100
  cpc             Decimal? @db.Decimal(8, 2) // Cost per click in INR
  isActive        Boolean  @default(true)
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  practice Practice          @relation(fields: [practiceId], references: [id], onDelete: Cascade)
  location Location?         @relation(fields: [locationId], references: [id])
  history  RankTrackingHistory[]

  @@unique([practiceId, locationId, keyword, searchEngine, device])
  @@index([practiceId])
  @@index([locationId])
  @@map("rank_tracking_keywords")
}

model RankTrackingHistory {
  id            String   @id @default(cuid())
  keywordId     String
  rank          Int?     // null = not in top 100
  page          Int?     // SERP page number
  urlFound      String?  // URL that ranked
  serpFeatures  String[] @default([]) // featured_snippet, local_pack, etc.
  checkedAt     DateTime

  keyword RankTrackingKeyword @relation(fields: [keywordId], references: [id], onDelete: Cascade)

  @@index([keywordId])
  @@index([checkedAt])
  @@map("rank_tracking_history")
}

model BacklinkMonitor {
  id              String   @id @default(cuid())
  practiceId      String
  sourceUrl       String   // The page linking to client
  targetUrl       String   // Client's page being linked to
  anchorText      String?
  linkType        String   @default("dofollow") // dofollow, nofollow, ugc, sponsored
  domainAuthority Int?     @default(0)
  pageAuthority   Int?     @default(0)
  firstSeenAt     DateTime @default(now())
  lastCheckedAt   DateTime @default(now())
  isActive        Boolean  @default(true)
  isLost          Boolean  @default(false)
  lostAt          DateTime?
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  practice Practice @relation(fields: [practiceId], references: [id], onDelete: Cascade)

  @@index([practiceId])
  @@index([sourceUrl])
  @@map("backlink_monitors")
}

1.3.10 Competitor Analysis#

model CompetitorPractice {
  id              String   @id @default(cuid())
  practiceId      String   // Our practice
  name            String
  gbpName         String?
  gbpPlaceId      String?
  website         String?
  category        String?
  address         String?
  city            String?
  rating          Decimal? @db.Decimal(2, 1)
  reviewCount     Int?
  photosCount     Int?
  postsFrequency  String?  // "daily", "weekly", etc.
  isActive        Boolean  @default(true)
  metadata        Json     @default("{}")
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  practice Practice           @relation(fields: [practiceId], references: [id], onDelete: Cascade)
  snapshots CompetitorSnapshot[]

  @@index([practiceId])
  @@map("competitor_practices")
}

model CompetitorSnapshot {
  id                  String   @id @default(cuid())
  competitorId        String
  snapshotDate        DateTime @db.Date
  gbpViews            Int?     @default(0)
  gbpClicks           Int?     @default(0)
  gbpCalls            Int?     @default(0)
  gbpDirections       Int?     @default(0)
  reviewCount         Int?     @default(0)
  avgRating           Decimal? @db.Decimal(2, 1)
  postCount7d         Int?     @default(0)
  photoCount          Int?     @default(0)
  websiteAuthority    Int?     @default(0)
  backlinkCount       Int?     @default(0)
  keywordOverlap      Int?     @default(0)
  createdAt           DateTime @default(now())

  competitor CompetitorPractice @relation(fields: [competitorId], references: [id], onDelete: Cascade)

  @@unique([competitorId, snapshotDate])
  @@index([competitorId])
  @@map("competitor_snapshots")
}

1.3.11 Report System#

model ReportSchedule {
  id              String         @id @default(cuid())
  practiceId      String
  name            String         @default("Monthly SEO Report")
  frequency       ReportFrequency @default(MONTHLY)
  dayOfWeek       Int?           // 0-6 for weekly
  dayOfMonth      Int?           @default(1) // 1-31
  hour            Int            @default(9)
  timezone        String         @default("Asia/Kolkata")
  recipients      String[]       @default([])
  includeSections String[]       @default(["executive_summary", "gbp_performance", "rank_tracking", "citations", "reviews", "competitors", "recommendations"])
  isActive        Boolean        @default(true)
  lastRunAt       DateTime?
  nextRunAt       DateTime?
  createdAt       DateTime       @default(now())
  updatedAt       DateTime       @updatedAt

  practice Practice @relation(fields: [practiceId], references: [id], onDelete: Cascade)
  reports  Report[]

  @@index([practiceId])
  @@index([nextRunAt])
  @@map("report_schedules")
}

model Report {
  id              String   @id @default(cuid())
  practiceId      String
  scheduleId      String?
  name            String
  periodStart     DateTime
  periodEnd       DateTime
  status          String   @default("GENERATING") // GENERATING, READY, FAILED, SENT
  sections        Json     // Structured report data per section
  summaryText     String?  @db.Text
  scoreOverall    Int?     @default(0)
  scoreGBP        Int?     @default(0)
  scoreCitations  Int?     @default(0)
  scoreReviews    Int?     @default(0)
  scoreRankings   Int?     @default(0)
  pdfUrl          String?  // R2 URL
  emailSentAt     DateTime?
  emailRecipients String[] @default([])
  viewedAt        DateTime?
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  practice Practice        @relation(fields: [practiceId], references: [id], onDelete: Cascade)
  schedule ReportSchedule? @relation(fields: [scheduleId], references: [id])

  @@index([practiceId])
  @@index([scheduleId])
  @@index([status])
  @@map("reports")
}

1.3.12 Billing & Invoices#

model Invoice {
  id                String   @id @default(cuid())
  practiceId        String
  provider          PaymentProvider
  providerInvoiceId String?   @unique
  amount            Decimal   @db.Decimal(10, 2)
  currency          String    @default("INR")
  status            String    @default("PENDING") // PENDING, PAID, FAILED, REFUNDED
  description       String?
  periodStart       DateTime?
  periodEnd         DateTime?
  paidAt            DateTime?
  failedAt          DateTime?
  failureReason     String?
  metadata          Json      @default("{}")
  createdAt         DateTime  @default(now())
  updatedAt         DateTime  @updatedAt

  practice Practice @relation(fields: [practiceId], references: [id], onDelete: Cascade)

  @@index([practiceId])
  @@index([providerInvoiceId])
  @@map("invoices")
}

1.3.13 Job Queue System#

model Job {
  id            String   @id @default(cuid())
  practiceId    String
  type          JobType
  status        JobStatus @default(PENDING)
  priority      Int       @default(5) // 1-10, higher = more important
  payload       Json     // Job-specific data
  result        Json?    // Job output
  errorMessage  String?
  errorStack    String?  @db.Text
  retryCount    Int      @default(0)
  maxRetries    Int      @default(3)
  startedAt     DateTime?
  completedAt   DateTime?
  failedAt      DateTime?
  scheduledFor  DateTime? // For delayed jobs
  queueName     String   @default("default")
  parentJobId   String?   // For job chains
  createdBy     String?   // User ID
  workerId      String?   // Which worker processed this
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  practice Practice @relation(fields: [practiceId], references: [id], onDelete: Cascade)
  creator  User?    @relation("JobCreator", fields: [createdBy], references: [id])

  @@index([practiceId])
  @@index([type])
  @@index([status])
  @@index([scheduledFor])
  @@index([priority, createdAt])
  @@index([parentJobId])
  @@map("jobs")
}

1.3.14 AI Cost Tracking#

model AICostLog {
  id              String     @id @default(cuid())
  practiceId      String?
  provider        AIProvider
  model           String     // Specific model version
  operationType   String     // content_generation, seo_audit, review_reply, etc.
  inputTokens     Int        @default(0)
  outputTokens    Int        @default(0)
  totalTokens     Int        @default(0)
  costUSD         Decimal    @db.Decimal(10, 6)
  latencyMs       Int?       // Response time
  wasFallback     Boolean    @default(false) // Was this a fallback from another provider
  errorMessage    String?
  createdAt       DateTime   @default(now())

  @@index([practiceId])
  @@index([provider])
  @@index([createdAt])
  @@map("ai_cost_logs")
}

1.3.15 Audit Logging#

model AuditLog {
  id          String      @id @default(cuid())
  practiceId  String?
  userId      String?
  action      AuditAction
  entityType  String      // table name or resource type
  entityId    String?     // specific record ID
  oldValue    Json?
  newValue    Json?
  metadata    Json        @default("{}")
  ipAddress   String?
  userAgent   String?
  createdAt   DateTime    @default(now())

  practice Practice? @relation(fields: [practiceId], references: [id], onDelete: SetNull)
  user     User?     @relation(fields: [userId], references: [id], onDelete: SetNull)

  @@index([practiceId])
  @@index([userId])
  @@index([action])
  @@index([entityType, entityId])
  @@index([createdAt])
  @@map("audit_logs")
}

1.3.16 API Keys & Notifications#

model ApiKey {
  id          String   @id @default(cuid())
  userId      String
  name        String   @default("API Key")
  keyHash     String   @unique // Hashed key for lookup
  keyPrefix   String   // First 8 chars for display
  scopes      String[] @default(["read", "write"])
  lastUsedAt  DateTime?
  expiresAt   DateTime?
  isRevoked   Boolean  @default(false)
  createdAt   DateTime @default(now())

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@map("api_keys")
}

model Notification {
  id          String   @id @default(cuid())
  userId      String
  type        String   // REVIEW_RECEIVED, CITATION_VERIFIED, RANK_CHANGE, etc.
  title       String
  message     String   @db.Text
  isRead      Boolean  @default(false)
  actionUrl   String?
  metadata    Json     @default("{}")
  createdAt   DateTime @default(now())

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@index([isRead])
  @@index([createdAt])
  @@map("notifications")
}

model UsageMetric {
  id              String   @id @default(cuid())
  practiceId      String
  metricDate      DateTime @db.Date
  gbpPosts        Int      @default(0)
  socialPosts     Int      @default(0)
  citationsSubmitted Int   @default(0)
  reviewsReplied  Int      @default(0)
  aiTokensUsed    Int      @default(0)
  aiCostUSD       Decimal  @default(0) @db.Decimal(10, 6)
  emailsSent      Int      @default(0)
  pagesGenerated  Int      @default(0)
  createdAt       DateTime @default(now())

  practice Practice @relation(fields: [practiceId], references: [id], onDelete: Cascade)

  @@unique([practiceId, metricDate])
  @@index([practiceId])
  @@map("usage_metrics")
}

1.4 Index Strategy#

Table Index Columns Type Purpose
jobs [practiceId, type, status] B-tree Dashboard filtering
jobs [scheduledFor, status] B-tree Scheduler polling
jobs [priority, createdAt] B-tree Worker dequeuing
gbp_insights [gbpLocationId, date] B-tree Time-series lookups
reviews [locationId, reviewDate] B-tree Review feeds
citations [locationId, directoryName] Unique Deduplication
rank_tracking_history [keywordId, checkedAt] B-tree Trend queries
audit_logs [practiceId, createdAt] B-tree Audit trail
content_pieces [practiceId, type, status] B-tree Content management
landing_pages [subdomain] B-tree Subdomain routing
landing_pages [customDomain] B-tree Custom domain routing

2. tRPC Router Structure#

2.1 Router Tree#

app/api/trpc/[trpc]/route.ts
  └── root router
      ├── auth              (NextAuth.js integration)
      ├── user              (profile, preferences, notifications)
      ├── practice          (CRUD, members, settings)
      ├── location          (NAP, hours, photos, services)
      ├── gbp               (account, locations, posts, reviews, insights, qa)
      ├── social            (accounts, posts, scheduling, analytics)
      ├── content           (AI generation, editing, approval workflow)
      ├── citation          (directories, submissions, NAP monitoring, blog sites)
      ├── landingPage       (pages, sections, deployments, templates)
      ├── rankTracking      (keywords, history, bulk operations)
      ├── backlink          (monitors, discovery)
      ├── competitor        (practices, snapshots, analysis)
      ├── report            (schedules, generation, PDF, email)
      ├── job               (queue monitoring, retry, cancellation)
      ├── billing           (subscriptions, invoices, usage)
      ├── ai                (cost tracking, provider config, prompt templates)
      ├── webhook           (incoming: Stripe, Razorpay, GBP, Composio)
      └── admin             (super admin: practices, users, platform stats)

2.2 Router Registration#

// src/server/api/root.ts
import { createTRPCRouter } from "~/server/api/trpc";
import { authRouter } from "~/server/api/routers/auth";
import { userRouter } from "~/server/api/routers/user";
import { practiceRouter } from "~/server/api/routers/practice";
import { locationRouter } from "~/server/api/routers/location";
import { gbpRouter } from "~/server/api/routers/gbp";
import { socialRouter } from "~/server/api/routers/social";
import { contentRouter } from "~/server/api/routers/content";
import { citationRouter } from "~/server/api/routers/citation";
import { landingPageRouter } from "~/server/api/routers/landing-page";
import { rankTrackingRouter } from "~/server/api/routers/rank-tracking";
import { backlinkRouter } from "~/server/api/routers/backlink";
import { competitorRouter } from "~/server/api/routers/competitor";
import { reportRouter } from "~/server/api/routers/report";
import { jobRouter } from "~/server/api/routers/job";
import { billingRouter } from "~/server/api/routers/billing";
import { aiRouter } from "~/server/api/routers/ai";
import { webhookRouter } from "~/server/api/routers/webhook";
import { adminRouter } from "~/server/api/routers/admin";

export const appRouter = createTRPCRouter({
  auth: authRouter,
  user: userRouter,
  practice: practiceRouter,
  location: locationRouter,
  gbp: gbpRouter,
  social: socialRouter,
  content: contentRouter,
  citation: citationRouter,
  landingPage: landingPageRouter,
  rankTracking: rankTrackingRouter,
  backlink: backlinkRouter,
  competitor: competitorRouter,
  report: reportRouter,
  job: jobRouter,
  billing: billingRouter,
  ai: aiRouter,
  webhook: webhookRouter,
  admin: adminRouter,
});

export type AppRouter = typeof appRouter;

2.3 Base tRPC Setup#

// src/server/api/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { type CreateNextContextOptions } from "@trpc/server/adapters/next";
import superjson from "superjson";
import { ZodError } from "zod";
import { getServerAuthSession } from "~/server/auth";
import { prisma } from "~/server/db";
import { redis } from "~/server/redis";

export const createTRPCContext = async (opts: CreateNextContextOptions) => {
  const { req, res } = opts;
  const session = await getServerAuthSession(req, res);

  return {
    prisma,
    redis,
    session,
    req,
    res,
    // Practice context is resolved per-request via middleware
  };
};

const t = initTRPC.context<typeof createTRPCContext>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;

// ─── Middleware ──────────────────────────────────────────────

/** Enforce user is authenticated */
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
  }
  return next({
    ctx: {
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});

export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);

/** Resolve active practice from header/context */
const resolvePractice = t.middleware(async ({ ctx, next }) => {
  const practiceId = ctx.req.headers["x-practice-id"] as string | undefined;

  if (!practiceId) {
    throw new TRPCError({ code: "BAD_REQUEST", message: "Practice ID required" });
  }

  const membership = await ctx.prisma.practiceMember.findUnique({
    where: {
      practiceId_userId: {
        practiceId,
        userId: ctx.session!.user.id,
      },
    },
    include: { practice: true },
  });

  if (!membership) {
    throw new TRPCError({ code: "FORBIDDEN", message: "Not a member of this practice" });
  }

  if (membership.practice.deletedAt) {
    throw new TRPCError({ code: "NOT_FOUND", message: "Practice not found" });
  }

  return next({
    ctx: {
      ...ctx,
      practice: membership.practice,
      membership,
    },
  });
});

export const practiceProcedure = protectedProcedure.use(resolvePractice);

/** Require minimum role level */
const requireRole = (minRole: "VIEWER" | "EDITOR" | "ADMIN" | "OWNER") => {
  const roleHierarchy = { VIEWER: 0, EDITOR: 1, ADMIN: 2, OWNER: 3 };
  return t.middleware(({ ctx, next }) => {
    const userRole = (ctx as any).membership?.role ?? "VIEWER";
    if (roleHierarchy[userRole] < roleHierarchy[minRole]) {
      throw new TRPCError({ code: "FORBIDDEN", message: `Requires ${minRole} role` });
    }
    return next({ ctx });
  });
};

export const editorProcedure = practiceProcedure.use(requireRole("EDITOR"));
export const adminProcedure = practiceProcedure.use(requireRole("ADMIN"));

/** Rate limiting middleware */
const rateLimit = (limit: number, windowSeconds: number) =>
  t.middleware(async ({ ctx, path, next }) => {
    const key = `ratelimit:${ctx.session!.user.id}:${path}`;
    const current = await ctx.redis.incr(key);
    if (current === 1) {
      await ctx.redis.expire(key, windowSeconds);
    }
    if (current > limit) {
      throw new TRPCError({
        code: "TOO_MANY_REQUESTS",
        message: `Rate limit: ${limit} requests per ${windowSeconds}s`,
      });
    }
    return next({ ctx });
  });

/** Audit logging middleware */
const auditLog = (action: AuditAction, entityType: string) =>
  t.middleware(async ({ ctx, path, type, input, next }) => {
    const result = await next({ ctx });
    // Fire-and-forget audit log
    if ((ctx as any).practice) {
      void ctx.prisma.auditLog.create({
        data: {
          practiceId: (ctx as any).practice.id,
          userId: ctx.session!.user.id,
          action,
          entityType,
          metadata: { trpcPath: path, input: input as any },
        },
      });
    }
    return result;
  });

2.4 Example Router Implementations#

2.4.1 Practice Router#

// src/server/api/routers/practice.ts
import { z } from "zod";
import { createTRPCRouter, practiceProcedure, adminProcedure, publicProcedure } from "~/server/api/trpc";
import { TRPCError } from "@trpc/server";
import { SubscriptionTier, UserRole } from "@prisma/client";

const createPracticeSchema = z.object({
  name: z.string().min(2).max(100),
  type: z.nativeEnum(PracticeType),
  slug: z.string().min(3).max(50).regex(/^[a-z0-9-]+$/),
  businessName: z.string().min(2).max(200),
  addressLine1: z.string().min(5).max(200),
  addressLine2: z.string().max(200).optional(),
  city: z.string().min(2).max(100),
  state: z.string().min(2).max(100),
  postalCode: z.string().regex(/^\d{6}$/), // Indian PIN code
  phone: z.string().regex(/^[6-9]\d{9}$/), // Indian mobile
  email: z.string().email().optional(),
  website: z.string().url().optional(),
  primaryCategory: z.string().default("Medical Clinic"),
  targetKeywords: z.array(z.string()).max(20).default([]),
});

const updatePracticeSchema = z.object({
  id: z.string().cuid(),
  name: z.string().min(2).max(100).optional(),
  subscriptionTier: z.nativeEnum(SubscriptionTier).optional(),
  customDomain: z.string().domain().optional().nullable(),
  whiteLabelBrandName: z.string().optional().nullable(),
  whiteLabelLogoUrl: z.string().url().optional().nullable(),
  settings: z.record(z.unknown()).optional(),
});

const inviteMemberSchema = z.object({
  practiceId: z.string().cuid(),
  email: z.string().email(),
  role: z.nativeEnum(UserRole).default("EDITOR"),
});

export const practiceRouter = createTRPCRouter({
  // ── Queries ─────────────────────────────────────────

  getBySlug: publicProcedure
    .input(z.object({ slug: z.string() }))
    .query(async ({ ctx, input }) => {
      return ctx.prisma.practice.findUnique({
        where: { slug: input.slug, deletedAt: null },
        include: {
          locations: { where: { deletedAt: null } },
          members: { include: { user: { select: { id: true, name: true, email: true, image: true } } } },
        },
      });
    }),

  getMyPractices: protectedProcedure.query(async ({ ctx }) => {
    const memberships = await ctx.prisma.practiceMember.findMany({
      where: { userId: ctx.session.user.id },
      include: {
        practice: {
          include: {
            locations: { where: { isPrimary: true }, take: 1 },
            _count: { select: { locations: true, members: true } },
          },
        },
      },
      orderBy: { createdAt: "desc" },
    });
    return memberships.map((m) => ({
      ...m.practice,
      myRole: m.role,
    }));
  }),

  getById: practiceProcedure
    .input(z.object({ id: z.string().cuid() }))
    .query(async ({ ctx, input }) => {
      if (ctx.practice.id !== input.id) {
        throw new TRPCError({ code: "FORBIDDEN" });
      }
      return ctx.prisma.practice.findUnique({
        where: { id: input.id },
        include: {
          locations: true,
          members: {
            include: { user: { select: { id: true, name: true, email: true, image: true } } },
          },
          notificationSettings: true,
          _count: {
            select: {
              citations: true,
              contentPieces: true,
              gbpAccounts: true,
              socialAccounts: true,
            },
          },
        },
      });
    }),

  getUsage: practiceProcedure.query(async ({ ctx }) => {
    const now = new Date();
    const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
    const [metrics, aiCosts] = await Promise.all([
      ctx.prisma.usageMetric.findMany({
        where: { practiceId: ctx.practice.id, metricDate: { gte: startOfMonth } },
      }),
      ctx.prisma.aICostLog.groupBy({
        by: ["provider"],
        where: { practiceId: ctx.practice.id, createdAt: { gte: startOfMonth } },
        _sum: { costUSD: true, totalTokens: true },
      }),
    ]);
    return { metrics, aiCosts };
  }),

  // ── Mutations ───────────────────────────────────────

  create: protectedProcedure
    .input(createPracticeSchema)
    .mutation(async ({ ctx, input }) => {
      // Slug uniqueness check
      const existing = await ctx.prisma.practice.findUnique({
        where: { slug: input.slug },
      });
      if (existing) {
        throw new TRPCError({ code: "CONFLICT", message: "Slug already taken" });
      }

      const { slug, name, type, businessName, addressLine1, addressLine2, city, state, postalCode, phone, email, website, primaryCategory, targetKeywords } = input;

      return ctx.prisma.$transaction(async (tx) => {
        const practice = await tx.practice.create({
          data: {
            slug,
            name,
            type,
            ownerId: ctx.session.user.id,
            subscriptionTier: "FREE",
            subscriptionStatus: "TRIAL",
            trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14-day trial
            wildcardSubdomain: `${slug}-${crypto.randomUUID().slice(0, 8)}`,
          },
        });

        await tx.location.create({
          data: {
            practiceId: practice.id,
            name: `${businessName} — ${city}`,
            businessName,
            addressLine1,
            addressLine2,
            city,
            state,
            postalCode,
            country: "IN",
            phone,
            email,
            website,
            primaryCategory,
            targetKeywords,
            isPrimary: true,
          },
        });

        await tx.practiceMember.create({
          data: {
            practiceId: practice.id,
            userId: ctx.session.user.id,
            role: "OWNER",
            acceptedAt: new Date(),
          },
        });

        return practice;
      });
    }),

  update: adminProcedure
    .input(updatePracticeSchema)
    .mutation(async ({ ctx, input }) => {
      const { id, ...data } = input;
      return ctx.prisma.practice.update({
        where: { id },
        data,
      });
    }),

  inviteMember: adminProcedure
    .input(inviteMemberSchema)
    .mutation(async ({ ctx, input }) => {
      const { practiceId, email, role } = input;
      // Check if user exists
      const existingUser = await ctx.prisma.user.findUnique({ where: { email } });
      const existingMember = await ctx.prisma.practiceMember.findUnique({
        where: {
          practiceId_userId: {
            practiceId,
            userId: existingUser?.id ?? "",
          },
        },
      });
      if (existingMember) {
        throw new TRPCError({ code: "CONFLICT", message: "User is already a member" });
      }
      // Create invitation
      const member = await ctx.prisma.practiceMember.create({
        data: {
          practiceId,
          userId: existingUser?.id ?? "",
          role,
          invitedBy: ctx.session.user.id,
          invitedAt: new Date(),
          // If user doesn't exist, they'll claim on signup
        },
      });
      // TODO: Send invitation email via queue
      return member;
    }),

  updateMemberRole: adminProcedure
    .input(z.object({
      practiceId: z.string().cuid(),
      userId: z.string().cuid(),
      role: z.nativeEnum(UserRole),
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.practiceMember.update({
        where: {
          practiceId_userId: {
            practiceId: input.practiceId,
            userId: input.userId,
          },
        },
        data: { role: input.role },
      });
    }),

  removeMember: adminProcedure
    .input(z.object({
      practiceId: z.string().cuid(),
      userId: z.string().cuid(),
    }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.practiceMember.delete({
        where: {
          practiceId_userId: {
            practiceId: input.practiceId,
            userId: input.userId,
          },
        },
      });
    }),
});

2.4.2 GBP Router#

// src/server/api/routers/gbp.ts
import { z } from "zod";
import { createTRPCRouter, practiceProcedure, editorProcedure } from "~/server/api/trpc";
import { TRPCError } from "@trpc/server";
import { getGbpClient } from "~/server/lib/gbp/client";

export const gbpRouter = createTRPCRouter({
  // ── Account Management ──────────────────────────────

  getAccounts: practiceProcedure.query(async ({ ctx }) => {
    return ctx.prisma.gbpAccount.findMany({
      where: { practiceId: ctx.practice.id, isActive: true },
      include: {
        locations: {
          include: {
            location: true,
            _count: { select: { posts: true, reviews: true } },
          },
        },
      },
    });
  }),

  initiateOAuth: practiceProcedure
    .input(z.object({
      redirectUrl: z.string().url(),
    }))
    .mutation(async ({ ctx, input }) => {
      const state = crypto.randomUUID();
      await ctx.prisma.gbpAccount.create({
        data: {
          practiceId: ctx.practice.id,
          accountEmail: "pending",
          accessToken: "",
          refreshToken: "",
          tokenExpiresAt: new Date(),
          oauthState: state,
        },
      });

      const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
      authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
      authUrl.searchParams.set("redirect_uri", input.redirectUrl);
      authUrl.searchParams.set("response_type", "code");
      authUrl.searchParams.set("scope", [
        "https://www.googleapis.com/auth/business.manage",
        "https://www.googleapis.com/auth/userinfo.email",
      ].join(" "));
      authUrl.searchParams.set("state", state);
      authUrl.searchParams.set("access_type", "offline");
      authUrl.searchParams.set("prompt", "consent");

      return { authUrl: authUrl.toString(), state };
    }),

  handleCallback: practiceProcedure
    .input(z.object({
      code: z.string(),
      state: z.string(),
      redirectUrl: z.string().url(),
    }))
    .mutation(async ({ ctx, input }) => {
      const pending = await ctx.prisma.gbpAccount.findUnique({
        where: { oauthState: input.state },
      });
      if (!pending) {
        throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid OAuth state" });
      }

      // Exchange code for tokens
      const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({
          code: input.code,
          client_id: process.env.GOOGLE_CLIENT_ID!,
          client_secret: process.env.GOOGLE_CLIENT_SECRET!,
          redirect_uri: input.redirectUrl,
          grant_type: "authorization_code",
        }),
      });
      const tokens = await tokenRes.json();

      // Fetch account email
      const userInfoRes = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
        headers: { Authorization: `Bearer ${tokens.access_token}` },
      });
      const userInfo = await userInfoRes.json();

      await ctx.prisma.gbpAccount.update({
        where: { id: pending.id },
        data: {
          accountEmail: userInfo.email,
          accessToken: tokens.access_token,
          refreshToken: tokens.refresh_token,
          tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
          scope: tokens.scope?.split(" ") ?? [],
          oauthState: null,
        },
      });

      // Queue sync job for GBP locations
      await ctx.prisma.job.create({
        data: {
          practiceId: ctx.practice.id,
          type: "GBP_QA_SYNC",
          status: "PENDING",
          payload: { gbpAccountId: pending.id, operation: "sync_locations" },
          createdBy: ctx.session.user.id,
        },
      });

      return { success: true, email: userInfo.email };
    }),

  // ── Post Management ─────────────────────────────────

  getPosts: practiceProcedure
    .input(z.object({
      gbpLocationId: z.string().cuid(),
      status: z.string().optional(),
      limit: z.number().min(1).max(100).default(20),
      offset: z.number().default(0),
    }))
    .query(async ({ ctx, input }) => {
      return ctx.prisma.gbpPost.findMany({
        where: {
          gbpLocationId: input.gbpLocationId,
          ...(input.status ? { status: input.status } : {}),
        },
        include: { contentPiece: true },
        orderBy: { createdAt: "desc" },
        take: input.limit,
        skip: input.offset,
      });
    }),

  schedulePost: editorProcedure
    .input(z.object({
      gbpLocationId: z.string().cuid(),
      summary: z.string().min(10).max(1500),
      topicType: z.enum(["STANDARD", "OFFER", "EVENT"]).default("STANDARD"),
      actionType: z.enum(["BOOK", "ORDER", "SHOP", "LEARN_MORE", "SIGN_UP", "CALL"]).optional(),
      actionUrl: z.string().url().optional(),
      mediaUrls: z.array(z.string().url()).max(10).default([]),
      offerTitle: z.string().optional(),
      offerCouponCode: z.string().optional(),
      offerTerms: z.string().optional(),
      eventTitle: z.string().optional(),
      eventStartTime: z.string().datetime().optional(),
      eventEndTime: z.string().datetime().optional(),
      searchTerms: z.array(z.string()).max(10).default([]),
      scheduledFor: z.string().datetime(),
    }))
    .mutation(async ({ ctx, input }) => {
      const post = await ctx.prisma.gbpPost.create({
        data: {
          gbpLocationId: input.gbpLocationId,
          topicType: input.topicType,
          summary: input.summary,
          actionType: input.actionType,
          actionUrl: input.actionUrl,
          mediaUrls: input.mediaUrls,
          offerTitle: input.offerTitle,
          offerCouponCode: input.offerCouponCode,
          offerTerms: input.offerTerms,
          eventTitle: input.eventTitle,
          eventStartTime: input.eventStartTime ? new Date(input.eventStartTime) : null,
          eventEndTime: input.eventEndTime ? new Date(input.eventEndTime) : null,
          searchTerms: input.searchTerms,
          status: "SCHEDULED",
          scheduledFor: new Date(input.scheduledFor),
        },
      });

      // Create job for scheduled publishing
      await ctx.prisma.job.create({
        data: {
          practiceId: ctx.practice.id,
          type: "GBP_POST_PUBLISH",
          status: "QUEUED",
          priority: 5,
          payload: { gbpPostId: post.id },
          scheduledFor: new Date(input.scheduledFor),
          createdBy: ctx.session.user.id,
        },
      });

      return post;
    }),

  generatePost: editorProcedure
    .input(z.object({
      gbpLocationId: z.string().cuid(),
      topic: z.string().min(5),
      tone: z.enum(["professional", "friendly", "urgent", "educational"]).default("professional"),
      includeCta: z.boolean().default(true),
      language: z.enum(["en", "hi", "mr", "ta", "te", "bn"]).default("en"),
    }))
    .mutation(async ({ ctx, input }) => {
      // Get location context
      const gbpLocation = await ctx.prisma.gbpLocation.findUnique({
        where: { id: input.gbpLocationId },
        include: { location: true },
      });

      if (!gbpLocation) {
        throw new TRPCError({ code: "NOT_FOUND", message: "GBP location not found" });
      }

      // Queue AI content generation job
      const job = await ctx.prisma.job.create({
        data: {
          practiceId: ctx.practice.id,
          type: "AI_CONTENT_GENERATE",
          status: "PENDING",
          priority: 7,
          payload: {
            contentType: "GBP_POST",
            topic: input.topic,
            tone: input.tone,
            includeCta: input.includeCta,
            language: input.language,
            locationContext: {
              name: gbpLocation.location.businessName,
              city: gbpLocation.location.city,
              category: gbpLocation.location.primaryCategory,
              services: gbpLocation.location.services,
            },
            gbpLocationId: input.gbpLocationId,
          },
          createdBy: ctx.session.user.id,
        },
      });

      return { jobId: job.id, status: job.status };
    }),

  // ── Review Management ───────────────────────────────

  getReviews: practiceProcedure
    .input(z.object({
      locationId: z.string().cuid().optional(),
      gbpLocationId: z.string().cuid().optional(),
      status: z.string().optional(),
      rating: z.number().min(1).max(5).optional(),
      limit: z.number().min(1).max(100).default(20),
      offset: z.number().default(0),
    }))
    .query(async ({ ctx, input }) => {
      return ctx.prisma.review.findMany({
        where: {
          ...(input.locationId ? { locationId: input.locationId } : {}),
          ...(input.gbpLocationId ? { gbpLocationId: input.gbpLocationId } : {}),
          ...(input.status ? { status: input.status } : {}),
          ...(input.rating ? { rating: input.rating } : {}),
          location: { practiceId: ctx.practice.id },
        },
        orderBy: { reviewDate: "desc" },
        take: input.limit,
        skip: input.offset,
      });
    }),

  replyToReview: editorProcedure
    .input(z.object({
      reviewId: z.string().cuid(),
      replyText: z.string().min(10).max(10000),
      useAI: z.boolean().default(false),
    }))
    .mutation(async ({ ctx, input }) => {
      const review = await ctx.prisma.review.findFirst({
        where: { id: input.reviewId, location: { practiceId: ctx.practice.id } },
      });
      if (!review) throw new TRPCError({ code: "NOT_FOUND" });

      if (input.useAI) {
        // Queue AI reply generation
        const job = await ctx.prisma.job.create({
          data: {
            practiceId: ctx.practice.id,
            type: "GBP_REVIEW_REPLY",
            status: "PENDING",
            payload: {
              reviewId: input.reviewId,
              reviewText: review.comment,
              rating: review.rating,
              operation: "generate_and_publish",
            },
            createdBy: ctx.session.user.id,
          },
        });
        return { jobId: job.id };
      }

      await ctx.prisma.review.update({
        where: { id: input.reviewId },
        data: {
          replyText: input.replyText,
          replyBy: ctx.session.user.name ?? "Owner",
          replyGeneratedByAI: false,
          repliedAt: new Date(),
          status: "REPLIED",
        },
      });

      // Queue publish to GBP
      await ctx.prisma.job.create({
        data: {
          practiceId: ctx.practice.id,
          type: "GBP_REVIEW_REPLY",
          status: "PENDING",
          payload: { reviewId: input.reviewId, operation: "publish_reply" },
          createdBy: ctx.session.user.id,
        },
      });

      return { success: true };
    }),

  // ── Insights ────────────────────────────────────────

  getInsights: practiceProcedure
    .input(z.object({
      gbpLocationId: z.string().cuid(),
      startDate: z.string().date(),
      endDate: z.string().date(),
    }))
    .query(async ({ ctx, input }) => {
      return ctx.prisma.gbpInsight.findMany({
        where: {
          gbpLocationId: input.gbpLocationId,
          date: {
            gte: new Date(input.startDate),
            lte: new Date(input.endDate),
          },
        },
        orderBy: { date: "asc" },
      });
    }),

  getInsightsSummary: practiceProcedure
    .input(z.object({
      gbpLocationId: z.string().cuid(),
      days: z.number().min(7).max(365).default(30),
    }))
    .query(async ({ ctx, input }) => {
      const since = new Date(Date.now() - input.days * 24 * 60 * 60 * 1000);
      const result = await ctx.prisma.gbpInsight.aggregate({
        where: {
          gbpLocationId: input.gbpLocationId,
          date: { gte: since },
        },
        _sum: {
          viewsSearch: true,
          viewsMaps: true,
          websiteClicks: true,
          phoneClicks: true,
          drivingDirections: true,
        },
        _avg: {
          viewsSearch: true,
          viewsMaps: true,
        },
      });
      return result;
    }),
});

2.4.3 Content Router#

// src/server/api/routers/content.ts
import { z } from "zod";
import { createTRPCRouter, practiceProcedure, editorProcedure } from "~/server/api/trpc";
import { ContentType, ContentStatus } from "@prisma/client";

export const contentRouter = createTRPCRouter({
  list: practiceProcedure
    .input(z.object({
      type: z.nativeEnum(ContentType).optional(),
      status: z.nativeEnum(ContentStatus).optional(),
      locationId: z.string().cuid().optional(),
      limit: z.number().min(1).max(100).default(20),
      offset: z.number().default(0),
    }))
    .query(async ({ ctx, input }) => {
      return ctx.prisma.contentPiece.findMany({
        where: {
          practiceId: ctx.practice.id,
          ...(input.type ? { type: input.type } : {}),
          ...(input.status ? { status: input.status } : {}),
          ...(input.locationId ? { locationId: input.locationId } : {}),
        },
        orderBy: { createdAt: "desc" },
        take: input.limit,
        skip: input.offset,
      });
    }),

  getById: practiceProcedure
    .input(z.object({ id: z.string().cuid() }))
    .query(async ({ ctx, input }) => {
      return ctx.prisma.contentPiece.findFirst({
        where: { id: input.id, practiceId: ctx.practice.id },
        include: { location: true, gbpPosts: true, socialPosts: true },
      });
    }),

  generate: editorProcedure
    .input(z.object({
      type: z.nativeEnum(ContentType),
      locationId: z.string().cuid(),
      topic: z.string().min(5).max(200),
      keywords: z.array(z.string()).max(10).default([]),
      tone: z.enum(["professional", "friendly", "authoritative", "conversational"]).default("professional"),
      targetWordCount: z.number().min(100).max(5000).default(500),
      language: z.string().default("en-IN"),
      additionalContext: z.string().max(2000).optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      const location = await ctx.prisma.location.findFirst({
        where: { id: input.locationId, practiceId: ctx.practice.id },
      });
      if (!location) {
        throw new TRPCError({ code: "NOT_FOUND", message: "Location not found" });
      }

      const job = await ctx.prisma.job.create({
        data: {
          practiceId: ctx.practice.id,
          type: "AI_CONTENT_GENERATE",
          status: "PENDING",
          priority: 6,
          payload: {
            contentType: input.type,
            locationId: input.locationId,
            topic: input.topic,
            keywords: input.keywords,
            tone: input.tone,
            targetWordCount: input.targetWordCount,
            language: input.language,
            additionalContext: input.additionalContext,
            locationContext: {
              businessName: location.businessName,
              city: location.city,
              state: location.state,
              category: location.primaryCategory,
              services: location.services,
            },
          },
          createdBy: ctx.session.user.id,
        },
      });

      return { jobId: job.id };
    }),

  update: editorProcedure
    .input(z.object({
      id: z.string().cuid(),
      title: z.string().optional(),
      content: z.string().optional(),
      seoTitle: z.string().max(70).optional(),
      seoDescription: z.string().max(160).optional(),
      focusKeywords: z.array(z.string()).optional(),
      status: z.nativeEnum(ContentStatus).optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      const { id, ...data } = input;
      return ctx.prisma.contentPiece.update({
        where: { id, practiceId: ctx.practice.id },
        data: {
          ...data,
          humanEdited: true,
          editedBy: ctx.session.user.id,
          editedAt: new Date(),
        },
      });
    }),

  approve: editorProcedure
    .input(z.object({ id: z.string().cuid() }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.contentPiece.update({
        where: { id: input.id, practiceId: ctx.practice.id },
        data: { status: "APPROVED" },
      });
    }),

  publish: editorProcedure
    .input(z.object({
      id: z.string().cuid(),
      publishTargets: z.array(z.enum(["GBP", "FACEBOOK", "INSTAGRAM", "LINKEDIN", "BLOG"])),
    }))
    .mutation(async ({ ctx, input }) => {
      const content = await ctx.prisma.contentPiece.findFirst({
        where: { id: input.id, practiceId: ctx.practice.id },
      });
      if (!content) throw new TRPCError({ code: "NOT_FOUND" });

      const jobs = [];
      for (const target of input.publishTargets) {
        const jobType = target === "GBP" ? "GBP_POST_PUBLISH" :
                       target === "BLOG" ? "CITATION_BLOG_POST" :
                       "SOCIAL_POST_PUBLISH";
        const job = await ctx.prisma.job.create({
          data: {
            practiceId: ctx.practice.id,
            type: jobType,
            status: "PENDING",
            payload: {
              contentPieceId: content.id,
              platform: target,
            },
            createdBy: ctx.session.user.id,
          },
        });
        jobs.push(job);
      }

      await ctx.prisma.contentPiece.update({
        where: { id: input.id },
        data: { status: "PUBLISHED" },
      });

      return { jobs };
    }),

  delete: editorProcedure
    .input(z.object({ id: z.string().cuid() }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.contentPiece.update({
        where: { id: input.id, practiceId: ctx.practice.id },
        data: { status: "ARCHIVED" },
      });
    }),
});

2.4.4 Job Router#

// src/server/api/routers/job.ts
import { z } from "zod";
import { createTRPCRouter, practiceProcedure, adminProcedure } from "~/server/api/trpc";
import { JobType, JobStatus } from "@prisma/client";

export const jobRouter = createTRPCRouter({
  list: practiceProcedure
    .input(z.object({
      type: z.nativeEnum(JobType).optional(),
      status: z.nativeEnum(JobStatus).optional(),
      limit: z.number().min(1).max(100).default(20),
      offset: z.number().default(0),
    }))
    .query(async ({ ctx, input }) => {
      return ctx.prisma.job.findMany({
        where: {
          practiceId: ctx.practice.id,
          ...(input.type ? { type: input.type } : {}),
          ...(input.status ? { status: input.status } : {}),
        },
        orderBy: { createdAt: "desc" },
        take: input.limit,
        skip: input.offset,
      });
    }),

  getById: practiceProcedure
    .input(z.object({ id: z.string().cuid() }))
    .query(async ({ ctx, input }) => {
      return ctx.prisma.job.findFirst({
        where: { id: input.id, practiceId: ctx.practice.id },
      });
    }),

  retry: adminProcedure
    .input(z.object({ id: z.string().cuid() }))
    .mutation(async ({ ctx, input }) => {
      const job = await ctx.prisma.job.findFirst({
        where: { id: input.id, practiceId: ctx.practice.id },
      });
      if (!job) throw new TRPCError({ code: "NOT_FOUND" });
      if (job.status !== "FAILED" && job.status !== "CANCELLED") {
        throw new TRPCError({ code: "BAD_REQUEST", message: "Only failed or cancelled jobs can be retried" });
      }

      return ctx.prisma.job.update({
        where: { id: input.id },
        data: {
          status: "PENDING",
          retryCount: 0,
          errorMessage: null,
          errorStack: null,
          failedAt: null,
        },
      });
    }),

  cancel: adminProcedure
    .input(z.object({ id: z.string().cuid() }))
    .mutation(async ({ ctx, input }) => {
      const job = await ctx.prisma.job.findFirst({
        where: { id: input.id, practiceId: ctx.practice.id },
      });
      if (!job) throw new TRPCError({ code: "NOT_FOUND" });
      if (job.status === "COMPLETED" || job.status === "FAILED") {
        throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot cancel completed/failed jobs" });
      }

      return ctx.prisma.job.update({
        where: { id: input.id },
        data: { status: "CANCELLED" },
      });
    }),

  getStats: practiceProcedure.query(async ({ ctx }) => {
    const [byStatus, recentFailed] = await Promise.all([
      ctx.prisma.job.groupBy({
        by: ["status"],
        where: { practiceId: ctx.practice.id },
        _count: { status: true },
      }),
      ctx.prisma.job.findMany({
        where: { practiceId: ctx.practice.id, status: "FAILED" },
        orderBy: { failedAt: "desc" },
        take: 5,
      }),
    ]);
    return { byStatus, recentFailed };
  }),
});

2.4.5 Webhook Router (Public)#

// src/server/api/routers/webhook.ts
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import crypto from "crypto";

export const webhookRouter = createTRPCRouter({
  stripe: publicProcedure
    .input(z.unknown())
    .mutation(async ({ ctx, input }) => {
      const sig = ctx.req.headers["stripe-signature"] as string;
      const event = verifyStripeWebhook(input, sig);

      switch (event.type) {
        case "invoice.paid":
          await handleInvoicePaid(ctx.prisma, event.data.object);
          break;
        case "invoice.payment_failed":
          await handleInvoiceFailed(ctx.prisma, event.data.object);
          break;
        case "customer.subscription.deleted":
          await handleSubscriptionCancelled(ctx.prisma, event.data.object);
          break;
      }

      return { received: true };
    }),

  razorpay: publicProcedure
    .input(z.unknown())
    .mutation(async ({ ctx, input }) => {
      const sig = ctx.req.headers["x-razorpay-signature"] as string;
      verifyRazorpayWebhook(input, sig);
      // Handle Razorpay events...
      return { received: true };
    }),

  gbpPush: publicProcedure
    .input(z.object({
      accountId: z.string(),
      locationId: z.string(),
      eventType: z.enum(["REVIEW_CREATED", "REVIEW_UPDATED", "QUESTION_CREATED", "POST_UPDATED"]),
    }))
    .mutation(async ({ ctx, input }) => {
      // Google Business Profile push notification
      await ctx.prisma.job.create({
        data: {
          practiceId: "system", // Resolved from accountId in worker
          type: "GBP_QA_SYNC",
          status: "PENDING",
          payload: input,
        },
      });
      return { received: true };
    }),
});

function verifyStripeWebhook(payload: unknown, signature: string) {
  const secret = process.env.STRIPE_WEBHOOK_SECRET!;
  // Stripe SDK verification
  return require("stripe")(process.env.STRIPE_SECRET_KEY!).webhooks.constructEvent(
    JSON.stringify(payload), signature, secret
  );
}

function verifyRazorpayWebhook(payload: unknown, signature: string) {
  const secret = process.env.RAZORPAY_WEBHOOK_SECRET!;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(JSON.stringify(payload))
    .digest("hex");
  if (expected !== signature) {
    throw new Error("Invalid Razorpay signature");
  }
}

2.5 Client-Side Hooks#

// src/lib/trpc/react.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { useState } from "react";
import { type AppRouter } from "~/server/api/root";
import SuperJSON from "superjson";

export const api = createTRPCReact<AppRouter>();

export function TRPCReactProvider(props: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: { staleTime: 5 * 60 * 1000, retry: 1 },
    },
  }));

  const [trpcClient] = useState(() =>
    api.createClient({
      transformer: SuperJSON,
      links: [
        loggerLink({
          enabled: (op) =>
            process.env.NODE_ENV === "development" ||
            (op.direction === "down" && op.result instanceof Error),
        }),
        unstable_httpBatchStreamLink({
          url: getBaseUrl() + "/api/trpc",
          headers() {
            const headers = new Map<string, string>();
            headers.set("x-practice-id", getActivePracticeId());
            return Object.fromEntries(headers);
          },
        }),
      ],
    })
  );

  return (
    <QueryClientProvider client={queryClient}>
      <api.Provider client={trpcClient} queryClient={queryClient}>
        {props.children}
      </api.Provider>
    </QueryClientProvider>
  );
}

function getBaseUrl() {
  if (typeof window !== "undefined") return "";
  return process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000";
}

function getActivePracticeId(): string {
  if (typeof window === "undefined") return "";
  return localStorage.getItem("activePracticeId") ?? "";
}

3. Job Queue Design#

3.1 Architecture Overview#

RankFlow uses a dual-queue architecture:

  • BullMQ (Redis-backed) for high-throughput, short-duration jobs (content generation, social posts, GBP operations)
  • Inngest for complex orchestration, scheduled workflows, and long-running processes (monthly reports, multi-step citation campaigns)

3.2 BullMQ Configuration#

// src/server/queue/config.ts
import { Queue, Worker, Job as BullJob } from "bullmq";
import { redis } from "~/server/redis";

export const QUEUE_NAMES = {
  DEFAULT: "default",
  GBP: "gbp-operations",
  SOCIAL: "social-media",
  CITATION: "citations",
  CONTENT: "ai-content",
  REPORT: "reports",
  LANDING: "landing-pages",
  NOTIFICATION: "notifications",
  WEBHOOK: "webhooks",
} as const;

export const queues = {
  default: new Queue(QUEUE_NAMES.DEFAULT, { connection: redis }),
  gbp: new Queue(QUEUE_NAMES.GBP, { connection: redis }),
  social: new Queue(QUEUE_NAMES.SOCIAL, { connection: redis }),
  citation: new Queue(QUEUE_NAMES.CITATION, { connection: redis }),
  content: new Queue(QUEUE_NAMES.CONTENT, { connection: redis }),
  report: new Queue(QUEUE_NAMES.REPORT, { connection: redis }),
  landing: new Queue(QUEUE_NAMES.LANDING, { connection: redis }),
  notification: new Queue(QUEUE_NAMES.NOTIFICATION, { connection: redis }),
  webhook: new Queue(QUEUE_NAMES.WEBHOOK, { connection: redis }),
};

// Queue-specific settings
export const QUEUE_SETTINGS: Record<string, {
  concurrency: number;
  attempts: number;
  backoff: { type: "exponential" | "fixed"; delay: number };
  priorityRange: { min: number; max: number };
}> = {
  [QUEUE_NAMES.DEFAULT]: {
    concurrency: 5,
    attempts: 3,
    backoff: { type: "exponential", delay: 5000 },
    priorityRange: { min: 1, max: 10 },
  },
  [QUEUE_NAMES.GBP]: {
    concurrency: 3, // Conservative — GBP API has strict quotas
    attempts: 5,
    backoff: { type: "exponential", delay: 10000 },
    priorityRange: { min: 1, max: 10 },
  },
  [QUEUE_NAMES.SOCIAL]: {
    concurrency: 8,
    attempts: 3,
    backoff: { type: "fixed", delay: 15000 },
    priorityRange: { min: 1, max: 10 },
  },
  [QUEUE_NAMES.CITATION]: {
    concurrency: 4, // Rate-limited by directory sites
    attempts: 5,
    backoff: { type: "exponential", delay: 60000 }, // 1 min base — directories are slow
    priorityRange: { min: 1, max: 10 },
  },
  [QUEUE_NAMES.CONTENT]: {
    concurrency: 6, // LLM API limits
    attempts: 3,
    backoff: { type: "exponential", delay: 3000 },
    priorityRange: { min: 1, max: 10 },
  },
  [QUEUE_NAMES.REPORT]: {
    concurrency: 2, // Heavy — PDF generation, chart rendering
    attempts: 3,
    backoff: { type: "exponential", delay: 30000 },
    priorityRange: { min: 1, max: 10 },
  },
  [QUEUE_NAMES.LANDING]: {
    concurrency: 2, // Build + deploy process
    attempts: 3,
    backoff: { type: "exponential", delay: 10000 },
    priorityRange: { min: 1, max: 10 },
  },
};

3.3 Job TypeScript Interfaces#

// src/server/queue/types.ts
import { JobType } from "@prisma/client";

interface BaseJobPayload {
  jobId?: string;      // Reference to database Job record
  practiceId: string;
  createdBy?: string;  // User ID
}

// ── GBP Operations ──────────────────────────────────

export interface GbpPostPublishPayload extends BaseJobPayload {
  gbpPostId: string;
}

export interface GbpReviewReplyPayload extends BaseJobPayload {
  reviewId: string;
  operation: "generate_reply" | "publish_reply" | "generate_and_publish";
  tone?: "professional" | "empathetic" | "brief";
}

export interface GbpPhotoUploadPayload extends BaseJobPayload {
  gbpLocationId: string;
  photoUrls: string[];
  category?: string;
}

export interface GbpQaSyncPayload extends BaseJobPayload {
  gbpLocationId?: string;
  operation: "sync_questions" | "sync_locations" | "answer_pending";
}

export interface GbpInsightsSyncPayload extends BaseJobPayload {
  gbpLocationId: string;
  startDate: string;
  endDate: string;
}

// ── Social Media ────────────────────────────────────

export interface SocialPostPublishPayload extends BaseJobPayload {
  socialPostId: string;
}

export interface SocialPostSchedulePayload extends BaseJobPayload {
  socialAccountIds: string[];
  contentPieceId: string;
  scheduledFor: string;
  platformSpecific: Record<string, {
    content?: string;
    mediaUrls?: string[];
    hashtags?: string[];
  }>;
}

// ── Citations ───────────────────────────────────────

export interface CitationSubmitPayload extends BaseJobPayload {
  citationId: string;
  directoryName: string;
  locationId: string;
}

export interface CitationNapScanPayload extends BaseJobPayload {
  citationId: string;
  directoryUrl: string;
  expectedNap: {
    name: string;
    address: string;
    phone: string;
  };
}

export interface CitationBlogPostPayload extends BaseJobPayload {
  blogSiteId: string;
  contentPieceId: string;
}

// ── Landing Pages ───────────────────────────────────

export interface LandingPageGeneratePayload extends BaseJobPayload {
  landingPageId: string;
  sections: string[]; // which sections to regenerate
}

export interface LandingPageDeployPayload extends BaseJobPayload {
  landingPageId: string;
  buildType: "full" | "incremental";
}

// ── Reports ─────────────────────────────────────────

export interface ReportGeneratePayload extends BaseJobPayload {
  reportScheduleId: string;
  periodStart: string;
  periodEnd: string;
  sections: string[];
}

export interface ReportEmailSendPayload extends BaseJobPayload {
  reportId: string;
  recipientEmails: string[];
}

// ── AI Content ──────────────────────────────────────

export interface AIContentGeneratePayload extends BaseJobPayload {
  contentType: string;
  locationId?: string;
  topic: string;
  keywords?: string[];
  tone?: string;
  targetWordCount?: number;
  language?: string;
  additionalContext?: string;
  saveToContentPiece?: boolean; // If true, creates ContentPiece record
}

export interface AISeoAuditPayload extends BaseJobPayload {
  locationId: string;
  auditType: "full" | "quick" | "competitor";
}

// ── Rank Tracking ───────────────────────────────────

export interface RankTrackingFetchPayload extends BaseJobPayload {
  keywordIds?: string[]; // If empty, all active keywords
  forceRefresh?: boolean;
}

// ── Landing Page System ─────────────────────────────

export interface LandingPageBuildPayload extends BaseJobPayload {
  landingPageId: string;
  templateId: string;
  themeConfig: Record<string, unknown>;
}

// Union type for all job payloads
export type JobPayload =
  | GbpPostPublishPayload
  | GbpReviewReplyPayload
  | GbpPhotoUploadPayload
  | GbpQaSyncPayload
  | GbpInsightsSyncPayload
  | SocialPostPublishPayload
  | SocialPostSchedulePayload
  | CitationSubmitPayload
  | CitationNapScanPayload
  | CitationBlogPostPayload
  | LandingPageGeneratePayload
  | LandingPageDeployPayload
  | ReportGeneratePayload
  | ReportEmailSendPayload
  | AIContentGeneratePayload
  | AISeoAuditPayload
  | RankTrackingFetchPayload
  | LandingPageBuildPayload;

3.4 Worker Implementation Pattern#

// src/server/queue/worker-factory.ts
import { Worker, Job as BullJob } from "bullmq";
import { redis } from "~/server/redis";
import { prisma } from "~/server/db";
import { QUEUE_SETTINGS } from "./config";

export type JobHandler<T = unknown> = (
  job: BullJob<T>,
  ctx: { prisma: typeof prisma; redis: typeof redis }
) => Promise<unknown>;

export function createWorker(
  queueName: string,
  handler: JobHandler,
  opts?: { concurrency?: number }
): Worker {
  const settings = QUEUE_SETTINGS[queueName] ?? QUEUE_SETTINGS["default"];

  const worker = new Worker(
    queueName,
    async (job) => {
      const startTime = Date.now();
      console.log(`[${queueName}] #${job.id} started: ${job.name}`);

      // Update DB job status
      const dbJobId = (job.data as any)?.jobId;
      if (dbJobId) {
        await prisma.job.update({
          where: { id: dbJobId },
          data: {
            status: "PROCESSING",
            startedAt: new Date(),
            workerId: `${queueName}-${process.pid}`,
          },
        });
      }

      try {
        const result = await handler(job, { prisma, redis });

        // Update DB job status
        if (dbJobId) {
          await prisma.job.update({
            where: { id: dbJobId },
            data: {
              status: "COMPLETED",
              completedAt: new Date(),
              result: result ? JSON.parse(JSON.stringify(result)) : null,
            },
          });
        }

        const duration = Date.now() - startTime;
        console.log(`[${queueName}] #${job.id} completed in ${duration}ms`);

        return result;
      } catch (error) {
        const duration = Date.now() - startTime;
        console.error(`[${queueName}] #${job.id} failed after ${duration}ms:`, error);

        // Update DB job status
        if (dbJobId) {
          await prisma.job.update({
            where: { id: dbJobId },
            data: {
              status: job.attemptsMade >= (job.opts.attempts ?? 3) ? "FAILED" : "RETRYING",
              ...(job.attemptsMade >= (job.opts.attempts ?? 3)
                ? {
                    failedAt: new Date(),
                    errorMessage: (error as Error).message,
                    errorStack: (error as Error).stack,
                  }
                : { retryCount: { increment: 1 } }),
            },
          });
        }

        throw error; // Let BullMQ handle retries
      }
    },
    {
      connection: redis,
      concurrency: opts?.concurrency ?? settings.concurrency,
      limiter: queueName === QUEUE_NAMES.GBP
        ? { max: 30, duration: 60000 } // GBP: 30 req/min
        : queueName === QUEUE_NAMES.CITATION
        ? { max: 10, duration: 60000 } // Citations: 10 req/min
        : undefined,
    }
  );

  // Event handlers
  worker.on("completed", (job) => {
    // Cleanup if needed
  });

  worker.on("failed", (job, error) => {
    if ((job?.attemptsMade ?? 0) >= (job?.opts.attempts ?? 3)) {
      // Dead letter queue — store for manual review
      void redis.lpush(
        `dead-letter:${queueName}`,
        JSON.stringify({
          jobId: job?.id,
          data: job?.data,
          error: error.message,
          failedAt: new Date().toISOString(),
        })
      );
    }
  });

  worker.on("stalled", (jobId) => {
    console.warn(`[${queueName}] Job ${jobId} stalled`);
  });

  return worker;
}

3.5 Job Worker Implementations#

// src/server/queue/workers/gbp-worker.ts
import { createWorker, type JobHandler } from "../worker-factory";
import { getGbpClient, GbpApiError } from "~/server/lib/gbp/client";
import { multiLLM } from "~/server/lib/ai/router";

const gbpPostPublishHandler: JobHandler<{ gbpPostId: string; practiceId: string }> =
  async (job, { prisma }) => {
    const { gbpPostId } = job.data;

    const post = await prisma.gbpPost.findUnique({
      where: { id: gbpPostId },
      include: { gbpLocation: { include: { gbpAccount: true } } },
    });
    if (!post) throw new Error(`GBP post ${gbpPostId} not found`);

    const client = await getGbpClient(post.gbpLocation.gbpAccount);

    const result = await client.createPost({
      locationId: post.gbpLocation.gbpLocationId,
      summary: post.summary,
      topicType: post.topicType,
      actionType: post.actionType,
      actionUrl: post.actionUrl,
      mediaUrls: post.mediaUrls,
      offerTitle: post.offerTitle,
      offerCouponCode: post.offerCouponCode,
      offerTerms: post.offerTerms,
      eventTitle: post.eventTitle,
      eventStartTime: post.eventStartTime,
      eventEndTime: post.eventEndTime,
      searchTerms: post.searchTerms,
    });

    await prisma.gbpPost.update({
      where: { id: gbpPostId },
      data: {
        gbpPostId: result.name, // Google's post resource name
        status: "PUBLISHED",
        publishedAt: new Date(),
      },
    });

    return { postId: result.name };
  };

const gbpReviewReplyHandler: JobHandler<{
  reviewId: string;
  operation: string;
  tone?: string;
  practiceId: string;
}> = async (job, { prisma }) => {
  const { reviewId, operation, tone } = job.data;

  const review = await prisma.review.findUnique({
    where: { id: reviewId },
    include: { gbpLocation: { include: { gbpAccount: true, location: true } } },
  });
  if (!review?.gbpLocation) throw new Error("Review or GBP location not found");

  let replyText = review.replyText;

  // Generate reply if needed
  if (operation === "generate_reply" || operation === "generate_and_publish") {
    const prompt = `Write a ${tone ?? "professional"} reply to this patient review for ${review.gbpLocation.location.businessName} in ${review.gbpLocation.location.city}:

Rating: ${review.rating}/5
Review: ${review.comment ?? "No comment"}

Guidelines:
- Thank the patient sincerely
- Address specific points they mentioned
- Keep it under 150 words
- Include the practice name once
- Sign off with the doctor's name or "Team [Practice Name]"`;

    const result = await multiLLM.generate({
      prompt,
      model: "CLAUDE_SONNET",
      maxTokens: 300,
      temperature: 0.7,
    });

    replyText = result.text;

    await prisma.review.update({
      where: { id: reviewId },
      data: {
        replyText,
        replyGeneratedByAI: true,
      },
    });
  }

  // Publish reply if needed
  if (operation === "publish_reply" || operation === "generate_and_publish") {
    const client = await getGbpClient(review.gbpLocation.gbpAccount);
    await client.replyToReview({
      locationId: review.gbpLocation.gbpLocationId,
      reviewId: review.gbpReviewId!,
      reply: replyText!,
    });

    await prisma.review.update({
      where: { id: reviewId },
      data: {
        replyPublished: true,
        repliedAt: new Date(),
        status: "REPLIED",
        replyBy: "Owner",
      },
    });
  }

  return { replyText };
};

export function createGbpWorkers() {
  return [
    createWorker("gbp-operations", gbpPostPublishHandler),
    createWorker("gbp-operations", gbpReviewReplyHandler, { concurrency: 2 }),
  ];
}
// src/server/queue/workers/ai-content-worker.ts
import { createWorker, type JobHandler } from "../worker-factory";
import { multiLLM } from "~/server/lib/ai/router";
import { ContentType } from "@prisma/client";

const aiContentGenerateHandler: JobHandler<{
  contentType: string;
  locationId?: string;
  topic: string;
  keywords?: string[];
  tone?: string;
  targetWordCount?: number;
  language?: string;
  additionalContext?: string;
  locationContext?: {
    businessName: string;
    city: string;
    state: string;
    category: string;
    services: string[];
  };
  saveToContentPiece?: boolean;
  practiceId: string;
  jobId?: string;
}> = async (job, { prisma }) => {
  const {
    contentType,
    locationId,
    topic,
    keywords,
    tone,
    targetWordCount,
    language,
    additionalContext,
    locationContext,
    saveToContentPiece = true,
    practiceId,
  } = job.data;

  // Build prompt based on content type
  const systemPrompt = buildSystemPrompt(contentType as ContentType, language);
  const userPrompt = buildUserPrompt({
    contentType: contentType as ContentType,
    topic,
    keywords,
    tone,
    targetWordCount,
    additionalContext,
    locationContext,
  });

  // Route to appropriate LLM based on content complexity
  const model = selectModelForContent(contentType as ContentType, targetWordCount);

  const startTime = Date.now();
  const result = await multiLLM.generate({
    systemPrompt,
    prompt: userPrompt,
    model,
    maxTokens: estimateTokens(targetWordCount ?? 500),
    temperature: 0.7,
  });
  const generationTime = Date.now() - startTime;

  // Parse and structure output
  const parsed = parseAIOutput(result.text, contentType as ContentType);

  if (saveToContentPiece) {
    const contentPiece = await prisma.contentPiece.create({
      data: {
        practiceId,
        locationId,
        type: contentType as ContentType,
        title: parsed.title ?? topic,
        content: parsed.content,
        excerpt: parsed.excerpt,
        seoTitle: parsed.seoTitle,
        seoDescription: parsed.seoDescription,
        focusKeywords: keywords ?? [],
        aiProvider: result.provider,
        aiModel: result.model,
        aiPrompt: userPrompt.slice(0, 4000),
        aiTokensUsed: result.tokensUsed,
        aiCostUSD: result.costUSD,
        generationTimeMs: generationTime,
        status: "PENDING_REVIEW",
        metadata: parsed.metadata ?? {},
      },
    });

    return { contentPieceId: contentPiece.id, generationTime };
  }

  return { content: parsed.content, generationTime };
};

function buildSystemPrompt(contentType: ContentType, language: string): string {
  const lang = language === "hi" ? "Hindi" : language === "mr" ? "Marathi" :
               language === "ta" ? "Tamil" : language === "bn" ? "Bengali" :
               language === "te" ? "Telugu" : "English";

  const base = `You are an expert medical content writer specializing in local SEO for Indian healthcare practices. Write in ${lang}.`;

  switch (contentType) {
    case "GBP_POST":
      return `${base} Create engaging Google Business Profile posts that drive patient engagement. Include relevant keywords naturally. Keep posts between 100-1500 characters.`;
    case "BLOG_POST":
      return `${base} Write comprehensive, SEO-optimized blog posts. Use proper heading structure (H2, H3), bullet points, and clear calls to action. Target Indian patients with culturally relevant examples.`;
    case "LANDING_PAGE":
      return `${base} Create conversion-focused landing page content. Include compelling headlines, benefit-driven copy, trust signals, and clear CTAs. Structure with proper HTML sections.`;
    case "FAQ_CONTENT":
      return `${base} Write concise, accurate FAQ content. Address common patient concerns with authoritative yet approachable answers. Include schema.org FAQPage structured data format.`;
    case "META_DESCRIPTION":
      return `${base} Write compelling meta descriptions under 160 characters that include target keywords and a call to action.`;
    case "SCHEMA_MARKUP":
      return `${base} Generate schema.org JSON-LD structured data for MedicalBusiness, Physician, or LocalBusiness as appropriate.`;
    default:
      return base;
  }
}

function buildUserPrompt(params: {
  contentType: ContentType;
  topic: string;
  keywords?: string[];
  tone?: string;
  targetWordCount?: number;
  additionalContext?: string;
  locationContext?: {
    businessName: string;
    city: string;
    state: string;
    category: string;
    services: string[];
  };
}): string {
  const parts = [
    `Content Type: ${params.contentType}`,
    `Topic: ${params.topic}`,
    params.keywords?.length ? `Target Keywords: ${params.keywords.join(", ")}` : "",
    params.tone ? `Tone: ${params.tone}` : "",
    params.targetWordCount ? `Target Length: ~${params.targetWordCount} words` : "",
    params.locationContext
      ? `Business: ${params.locationContext.businessName} in ${params.locationContext.city}, ${params.locationContext.state}
Category: ${params.locationContext.category}
Services: ${params.locationContext.services.join(", ")}`
      : "",
    params.additionalContext ? `Additional Context: ${params.additionalContext}` : "",
  ];

  return parts.filter(Boolean).join("\n\n");
}

function selectModelForContent(contentType: ContentType, wordCount?: number): string {
  if (contentType === "BLOG_POST" && (wordCount ?? 0) > 1000) return "CLAUDE_SONNET";
  if (contentType === "LANDING_PAGE") return "GPT_4";
  if (contentType === "SCHEMA_MARKUP") return "GPT_3_5"; // Simple structured output
  return "CLAUDE_SONNET"; // Default — best quality/cost ratio
}

function estimateTokens(wordCount: number): number {
  // Rough approximation: 1 word ≈ 1.3 tokens
  return Math.ceil(wordCount * 1.5) + 200;
}

function parseAIOutput(text: string, contentType: ContentType) {
  // Parse structured output from AI
  const titleMatch = text.match(/(?:^#\s*|Title:\s*)(.+)/m);
  const seoTitleMatch = text.match(/SEOTitle:\s*(.+)/mi);
  const seoDescMatch = text.match(/SEODescription:\s*(.+)/mi);

  // Extract content (everything after title)
  let content = text;
  if (titleMatch) {
    content = text.replace(/^#\s*.+\n?/m, "").trim();
  }

  // Remove SEO meta lines from content
  content = content.replace(/SEOTitle:\s*.+\n?/gi, "").replace(/SEODescription:\s*.+\n?/gi, "").trim();

  // Generate excerpt
  const excerpt = content.slice(0, 200).replace(/[#*`]/g, "").trim() + "...";

  return {
    title: titleMatch?.[1]?.trim(),
    content,
    excerpt,
    seoTitle: seoTitleMatch?.[1]?.trim(),
    seoDescription: seoDescMatch?.[1]?.trim(),
    metadata: {},
  };
}

export function createAIWorkers() {
  return [
    createWorker("ai-content", aiContentGenerateHandler, { concurrency: 6 }),
  ];
}

3.6 Cron Schedule Configuration#

// src/server/queue/cron.ts
import { Queue } from "bullmq";
import { queues } from "./config";

export async function scheduleCronJobs() {
  // ── GBP Insights Sync ─ Every 6 hours ─────────────────
  await queues.gbp.add(
    "sync-insights",
    { operation: "sync_all_insights" },
    { repeat: { pattern: "0 */6 * * *" }, priority: 3 }
  );

  // ── GBP Q&A Sync ─ Every 2 hours ──────────────────────
  await queues.gbp.add(
    "sync-qa",
    { operation: "sync_all_qa" },
    { repeat: { pattern: "0 */2 * * *" }, priority: 4 }
  );

  // ── GBP Review Sync ─ Every 30 minutes ────────────────
  await queues.gbp.add(
    "sync-reviews",
    { operation: "sync_all_reviews" },
    { repeat: { pattern: "*/30 * * * *" }, priority: 5 }
  );

  // ── NAP Scan ─ Daily at 2 AM ──────────────────────────
  await queues.citation.add(
    "nap-scan-all",
    { operation: "scan_all_citations" },
    { repeat: { pattern: "0 2 * * *" }, priority: 3 }
  );

  // ── Rank Tracking — Daily at 4 AM ─────────────────────
  await queues.default.add(
    "rank-tracking-fetch",
    { operation: "fetch_all_ranks" },
    { repeat: { pattern: "0 4 * * *" }, priority: 4 }
  );

  // ── Competitor Snapshot — Weekly on Monday at 5 AM ────
  await queues.default.add(
    "competitor-snapshot",
    { operation: "snapshot_all_competitors" },
    { repeat: { pattern: "0 5 * * 1" }, priority: 2 }
  );

  // ── Backlink Monitor — Every 3 days ───────────────────
  await queues.default.add(
    "backlink-check",
    { operation: "check_all_backlinks" },
    { repeat: { pattern: "0 6 */3 * *" }, priority: 2 }
  );

  // ── Cleanup old jobs — Daily at 3 AM ──────────────────
  await queues.default.add(
    "cleanup-old-jobs",
    { operation: "cleanup", olderThanDays: 30 },
    { repeat: { pattern: "0 3 * * *" }, priority: 1 }
  );

  // ── AI Cost Reporting — Daily at midnight ─────────────
  await queues.default.add(
    "ai-cost-report",
    { operation: "generate_daily_cost_report" },
    { repeat: { pattern: "0 0 * * *" }, priority: 1 }
  );

  console.log("[Cron] All recurring jobs scheduled");
}

3.7 Dead Letter Queue Handler#

// src/server/queue/dlq.ts
import { redis } from "~/server/redis";

const DLQ_MAX_AGE_DAYS = 30;

export async function processDeadLetterQueue(queueName: string) {
  const dlqKey = `dead-letter:${queueName}`;
  const items = await redis.lrange(dlqKey, 0, -1);

  for (const item of items) {
    const deadJob = JSON.parse(item);
    const age = Date.now() - new Date(deadJob.failedAt).getTime();

    // Auto-retry jobs younger than 1 hour (transient failures)
    if (age < 60 * 60 * 1000) {
      console.log(`[DLQ] Auto-retrying job ${deadJob.jobId}`);
      // Re-queue logic...
      await redis.lrem(dlqKey, 0, item);
      continue;
    }

    // Send alert for critical failures
    if (queueName === "gbp-operations" || queueName === "citations") {
      await sendFailureAlert(deadJob);
    }

    // Clean up old entries
    if (age > DLQ_MAX_AGE_DAYS * 24 * 60 * 60 * 1000) {
      await redis.lrem(dlqKey, 0, item);
    }
  }
}

async function sendFailureAlert(deadJob: unknown) {
  // Send to notification queue or external alerting
  console.error(`[DLQ] CRITICAL FAILURE:`, deadJob);
}

3.8 Worker Bootstrapping (VPS Entry Point)#

// src/server/queue/bootstrap.ts
import { createGbpWorkers } from "./workers/gbp-worker";
import { createSocialWorkers } from "./workers/social-worker";
import { createCitationWorkers } from "./workers/citation-worker";
import { createAIWorkers } from "./workers/ai-content-worker";
import { createReportWorkers } from "./workers/report-worker";
import { createLandingPageWorkers } from "./workers/landing-page-worker";
import { scheduleCronJobs } from "./cron";
import { processDeadLetterQueue } from "./dlq";

export async function bootstrapWorkers() {
  console.log("[Workers] Bootstrapping all job workers...");

  const allWorkers = [
    ...createGbpWorkers(),
    ...createSocialWorkers(),
    ...createCitationWorkers(),
    ...createAIWorkers(),
    ...createReportWorkers(),
    ...createLandingPageWorkers(),
  ];

  console.log(`[Workers] ${allWorkers.length} workers started`);

  // Schedule cron jobs
  await scheduleCronJobs();

  // Periodic DLQ processing
  setInterval(() => {
    void processDeadLetterQueue("gbp-operations");
    void processDeadLetterQueue("citations");
    void processDeadLetterQueue("social-media");
  }, 5 * 60 * 1000); // Every 5 minutes

  // Graceful shutdown
  process.on("SIGTERM", async () => {
    console.log("[Workers] SIGTERM received, closing workers...");
    await Promise.all(allWorkers.map((w) => w.close()));
    process.exit(0);
  });

  process.on("SIGINT", async () => {
    console.log("[Workers] SIGINT received, closing workers...");
    await Promise.all(allWorkers.map((w) => w.close()));
    process.exit(0);
  });
}

// Entry point: src/server/queue/main.ts
import { bootstrapWorkers } from "./bootstrap";
bootstrapWorkers().catch(console.error);

4. Multi-LLM Router Specification#

4.1 Design Goals#

  1. Cost optimization: Route simple tasks to cheaper models, complex tasks to premium models
  2. Reliability: Automatic fallback chain when providers fail
  3. Rate limiting: Per-provider token/request rate limiting
  4. Cost tracking: Detailed cost attribution per practice
  5. Quality control: Periodic A/B testing between models

4.2 Provider Interface#

// src/server/lib/ai/types.ts
export interface LLMProvider {
  readonly name: AIProvider;
  readonly models: string[];

  generate(request: GenerationRequest): Promise<GenerationResult>;
  getModelPricing(model: string): ModelPricing;
  checkHealth(): Promise<boolean>;
}

export interface GenerationRequest {
  systemPrompt?: string;
  prompt: string;
  model: string;
  maxTokens?: number;
  temperature?: number;
  topP?: number;
  jsonMode?: boolean;
  imageUrl?: string; // For vision-capable models
}

export interface GenerationResult {
  text: string;
  provider: AIProvider;
  model: string;
  tokensUsed: number;
  inputTokens: number;
  outputTokens: number;
  costUSD: number;
  latencyMs: number;
  finishReason: "stop" | "length" | "error";
}

export interface ModelPricing {
  inputPer1k: number;   // USD per 1000 input tokens
  outputPer1k: number;  // USD per 1000 output tokens
  requestFee?: number;  // Fixed fee per request
}

// Cost tier definitions
export type CostTier = "premium" | "standard" | "economy" | "local";

export interface RoutingRule {
  contentType: string;
  preferredTier: CostTier;
  fallbackTiers: CostTier[];
  maxLatencyMs?: number;
  requireJsonOutput?: boolean;
  requireVision?: boolean;
}

4.3 Provider Implementations#

// src/server/lib/ai/providers/anthropic.ts
import Anthropic from "@anthropic-ai/sdk";
import { type LLMProvider, type GenerationRequest, type GenerationResult, type ModelPricing } from "../types";

export class AnthropicProvider implements LLMProvider {
  readonly name = "CLAUDE_SONNET" as const;
  readonly models = ["claude-sonnet-4-20250514", "claude-3-5-sonnet-20241022"];

  private client: Anthropic;
  private pricing: Record<string, ModelPricing> = {
    "claude-sonnet-4-20250514": { inputPer1k: 0.003, outputPer1k: 0.015 },
    "claude-3-5-sonnet-20241022": { inputPer1k: 0.003, outputPer1k: 0.015 },
  };

  constructor() {
    this.client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
  }

  async generate(request: GenerationRequest): Promise<GenerationResult> {
    const startTime = Date.now();
    const model = request.model ?? "claude-sonnet-4-20250514";

    const response = await this.client.messages.create({
      model,
      max_tokens: request.maxTokens ?? 1024,
      temperature: request.temperature ?? 0.7,
      system: request.systemPrompt,
      messages: [
        ...(request.imageUrl
          ? [{
              role: "user" as const,
              content: [
                { type: "image" as const, source: { type: "url" as const, url: request.imageUrl } },
                { type: "text" as const, text: request.prompt },
              ],
            }]
          : [{ role: "user" as const, content: request.prompt }]),
      ],
    });

    const text = response.content
      .filter((c): c is Anthropic.TextBlock => c.type === "text")
      .map((c) => c.text)
      .join("");

    const inputTokens = response.usage.input_tokens;
    const outputTokens = response.usage.output_tokens;
    const pricing = this.pricing[model];
    const costUSD = (inputTokens / 1000) * pricing.inputPer1k + (outputTokens / 1000) * pricing.outputPer1k;

    return {
      text,
      provider: "CLAUDE_SONNET",
      model,
      tokensUsed: inputTokens + outputTokens,
      inputTokens,
      outputTokens,
      costUSD,
      latencyMs: Date.now() - startTime,
      finishReason: response.stop_reason === "max_tokens" ? "length" : "stop",
    };
  }

  getModelPricing(model: string): ModelPricing {
    return this.pricing[model] ?? this.pricing[this.models[0]!]!;
  }

  async checkHealth(): Promise<boolean> {
    try {
      await this.client.messages.create({
        model: "claude-sonnet-4-20250514",
        max_tokens: 10,
        messages: [{ role: "user", content: "Hi" }],
      });
      return true;
    } catch {
      return false;
    }
  }
}
// src/server/lib/ai/providers/openai.ts
import OpenAI from "openai";
import { type LLMProvider, type GenerationRequest, type GenerationResult, type ModelPricing } from "../types";

export class OpenAIProvider implements LLMProvider {
  readonly name = "GPT_4" as const;
  readonly models = ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo"];

  private client: OpenAI;
  private pricing: Record<string, ModelPricing> = {
    "gpt-4o": { inputPer1k: 0.0025, outputPer1k: 0.01 },
    "gpt-4o-mini": { inputPer1k: 0.00015, outputPer1k: 0.0006 },
    "gpt-4-turbo": { inputPer1k: 0.01, outputPer1k: 0.03 },
  };

  constructor() {
    this.client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
  }

  async generate(request: GenerationRequest): Promise<GenerationResult> {
    const startTime = Date.now();
    const model = request.model ?? "gpt-4o";

    const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [];
    if (request.systemPrompt) {
      messages.push({ role: "system", content: request.systemPrompt });
    }
    if (request.imageUrl) {
      messages.push({
        role: "user",
        content: [
          { type: "image_url", image_url: { url: request.imageUrl } },
          { type: "text", text: request.prompt },
        ],
      });
    } else {
      messages.push({ role: "user", content: request.prompt });
    }

    const response = await this.client.chat.completions.create({
      model,
      messages,
      max_tokens: request.maxTokens ?? 1024,
      temperature: request.temperature ?? 0.7,
      response_format: request.jsonMode ? { type: "json_object" } : undefined,
    });

    const choice = response.choices[0]!;
    const usage = response.usage!;

    const pricing = this.pricing[model];
    const costUSD = (usage.prompt_tokens / 1000) * pricing.inputPer1k + (usage.completion_tokens / 1000) * pricing.outputPer1k;

    return {
      text: choice.message.content ?? "",
      provider: model === "gpt-4o-mini" ? "GPT_3_5" : model === "gpt-4-turbo" ? "GPT_4_TURBO" : "GPT_4",
      model,
      tokensUsed: usage.total_tokens,
      inputTokens: usage.prompt_tokens,
      outputTokens: usage.completion_tokens,
      costUSD,
      latencyMs: Date.now() - startTime,
      finishReason: choice.finish_reason === "length" ? "length" : "stop",
    };
  }

  getModelPricing(model: string): ModelPricing {
    return this.pricing[model] ?? this.pricing["gpt-4o"]!;
  }

  async checkHealth(): Promise<boolean> {
    try {
      await this.client.chat.completions.create({
        model: "gpt-4o-mini",
        messages: [{ role: "user", content: "Hi" }],
        max_tokens: 10,
      });
      return true;
    } catch {
      return false;
    }
  }
}
// src/server/lib/ai/providers/ollama.ts
import { type LLMProvider, type GenerationRequest, type GenerationResult, type ModelPricing } from "../types";

export class OllamaProvider implements LLMProvider {
  readonly name = "LLAMA_3_70B" as const;
  readonly models = ["llama3:70b", "mistral:7b", "mixtral"];

  private baseUrl: string;
  private pricing: Record<string, ModelPricing> = {
    "llama3:70b": { inputPer1k: 0.0002, outputPer1k: 0.0004 }, // Self-hosted marginal cost
    "mistral:7b": { inputPer1k: 0.00005, outputPer1k: 0.0001 },
    "mixtral": { inputPer1k: 0.0001, outputPer1k: 0.0002 },
  };

  constructor() {
    this.baseUrl = process.env.OLLAMA_BASE_URL ?? "http://localhost:11434";
  }

  async generate(request: GenerationRequest): Promise<GenerationResult> {
    const startTime = Date.now();
    const model = request.model ?? "llama3:70b";

    const response = await fetch(`${this.baseUrl}/api/generate`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        model,
        prompt: request.systemPrompt
          ? `${request.systemPrompt}\n\n${request.prompt}`
          : request.prompt,
        stream: false,
        options: {
          temperature: request.temperature ?? 0.7,
          num_predict: request.maxTokens ?? 1024,
        },
      }),
    });

    if (!response.ok) {
      throw new Error(`Ollama error: ${response.status} ${await response.text()}`);
    }

    const result = await response.json();
    const text = result.response as string;
    // Ollama doesn't return token counts; estimate
    const estimatedTokens = Math.ceil(text.length / 4);
    const inputTokens = Math.ceil(request.prompt.length / 4);
    const pricing = this.pricing[model];
    const costUSD = (inputTokens / 1000) * pricing.inputPer1k + (estimatedTokens / 1000) * pricing.outputPer1k;

    return {
      text,
      provider: model.includes("mistral") ? "MISTRAL_7B" : "LLAMA_3_70B",
      model,
      tokensUsed: inputTokens + estimatedTokens,
      inputTokens,
      outputTokens: estimatedTokens,
      costUSD,
      latencyMs: Date.now() - startTime,
      finishReason: result.done ? "stop" : "length",
    };
  }

  getModelPricing(): ModelPricing {
    return this.pricing[this.models[0]!]!;
  }

  async checkHealth(): Promise<boolean> {
    try {
      const res = await fetch(`${this.baseUrl}/api/tags`, { signal: AbortSignal.timeout(5000) });
      return res.ok;
    } catch {
      return false;
    }
  }
}

4.4 Router Core#

// src/server/lib/ai/router.ts
import { Redis } from "ioredis";
import { type GenerationRequest, type GenerationResult, type CostTier, type RoutingRule } from "./types";
import { AnthropicProvider } from "./providers/anthropic";
import { OpenAIProvider } from "./providers/openai";
import { OllamaProvider } from "./providers/ollama";
import { prisma } from "~/server/db";

const redis = new Redis(process.env.REDIS_URL!);

// Provider registry
const providers = {
  CLAUDE_SONNET: new AnthropicProvider(),
  GPT_4: new OpenAIProvider(),
  GPT_4_TURBO: new OpenAIProvider(),
  GPT_3_5: new OpenAIProvider(),
  LLAMA_3_70B: new OllamaProvider(),
  MISTRAL_7B: new OllamaProvider(),
  MISTRAL_Large: new OllamaProvider(),
  GEMINI_PRO: new OpenAIProvider(), // Placeholder — swap with actual Gemini SDK
};

// Tier mapping
const tierProviders: Record<CostTier, string[]> = {
  premium: ["CLAUDE_SONNET", "GPT_4"],
  standard: ["GPT_4_TURBO", "CLAUDE_SONNET"],
  economy: ["GPT_3_5", "MISTRAL_Large"],
  local: ["LLAMA_3_70B", "MISTRAL_7B"],
};

// Routing rules by content type
const routingRules: Record<string, RoutingRule> = {
  GBP_POST: {
    contentType: "GBP_POST",
    preferredTier: "standard",
    fallbackTiers: ["economy", "local"],
    maxLatencyMs: 15000,
  },
  BLOG_POST: {
    contentType: "BLOG_POST",
    preferredTier: "premium",
    fallbackTiers: ["standard", "economy"],
    maxLatencyMs: 60000,
  },
  LANDING_PAGE: {
    contentType: "LANDING_PAGE",
    preferredTier: "premium",
    fallbackTiers: ["standard"],
    maxLatencyMs: 60000,
  },
  REVIEW_REPLY: {
    contentType: "REVIEW_REPLY",
    preferredTier: "economy",
    fallbackTiers: ["local", "standard"],
    maxLatencyMs: 10000,
  },
  FAQ_CONTENT: {
    contentType: "FAQ_CONTENT",
    preferredTier: "economy",
    fallbackTiers: ["local", "standard"],
    maxLatencyMs: 15000,
  },
  SCHEMA_MARKUP: {
    contentType: "SCHEMA_MARKUP",
    preferredTier: "local",
    fallbackTiers: ["economy"],
    requireJsonOutput: true,
    maxLatencyMs: 10000,
  },
  SEO_AUDIT: {
    contentType: "SEO_AUDIT",
    preferredTier: "premium",
    fallbackTiers: ["standard"],
    maxLatencyMs: 120000,
  },
  META_DESCRIPTION: {
    contentType: "META_DESCRIPTION",
    preferredTier: "local",
    fallbackTiers: ["economy"],
    maxLatencyMs: 8000,
  },
};

// ─── Rate Limiting ───────────────────────────────────

async function checkRateLimit(providerName: string): Promise<boolean> {
  const key = `llm:ratelimit:${providerName}`;
  const limit = getProviderRateLimit(providerName);

  const current = await redis.incr(key);
  if (current === 1) {
    await redis.pexpire(key, 60000); // 1-minute window
  }

  return current <= limit;
}

function getProviderRateLimit(provider: string): number {
  const limits: Record<string, number> = {
    CLAUDE_SONNET: 50,  // 50 req/min
    GPT_4: 80,
    GPT_4_TURBO: 100,
    GPT_3_5: 200,
    LLAMA_3_70B: 500,   // Self-hosted, generous limit
    MISTRAL_7B: 500,
    MISTRAL_Large: 300,
    GEMINI_PRO: 100,
  };
  return limits[provider] ?? 50;
}

// ─── Core Router ─────────────────────────────────────

export const multiLLM = {
  async generate(request: GenerationRequest & { contentType?: string; practiceId?: string }): Promise<GenerationResult> {
    const { contentType = "GENERIC", practiceId } = request;
    const rule = routingRules[contentType] ?? {
      contentType: "GENERIC",
      preferredTier: "standard",
      fallbackTiers: ["economy", "local"],
    };

    // Build fallback chain
    const chain = [rule.preferredTier, ...rule.fallbackTiers];
    const errors: string[] = [];

    for (const tier of chain) {
      const providerNames = tierProviders[tier];

      for (const providerName of providerNames) {
        // Check rate limit
        const withinLimit = await checkRateLimit(providerName);
        if (!withinLimit) {
          errors.push(`${providerName}: rate limited`);
          continue;
        }

        // Check provider health
        const provider = providers[providerName as keyof typeof providers];
        if (!provider) continue;

        const healthy = await provider.checkHealth();
        if (!healthy) {
          errors.push(`${providerName}: unhealthy`);
          continue;
        }

        try {
          const startTime = Date.now();
          const result = await Promise.race([
            provider.generate(request),
            new Promise<never>((_, reject) =>
              setTimeout(() => reject(new Error("Timeout")), rule.maxLatencyMs ?? 30000)
            ),
          ]);

          // Log cost
          if (practiceId) {
            await prisma.aICostLog.create({
              data: {
                practiceId,
                provider: providerName as any,
                model: result.model,
                operationType: contentType,
                inputTokens: result.inputTokens,
                outputTokens: result.outputTokens,
                totalTokens: result.tokensUsed,
                costUSD: result.costUSD,
                latencyMs: result.latencyMs,
                wasFallback: providerName !== providerNames[0],
              },
            });
          }

          return result;
        } catch (error) {
          const msg = (error as Error).message;
          errors.push(`${providerName}: ${msg}`);
          console.warn(`[LLM Router] ${providerName} failed: ${msg}`);
        }
      }
    }

    // All providers failed
    throw new Error(`All LLM providers failed for ${contentType}: ${errors.join("; ")}`);
  },

  async generateBulk(
    requests: (GenerationRequest & { contentType?: string })[]
  ): Promise<GenerationResult[]> {
    // Process with concurrency limit of 3
    const results: GenerationResult[] = [];
    for (let i = 0; i < requests.length; i += 3) {
      const batch = requests.slice(i, i + 3);
      const batchResults = await Promise.allSettled(
        batch.map((r) => this.generate(r))
      );
      for (const result of batchResults) {
        if (result.status === "fulfilled") {
          results.push(result.value);
        } else {
          // Return error placeholder
          results.push({
            text: "",
            provider: "CLAUDE_SONNET",
            model: "error",
            tokensUsed: 0,
            inputTokens: 0,
            outputTokens: 0,
            costUSD: 0,
            latencyMs: 0,
            finishReason: "error",
          });
        }
      }
    }
    return results;
  },

  // Cost reporting
  async getCostSummary(practiceId: string, period: { start: Date; end: Date }) {
    return prisma.aICostLog.groupBy({
      by: ["provider", "operationType"],
      where: {
        practiceId,
        createdAt: { gte: period.start, lte: period.end },
      },
      _sum: { costUSD: true, totalTokens: true },
      _avg: { latencyMs: true },
      _count: { _all: true },
    });
  },
};

4.5 Provider Health Monitoring#

// src/server/lib/ai/health-check.ts
import { redis } from "~/server/redis";

const HEALTH_CHECK_INTERVAL = 60 * 1000; // 1 minute

export async function runLLMHealthChecks() {
  const providers = ["CLAUDE_SONNET", "GPT_4", "LLAMA_3_70B", "MISTRAL_7B"];

  for (const provider of providers) {
    const healthKey = `llm:health:${provider}`;
    try {
      // Each provider has its own health check
      const healthy = await checkProviderHealth(provider);
      await redis.setex(healthKey, 120, healthy ? "1" : "0");
    } catch {
      await redis.setex(healthKey, 120, "0");
    }
  }
}

async function checkProviderHealth(provider: string): Promise<boolean> {
  // Delegate to router
  const { multiLLM } = await import("./router");
  return true; // Simplified — actual implementation checks each provider
}

// Run on worker startup
setInterval(runLLMHealthChecks, HEALTH_CHECK_INTERVAL);

5. Landing Page Architecture#

5.1 Multi-Tenant Landing Page System#

RankFlow generates SEO-optimized landing pages for each medical practice. These pages are served via:

  1. Subdomain: {slug}.rankflow.ai (default)
  2. Custom domain: drsharma.example.com (CNAME)
  3. Wildcard routing: Dynamic routing based on Host header

5.2 Next.js Middleware for Subdomain Routing#

// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export const config = {
  matcher: [
    // Skip internal paths
    "/((?!_next/static|_next/image|favicon.ico|api|auth|admin|assets).*)",
  ],
};

export async function middleware(request: NextRequest) {
  const { pathname, search } = request.nextUrl;
  const host = request.headers.get("host") ?? "";

  // Extract subdomain from hostname
  const isDev = process.env.NODE_ENV === "development";
  const rootDomain = isDev ? "localhost:3000" : process.env.ROOT_DOMAIN ?? "rankflow.ai";

  // Check if this is a custom domain or subdomain request
  let practiceSlug: string | null = null;
  let landingPageSlug: string | null = null;

  if (host === rootDomain || host === `www.${rootDomain}`) {
    // Main app — pass through
    return NextResponse.next();
  }

  // Check for custom domain (not matching root domain)
  if (!host.endsWith(rootDomain)) {
    // Custom domain — look up practice by customDomain
    const practice = await lookupPracticeByDomain(host);
    if (practice) {
      practiceSlug = practice.slug;
      // Rewrite to the landing page router
      return NextResponse.rewrite(
        new URL(`/lp/${practiceSlug}${pathname}${search}`, request.url)
      );
    }
  }

  // Check for subdomain
  const subdomain = host.replace(`.${rootDomain}`, "").replace(":3000", "");
  if (subdomain && subdomain !== "www" && subdomain !== "app" && subdomain !== "api") {
    // Check if this is a practice subdomain
    const practice = await lookupPracticeBySubdomain(subdomain);
    if (practice) {
      practiceSlug = practice.slug;
      return NextResponse.rewrite(
        new URL(`/lp/${practiceSlug}${pathname}${search}`, request.url)
      );
    }
  }

  // Not found — return 404
  return new NextResponse("Not Found", { status: 404 });
}

// Cache lookups for performance
const domainCache = new Map<string, { slug: string; ts: number }>();
const CACHE_TTL = 60 * 1000; // 1 minute

async function lookupPracticeByDomain(domain: string) {
  const cached = domainCache.get(domain);
  if (cached && Date.now() - cached.ts < CACHE_TTL) {
    return { slug: cached.slug };
  }

  // In production, fetch from Prisma or Redis
  // This is simplified — actual implementation uses tRPC or direct DB call
  const result = null; // await prisma.practice.findUnique({ where: { customDomain: domain } })
  if (result) {
    domainCache.set(domain, { slug: result.slug, ts: Date.now() });
  }
  return result;
}

async function lookupPracticeBySubdomain(subdomain: string) {
  const cached = domainCache.get(subdomain);
  if (cached && Date.now() - cached.ts < CACHE_TTL) {
    return { slug: cached.slug };
  }

  // await prisma.practice.findFirst({ where: { wildcardSubdomain: subdomain } })
  return null;
}

5.3 Landing Page Route Handler#

// src/app/lp/[practiceSlug]/page.tsx
import { notFound } from "next/navigation";
import { prisma } from "~/server/db";
import { LandingPageTemplate } from "~/components/landing-page/template";
import { Metadata } from "next";

interface Props {
  params: { practiceSlug: string };
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const landingPage = await getLandingPageData(params.practiceSlug);
  if (!landingPage) return { title: "Not Found" };

  return {
    title: landingPage.seoTitle ?? landingPage.title,
    description: landingPage.seoDescription,
    alternates: {
      canonical: landingPage.canonicalUrl,
    },
    openGraph: {
      title: landingPage.seoTitle ?? landingPage.title,
      description: landingPage.seoDescription,
      type: "website",
    },
    robots: landingPage.isPublished ? "index, follow" : "noindex, nofollow",
  };
}

export async function generateStaticParams() {
  // For SSG: pre-build popular pages
  const practices = await prisma.practice.findMany({
    where: { status: "active", isWhiteLabel: false },
    select: { slug: true },
    take: 100, // Limit for build time
  });

  return practices.map((p) => ({ practiceSlug: p.slug }));
}

export const revalidate = 3600; // ISR: revalidate every hour

export default async function LandingPage({ params }: Props) {
  const data = await getLandingPageData(params.practiceSlug);
  if (!data || !data.isPublished) {
    notFound();
  }

  return <LandingPageTemplate data={data} />;
}

async function getLandingPageData(slug: string) {
  // With caching via React's cache() or Next.js unstable_cache
  return prisma.landingPage.findFirst({
    where: {
      practice: { slug },
      isPublished: true,
      deletedAt: null,
    },
    include: {
      contents: {
        where: { isVisible: true },
        orderBy: { sortOrder: "asc" },
      },
      practice: {
        include: {
          locations: {
            where: { isPrimary: true },
            take: 1,
          },
        },
      },
    },
  });
}

5.4 Template System Design#

// src/components/landing-page/template.tsx
import { type LandingPage, type LandingPageContent } from "@prisma/client";
import { HeroSection } from "./sections/hero";
import { AboutSection } from "./sections/about";
import { ServicesSection } from "./sections/services";
import { TestimonialsSection } from "./sections/testimonials";
import { FAQSection } from "./sections/faq";
import { ContactSection } from "./sections/contact";
import { CTASection } from "./sections/cta";
import { SchemaInjector } from "./schema-injector";

interface LandingPageData extends LandingPage {
  contents: LandingPageContent[];
  practice: {
    name: string;
    whiteLabelBrandName?: string | null;
    whiteLabelLogoUrl?: string | null;
    whiteLabelPrimaryColor?: string | null;
    locations: Array<{
      businessName: string;
      addressLine1: string;
      city: string;
      state: string;
      postalCode: string;
      phone: string;
      email?: string | null;
      businessHours: unknown;
      services: string[];
    }>;
  };
}

const sectionComponents: Record<string, React.FC<{ content: LandingPageContent; practice: LandingPageData["practice"] }>> = {
  hero: HeroSection,
  about: AboutSection,
  services: ServicesSection,
  testimonials: TestimonialsSection,
  faq: FAQSection,
  contact: ContactSection,
  cta: CTASection,
};

export function LandingPageTemplate({ data }: { data: LandingPageData }) {
  const primaryColor = data.practice.whiteLabelPrimaryColor ?? "#2563eb";

  return (
    <div style={{ "--primary": primaryColor } as React.CSSProperties}>
      <SchemaInjector
        type="MedicalBusiness"
        data={{
          name: data.practice.locations[0]?.businessName ?? data.practice.name,
          address: data.practice.locations[0],
          phone: data.practice.locations[0]?.phone,
          email: data.practice.locations[0]?.email,
          description: data.seoDescription,
        }}
      />

      {data.contents.map((content) => {
        const Section = sectionComponents[content.sectionKey];
        if (!Section) return null;

        return (
          <section
            key={content.id}
            id={content.sectionKey}
            className={`section-${content.sectionKey}`}
          >
            <Section content={content} practice={data.practice} />
          </section>
        );
      })}
    </div>
  );
}

5.5 Section Components#

// src/components/landing-page/sections/hero.tsx
interface Props {
  content: { content: string; mediaUrls: string[]; config: Record<string, unknown> };
  practice: { whiteLabelBrandName?: string | null; whiteLabelLogoUrl?: string | null };
}

export function HeroSection({ content, practice }: Props) {
  const config = content.config as {
    layout?: "centered" | "split" | "fullscreen";
    buttonText?: string;
    buttonUrl?: string;
    subtitle?: string;
  };

  return (
    <div className={`hero hero--${config.layout ?? "centered"}`}>
      <div className="hero__content">
        {practice.whiteLabelLogoUrl && (
          <img src={practice.whiteLabelLogoUrl} alt="" className="hero__logo" loading="eager" />
        )}
        <div
          className="hero__text"
          dangerouslySetInnerHTML={{ __html: content.content }}
        />
        {config.subtitle && <p className="hero__subtitle">{config.subtitle}</p>}
        {config.buttonText && (
          <a href={config.buttonUrl ?? "#contact"} className="hero__cta">
            {config.buttonText}
          </a>
        )}
      </div>
      {content.mediaUrls[0] && (
        <div className="hero__media">
          <img src={content.mediaUrls[0]} alt="" loading="eager" />
        </div>
      )}
    </div>
  );
}

5.6 Schema.org Injection#

// src/components/landing-page/schema-injector.tsx
interface SchemaProps {
  type: "MedicalBusiness" | "Physician" | "LocalBusiness" | "FAQPage";
  data: Record<string, unknown>;
}

export function SchemaInjector({ type, data }: SchemaProps) {
  const schema = buildSchema(type, data);

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify(schema),
      }}
    />
  );
}

function buildSchema(type: string, data: Record<string, unknown>) {
  const base = {
    "@context": "https://schema.org",
    "@type": type,
  };

  switch (type) {
    case "MedicalBusiness":
    case "Physician":
      return {
        ...base,
        name: data.name,
        description: data.description,
        image: data.image,
        telephone: data.phone,
        email: data.email,
        url: data.url,
        address: data.address
          ? {
              "@type": "PostalAddress",
              streetAddress: (data.address as any).addressLine1,
              addressLocality: (data.address as any).city,
              addressRegion: (data.address as any).state,
              postalCode: (data.address as any).postalCode,
              addressCountry: "IN",
            }
          : undefined,
        geo: (data as any).latitude
          ? {
              "@type": "GeoCoordinates",
              latitude: (data as any).latitude,
              longitude: (data as any).longitude,
            }
          : undefined,
        priceRange: "$$",
        openingHoursSpecification: buildOpeningHours((data.address as any)?.businessHours),
        aggregateRating: (data as any).rating
          ? {
              "@type": "AggregateRating",
              ratingValue: (data as any).rating,
              reviewCount: (data as any).reviewCount,
            }
          : undefined,
      };

    case "FAQPage":
      return {
        ...base,
        mainEntity: (data.questions as any[] ?? []).map((q) => ({
          "@type": "Question",
          name: q.question,
          acceptedAnswer: {
            "@type": "Answer",
            text: q.answer,
          },
        })),
      };

    default:
      return base;
  }
}

function buildOpeningHours(hours: unknown) {
  if (!hours || typeof hours !== "object") return undefined;
  const dayMap: Record<string, string> = {
    monday: "Monday", tuesday: "Tuesday", wednesday: "Wednesday",
    thursday: "Thursday", friday: "Friday", saturday: "Saturday", sunday: "Sunday",
  };

  return Object.entries(hours as Record<string, { open: string; close: string }>)
    .filter(([_, v]) => v.open && v.close)
    .map(([day, times]) => ({
      "@type": "OpeningHoursSpecification",
      dayOfWeek: dayMap[day] ?? day,
      opens: times.open,
      closes: times.close,
    }));
}

5.7 Custom Domain + Wildcard DNS#

// src/server/lib/dns/cloudflare.ts
// Cloudflare API integration for automatic DNS management

interface DNSRecord {
  type: "CNAME" | "A" | "TXT";
  name: string;
  content: string;
  ttl?: number;
  proxied?: boolean;
}

export class CloudflareDNSManager {
  private apiToken: string;
  private zoneId: string;
  private baseUrl = "https://api.cloudflare.com/client/v4";

  constructor() {
    this.apiToken = process.env.CLOUDFLARE_API_TOKEN!;
    this.zoneId = process.env.CLOUDFLARE_ZONE_ID!;
  }

  async addSubdomainRecord(subdomain: string): Promise<void> {
    await this.createRecord({
      type: "CNAME",
      name: subdomain,
      content: process.env.ROOT_DOMAIN!,
      proxied: true, // Enable Cloudflare proxying
    });
  }

  async addCustomDomain(hostname: string): Promise<{ verificationRecord: DNSRecord }> {
    // 1. Create CNAME pointing to our domain
    await this.createRecord({
      type: "CNAME",
      name: hostname,
      content: process.env.ROOT_DOMAIN!,
      proxied: true,
    });

    // 2. Return verification record for user to add
    const verificationRecord: DNSRecord = {
      type: "TXT",
      name: `_rankflow.${hostname}`,
      content: `verify=${crypto.randomUUID()}`,
    };

    await this.createRecord(verificationRecord);
    return { verificationRecord };
  }

  async verifyDomain(hostname: string): Promise<boolean> {
    // Check DNS propagation
    try {
      const records = await this.listRecords({ type: "CNAME", name: hostname });
      return records.some((r) => r.content === process.env.ROOT_DOMAIN);
    } catch {
      return false;
    }
  }

  async removeRecords(hostname: string): Promise<void> {
    const records = await this.listRecords({ name: hostname });
    await Promise.all(records.map((r) => this.deleteRecord(r.id)));
  }

  private async createRecord(record: DNSRecord): Promise<void> {
    const response = await fetch(`${this.baseUrl}/zones/${this.zoneId}/dns_records`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${this.apiToken}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(record),
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Cloudflare DNS error: ${error}`);
    }
  }

  private async listRecords(filters: { type?: string; name?: string }): Promise<Array<{ id: string } & DNSRecord>> {
    const params = new URLSearchParams();
    if (filters.type) params.set("type", filters.type);
    if (filters.name) params.set("name", filters.name);

    const response = await fetch(
      `${this.baseUrl}/zones/${this.zoneId}/dns_records?${params}`,
      { headers: { Authorization: `Bearer ${this.apiToken}` } }
    );

    const data = await response.json();
    return data.result ?? [];
  }

  private async deleteRecord(recordId: string): Promise<void> {
    await fetch(`${this.baseUrl}/zones/${this.zoneId}/dns_records/${recordId}`, {
      method: "DELETE",
      headers: { Authorization: `Bearer ${this.apiToken}` },
    });
  }
}

5.8 ISR/SSG Strategy#

// src/app/lp/[practiceSlug]/[...path]/page.tsx
import { unstable_cache } from "next/cache";

// Cache landing page data for 1 hour
const getCachedLandingPage = unstable_cache(
  async (slug: string) => {
    return prisma.landingPage.findFirst({
      where: { practice: { slug }, isPublished: true },
      include: { contents: true, practice: { include: { locations: true } } },
    });
  },
  ["landing-page"],
  { revalidate: 3600, tags: ["landing-page"] }
);

// On-demand revalidation endpoint
// src/app/api/lp/revalidate/route.ts
import { revalidateTag } from "next/cache";

export async function POST(request: Request) {
  const { practiceSlug, secret } = await request.json();

  if (secret !== process.env.REVALIDATE_SECRET) {
    return new Response("Unauthorized", { status: 401 });
  }

  revalidateTag(`landing-page-${practiceSlug}`);
  return Response.json({ revalidated: true });
}

5.9 Template Configuration#

// src/lib/landing-page/templates.ts
export interface TemplateConfig {
  id: string;
  name: string;
  description: string;
  thumbnail: string;
  sections: TemplateSection[];
  defaultTheme: ThemeConfig;
}

export interface TemplateSection {
  key: string;
  name: string;
  required: boolean;
  defaultConfig: Record<string, unknown>;
}

export interface ThemeConfig {
  primaryColor: string;
  secondaryColor: string;
  fontFamily: string;
  borderRadius: "none" | "sm" | "md" | "lg" | "full";
  buttonStyle: "solid" | "outline" | "ghost";
}

export const TEMPLATES: Record<string, TemplateConfig> = {
  "default-medical": {
    id: "default-medical",
    name: "Medical Professional",
    description: "Clean, trustworthy design for doctors and clinics",
    thumbnail: "/templates/medical-default.jpg",
    sections: [
      { key: "hero", name: "Hero Banner", required: true, defaultConfig: { layout: "centered", buttonText: "Book Appointment" } },
      { key: "about", name: "About", required: true, defaultConfig: {} },
      { key: "services", name: "Services", required: true, defaultConfig: {} },
      { key: "testimonials", name: "Testimonials", required: false, defaultConfig: {} },
      { key: "faq", name: "FAQ", required: false, defaultConfig: {} },
      { key: "contact", name: "Contact", required: true, defaultConfig: {} },
      { key: "cta", name: "Call to Action", required: false, defaultConfig: { buttonText: "Call Now" } },
    ],
    defaultTheme: {
      primaryColor: "#2563eb",
      secondaryColor: "#f8fafc",
      fontFamily: "Inter, system-ui, sans-serif",
      borderRadius: "md",
      buttonStyle: "solid",
    },
  },
  "premium-clinic": {
    id: "premium-clinic",
    name: "Premium Clinic",
    description: "High-end design for multi-specialty clinics",
    thumbnail: "/templates/premium-clinic.jpg",
    sections: [
      { key: "hero", name: "Hero Banner", required: true, defaultConfig: { layout: "split" } },
      { key: "stats", name: "Stats Counter", required: true, defaultConfig: {} },
      { key: "about", name: "About", required: true, defaultConfig: {} },
      { key: "services", name: "Services", required: true, defaultConfig: { layout: "cards" } },
      { key: "team", name: "Team", required: false, defaultConfig: {} },
      { key: "testimonials", name: "Testimonials", required: true, defaultConfig: { layout: "carousel" } },
      { key: "faq", name: "FAQ", required: true, defaultConfig: {} },
      { key: "blog", name: "Latest Posts", required: false, defaultConfig: {} },
      { key: "contact", name: "Contact", required: true, defaultConfig: {} },
      { key: "cta", name: "Call to Action", required: true, defaultConfig: {} },
    ],
    defaultTheme: {
      primaryColor: "#0f172a",
      secondaryColor: "#f1f5f9",
      fontFamily: "Poppins, system-ui, sans-serif",
      borderRadius: "lg",
      buttonStyle: "solid",
    },
  },
  "minimal-dentist": {
    id: "minimal-dentist",
    name: "Dental Studio",
    description: "Minimal, calming design for dental practices",
    thumbnail: "/templates/minimal-dentist.jpg",
    sections: [
      { key: "hero", name: "Hero Banner", required: true, defaultConfig: { layout: "fullscreen" } },
      { key: "about", name: "About", required: true, defaultConfig: {} },
      { key: "services", name: "Treatments", required: true, defaultConfig: {} },
      { key: "gallery", name: "Photo Gallery", required: false, defaultConfig: {} },
      { key: "testimonials", name: "Smile Stories", required: true, defaultConfig: {} },
      { key: "faq", name: "FAQ", required: true, defaultConfig: {} },
      { key: "contact", name: "Book Appointment", required: true, defaultConfig: {} },
    ],
    defaultTheme: {
      primaryColor: "#06b6d4",
      secondaryColor: "#ecfeff",
      fontFamily: "DM Sans, system-ui, sans-serif",
      borderRadius: "full",
      buttonStyle: "outline",
    },
  },
};

6. GBP Integration Spec#

6.1 OAuth Flow Architecture#

// src/server/lib/gbp/auth.ts
import { prisma } from "~/server/db";
import { redis } from "~/server/redis";

const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
const GOOGLE_REFRESH_URL = "https://oauth2.googleapis.com/token";

export interface GbpTokens {
  accessToken: string;
  refreshToken: string;
  expiresAt: Date;
  scope: string[];
}

/**
 * Step 1: Generate OAuth URL for user to authorize
 */
export function generateAuthUrl(params: {
  redirectUri: string;
  state: string;
  additionalScopes?: string[];
}): string {
  const scopes = [
    "https://www.googleapis.com/auth/business.manage",
    "https://www.googleapis.com/auth/userinfo.email",
    "https://www.googleapis.com/auth/userinfo.profile",
    ...(params.additionalScopes ?? []),
  ];

  const url = new URL(GOOGLE_AUTH_URL);
  url.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
  url.searchParams.set("redirect_uri", params.redirectUri);
  url.searchParams.set("response_type", "code");
  url.searchParams.set("scope", scopes.join(" "));
  url.searchParams.set("state", params.state);
  url.searchParams.set("access_type", "offline");
  url.searchParams.set("prompt", "consent");
  url.searchParams.set("include_granted_scopes", "true");

  return url.toString();
}

/**
 * Step 2: Exchange authorization code for tokens
 */
export async function exchangeCodeForTokens(params: {
  code: string;
  redirectUri: string;
}): Promise<GbpTokens & { email: string; name: string; picture?: string }> {
  const response = await fetch(GOOGLE_TOKEN_URL, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      code: params.code,
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
      redirect_uri: params.redirectUri,
      grant_type: "authorization_code",
    }),
  });

  if (!response.ok) {
    const error = await response.text();
    throw new GbpAuthError(`Token exchange failed: ${error}`);
  }

  const data = await response.json();

  // Get user info
  const userInfo = await fetchUserInfo(data.access_token);

  return {
    accessToken: data.access_token,
    refreshToken: data.refresh_token,
    expiresAt: new Date(Date.now() + data.expires_in * 1000),
    scope: (data.scope ?? "").split(" ").filter(Boolean),
    email: userInfo.email,
    name: userInfo.name,
    picture: userInfo.picture,
  };
}

/**
 * Step 3: Refresh access token (called automatically before API calls)
 */
export async function refreshAccessToken(refreshToken: string): Promise<{
  accessToken: string;
  expiresAt: Date;
  newRefreshToken?: string;
}> {
  const response = await fetch(GOOGLE_REFRESH_URL, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      refresh_token: refreshToken,
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
      grant_type: "refresh_token",
    }),
  });

  if (!response.ok) {
    const error = await response.text();
    throw new GbpAuthError(`Token refresh failed: ${error}`, "TOKEN_EXPIRED");
  }

  const data = await response.json();

  return {
    accessToken: data.access_token,
    expiresAt: new Date(Date.now() + data.expires_in * 1000),
    newRefreshToken: data.refresh_token, // Sometimes a new refresh token is returned
  };
}

/**
 * Auto-refresh wrapper for API calls
 */
export async function withFreshToken<T>(
  accountId: string,
  operation: (accessToken: string) => Promise<T>
): Promise<T> {
  const cacheKey = `gbp:tokens:${accountId}`;

  // Try cache first
  const cached = await redis.get(cacheKey);
  let accessToken: string;

  if (cached) {
    const parsed = JSON.parse(cached);
    // Refresh if expiring within 5 minutes
    if (new Date(parsed.expiresAt).getTime() - Date.now() < 5 * 60 * 1000) {
      const account = await prisma.gbpAccount.findUnique({ where: { id: accountId } });
      if (!account) throw new GbpAuthError("Account not found");

      const refreshed = await refreshAccessToken(account.refreshToken);
      await prisma.gbpAccount.update({
        where: { id: accountId },
        data: {
          accessToken: refreshed.accessToken,
          tokenExpiresAt: refreshed.expiresAt,
          ...(refreshed.newRefreshToken ? { refreshToken: refreshed.newRefreshToken } : {}),
        },
      });

      await redis.setex(cacheKey, 300, JSON.stringify({
        accessToken: refreshed.accessToken,
        expiresAt: refreshed.expiresAt,
      }));

      accessToken = refreshed.accessToken;
    } else {
      accessToken = parsed.accessToken;
    }
  } else {
    // Fetch from DB
    const account = await prisma.gbpAccount.findUnique({ where: { id: accountId } });
    if (!account) throw new GbpAuthError("Account not found");

    // Refresh if needed
    if (account.tokenExpiresAt.getTime() - Date.now() < 5 * 60 * 1000) {
      const refreshed = await refreshAccessToken(account.refreshToken);
      await prisma.gbpAccount.update({
        where: { id: accountId },
        data: {
          accessToken: refreshed.accessToken,
          tokenExpiresAt: refreshed.expiresAt,
        },
      });
      accessToken = refreshed.accessToken;
    } else {
      accessToken = account.accessToken;
    }

    // Cache for 5 minutes
    await redis.setex(cacheKey, 300, JSON.stringify({
      accessToken,
      expiresAt: account.tokenExpiresAt,
    }));
  }

  return operation(accessToken);
}

async function fetchUserInfo(accessToken: string) {
  const response = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  return response.json();
}

class GbpAuthError extends Error {
  constructor(message: string, public code?: string) {
    super(message);
    this.name = "GbpAuthError";
  }
}

6.2 GBP API Client#

// src/server/lib/gbp/client.ts
import { withFreshToken } from "./auth";
import type { GbpAccount, GbpLocation } from "@prisma/client";

const GBP_API_BASE = "https://mybusinessbusinessinformation.googleapis.com/v1";
const GBP_ACCOUNTS_BASE = "https://mybusinessaccountmanagement.googleapis.com/v1";
const GBP_POSTS_BASE = "https://mybusiness.googleapis.com/v4";

export class GbpApiError extends Error {
  constructor(
    message: string,
    public statusCode: number,
    public gbpErrorCode?: string
  ) {
    super(message);
    this.name = "GbpApiError";
  }
}

export async function getGbpClient(account: GbpAccount) {
  return {
    // ── Location Management ──────────────────────────

    async listLocations(): Promise<Array<{
      name: string;
      locationName: string;
      primaryPhone?: string;
      websiteUri?: string;
      metadata?: Record<string, unknown>;
    }>> {
      return withFreshToken(account.id, async (token) => {
        const response = await fetch(
          `${GBP_API_BASE}/${account.googleAccountId}/locations?readMask=name,locationName,primaryPhone,websiteUri,metadata`,
          { headers: { Authorization: `Bearer ${token}` } }
        );
        if (!response.ok) throw await parseError(response);
        const data = await response.json();
        return data.locations ?? [];
      });
    },

    async getLocation(locationId: string) {
      return withFreshToken(account.id, async (token) => {
        const response = await fetch(
          `${GBP_API_BASE}/${locationId}?readMask=*`,
          { headers: { Authorization: `Bearer ${token}` } }
        );
        if (!response.ok) throw await parseError(response);
        return response.json();
      });
    },

    // ── Posts ────────────────────────────────────────

    async createPost(params: {
      locationId: string;
      summary: string;
      topicType: string;
      actionType?: string | null;
      actionUrl?: string | null;
      mediaUrls?: string[];
      offerTitle?: string | null;
      offerCouponCode?: string | null;
      offerTerms?: string | null;
      eventTitle?: string | null;
      eventStartTime?: Date | null;
      eventEndTime?: Date | null;
      searchTerms?: string[];
    }) {
      return withFreshToken(account.id, async (token) => {
        const body: Record<string, unknown> = {
          languageCode: "en-IN",
          summary: params.summary,
          topicType: params.topicType,
          searchTerms: params.searchTerms ?? [],
        };

        if (params.actionType) {
          body.callToAction = {
            actionType: params.actionType,
            url: params.actionUrl,
          };
        }

        if (params.mediaUrls?.length) {
          body.media = params.mediaUrls.map((url) => ({
            mediaFormat: url.match(/\.(mp4|mov)/i) ? "VIDEO" : "PHOTO",
            sourceUrl: url,
          }));
        }

        if (params.offerTitle) {
          body.offer = {
            couponCode: params.offerCouponCode,
            redemptionChannel: "ONLINE",
            title: params.offerTitle,
            termsConditions: params.offerTerms,
          };
        }

        if (params.eventTitle) {
          body.event = {
            title: params.eventTitle,
            startDateTime: params.eventStartTime?.toISOString(),
            endDateTime: params.eventEndTime?.toISOString(),
          };
        }

        const response = await fetch(
          `${GBP_POSTS_BASE}/${params.locationId}/localPosts`,
          {
            method: "POST",
            headers: {
              Authorization: `Bearer ${token}`,
              "Content-Type": "application/json",
            },
            body: JSON.stringify(body),
          }
        );

        if (!response.ok) throw await parseError(response);
        return response.json();
      });
    },

    async deletePost(locationId: string, postName: string) {
      return withFreshToken(account.id, async (token) => {
        const response = await fetch(
          `${GBP_POSTS_BASE}/${postName}`,
          {
            method: "DELETE",
            headers: { Authorization: `Bearer ${token}` },
          }
        );
        if (!response.ok && response.status !== 404) throw await parseError(response);
        return { success: true };
      });
    },

    // ── Reviews ──────────────────────────────────────

    async listReviews(locationId: string, params?: {
      pageSize?: number;
      pageToken?: string;
      orderBy?: string;
    }) {
      return withFreshToken(account.id, async (token) => {
        const searchParams = new URLSearchParams();
        if (params?.pageSize) searchParams.set("pageSize", String(params.pageSize));
        if (params?.pageToken) searchParams.set("pageToken", params.pageToken);
        if (params?.orderBy) searchParams.set("orderBy", params.orderBy);

        const response = await fetch(
          `${GBP_POSTS_BASE}/${locationId}/reviews?${searchParams}`,
          { headers: { Authorization: `Bearer ${token}` } }
        );
        if (!response.ok) throw await parseError(response);
        return response.json();
      });
    },

    async replyToReview(params: { locationId: string; reviewId: string; reply: string }) {
      return withFreshToken(account.id, async (token) => {
        const response = await fetch(
          `${GBP_POSTS_BASE}/${params.locationId}/reviews/${params.reviewId}/reply`,
          {
            method: "PUT",
            headers: {
              Authorization: `Bearer ${token}`,
              "Content-Type": "application/json",
            },
            body: JSON.stringify({ comment: params.reply }),
          }
        );
        if (!response.ok) throw await parseError(response);
        return response.json();
      });
    },

    // ── Photos ───────────────────────────────────────

    async uploadPhoto(locationId: string, photoUrl: string, category?: string) {
      return withFreshToken(account.id, async (token) => {
        const response = await fetch(
          `${GBP_POSTS_BASE}/${locationId}/media`,
          {
            method: "POST",
            headers: {
              Authorization: `Bearer ${token}`,
              "Content-Type": "application/json",
            },
            body: JSON.stringify({
              mediaFormat: photoUrl.match(/\.(mp4|mov)/i) ? "VIDEO" : "PHOTO",
              sourceUrl: photoUrl,
              locationAssociation: category
                ? { category: category.toUpperCase() }
                : undefined,
            }),
          }
        );
        if (!response.ok) throw await parseError(response);
        return response.json();
      });
    },

    // ── Q&A ──────────────────────────────────────────

    async listQuestions(locationId: string) {
      return withFreshToken(account.id, async (token) => {
        const response = await fetch(
          `${GBP_POSTS_BASE}/${locationId}/questions`,
          { headers: { Authorization: `Bearer ${token}` } }
        );
        if (!response.ok) throw await parseError(response);
        return response.json();
      });
    },

    async answerQuestion(locationId: string, questionName: string, answer: string) {
      return withFreshToken(account.id, async (token) => {
        const response = await fetch(
          `${GBP_POSTS_BASE}/${questionName}/answers`,
          {
            method: "POST",
            headers: {
              Authorization: `Bearer ${token}`,
              "Content-Type": "application/json",
            },
            body: JSON.stringify({ text: answer }),
          }
        );
        if (!response.ok) throw await parseError(response);
        return response.json();
      });
    },

    // ── Insights ─────────────────────────────────────

    async getInsights(params: {
      locationId: string;
      startDate: string;
      endDate: string;
      metrics: string[];
    }) {
      return withFreshToken(account.id, async (token) => {
        const body = {
          locationNames: [params.locationId],
          basicRequest: {
            metricRequests: params.metrics.map((metric) => ({
              metric: metric.toUpperCase(),
            })),
            timeRange: {
              startTime: `${params.startDate}T00:00:00Z`,
              endTime: `${params.endDate}T23:59:59Z`,
            },
          },
        };

        const response = await fetch(
          `${GBP_POSTS_BASE}/locations:reportInsights`,
          {
            method: "POST",
            headers: {
              Authorization: `Bearer ${token}`,
              "Content-Type": "application/json",
            },
            body: JSON.stringify(body),
          }
        );
        if (!response.ok) throw await parseError(response);
        return response.json();
      });
    },
  };
}

async function parseError(response: Response): Promise<GbpApiError> {
  let errorData: any = {};
  try {
    errorData = await response.json();
  } catch { /* ignore */ }

  return new GbpApiError(
    errorData.error?.message ?? `HTTP ${response.status}`,
    response.status,
    errorData.error?.code ?? errorData.error?.status
  );
}

6.3 Rate Limiting & Quota Management#

// src/server/lib/gbp/ratelimit.ts
import { redis } from "~/server/redis";

/**
 * GBP API Quota Limits (per Google project):
 * - Business Information API: 10,000 requests/day
 * - Posts API: 500 posts/day per location
 * - Reviews API: 1,000 requests/day
 * - Photos API: 1,000 requests/day
 * - Insights API: 500 requests/day
 */

const QUOTA_LIMITS: Record<string, { daily: number; perMinute: number }> = {
  "business-information": { daily: 10000, perMinute: 100 },
  posts: { daily: 500, perMinute: 30 },
  reviews: { daily: 1000, perMinute: 60 },
  photos: { daily: 1000, perMinute: 60 },
  insights: { daily: 500, perMinute: 30 },
  qa: { daily: 500, perMinute: 30 },
};

export async function checkGbpQuota(
  operation: keyof typeof QUOTA_LIMITS,
  accountId: string
): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
  const limit = QUOTA_LIMITS[operation];
  const dailyKey = `gbp:quota:${operation}:${accountId}:${new Date().toISOString().slice(0, 10)}`;
  const minuteKey = `gbp:ratelimit:${operation}:${accountId}`;

  const [dailyCount, minuteCount] = await Promise.all([
    redis.incr(dailyKey),
    redis.incr(minuteKey),
  ]);

  if (dailyCount === 1) await redis.expire(dailyKey, 86400);
  if (minuteCount === 1) await redis.pexpire(minuteKey, 60000);

  const allowed = dailyCount <= limit.daily && minuteCount <= limit.perMinute;
  const remaining = Math.max(0, limit.daily - dailyCount);
  const resetAt = new Date(Date.now() + 86400 * 1000);

  return { allowed, remaining, resetAt };
}

export async function getQuotaStatus(accountId: string) {
  const today = new Date().toISOString().slice(0, 10);
  const operations = Object.keys(QUOTA_LIMITS) as Array<keyof typeof QUOTA_LIMITS>;

  const results = await Promise.all(
    operations.map(async (op) => {
      const count = await redis.get(`gbp:quota:${op}:${accountId}:${today}`);
      return {
        operation: op,
        used: parseInt(count ?? "0", 10),
        limit: QUOTA_LIMITS[op].daily,
        remaining: QUOTA_LIMITS[op].daily - parseInt(count ?? "0", 10),
      };
    })
  );

  return results;
}

6.4 Suspension Monitoring#

// src/server/lib/gbp/monitor.ts
import { prisma } from "~/server/db";

/**
 * Monitor GBP locations for status changes.
 * Runs as a scheduled job every 30 minutes.
 */
export async function monitorGbpSuspensions() {
  const accounts = await prisma.gbpAccount.findMany({
    where: { isActive: true },
    include: {
      locations: {
        include: { location: true },
      },
    },
  });

  for (const account of accounts) {
    try {
      const client = await getGbpClient(account);
      for (const gbpLoc of account.locations) {
        try {
          const locationData = await client.getLocation(gbpLoc.gbpLocationId);
          const newStatus = locationData.locationState?.isPublished === false
            ? "SUSPENDED"
            : locationData.locationState?.isDisabled === true
            ? "DISABLED"
            : "ACTIVE";

          if (newStatus !== gbpLoc.status) {
            await prisma.gbpLocation.update({
              where: { id: gbpLoc.id },
              data: {
                status: newStatus,
                metadata: { ...gbpLoc.metadata as any, lastStatusCheck: locationData },
              },
            });

            // Send alert if suspended
            if (newStatus === "SUSPENDED") {
              await sendSuspensionAlert(account.practiceId, {
                locationName: gbpLoc.name,
                previousStatus: gbpLoc.status,
                reason: locationData.locationState?.canUpdate ?? "Unknown",
              });
            }
          }
        } catch (error) {
          console.error(`[GBP Monitor] Error checking ${gbpLoc.gbpLocationId}:`, error);
        }
      }
    } catch (error) {
      console.error(`[GBP Monitor] Error with account ${account.id}:`, error);
    }
  }
}

async function sendSuspensionAlert(practiceId: string, details: {
  locationName: string;
  previousStatus: string;
  reason: string;
}) {
  // Queue notification
  console.error(`[GBP ALERT] Practice ${practiceId}: Location "${details.locationName}" suspended!`);
  // TODO: Send email/SMS via notification queue
}

7. Social Media Integration Spec#

7.1 Platform Abstraction Layer#

// src/server/lib/social/types.ts
export interface SocialPlatform {
  readonly name: PlatformType;
  readonly displayName: string;
  readonly maxContentLength: number;
  readonly supportsMedia: boolean;
  readonly maxMediaCount: number;
  readonly supportedMediaTypes: ("image" | "video" | "carousel")[];
  readonly requiresApproval: boolean;
  readonly characterEncoding: "utf8" | "utf16";

  // Authentication
  getAuthUrl(redirectUri: string, state: string): string;
  exchangeCode(code: string, redirectUri: string): Promise<SocialAuthTokens>;
  refreshTokens(refreshToken: string): Promise<SocialAuthTokens>;

  // Publishing
  publishPost(
    tokens: SocialAuthTokens,
    content: PostContent
  ): Promise<PublishResult>;

  // Scheduling (via Zernio)
  schedulePost?(
    tokens: SocialAuthTokens,
    content: PostContent,
    scheduledFor: Date
  ): Promise<ScheduleResult>;

  // Analytics
  getPostAnalytics?(
    tokens: SocialAuthTokens,
    externalPostId: string
  ): Promise<PostAnalytics>;

  // Account info
  getAccountInfo?(tokens: SocialAuthTokens): Promise<AccountInfo>;
}

export interface SocialAuthTokens {
  accessToken: string;
  refreshToken?: string;
  expiresAt?: Date;
  scope?: string[];
}

export interface PostContent {
  text: string;
  mediaUrls?: string[];
  mediaType?: "image" | "video" | "carousel";
  linkUrl?: string;
  hashtags?: string[];
  mentions?: string[];
  location?: { lat: number; lng: number };
  firstComment?: string; // Instagram-specific
}

export interface PublishResult {
  externalPostId: string;
  postUrl: string;
  publishedAt: Date;
  engagement?: { likes: number; comments: number; shares: number };
}

export interface ScheduleResult extends PublishResult {
  scheduledFor: Date;
  schedulerJobId: string;
}

export interface PostAnalytics {
  impressions: number;
  reach: number;
  likes: number;
  comments: number;
  shares: number;
  saves?: number;
  clicks: number;
  videoViews?: number;
  watchTimeSeconds?: number;
}

export interface AccountInfo {
  accountId: string;
  accountName: string;
  username?: string;
  profileUrl: string;
  avatarUrl?: string;
  followerCount?: number;
}

7.2 Facebook Platform Implementation (via Composio)#

// src/server/lib/social/platforms/facebook.ts
import { type SocialPlatform, type SocialAuthTokens, type PostContent, type PublishResult } from "../types";

export class FacebookPlatform implements SocialPlatform {
  readonly name = "FACEBOOK" as const;
  readonly displayName = "Facebook";
  readonly maxContentLength = 63206;
  readonly supportsMedia = true;
  readonly maxMediaCount = 10;
  readonly supportedMediaTypes = ["image", "video", "carousel"] as const;
  readonly requiresApproval = false;
  readonly characterEncoding = "utf8";

  private composioBaseUrl = "https://backend.composio.dev/api/v1";

  getAuthUrl(redirectUri: string, state: string): string {
    const url = new URL(`${this.composioBaseUrl}/auth-launch`);
    url.searchParams.set("appName", "FACEBOOK");
    url.searchParams.set("redirectUri", redirectUri);
    url.searchParams.set("state", state);
    url.searchParams.set("integrationId", process.env.COMPOSIO_FACEBOOK_INTEGRATION!);
    return url.toString();
  }

  async exchangeCode(code: string): Promise<SocialAuthTokens> {
    // Composio handles the OAuth exchange
    const response = await fetch(`${this.composioBaseUrl}/connectedAccounts`, {
      method: "POST",
      headers: {
        "x-api-key": process.env.COMPOSIO_API_KEY!,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ code, appName: "FACEBOOK" }),
    });

    const data = await response.json();
    return {
      accessToken: data.accessToken,
      refreshToken: data.refreshToken,
      expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined,
    };
  }

  async refreshTokens(refreshToken: string): Promise<SocialAuthTokens> {
    const response = await fetch(`${this.composioBaseUrl}/connectedAccounts/refresh`, {
      method: "POST",
      headers: { "x-api-key": process.env.COMPOSIO_API_KEY! },
      body: JSON.stringify({ refreshToken, appName: "FACEBOOK" }),
    });

    const data = await response.json();
    return {
      accessToken: data.accessToken,
      refreshToken: data.refreshToken,
      expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined,
    };
  }

  async publishPost(tokens: SocialAuthTokens, content: PostContent): Promise<PublishResult> {
    // Use Composio action for Facebook posting
    const actionInput: Record<string, unknown> = {
      accessToken: tokens.accessToken,
      message: this.buildPostText(content),
      link: content.linkUrl,
    };

    if (content.mediaUrls?.length) {
      actionInput.photos = content.mediaUrls;
    }

    const response = await fetch(`${this.composioBaseUrl}/actions/FACEBOOK_PUBLISH_POST/execute`, {
      method: "POST",
      headers: {
        "x-api-key": process.env.COMPOSIO_API_KEY!,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ input: actionInput }),
    });

    if (!response.ok) {
      throw new Error(`Facebook publish failed: ${await response.text()}`);
    }

    const result = await response.json();
    return {
      externalPostId: result.data.postId,
      postUrl: `https://facebook.com/${result.data.postId}`,
      publishedAt: new Date(),
    };
  }

  private buildPostText(content: PostContent): string {
    let text = content.text;
    if (content.hashtags?.length) {
      text += "\n\n" + content.hashtags.map((h) => `#${h}`).join(" ");
    }
    return text;
  }

  async getPostAnalytics(tokens: SocialAuthTokens, externalPostId: string) {
    const response = await fetch(
      `${this.composioBaseUrl}/actions/FACEBOOK_GET_POST_INSIGHTS/execute`,
      {
        method: "POST",
        headers: {
          "x-api-key": process.env.COMPOSIO_API_KEY!,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          input: {
            accessToken: tokens.accessToken,
            postId: externalPostId,
          },
        }),
      }
    );

    const result = await response.json();
    return {
      impressions: result.data.impressions ?? 0,
      reach: result.data.reach ?? 0,
      likes: result.data.likes?.summary?.total_count ?? 0,
      comments: result.data.comments?.summary?.total_count ?? 0,
      shares: result.data.shares?.count ?? 0,
      clicks: result.data.clicks ?? 0,
    };
  }
}

7.3 Instagram Platform Implementation#

// src/server/lib/social/platforms/instagram.ts
import { type SocialPlatform, type SocialAuthTokens, type PostContent, type PublishResult } from "../types";

export class InstagramPlatform implements SocialPlatform {
  readonly name = "INSTAGRAM" as const;
  readonly displayName = "Instagram";
  readonly maxContentLength = 2200;
  readonly supportsMedia = true;
  readonly maxMediaCount = 10; // Carousel
  readonly supportedMediaTypes = ["image", "video", "carousel"] as const;
  readonly requiresApproval = false;
  readonly characterEncoding = "utf8";

  private graphApiBase = "https://graph.facebook.com/v18.0";

  getAuthUrl(redirectUri: string, state: string): string {
    const scopes = [
      "instagram_basic",
      "instagram_content_publish",
      "instagram_manage_insights",
      "pages_read_engagement",
    ];

    const url = new URL("https://www.facebook.com/v18.0/dialog/oauth");
    url.searchParams.set("client_id", process.env.FACEBOOK_APP_ID!);
    url.searchParams.set("redirect_uri", redirectUri);
    url.searchParams.set("scope", scopes.join(","));
    url.searchParams.set("state", state);
    url.searchParams.set("response_type", "code");
    return url.toString();
  }

  async exchangeCode(code: string, redirectUri: string): Promise<SocialAuthTokens> {
    // Exchange for Facebook access token
    const tokenResponse = await fetch(
      `https://graph.facebook.com/v18.0/oauth/access_token?` +
        `client_id=${process.env.FACEBOOK_APP_ID}&` +
        `client_secret=${process.env.FACEBOOK_APP_SECRET}&` +
        `code=${code}&` +
        `redirect_uri=${encodeURIComponent(redirectUri)}`
    );

    const tokenData = await tokenResponse.json();

    // Get Instagram Business Account ID
    const pagesResponse = await fetch(
      `${this.graphApiBase}/me/accounts?access_token=${tokenData.access_token}`
    );
    const pagesData = await pagesResponse.json();
    const page = pagesData.data?.[0];

    if (!page) throw new Error("No Facebook page found");

    const igResponse = await fetch(
      `${this.graphApiBase}/${page.id}?fields=instagram_business_account&access_token=${tokenData.access_token}`
    );
    const igData = await igResponse.json();

    return {
      accessToken: tokenData.access_token,
      refreshToken: tokenData.access_token, // Long-lived token
      expiresAt: undefined, // Long-lived tokens don't expire
      scope: ["instagram_basic", "instagram_content_publish"],
    };
  }

  async refreshTokens(): Promise<SocialAuthTokens> {
    // Instagram long-lived tokens are valid for 60 days
    // Refresh 30 days before expiry
    throw new Error("Instagram uses long-lived tokens; refresh via Facebook OAuth");
  }

  async publishPost(tokens: SocialAuthTokens, content: PostContent): Promise<PublishResult> {
    const igAccountId = await this.getInstagramAccountId(tokens);

    // Step 1: Create media container
    const containerUrl = new URL(`${this.graphApiBase}/${igAccountId}/media`);
    containerUrl.searchParams.set("access_token", tokens.accessToken);
    containerUrl.searchParams.set("caption", content.text);

    if (content.mediaUrls?.length === 1) {
      // Single image/video
      const isVideo = content.mediaType === "video";
      containerUrl.searchParams.set(isVideo ? "video_url" : "image_url", content.mediaUrls[0]!);
      containerUrl.searchParams.set("media_type", isVideo ? "REELS" : "CAROUSEL");
    } else if (content.mediaUrls && content.mediaUrls.length > 1) {
      // Carousel — create children first
      const children = await this.createCarouselChildren(
        tokens,
        igAccountId,
        content.mediaUrls,
        content.mediaType
      );
      containerUrl.searchParams.set("media_type", "CAROUSEL");
      containerUrl.searchParams.set("children", children.join(","));
    }

    const containerResponse = await fetch(containerUrl.toString(), { method: "POST" });
    const containerData = await containerResponse.json();

    if (!containerData.id) {
      throw new Error(`Media container creation failed: ${JSON.stringify(containerData)}`);
    }

    // Step 2: Publish the container
    const publishUrl = new URL(`${this.graphApiBase}/${igAccountId}/media_publish`);
    publishUrl.searchParams.set("access_token", tokens.accessToken);
    publishUrl.searchParams.set("creation_id", containerData.id);

    // Wait for media processing (especially for videos)
    await this.waitForMediaReady(tokens, containerData.id);

    const publishResponse = await fetch(publishUrl.toString(), { method: "POST" });
    const publishData = await publishResponse.json();

    if (content.firstComment) {
      // Post first comment if provided
      await this.publishComment(tokens, publishData.id, content.firstComment);
    }

    return {
      externalPostId: publishData.id,
      postUrl: `https://instagram.com/p/${publishData.id}`,
      publishedAt: new Date(),
    };
  }

  private async createCarouselChildren(
    tokens: SocialAuthTokens,
    igAccountId: string,
    mediaUrls: string[],
    mediaType?: string
  ): Promise<string[]> {
    const children: string[] = [];
    for (const url of mediaUrls) {
      const childUrl = new URL(`${this.graphApiBase}/${igAccountId}/media`);
      childUrl.searchParams.set("access_token", tokens.accessToken);
      childUrl.searchParams.set(
        mediaType === "video" ? "video_url" : "image_url",
        url
      );
      childUrl.searchParams.set("is_carousel_item", "true");

      const response = await fetch(childUrl.toString(), { method: "POST" });
      const data = await response.json();
      children.push(data.id);
    }
    return children;
  }

  private async waitForMediaReady(tokens: SocialAuthTokens, containerId: string): Promise<void> {
    for (let i = 0; i < 20; i++) {
      await new Promise((r) => setTimeout(r, 3000));
      const response = await fetch(
        `${this.graphApiBase}/${containerId}?fields=status_code&access_token=${tokens.accessToken}`
      );
      const data = await response.json();
      if (data.status_code === "FINISHED") return;
      if (data.status_code === "ERROR") throw new Error("Media processing failed");
    }
    throw new Error("Media processing timeout");
  }

  private async publishComment(tokens: SocialAuthTokens, mediaId: string, text: string) {
    await fetch(`${this.graphApiBase}/${mediaId}/comments`, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        access_token: tokens.accessToken,
        message: text,
      }),
    });
  }

  private async getInstagramAccountId(tokens: SocialAuthTokens): Promise<string> {
    // Store this during account connection
    throw new Error("Implement account ID caching");
  }
}

7.4 Zernio Scheduling Integration#

// src/server/lib/social/zernio.ts
/**
 * Zernio provides a scheduling layer for social media posts.
 * It handles queue management, optimal timing, and retry logic.
 */

export interface ZernioScheduleRequest {
  posts: Array<{
    platform: string;
    accountId: string;
    content: string;
    mediaUrls?: string[];
    scheduledFor: string; // ISO 8601
    timezone: string;
    metadata?: Record<string, unknown>;
  }>;
  webhookUrl?: string; // Callback when published
}

export interface ZernioScheduleResponse {
  batchId: string;
  scheduled: Array<{
    postId: string;
    status: "scheduled" | "failed";
    scheduledFor: string;
  }>;
}

export class ZernioClient {
  private baseUrl: string;
  private apiKey: string;

  constructor() {
    this.baseUrl = process.env.ZERNIO_API_URL ?? "https://api.zernio.com/v1";
    this.apiKey = process.env.ZERNIO_API_KEY!;
  }

  async scheduleBatch(request: ZernioScheduleRequest): Promise<ZernioScheduleResponse> {
    const response = await fetch(`${this.baseUrl}/schedule/batch`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(request),
    });

    if (!response.ok) {
      throw new Error(`Zernio scheduling failed: ${await response.text()}`);
    }

    return response.json();
  }

  async cancelSchedule(batchId: string): Promise<void> {
    await fetch(`${this.baseUrl}/schedule/${batchId}/cancel`, {
      method: "POST",
      headers: { Authorization: `Bearer ${this.apiKey}` },
    });
  }

  async getOptimalTimes(params: {
    platform: string;
    accountId: string;
    timezone: string;
    daysAhead?: number;
  }): Promise<Array<{ hour: number; dayOfWeek: number; score: number }>> {
    const response = await fetch(
      `${this.baseUrl}/analytics/optimal-times?` +
        new URLSearchParams(params as Record<string, string>),
      { headers: { Authorization: `Bearer ${this.apiKey}` } }
    );

    return response.json();
  }

  async getPublishingStatus(batchId: string): Promise<{
    batchId: string;
    status: "pending" | "in_progress" | "completed" | "failed";
    posts: Array<{
      postId: string;
      status: string;
      publishedAt?: string;
      error?: string;
    }>;
  }> {
    const response = await fetch(`${this.baseUrl}/schedule/${batchId}/status`, {
      headers: { Authorization: `Bearer ${this.apiKey}` },
    });

    return response.json();
  }
}

// Usage in scheduling pipeline
export async function scheduleSocialPost(
  zernio: ZernioClient,
  params: {
    practiceId: string;
    socialAccountId: string;
    contentPieceId: string;
    scheduledFor: Date;
    platforms: string[];
  }
) {
  // Get content and account info
  const [content, account] = await Promise.all([
    prisma.contentPiece.findUnique({ where: { id: params.contentPieceId } }),
    prisma.socialAccount.findUnique({ where: { id: params.socialAccountId } }),
  ]);

  if (!content || !account) throw new Error("Content or account not found");

  // Build Zernio schedule request
  const scheduleRequest: ZernioScheduleRequest = {
    posts: params.platforms.map((platform) => ({
      platform,
      accountId: account.accountId ?? account.id,
      content: content.content,
      mediaUrls: (content.metadata as any)?.mediaUrls ?? [],
      scheduledFor: params.scheduledFor.toISOString(),
      timezone: account.timezone ?? "Asia/Kolkata",
      metadata: {
        contentPieceId: content.id,
        practiceId: params.practiceId,
        hashtags: content.focusKeywords.map((k) => k.replace(/\s+/g, "")),
      },
    })),
    webhookUrl: `${process.env.NEXT_PUBLIC_APP_URL}/api/webhooks/zernio`,
  };

  const result = await zernio.scheduleBatch(scheduleRequest);

  // Store scheduled posts in DB
  for (const scheduled of result.scheduled) {
    await prisma.socialPost.create({
      data: {
        socialAccountId: params.socialAccountId,
        contentPieceId: params.contentPieceId,
        content: content.content,
        scheduledFor: params.scheduledFor,
        status: "SCHEDULED",
      },
    });
  }

  return result;
}

7.5 Error Handling & Retry Logic#

// src/server/lib/social/error-handler.ts
import { prisma } from "~/server/db";

export interface SocialError {
  platform: string;
  operation: string;
  error: Error;
  retryable: boolean;
  retryAfter?: number; // Seconds to wait before retry
  context: Record<string, unknown>;
}

/**
 * Classify social media errors for appropriate handling
 */
export function classifySocialError(error: Error, platform: string): SocialError {
  const message = error.message.toLowerCase();

  // Rate limiting
  if (message.includes("rate limit") || message.includes("too many requests") || message.includes("429")) {
    const retryAfter = extractRetryAfter(error.message);
    return {
      platform,
      operation: "publish",
      error,
      retryable: true,
      retryAfter,
      context: { classification: "RATE_LIMIT" },
    };
  }

  // Authentication errors
  if (message.includes("unauthorized") || message.includes("invalid token") || message.includes("401")) {
    return {
      platform,
      operation: "publish",
      error,
      retryable: false,
      context: { classification: "AUTH_ERROR", needsReconnect: true },
    };
  }

  // Content policy violations
  if (message.includes("content policy") || message.includes("community guidelines") || message.includes("400")) {
    return {
      platform,
      operation: "publish",
      error,
      retryable: false,
      context: { classification: "CONTENT_VIOLATION" },
    };
  }

  // Media processing errors (usually retryable)
  if (message.includes("media") || message.includes("processing") || message.includes("video")) {
    return {
      platform,
      operation: "publish",
      error,
      retryable: true,
      retryAfter: 300, // 5 minutes
      context: { classification: "MEDIA_ERROR" },
    };
  }

  // Network/timeout errors (always retryable)
  if (message.includes("timeout") || message.includes("network") || message.includes("econnrefused") || message.includes("503")) {
    return {
      platform,
      operation: "publish",
      error,
      retryable: true,
      retryAfter: 60,
      context: { classification: "NETWORK_ERROR" },
    };
  }

  // Default: unknown, don't retry
  return {
    platform,
    operation: "publish",
    error,
    retryable: false,
    context: { classification: "UNKNOWN" },
  };
}

/**
 * Handle social media publishing with full error recovery
 */
export async function publishWithRecovery(
  publishFn: () => Promise<void>,
  errorContext: { platform: string; postId: string }
): Promise<{ success: boolean; error?: SocialError }> {
  const maxRetries = 3;
  let lastError: SocialError | undefined;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      await publishFn();
      return { success: true };
    } catch (error) {
      lastError = classifySocialError(error as Error, errorContext.platform);

      if (!lastError.retryable) {
        // Non-retryable — fail immediately
        break;
      }

      // Wait before retry
      const delay = (lastError.retryAfter ?? 60) * 1000 * attempt;
      console.log(`[Social] Retry ${attempt}/${maxRetries} for ${errorContext.postId} after ${delay}ms`);
      await new Promise((r) => setTimeout(r, delay));
    }
  }

  // Log final failure
  await logSocialError(lastError!, errorContext.postId);

  return { success: false, error: lastError };
}

function extractRetryAfter(message: string): number {
  const match = message.match(/retry after (\d+)/i);
  return match ? parseInt(match[1], 10) : 60;
}

async function logSocialError(error: SocialError, postId: string) {
  await prisma.socialPost.update({
    where: { id: postId },
    data: {
      status: "FAILED",
      failedReason: `${error.context.classification}: ${error.error.message}`.slice(0, 500),
    },
  });
}

7.6 Platform Registry#

// src/server/lib/social/registry.ts
import { FacebookPlatform } from "./platforms/facebook";
import { InstagramPlatform } from "./platforms/instagram";
import { LinkedInPlatform } from "./platforms/linkedin";
import { TwitterPlatform } from "./platforms/twitter";
import { type SocialPlatform, PlatformType } from "./types";

const registry: Record<string, SocialPlatform> = {
  FACEBOOK: new FacebookPlatform(),
  INSTAGRAM: new InstagramPlatform(),
  LINKEDIN: new LinkedInPlatform(),
  TWITTER: new TwitterPlatform(),
};

export function getPlatform(type: PlatformType): SocialPlatform {
  const platform = registry[type];
  if (!platform) throw new Error(`Unsupported platform: ${type}`);
  return platform;
}

export function getSupportedPlatforms(): PlatformType[] {
  return Object.keys(registry) as PlatformType[];
}

8. Citation Engine Spec#

8.1 Plugin Architecture#

The citation engine uses a plugin-based architecture where each directory is a self-contained module implementing the CitationPlugin interface.

// src/server/lib/citations/types.ts
export interface CitationPlugin {
  readonly directoryName: string;
  readonly displayName: string;
  readonly domain: string;
  readonly category: "medical" | "general" | "local";
  readonly requiresCaptcha: boolean;
  readonly requiresPhoneVerify: boolean;
  readonly signupFlow: SignupFlow[];

  /**
   * Check if the business is already listed
   */
  checkListing(nap: NAPData): Promise<CheckResult>;

  /**
   * Submit the business listing
   */
  submitListing(data: CitationSubmissionData): Promise<SubmitResult>;

  /**
   * Verify NAP consistency on the listing page
   */
  verifyNap(listingUrl: string, expectedNap: NAPData): Promise<NapVerificationResult>;

  /**
   * Update existing listing if NAP is different
   */
  updateListing?(data: CitationSubmissionData & { listingUrl: string }): Promise<SubmitResult>;

  /**
   * Get the listing URL from submission response
   */
  extractListingUrl(response: SubmitResult): string | undefined;
}

export interface NAPData {
  name: string;
  address: string;
  phone: string;
  email?: string;
  website?: string;
  category?: string;
  description?: string;
  hours?: Record<string, { open: string; close: string }>;
  photos?: string[];
}

export interface SignupFlow {
  step: number;
  action: "navigate" | "fill_form" | "solve_captcha" | "verify_phone" | "confirm";
  url?: string;
  formFields?: Array<{
    selector: string;
    field: string;
    type: "text" | "select" | "checkbox" | "file" | "captcha";
    required: boolean;
  }>;
  expectedResult?: string; // URL or element to wait for
}

export interface CheckResult {
  found: boolean;
  listingUrl?: string;
  currentNap?: Partial<NAPData>;
  status?: "claimed" | "unclaimed" | "pending";
}

export interface SubmitResult {
  success: boolean;
  listingUrl?: string;
  verificationRequired?: boolean;
  message?: string;
  screenshotPath?: string; // For manual review
}

export interface NapVerificationResult {
  status: "MATCHED" | "MISMATCH_NAME" | "MISMATCH_ADDRESS" | "MISMATCH_PHONE" | "MISMATCH_ALL" | "NOT_FOUND";
  foundNap?: Partial<NAPData>;
  differences?: Array<{ field: string; expected: string; found: string }>;
  screenshotPath?: string;
}

export interface CitationSubmissionData {
  nap: NAPData;
  credentials?: { username?: string; password?: string };
  photos?: string[];
  services?: string[];
  hours?: Record<string, { open: string; close: string }>;
  metadata?: Record<string, unknown>;
}

8.2 Puppeteer-based Submission Engine#

// src/server/lib/citations/engine.ts
import puppeteer, { Browser, Page } from "puppeteer";
import { type CitationPlugin, type CitationSubmissionData, type NAPData } from "./types";

/**
 * Browser pool for citation submissions
 */
class BrowserPool {
  private browsers: Browser[] = [];
  private maxBrowsers = 3;
  private userAgents = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.0",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.0",
    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.0",
  ];

  async getBrowser(): Promise<Browser> {
    // Check for existing available browser
    const available = this.browsers.find((b) => !b.isConnected());
    if (available) return available;

    if (this.browsers.length >= this.maxBrowsers) {
      throw new Error("Browser pool exhausted");
    }

    const browser = await puppeteer.launch({
      headless: true,
      args: [
        "--no-sandbox",
        "--disable-setuid-sandbox",
        "--disable-dev-shm-usage",
        "--disable-accelerated-2d-canvas",
        "--disable-gpu",
        "--window-size=1920,1080",
        "--lang=en-IN,en",
      ],
    });

    this.browsers.push(browser);
    return browser;
  }

  async closeAll(): Promise<void> {
    await Promise.all(this.browsers.map((b) => b.close()));
    this.browsers = [];
  }
}

const browserPool = new BrowserPool();

/**
 * Execute a citation submission using the plugin's flow definition
 */
export async function executeSubmission(
  plugin: CitationPlugin,
  data: CitationSubmissionData,
  options?: {
    twoCaptchaApiKey?: string;
    proxyUrl?: string;
  }
): Promise<SubmitResult> {
  const browser = await browserPool.getBrowser();
  const page = await browser.newPage();

  try {
    // Configure page
    await page.setUserAgent(getRandomUserAgent());
    await page.setViewport({ width: 1920, height: 1080 });
    await page.setExtraHTTPHeaders({
      "Accept-Language": "en-IN,en;q=0.9",
      "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    });

    // Execute each step in the signup flow
    for (const step of plugin.signupFlow) {
      console.log(`[Citation] Step ${step.step}: ${step.action}`);

      switch (step.action) {
        case "navigate":
          await page.goto(step.url!, { waitUntil: "networkidle2", timeout: 30000 });
          break;

        case "fill_form":
          await fillForm(page, step.formFields!, data);
          break;

        case "solve_captcha":
          await solveCaptcha(page, options?.twoCaptchaApiKey);
          break;

        case "verify_phone":
          // Phone verification is handled manually or via SMS API
          await page.waitForNavigation({ timeout: 120000 });
          break;

        case "confirm":
          // Click submit/confirm button
          const submitButton = await page.$("button[type='submit'], input[type='submit'], .submit-btn");
          if (submitButton) {
            await Promise.all([
              page.waitForNavigation({ waitUntil: "networkidle2", timeout: 30000 }).catch(() => {}),
              submitButton.click(),
            ]);
          }
          break;
      }
    }

    // Take screenshot for verification
    const screenshotPath = `/tmp/citation-${plugin.directoryName}-${Date.now()}.png`;
    await page.screenshot({ path: screenshotPath, fullPage: true });

    // Extract result
    const currentUrl = page.url();
    const pageContent = await page.content();

    const success = plugin.signupFlow.some(
      (step) => step.expectedResult && currentUrl.includes(step.expectedResult)
    );

    return {
      success,
      listingUrl: success ? currentUrl : undefined,
      screenshotPath,
      message: success ? "Submitted successfully" : "Submission status unclear",
    };
  } catch (error) {
    const screenshotPath = `/tmp/citation-${plugin.directoryName}-${Date.now()--error.png`;
    await page.screenshot({ path: screenshotPath }).catch(() => {});

    return {
      success: false,
      message: (error as Error).message,
      screenshotPath,
    };
  } finally {
    await page.close();
  }
}

async function fillForm(
  page: Page,
  fields: Array<{ selector: string; field: string; type: string; required: boolean }>,
  data: CitationSubmissionData
) {
  for (const field of fields) {
    const value = getFieldValue(field.field, data);
    if (!value && field.required) {
      throw new Error(`Required field ${field.field} is empty`);
    }
    if (!value) continue;

    const element = await page.$(field.selector);
    if (!element) {
      console.warn(`[Citation] Field selector not found: ${field.selector}`);
      continue;
    }

    switch (field.type) {
      case "text":
        await element.click({ clickCount: 3 }); // Select all
        await element.type(String(value));
        break;
      case "select":
        await page.select(field.selector, String(value));
        break;
      case "checkbox":
        if (value) await element.click();
        break;
      case "file":
        // Handle file upload if photos provided
        if (data.photos?.length) {
          const uploadHandle = await page.$(field.selector);
          if (uploadHandle) {
            await uploadHandle.uploadFile(data.photos[0]);
          }
        }
        break;
    }

    // Small delay between fields to appear human
    await new Promise((r) => setTimeout(r, 200 + Math.random() * 300));
  }
}

async function solveCaptcha(page: Page, twoCaptchaKey?: string): Promise<void> {
  if (!twoCaptchaKey) {
    throw new Error("CAPTCHA solving requires 2captcha API key");
  }

  // Detect CAPTCHA type
  const hasRecaptcha = await page.$(".g-recaptcha") !== null;
  const hasHCaptcha = await page.$(".h-captcha") !== null;

  if (!hasRecaptcha && !hasHCaptcha) {
    console.log("[Citation] No CAPTCHA detected, continuing...");
    return;
  }

  // Use 2captcha service
  const siteKey = await page.evaluate(() => {
    const el = document.querySelector(".g-recaptcha");
    return el?.getAttribute("data-sitekey") ?? "";
  });

  const pageUrl = page.url();

  // Submit to 2captcha
  const submitRes = await fetch("http://2captcha.com/in.php", {
    method: "POST",
    body: new URLSearchParams({
      key: twoCaptchaKey,
      method: "userrecaptcha",
      googlekey: siteKey,
      pageurl: pageUrl,
      json: "1",
    }),
  });

  const submitData = await submitRes.json();
  const captchaId = submitData.request;

  // Poll for solution
  for (let i = 0; i < 60; i++) {
    await new Promise((r) => setTimeout(r, 5000));

    const resultRes = await fetch(
      `http://2captcha.com/res.php?key=${twoCaptchaKey}&action=get&id=${captchaId}&json=1`
    );
    const resultData = await resultRes.json();

    if (resultData.status === 1) {
      // Solution found
      const token = resultData.request;
      await page.evaluate((t) => {
        (window as any).grecaptcha.getResponse = () => t;
      }, token);

      // Submit the form with the token
      await page.evaluate((t) => {
        const textarea = document.querySelector("#g-recaptcha-response") as HTMLTextAreaElement;
        if (textarea) textarea.value = t;
      }, token);

      return;
    }
  }

  throw new Error("CAPTCHA solving timeout");
}

function getFieldValue(field: string, data: CitationSubmissionData): string | boolean | undefined {
  const fieldMap: Record<string, () => string | boolean | undefined> = {
    business_name: () => data.nap.name,
    address: () => data.nap.address,
    phone: () => data.nap.phone,
    email: () => data.nap.email,
    website: () => data.nap.website,
    category: () => data.nap.category,
    description: () => data.nap.description,
    agree_terms: () => true,
    city: () => data.nap.address?.split(",").map((s) => s.trim()).pop() ?? "",
  };

  return fieldMap[field]?.();
}

function getRandomUserAgent(): string {
  const agents = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.0",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.0",
    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.0",
  ];
  return agents[Math.floor(Math.random() * agents.length)]!;
}

8.3 NAP Monitoring Crawler#

// src/server/lib/citations/nap-monitor.ts
import { load } from "cheerio";
import { redis } from "~/server/redis";
import { type NAPData, type NapVerificationResult } from "./types";

/**
 * Crawl a directory listing and verify NAP consistency
 */
export async function verifyNAPOnListing(
  listingUrl: string,
  expectedNap: NAPData
): Promise<NapVerificationResult> {
  // Check cache first
  const cacheKey = `nap:verify:${Buffer.from(listingUrl).toString("base64")}`;
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  try {
    const response = await fetch(listingUrl, {
      headers: {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        "Accept": "text/html,application/xhtml+xml",
        "Accept-Language": "en-IN,en;q=0.9",
      },
    });

    if (!response.ok) {
      return {
        status: "NOT_FOUND",
        foundNap: undefined,
        differences: [],
      };
    }

    const html = await response.text();
    const $ = load(html);

    // Extract text from page
    const pageText = $("body").text().toLowerCase();

    // Check for business name
    const nameMatch = fuzzyMatch(expectedNap.name, pageText);
    const phoneMatch = normalizePhone(expectedNap.phone).some((p) =>
      pageText.includes(p)
    );
    const addressMatch = checkAddressMatch(expectedNap.address, pageText);

    const differences: Array<{ field: string; expected: string; found: string }> = [];

    if (!nameMatch) {
      differences.push({ field: "name", expected: expectedNap.name, found: "not found" });
    }
    if (!addressMatch) {
      differences.push({ field: "address", expected: expectedNap.address, found: "not found" });
    }
    if (!phoneMatch) {
      differences.push({ field: "phone", expected: expectedNap.phone, found: "not found" });
    }

    let status: NapVerificationResult["status"];
    if (differences.length === 0) status = "MATCHED";
    else if (differences.length === 3) status = "MISMATCH_ALL";
    else if (differences.some((d) => d.field === "name")) {
      status = differences.some((d) => d.field === "address")
        ? "MISMATCH_NAME"
        : differences.some((d) => d.field === "phone")
        ? "MISMATCH_NAME"
        : "MISMATCH_NAME";
    } else if (differences.some((d) => d.field === "address")) {
      status = differences.some((d) => d.field === "phone")
        ? "MISMATCH_ADDRESS"
        : "MISMATCH_ADDRESS";
    } else {
      status = "MISMATCH_PHONE";
    }

    const result: NapVerificationResult = {
      status,
      differences: differences.length > 0 ? differences : undefined,
    };

    // Cache for 24 hours
    await redis.setex(cacheKey, 86400, JSON.stringify(result));

    return result;
  } catch (error) {
    return {
      status: "NOT_FOUND",
      differences: [{ field: "page", expected: "accessible", found: (error as Error).message }],
    };
  }
}

/**
 * Fuzzy string matching for business names
 */
function fuzzyMatch(expected: string, pageText: string): boolean {
  const normalized = expected.toLowerCase().replace(/[^a-z0-9]/g, "");
  const normalizedPage = pageText.replace(/[^a-z0-9]/g, "");

  // Exact match
  if (normalizedPage.includes(normalized)) return true;

  // Word-level match (at least 80% of words)
  const expectedWords = expected.toLowerCase().split(/\s+/);
  const matchedWords = expectedWords.filter((word) =>
    word.length > 2 && pageText.includes(word)
  );

  return matchedWords.length / expectedWords.length >= 0.8;
}

/**
 * Normalize Indian phone numbers for comparison
 */
function normalizePhone(phone: string): string[] {
  const cleaned = phone.replace(/\D/g, "");
  const variants: string[] = [cleaned];

  // With +91
  if (cleaned.length === 10) {
    variants.push(`+91${cleaned}`);
    variants.push(`91${cleaned}`);
  }

  // With spaces
  if (cleaned.length === 10) {
    variants.push(`${cleaned.slice(0, 5)} ${cleaned.slice(5)}`);
  }

  return variants;
}

function checkAddressMatch(address: string, pageText: string): boolean {
  // Extract key parts of address (city, PIN code, landmarks)
  const parts = address.toLowerCase().split(",").map((s) => s.trim());

  // Check for city match
  const city = parts.find((p) => p.length > 3 && !/^\d+$/.test(p));
  if (city && !pageText.includes(city)) return false;

  // Check for PIN code
  const pinCode = address.match(/(\d{6})/)?.[1];
  if (pinCode && !pageText.includes(pinCode)) return false;

  return true;
}

8.4 India-Specific Medical Directory Plugins#

// src/server/lib/citations/plugins/justdial.ts
import { type CitationPlugin, type CitationSubmissionData, type NAPData, type CheckResult, type SubmitResult, type NapVerificationResult } from "../types";

export class JustdialPlugin implements CitationPlugin {
  readonly directoryName = "justdial";
  readonly displayName = "Justdial";
  readonly domain = "justdial.com";
  readonly category = "local";
  readonly requiresCaptcha = true;
  readonly requiresPhoneVerify = true;

  readonly signupFlow = [
    {
      step: 1,
      action: "navigate" as const,
      url: "https://www.justdial.com/Free-Listing",
    },
    {
      step: 2,
      action: "fill_form" as const,
      formFields: [
        { selector: "input[name='cnm']", field: "business_name", type: "text", required: true },
        { selector: "input[name='city']", field: "city", type: "text", required: true },
        { selector: "input[name='mn']", field: "phone", type: "text", required: true },
        { selector: "input[name='cat']", field: "category", type: "text", required: true },
      ],
    },
    {
      step: 3,
      action: "solve_captcha" as const,
    },
    {
      step: 4,
      action: "verify_phone" as const,
    },
    {
      step: 5,
      action: "confirm" as const,
      expectedResult: "success",
    },
  ];

  async checkListing(nap: NAPData): Promise<CheckResult> {
    // Search Justdial for the business
    const searchUrl = `https://www.justdial.com/${encodeURIComponent(nap.name)}/search`;
    const response = await fetch(searchUrl, {
      headers: {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
      },
    });

    if (!response.ok) return { found: false };

    const html = await response.text();
    // Parse results — simplified
    const hasMatch = html.toLowerCase().includes(nap.name.toLowerCase().slice(0, 20));

    return {
      found: hasMatch,
      status: hasMatch ? "claimed" : undefined,
    };
  }

  async submitListing(data: CitationSubmissionData): Promise<SubmitResult> {
    // Delegate to the puppeteer engine
    const { executeSubmission } = await import("../engine");
    return executeSubmission(this, data, {
      twoCaptchaApiKey: process.env.TWOCAPTCHA_API_KEY,
    });
  }

  async verifyNap(listingUrl: string, expectedNap: NAPData): Promise<NapVerificationResult> {
    const { verifyNAPOnListing } = await import("../nap-monitor");
    return verifyNAPOnListing(listingUrl, expectedNap);
  }

  extractListingUrl(response: SubmitResult): string | undefined {
    return response.listingUrl;
  }
}
// src/server/lib/citations/plugins/practo.ts
import { type CitationPlugin, type CitationSubmissionData, type NAPData, type CheckResult, type SubmitResult } from "../types";

export class PractoPlugin implements CitationPlugin {
  readonly directoryName = "practo";
  readonly displayName = "Practo";
  readonly domain = "practo.com";
  readonly category = "medical";
  readonly requiresCaptcha = false;
  readonly requiresPhoneVerify = true;

  readonly signupFlow = [
    {
      step: 1,
      action: "navigate" as const,
      url: "https://www.practo.com/doctor-signup",
    },
    {
      step: 2,
      action: "fill_form" as const,
      formFields: [
        { selector: "input[name='name']", field: "business_name", type: "text", required: true },
        { selector: "input[name='mobile']", field: "phone", type: "text", required: true },
        { selector: "input[name='email']", field: "email", type: "text", required: true },
        { selector: "input[name='city']", field: "city", type: "select", required: true },
        { selector: "input[name='specialization']", field: "category", type: "select", required: true },
      ],
    },
    {
      step: 3,
      action: "verify_phone" as const,
    },
    {
      step: 4,
      action: "confirm" as const,
      expectedResult: "profile",
    },
  ];

  async checkListing(nap: NAPData): Promise<CheckResult> {
    const searchUrl = `https://www.practo.com/search?results_type=doctor&q=${encodeURIComponent(nap.name)}&city=${encodeURIComponent(nap.address.split(",").pop()?.trim() ?? "")}`;
    const response = await fetch(searchUrl, {
      headers: {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
      },
    });

    if (!response.ok) return { found: false };
    const html = await response.text();

    const found = html.toLowerCase().includes(nap.name.toLowerCase().slice(0, 15));
    return { found, status: found ? "claimed" : undefined };
  }

  async submitListing(data: CitationSubmissionData): Promise<SubmitResult> {
    const { executeSubmission } = await import("../engine");
    return executeSubmission(this, data);
  }

  async verifyNap(listingUrl: string, expectedNap: NAPData) {
    const { verifyNAPOnListing } = await import("../nap-monitor");
    return verifyNAPOnListing(listingUrl, expectedNap);
  }

  extractListingUrl(response: SubmitResult): string | undefined {
    return response.listingUrl;
  }
}

8.5 Plugin Registry#

// src/server/lib/citations/registry.ts
import { JustdialPlugin } from "./plugins/justdial";
import { PractoPlugin } from "./plugins/practo";
import { LybratePlugin } from "./plugins/lybrate";
import { SulekhaPlugin } from "./plugins/sulekha";
import { GoogleMapsPlugin } from "./plugins/google-maps";
import { type CitationPlugin } from "./types";

const plugins: Map<string, CitationPlugin> = new Map([
  ["justdial", new JustdialPlugin()],
  ["practo", new PractoPlugin()],
  ["lybrate", new LybratePlugin()],
  ["sulekha", new SulekhaPlugin()],
  ["google-maps", new GoogleMapsPlugin()],
  // Additional directories:
  // "google-my-business" — handled via GBP integration
  // "facebook" — handled via social integration
  // "bing-places" — Microsoft
  // "yahoo-local" — Yahoo
  // "foursquare" — Foursquare/Swarm
  // "yelp" — Yelp (limited in India)
  // "yellowpages" — India Yellow Pages
  // "grotal" — Indian directory
  // "tradeindia" — B2B (for clinics with products)
  // "indiamart" — B2B (for clinics with products)
]);

export function getCitationPlugin(directoryName: string): CitationPlugin | undefined {
  return plugins.get(directoryName);
}

export function getAllPlugins(): CitationPlugin[] {
  return Array.from(plugins.values());
}

export function getMedicalPlugins(): CitationPlugin[] {
  return Array.from(plugins.values()).filter((p) => p.category === "medical");
}

export function getLocalPlugins(): CitationPlugin[] {
  return Array.from(plugins.values()).filter((p) => p.category === "local");
}

8.6 Success/Failure Tracking#

// src/server/lib/citations/tracker.ts
import { prisma } from "~/server/db";
import { CitationStatus, NAPMatchStatus } from "@prisma/client";

/**
 * Track the result of a citation submission attempt
 */
export async function trackSubmissionResult(params: {
  citationId: string;
  success: boolean;
  listingUrl?: string;
  errorMessage?: string;
  screenshotPath?: string;
  retryCount: number;
}) {
  const { citationId, success, listingUrl, errorMessage, retryCount } = params;

  if (success) {
    await prisma.citation.update({
      where: { id: citationId },
      data: {
        status: CitationStatus.SUBMITTED,
        directoryUrl: listingUrl,
        submittedAt: new Date(),
        errorMessage: null,
        retryCount,
      },
    });
  } else {
    const shouldRetry = retryCount < 3;
    await prisma.citation.update({
      where: { id: citationId },
      data: {
        status: shouldRetry ? CitationStatus.PENDING : CitationStatus.FAILED,
        errorMessage: errorMessage?.slice(0, 500),
        retryCount,
      },
    });

    if (!shouldRetry) {
      // Create notification for manual intervention
      await prisma.notification.create({
        data: {
          userId: "", // System notification
          type: "CITATION_FAILED",
          title: "Citation Submission Failed",
          message: `Failed to submit citation after ${retryCount} attempts: ${errorMessage}`,
          actionUrl: `/dashboard/citations/${citationId}`,
        },
      });
    }
  }
}

/**
 * Update citation status after NAP scan
 */
export async function trackNapScanResult(
  citationId: string,
  result: { status: NAPMatchStatus; differences?: Array<{ field: string }> }
) {
  await prisma.citation.update({
    where: { id: citationId },
    data: {
      matchStatus: result.status,
      lastScannedAt: new Date(),
      status: result.status === NAPMatchStatus.MATCHED
        ? CitationStatus.VERIFIED
        : result.status === NAPMatchStatus.NOT_FOUND
        ? CitationStatus.NEEDS_UPDATE
        : CitationStatus.SUBMITTED,
    },
  });

  // If NAP mismatch, queue update job
  if (result.status !== NAPMatchStatus.MATCHED && result.status !== NAPMatchStatus.NOT_FOUND) {
    await prisma.job.create({
      data: {
        practiceId: (await prisma.citation.findUnique({ where: { id: citationId } }))!.practiceId,
        type: "CITATION_SUBMIT",
        status: "PENDING",
        payload: { citationId, operation: "update_nap" },
      },
    });
  }
}

9. Email Report System#

9.1 Monthly Report Generation Pipeline#

// src/server/lib/reports/pipeline.ts
import { prisma } from "~/server/db";
import { type ReportGeneratePayload } from "~/server/queue/types";

/**
 * The report generation pipeline follows these stages:
 *
 * Stage 1: Data Collection (parallel)
 *   - Fetch GBP insights for the period
 *   - Fetch rank tracking data
 *   - Fetch citation status
 *   - Fetch review statistics
 *   - Fetch competitor snapshots
 *   - Fetch backlink data
 *
 * Stage 2: Analysis & Scoring
 *   - Calculate period-over-period changes
 *   - Score each SEO pillar (0-100)
 *   - Generate AI-powered insights
 *
 * Stage 3: Chart Generation
 *   - Generate SVG charts for each metric
 *   - Create sparklines for trends
 *
 * Stage 4: HTML Assembly
 *   - Build responsive HTML email template
 *   - Inline CSS for email client compatibility
 *   - Embed SVG charts as base64
 *
 * Stage 5: PDF Generation (Playwright)
 *   - Render HTML in headless browser
 *   - Export as PDF
 *   - Upload to R2
 *
 * Stage 6: Email Delivery
 *   - Send via Resend/SES
 *   - Track opens/clicks
 */

export async function generateReport(payload: ReportGeneratePayload) {
  const { practiceId, periodStart, periodEnd, sections } = payload;
  const start = new Date(periodStart);
  const end = new Date(periodEnd);

  // Stage 1: Data Collection
  const rawData = await collectReportData(practiceId, start, end);

  // Stage 2: Analysis & Scoring
  const analysis = analyzeData(rawData, start, end);

  // Stage 3: Generate charts (SVG)
  const charts = await generateCharts(analysis);

  // Stage 4: Build HTML
  const html = buildReportHtml({
    practice: rawData.practice,
    period: { start, end },
    analysis,
    charts,
    sections,
  });

  // Stage 5: PDF Generation
  const pdfUrl = await generatePdf(html, practiceId, start, end);

  // Stage 6: Save report
  const report = await prisma.report.create({
    data: {
      practiceId,
      name: `SEO Report — ${start.toLocaleDateString("en-IN", { month: "long", year: "numeric" })}`,
      periodStart: start,
      periodEnd: end,
      status: "READY",
      sections: JSON.parse(JSON.stringify(analysis)),
      scoreOverall: analysis.overall.score,
      scoreGBP: analysis.gbp.score,
      scoreCitations: analysis.citations.score,
      scoreReviews: analysis.reviews.score,
      scoreRankings: analysis.rankings.score,
      pdfUrl,
    },
  });

  return report;
}

async function collectReportData(practiceId: string, start: Date, end: Date) {
  const [practice, gbpInsights, rankData, citationData, reviews, competitors, backlinks] =
    await Promise.all([
      prisma.practice.findUnique({
        where: { id: practiceId },
        include: { locations: true },
      }),

      // GBP Insights
      prisma.gbpInsight.findMany({
        where: {
          gbpLocation: { gbpAccount: { practiceId } },
          date: { gte: start, lte: end },
        },
        orderBy: { date: "asc" },
      }),

      // Rank tracking
      prisma.rankTrackingKeyword.findMany({
        where: { practiceId, isActive: true },
        include: {
          history: {
            where: { checkedAt: { gte: start, lte: end } },
            orderBy: { checkedAt: "asc" },
          },
        },
      }),

      // Citations
      prisma.citation.findMany({
        where: { practiceId },
        include: { location: true },
      }),

      // Reviews
      prisma.review.findMany({
        where: {
          location: { practiceId },
          reviewDate: { gte: start, lte: end },
        },
      }),

      // Competitors
      prisma.competitorSnapshot.findMany({
        where: {
          competitor: { practiceId },
          snapshotDate: { gte: start, lte: end },
        },
        include: { competitor: true },
      }),

      // Backlinks
      prisma.backlinkMonitor.findMany({
        where: { practiceId },
      }),
    ]);

  return { practice, gbpInsights, rankData, citationData, reviews, competitors, backlinks };
}

function analyzeData(data: Awaited<ReturnType<typeof collectReportData>>, start: Date, end: Date) {
  const days = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));

  // GBP Analysis
  const gbpTotalViews = data.gbpInsights.reduce((s, i) => s + i.totalViews, 0);
  const gbpTotalClicks = data.gbpInsights.reduce(
    (s, i) => s + i.websiteClicks + i.phoneClicks + i.drivingDirections,
    0
  );
  const gbpPrevViews = data.gbpInsights.reduce((s, i) => s + i.viewsSearchPrevPeriod + i.viewsMapsPrevPeriod, 0);
  const gbpChange = gbpPrevViews > 0 ? ((gbpTotalViews - gbpPrevViews) / gbpPrevViews) * 100 : 0;

  // Citation Analysis
  const totalCitations = data.citationData.length;
  const verifiedCitations = data.citationData.filter((c) => c.status === "VERIFIED").length;
  const matchedNap = data.citationData.filter(
    (c) => c.matchStatus === "MATCHED"
  ).length;

  // Review Analysis
  const avgRating =
    data.reviews.length > 0
      ? data.reviews.reduce((s, r) => s + r.rating, 0) / data.reviews.length
      : 0;
  const repliedCount = data.reviews.filter((r) => r.replyPublished).length;

  // Rank Tracking Analysis
  const improvedKeywords = data.rankData.filter(
    (k) => k.currentRank && k.previousRank && k.currentRank < k.previousRank
  ).length;
  const lostKeywords = data.rankData.filter(
    (k) => k.currentRank && k.previousRank && k.currentRank > k.previousRank
  ).length;

  return {
    overall: {
      score: calculateOverallScore({ gbp: gbpChange, citations: verifiedCitations, reviews: avgRating, rankings: improvedKeywords }),
    },
    gbp: {
      score: Math.min(100, Math.round(gbpTotalViews / 10)),
      totalViews: gbpTotalViews,
      searchViews: data.gbpInsights.reduce((s, i) => s + i.viewsSearch, 0),
      mapsViews: data.gbpInsights.reduce((s, i) => s + i.viewsMaps, 0),
      totalClicks: gbpTotalClicks,
      websiteClicks: data.gbpInsights.reduce((s, i) => s + i.websiteClicks, 0),
      phoneClicks: data.gbpInsights.reduce((s, i) => s + i.phoneClicks, 0),
      directionClicks: data.gbpInsights.reduce((s, i) => s + i.drivingDirections, 0),
      changePercent: Math.round(gbpChange * 10) / 10,
      dailyTrends: data.gbpInsights.map((i) => ({
        date: i.date.toISOString().slice(0, 10),
        views: i.totalViews,
        clicks: i.websiteClicks + i.phoneClicks + i.drivingDirections,
      })),
    },
    citations: {
      score: totalCitations > 0 ? Math.round((verifiedCitations / totalCitations) * 100) : 0,
      total: totalCitations,
      verified: verifiedCitations,
      pending: data.citationData.filter((c) => c.status === "PENDING").length,
      failed: data.citationData.filter((c) => c.status === "FAILED").length,
      napMatchPercent: totalCitations > 0 ? Math.round((matchedNap / totalCitations) * 100) : 0,
      byDirectory: groupBy(data.citationData, "directoryName"),
    },
    reviews: {
      score: Math.round((avgRating / 5) * 100),
      totalNew: data.reviews.length,
      avgRating: Math.round(avgRating * 10) / 10,
      replied: repliedCount,
      replyRate: data.reviews.length > 0 ? Math.round((repliedCount / data.reviews.length) * 100) : 0,
      byRating: groupBy(data.reviews, "rating"),
      recentReviews: data.reviews.slice(0, 5),
    },
    rankings: {
      score: Math.min(100, improvedKeywords * 5 + 50),
      totalKeywords: data.rankData.length,
      avgRank: Math.round(
        data.rankData.reduce((s, k) => s + (k.currentRank ?? 100), 0) /
          (data.rankData.length || 1)
      ),
      improved: improvedKeywords,
      declined: lostKeywords,
      inTop3: data.rankData.filter((k) => k.currentRank && k.currentRank <= 3).length,
      inTop10: data.rankData.filter((k) => k.currentRank && k.currentRank <= 10).length,
      keywordDetails: data.rankData.map((k) => ({
        keyword: k.keyword,
        current: k.currentRank,
        previous: k.previousRank,
        change: k.previousRank && k.currentRank ? k.previousRank - k.currentRank : 0,
      })),
    },
    competitors: {
      tracked: data.competitors.length,
      snapshots: data.competitors,
    },
    backlinks: {
      total: data.backlinks.length,
      active: data.backlinks.filter((b) => b.isActive && !b.isLost).length,
      lost: data.backlinks.filter((b) => b.isLost).length,
    },
  };
}

function calculateOverallScore(scores: Record<string, number>): number {
  const values = Object.values(scores);
  return Math.round(values.reduce((s, v) => s + Math.min(v, 100), 0) / values.length);
}

function groupBy<T>(arr: T[], key: keyof T): Record<string, number> {
  return arr.reduce((acc, item) => {
    const val = String(item[key]);
    acc[val] = (acc[val] ?? 0) + 1;
    return acc;
  }, {} as Record<string, number>);
}

9.2 SVG Chart Generation#

// src/server/lib/reports/charts.ts
/**
 * Generate SVG charts server-side for email embedding.
 * No external dependencies — pure SVG string generation.
 */

export interface ChartData {
  labels: string[];
  datasets: Array<{
    label: string;
    values: number[];
    color?: string;
  }>;
}

export function generateLineChart(data: ChartData, options: {
  width?: number;
  height?: number;
  title?: string;
} = {}): string {
  const { width = 600, height = 200, title } = options;
  const padding = { top: title ? 30 : 10, right: 10, bottom: 30, left: 50 };
  const chartWidth = width - padding.left - padding.right;
  const chartHeight = height - padding.top - padding.bottom;

  const allValues = data.datasets.flatMap((d) => d.values);
  const maxVal = Math.max(...allValues, 1);
  const minVal = Math.min(...allValues, 0);

  // Generate grid lines
  const gridLines = [];
  const gridSteps = 5;
  for (let i = 0; i <= gridSteps; i++) {
    const y = padding.top + (chartHeight / gridSteps) * i;
    const val = Math.round(maxVal - (maxVal - minVal) * (i / gridSteps));
    gridLines.push(`<line x1="${padding.left}" y1="${y}" x2="${width - padding.right}" y2="${y}" stroke="#e5e7eb" stroke-width="1"/>`);
    gridLines.push(`<text x="${padding.left - 5}" y="${y + 4}" text-anchor="end" font-size="10" fill="#6b7280">${val}</text>`);
  }

  // Generate paths
  const paths = data.datasets.map((dataset, di) => {
    const color = dataset.color ?? ["#2563eb", "#10b981", "#f59e0b"][di] ?? "#6b7280";
    const points = dataset.values.map((val, i) => {
      const x = padding.left + (i / (dataset.values.length - 1 || 1)) * chartWidth;
      const y = padding.top + (1 - (val - minVal) / (maxVal - minVal || 1)) * chartHeight;
      return `${x},${y}`;
    });

    const pathD = `M ${points.join(" L ")}`;
    return `<path d="${pathD}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`;
  });

  // X-axis labels (show every Nth to avoid crowding)
  const labelStep = Math.ceil(data.labels.length / 8);
  const xLabels = data.labels
    .filter((_, i) => i % labelStep === 0 || i === data.labels.length - 1)
    .map((label, i, arr) => {
      const dataIndex = i * labelStep;
      const x = padding.left + (dataIndex / (data.labels.length - 1 || 1)) * chartWidth;
      return `<text x="${x}" y="${height - 5}" text-anchor="middle" font-size="10" fill="#6b7280">${label}</text>`;
    });

  return `
    <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
      ${title ? `<text x="${width / 2}" y="18" text-anchor="middle" font-size="12" font-weight="bold" fill="#111827">${title}</text>` : ""}
      ${gridLines.join("\n")}
      ${paths.join("\n")}
      ${xLabels.join("\n")}
    </svg>
  `;
}

export function generateBarChart(data: ChartData, options: {
  width?: number;
  height?: number;
  title?: string;
} = {}): string {
  const { width = 600, height = 200, title } = options;
  const padding = { top: title ? 30 : 10, right: 10, bottom: 30, left: 50 };
  const chartWidth = width - padding.left - padding.right;
  const chartHeight = height - padding.top - padding.bottom;

  const allValues = data.datasets.flatMap((d) => d.values);
  const maxVal = Math.max(...allValues, 1);

  const barCount = data.labels.length;
  const barWidth = (chartWidth / barCount) * 0.7;
  const barGap = (chartWidth / barCount) * 0.3;

  const bars = data.labels.map((label, i) => {
    const value = data.datasets[0]?.values[i] ?? 0;
    const barHeight = (value / maxVal) * chartHeight;
    const x = padding.left + i * (barWidth + barGap) + barGap / 2;
    const y = padding.top + chartHeight - barHeight;
    const color = data.datasets[0]?.color ?? "#2563eb";

    return `
      <rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" fill="${color}" rx="2"/>
      <text x="${x + barWidth / 2}" y="${height - 8}" text-anchor="middle" font-size="9" fill="#6b7280">${label.slice(0, 8)}</text>
    `;
  });

  return `
    <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
      ${title ? `<text x="${width / 2}" y="18" text-anchor="middle" font-size="12" font-weight="bold" fill="#111827">${title}</text>` : ""}
      <line x1="${padding.left}" y1="${padding.top}" x2="${padding.left}" y2="${height - padding.bottom}" stroke="#d1d5db" stroke-width="1"/>
      <line x1="${padding.left}" y1="${height - padding.bottom}" x2="${width - padding.right}" y2="${height - padding.bottom}" stroke="#d1d5db" stroke-width="1"/>
      ${bars.join("\n")}
    </svg>
  `;
}

export function generateDonutChart(
  segments: Array<{ label: string; value: number; color: string }>,
  options: { width?: number; height?: number; title?: string } = {}
): string {
  const { width = 200, height = 200, title } = options;
  const cx = width / 2;
  const cy = height / 2;
  const radius = Math.min(cx, cy) - 30;
  const innerRadius = radius * 0.6;

  const total = segments.reduce((s, seg) => s + seg.value, 0);
  let currentAngle = -Math.PI / 2; // Start at top

  const paths = segments.map((seg) => {
    const angle = (seg.value / total) * Math.PI * 2;
    const startAngle = currentAngle;
    const endAngle = currentAngle + angle;
    currentAngle = endAngle;

    const x1 = cx + radius * Math.cos(startAngle);
    const y1 = cy + radius * Math.sin(startAngle);
    const x2 = cx + radius * Math.cos(endAngle);
    const y2 = cy + radius * Math.sin(endAngle);
    const x3 = cx + innerRadius * Math.cos(endAngle);
    const y3 = cy + innerRadius * Math.sin(endAngle);
    const x4 = cx + innerRadius * Math.cos(startAngle);
    const y4 = cy + innerRadius * Math.sin(startAngle);

    const largeArc = angle > Math.PI ? 1 : 0;

    return `<path d="M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} L ${x3} ${y3} A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${x4} ${y4} Z" fill="${seg.color}"/>`;
  });

  return `
    <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
      ${title ? `<text x="${cx}" y="15" text-anchor="middle" font-size="11" font-weight="bold" fill="#111827">${title}</text>` : ""}
      ${paths.join("\n")}
      <text x="${cx}" y="${cy - 5}" text-anchor="middle" font-size="14" font-weight="bold" fill="#111827">${total}</text>
      <text x="${cx}" y="${cy + 10}" text-anchor="middle" font-size="9" fill="#6b7280">Total</text>
    </svg>
  `;
}

async function generateCharts(analysis: ReturnType<typeof analyzeData>) {
  const charts: Record<string, string> = {};

  // GBP views trend
  if (analysis.gbp.dailyTrends.length > 0) {
    charts.gbpViewsTrend = generateLineChart({
      labels: analysis.gbp.dailyTrends.map((d) => d.date.slice(5)), // MM-DD
      datasets: [{
        label: "Views",
        values: analysis.gbp.dailyTrends.map((d) => d.views),
        color: "#2563eb",
      }],
    }, { title: "GBP Views Trend" });
  }

  // Citation status
  charts.citationStatus = generateDonutChart([
    { label: "Verified", value: analysis.citations.verified, color: "#10b981" },
    { label: "Pending", value: analysis.citations.pending, color: "#f59e0b" },
    { label: "Failed", value: analysis.citations.failed, color: "#ef4444" },
  ], { title: "Citations" });

  // Review distribution
  const reviewCounts = Object.entries(analysis.reviews.byRating).map(([rating, count]) => ({
    label: `${rating}\u2605`,
    value: count,
    color: ["#ef4444", "#f97316", "#eab308", "#84cc16", "#22c55e"][parseInt(rating) - 1] ?? "#6b7280",
  }));
  charts.reviewDistribution = generateBarChart({
    labels: reviewCounts.map((r) => r.label),
    datasets: [{ label: "Reviews", values: reviewCounts.map((r) => r.value), color: "#2563eb" }],
  }, { title: "Review Distribution" });

  return charts;
}

9.3 HTML Email Template System#

// src/server/lib/reports/email-template.ts
/**
 * Build a responsive HTML email template with embedded SVG charts.
 * Uses table-based layout for maximum email client compatibility.
 */

interface EmailTemplateData {
  practice: { name: string; whiteLabelBrandName?: string | null };
  period: { start: Date; end: Date };
  analysis: ReturnType<typeof analyzeData>;
  charts: Record<string, string>;
  reportUrl: string;
}

export function buildReportHtml(data: EmailTemplateData): string {
  const { practice, period, analysis, charts, reportUrl } = data;
  const brandName = practice.whiteLabelBrandName ?? "RankFlow AI";
  const periodText = `${period.start.toLocaleDateString("en-IN", { month: "long", day: "numeric" })} — ${period.end.toLocaleDateString("en-IN", { month: "long", day: "numeric", year: "numeric" })}`;

  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SEO Report — ${practice.name}</title>
  <style>
    @media only screen and (max-width: 600px) {
      .container { width: 100% !important; }
      .metric-card { width: 100% !important; display: block !important; }
      .chart { width: 100% !important; height: auto !important; }
    }
  </style>
</head>
<body style="margin:0;padding:0;background-color:#f3f4f6;font-family:Inter,system-ui,-apple-system,sans-serif;">
  <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color:#f3f4f6;">
    <tr>
      <td align="center" style="padding:20px 0;">
        <table role="presentation" class="container" cellpadding="0" cellspacing="0" width="640" style="background-color:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);">

          <!-- Header -->
          <tr>
            <td style="background:linear-gradient(135deg,#2563eb 0%,#1d4ed8 100%);padding:30px 40px;text-align:center;">
              <h1 style="color:#ffffff;margin:0 0 8px;font-size:24px;font-weight:700;">${practice.name}</h1>
              <p style="color:#bfdbfe;margin:0;font-size:14px;">Monthly SEO Report — ${periodText}</p>
            </td>
          </tr>

          <!-- Overall Score -->
          <tr>
            <td style="padding:30px 40px;text-align:center;border-bottom:1px solid #e5e7eb;">
              <div style="display:inline-block;background:#f0fdf4;border:2px solid #22c55e;border-radius:50%;width:100px;height:100px;line-height:96px;margin-bottom:15px;">
                <span style="font-size:36px;font-weight:800;color:#15803d;">${analysis.overall.score}</span>
              </div>
              <p style="margin:0;font-size:14px;color:#6b7280;">Overall SEO Score</p>
              <p style="margin:8px 0 0;font-size:13px;color:#22c55e;font-weight:600;">
                ${analysis.gbp.changePercent >= 0 ? "+" : ""}${analysis.gbp.changePercent}% vs last period
              </p>
            </td>
          </tr>

          <!-- Score Breakdown -->
          <tr>
            <td style="padding:20px 40px;border-bottom:1px solid #e5e7eb;">
              <table role="presentation" cellpadding="0" cellspacing="0" width="100%">
                <tr>
                  ${renderScoreCard("GBP", analysis.gbp.score, analysis.gbp.totalViews, "Views", "#2563eb")}
                  ${renderScoreCard("Citations", analysis.citations.score, analysis.citations.verified, "Verified", "#10b981")}
                </tr>
                <tr>
                  ${renderScoreCard("Reviews", analysis.reviews.score, analysis.reviews.avgRating, "Avg Rating", "#f59e0b")}
                  ${renderScoreCard("Rankings", analysis.rankings.score, analysis.rankings.inTop10, "In Top 10", "#8b5cf6")}
                </tr>
              </table>
            </td>
          </tr>

          <!-- GBP Section -->
 ${renderSection("Google Business Profile", analysis.gbp.score, `
            <table role="presentation" cellpadding="0" cellspacing="0" width="100%">
              <tr>
                <td width="50%" style="padding:10px;">
                  ${charts.gbpViewsTrend ? `<div style="background:#f9fafb;border-radius:6px;padding:10px;">${charts.gbpViewsTrend}</div>` : ""}
                </td>
                <td width="50%" style="padding:10px;vertical-align:top;">
                  ${renderMetricList([
                    { label: "Total Views", value: analysis.gbp.totalViews.toLocaleString() },
                    { label: "Search Views", value: analysis.gbp.searchViews.toLocaleString() },
                    { label: "Maps Views", value: analysis.gbp.mapsViews.toLocaleString() },
                    { label: "Website Clicks", value: analysis.gbp.websiteClicks.toLocaleString() },
                    { label: "Phone Clicks", value: analysis.gbp.phoneClicks.toLocaleString() },
                    { label: "Directions", value: analysis.gbp.directionClicks.toLocaleString() },
                  ])}
                </td>
              </tr>
            </table>
          `)}

          <!-- Citations Section -->
          ${renderSection("Citation Building", analysis.citations.score, `
            <table role="presentation" cellpadding="0" cellspacing="0" width="100%">
              <tr>
                <td width="40%" style="padding:10px;text-align:center;">
                  ${charts.citationStatus}
                </td>
                <td width="60%" style="padding:10px;vertical-align:top;">
                  ${renderMetricList([
                    { label: "Total Citations", value: String(analysis.citations.total) },
                    { label: "Verified", value: String(analysis.citations.verified), highlight: true },
                    { label: "NAP Match Rate", value: `${analysis.citations.napMatchPercent}%` },
                    { label: "Pending", value: String(analysis.citations.pending) },
                    { label: "Failed", value: String(analysis.citations.failed) },
                  ])}
                </td>
              </tr>
            </table>
          `)}

          <!-- Reviews Section -->
          ${renderSection("Review Management", analysis.reviews.score, `
            <table role="presentation" cellpadding="0" cellspacing="0" width="100%">
              <tr>
                <td width="50%" style="padding:10px;text-align:center;">
                  ${charts.reviewDistribution}
                </td>
                <td width="50%" style="padding:10px;vertical-align:top;">
                  ${renderMetricList([
                    { label: "New Reviews", value: String(analysis.reviews.totalNew) },
                    { label: "Average Rating", value: `${analysis.reviews.avgRating}/5.0` },
                    { label: "Reply Rate", value: `${analysis.reviews.replyRate}%` },
                    { label: "Replied", value: String(analysis.reviews.replied) },
                  ])}
                  ${analysis.reviews.recentReviews.length > 0 ? `
                    <p style="font-size:12px;font-weight:600;color:#111827;margin:15px 0 8px;">Recent Reviews</p>
                    ${analysis.reviews.recentReviews.slice(0, 3).map((r) => `
                      <div style="background:#f9fafb;border-radius:4px;padding:8px;margin-bottom:6px;">
                        <span style="color:#f59e0b;">${"\u2605".repeat(r.rating)}${"\u2606".repeat(5 - r.rating)}</span>
                        <p style="margin:4px 0 0;font-size:11px;color:#374151;line-height:1.4;">${(r.comment ?? "No comment").slice(0, 120)}${(r.comment ?? "").length > 120 ? "..." : ""}</p>
                      </div>
                    `).join("")}
                  ` : ""}
                </td>
              </tr>
            </table>
          `)}

          <!-- Rankings Section -->
          ${renderSection("Keyword Rankings", analysis.rankings.score, `
            ${renderMetricList([
              { label: "Tracked Keywords", value: String(analysis.rankings.totalKeywords) },
              { label: "Average Position", value: `#${analysis.rankings.avgRank}` },
              { label: "In Top 3", value: String(analysis.rankings.inTop3), highlight: true },
              { label: "In Top 10", value: String(analysis.rankings.inTop10) },
              { label: "Improved", value: `+${analysis.rankings.improved}`, color: "#22c55e" },
              { label: "Declined", value: `-${analysis.rankings.declined}`, color: "#ef4444" },
            ])}
            ${analysis.rankings.keywordDetails.length > 0 ? `
              <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin-top:15px;font-size:12px;">
                <tr style="background:#f3f4f6;">
                  <th style="padding:6px 10px;text-align:left;font-weight:600;">Keyword</th>
                  <th style="padding:6px 10px;text-align:center;font-weight:600;width:60px;">Current</th>
                  <th style="padding:6px 10px;text-align:center;font-weight:600;width:60px;">Change</th>
                </tr>
                ${analysis.rankings.keywordDetails.slice(0, 10).map((k) => `
                  <tr style="border-bottom:1px solid #f3f4f6;">
                    <td style="padding:6px 10px;">${k.keyword}</td>
                    <td style="padding:6px 10px;text-align:center;">${k.current ?? "—"}</td>
                    <td style="padding:6px 10px;text-align:center;color:${(k.change ?? 0) > 0 ? "#22c55e" : (k.change ?? 0) < 0 ? "#ef4444" : "#6b7280"};">
                      ${k.change && k.change > 0 ? "+" : ""}${k.change ?? "—"}
                    </td>
                  </tr>
                `).join("")}
              </table>
            ` : ""}
          `)}

          <!-- CTA -->
          <tr>
            <td style="padding:30px 40px;text-align:center;background:#f9fafb;">
              <a href="${reportUrl}" style="display:inline-block;background:#2563eb;color:#ffffff;padding:12px 30px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
                View Full Report in Dashboard
              </a>
              <p style="margin:15px 0 0;font-size:11px;color:#9ca3af;">
                Powered by ${brandName}
              </p>
            </td>
          </tr>

        </table>
      </td>
    </tr>
  </table>
</body>
</html>`;
}

// Helper functions for template sections
function renderScoreCard(label: string, score: number, value: string | number, sublabel: string, color: string): string {
  return `
    <td class="metric-card" width="50%" style="padding:10px;">
      <div style="background:#f9fafb;border-radius:8px;padding:15px;text-align:center;border-left:4px solid ${color};">
        <p style="margin:0 0 5px;font-size:12px;color:#6b7280;font-weight:500;text-transform:uppercase;">${label}</p>
        <p style="margin:0 0 3px;font-size:28px;font-weight:800;color:${color};">${score}</p>
        <p style="margin:0;font-size:11px;color:#6b7280;">${typeof value === "number" ? value.toLocaleString() : value} ${sublabel}</p>
      </div>
    </td>
  `;
}

function renderSection(title: string, score: number, content: string): string {
  return `
    <tr>
      <td style="padding:25px 40px;border-bottom:1px solid #e5e7eb;">
        <table role="presentation" cellpadding="0" cellspacing="0" width="100%">
          <tr>
            <td style="padding-bottom:15px;">
              <h2 style="margin:0;font-size:18px;font-weight:700;color:#111827;display:inline;">${title}</h2>
              <span style="display:inline-block;background:#eff6ff;color:#2563eb;padding:2px 10px;border-radius:12px;font-size:12px;font-weight:600;margin-left:10px;">${score}/100</span>
            </td>
          </tr>
          <tr><td>${content}</td></tr>
        </table>
      </td>
    </tr>
  `;
}

function renderMetricList(metrics: Array<{ label: string; value: string; highlight?: boolean; color?: string }>): string {
  return `
    <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="font-size:13px;">
      ${metrics.map((m) => `
        <tr style="border-bottom:1px solid #f3f4f6;">
          <td style="padding:8px 0;color:#6b7280;">${m.label}</td>
          <td style="padding:8px 0;text-align:right;font-weight:600;color:${m.color ?? (m.highlight ? "#2563eb" : "#111827")};">${m.value}</td>
        </tr>
      `).join("")}
    </table>
  `;
}

9.4 PDF Generation with Playwright#

// src/server/lib/reports/pdf-generator.ts
import { chromium } from "playwright";
import { put } from "@vercel/blob";

/**
 * Generate PDF from HTML using Playwright.
 * Runs on the VPS (not Vercel) due to binary size.
 */
export async function generatePdf(
  html: string,
  practiceId: string,
  start: Date,
  end: Date
): Promise<string> {
  const browser = await chromium.launch({ headless: true });

  try {
    const page = await browser.newPage();
    await page.setContent(html, { waitUntil: "networkidle" });

    // Wait for fonts and images
    await page.waitForTimeout(2000);

    const pdfBuffer = await page.pdf({
      format: "A4",
      printBackground: true,
      margin: { top: "20px", right: "20px", bottom: "20px", left: "20px" },
      displayHeaderFooter: true,
      headerTemplate: `<div style="font-size:9px;color:#6b7280;width:100%;text-align:center;padding:10px 40px;border-bottom:1px solid #e5e7eb;">
        SEO Performance Report — ${start.toLocaleDateString("en-IN")} to ${end.toLocaleDateString("en-IN")}
      </div>`,
      footerTemplate: `<div style="font-size:9px;color:#6b7280;width:100%;text-align:center;padding:10px 40px;border-top:1px solid #e5e7eb;">
        <span class="pageNumber"></span> of <span class="totalPages"></span>
      </div>`,
    });

    // Upload to R2
    const fileName = `reports/${practiceId}/${start.toISOString().slice(0, 7)}.pdf`;
    const result = await uploadToR2(fileName, pdfBuffer, "application/pdf");

    return result.url;
  } finally {
    await browser.close();
  }
}

async function uploadToR2(key: string, data: Buffer, contentType: string) {
  // Using S3-compatible API for Cloudflare R2
  const { S3Client, PutObjectCommand } = await import("@aws-sdk/client-s3");
  const { getSignedUrl } = await import("@aws-sdk/s3-request-presigner");

  const client = new S3Client({
    region: "auto",
    endpoint: process.env.R2_ENDPOINT!,
    credentials: {
      accessKeyId: process.env.R2_ACCESS_KEY_ID!,
      secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
    },
  });

  await client.send(
    new PutObjectCommand({
      Bucket: process.env.R2_BUCKET_NAME!,
      Key: key,
      Body: data,
      ContentType: contentType,
    })
  );

  return { url: `${process.env.R2_PUBLIC_URL}/${key}` };
}

9.5 Email Delivery#

// src/server/lib/reports/email.ts
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

interface SendReportEmailParams {
  to: string[];
  subject: string;
  html: string;
  reportUrl: string;
  pdfUrl?: string;
  practiceName: string;
}

export async function sendReportEmail(params: SendReportEmailParams) {
  const { data, error } = await resend.emails.send({
    from: "RankFlow AI <reports@rankflow.ai>",
    to: params.to,
    subject: params.subject,
    html: params.html,
    attachments: params.pdfUrl
      ? [
          {
            filename: `SEO-Report-${new Date().toISOString().slice(0, 7)}.pdf`,
            path: params.pdfUrl,
          },
        ]
      : undefined,
    headers: {
      "X-Practice-Name": params.practiceName,
      "X-Mailer": "RankFlow AI Report System",
    },
  });

  if (error) {
    throw new Error(`Email send failed: ${error.message}`);
  }

  return data;
}

/**
 * Fallback to AWS SES if Resend fails
 */
export async function sendReportEmailSES(params: SendReportEmailParams) {
  const { SESClient, SendEmailCommand } = await import("@aws-sdk/client-ses");

  const client = new SESClient({
    region: process.env.AWS_REGION ?? "ap-south-1",
    credentials: {
      accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
    },
  });

  await client.send(
    new SendEmailCommand({
      Source: "reports@rankflow.ai",
      Destination: { ToAddresses: params.to },
      Message: {
        Subject: { Data: params.subject },
        Body: { Html: { Data: params.html } },
      },
    })
  );
}

// Track email engagement
export async function trackEmailOpen(reportId: string) {
  await prisma.report.update({
    where: { id: reportId },
    data: { viewedAt: new Date() },
  });
}

10. Deployment Architecture#

10.1 Overview#

                    [ Cloudflare DNS / CDN ]
                              |
        +---------------------+---------------------+
        |                                           |
   [ Vercel Edge ]                          [ VPS (Hetzner/AWS) ]
   Next.js App Router                              |
   - Landing pages (ISR)                    [ Docker Compose ]
   - tRPC API routes                        - BullMQ Workers
   - Auth (NextAuth)                        - Playwright (PDF gen)
   - Admin dashboard                        - Puppeteer (Citations)
   - Stripe/Razorpay webhooks               - Ollama (LLM inference)
        |                                    - Redis
   [ PostgreSQL ]                               |
   (Supabase / AWS RDS)                  [ S3-Compatible ]
        |                                    (Cloudflare R2)
   [ Redis ]
   (Upstash / Self-hosted)

10.2 Vercel Configuration#

// vercel.json
{
  "version": 2,
  "framework": "nextjs",
  "buildCommand": "prisma generate && next build",
  "installCommand": "npm install",
  "regions": ["bom1"],
  "functions": {
    "src/app/api/trpc/[trpc]/route.ts": {
      "maxDuration": 30
    },
    "src/app/api/webhooks/**/*.ts": {
      "maxDuration": 10
    }
  },
  "crons": [
    {
      "path": "/api/cron/job-scheduler",
      "schedule": "*/5 * * * *"
    },
    {
      "path": "/api/cron/gbp-sync",
      "schedule": "*/30 * * * *"
    },
    {
      "path": "/api/cron/nap-scan",
      "schedule": "0 2 * * *"
    },
    {
      "path": "/api/cron/report-dispatcher",
      "schedule": "0 9 1 * *"
    }
  ],
  "headers": [
    {
      "source": "/lp/(.*)",
      "headers": [
        {
          "key": "X-Robots-Tag",
          "value": "index, follow"
        },
        {
          "key": "Cache-Control",
          "value": "public, max-age=3600, stale-while-revalidate=86400"
        }
      ]
    },
    {
      "source": "/api/(.*)",
      "headers": [
        {
          "key": "Access-Control-Allow-Origin",
          "value": "*"
        },
        {
          "key": "Access-Control-Allow-Methods",
          "value": "GET, POST, PUT, DELETE, OPTIONS"
        },
        {
          "key": "Access-Control-Allow-Headers",
          "value": "Content-Type, Authorization, X-Practice-Id"
        }
      ]
    }
  ],
  "rewrites": [
    {
      "source": "/api/cron/:path*",
      "destination": "/api/cron/:path*"
    }
  ]
}
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverComponentsExternalPackages: ["@prisma/client", "bullmq"],
  },
  images: {
    remotePatterns: [
      { protocol: "https", hostname: "**.googleusercontent.com" },
      { protocol: "https", hostname: "**.fbcdn.net" },
      { protocol: "https", hostname: "**.instagram.com" },
      { protocol: "https", hostname: "**.cloudflare.com" },
      { protocol: "https", hostname: "**.rankflow.ai" },
    ],
    minimumCacheTTL: 86400,
  },
  headers: async () => [
    {
      source: "/:path*",
      headers: [
        { key: "X-DNS-Prefetch-Control", value: "on" },
        { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
        { key: "X-Content-Type-Options", value: "nosniff" },
        { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
        { key: "X-Frame-Options", value: "SAMEORIGIN" },
        { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(self)" },
      ],
    },
  ],
};

module.exports = nextConfig;

10.3 VPS Setup — Docker Compose#

# docker-compose.yml (VPS)
version: "3.8"

services:
  # ── BullMQ Workers ────────────────────────────
  workers:
    build:
      context: .
      dockerfile: Dockerfile.workers
    container_name: rf-workers
    restart: unless-stopped
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - REDIS_URL=${REDIS_URL}
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
      - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
      - COMPOSIO_API_KEY=${COMPOSIO_API_KEY}
      - ZERNIO_API_KEY=${ZERNIO_API_KEY}
      - R2_ENDPOINT=${R2_ENDPOINT}
      - R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
      - R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
      - R2_BUCKET_NAME=${R2_BUCKET_NAME}
      - R2_PUBLIC_URL=${R2_PUBLIC_URL}
      - TWOCAPTCHA_API_KEY=${TWOCAPTCHA_API_KEY}
      - RESEND_API_KEY=${RESEND_API_KEY}
      - AWS_REGION=${AWS_REGION}
      - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
      - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
      - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
      - RAZORPAY_KEY_SECRET=${RAZORPAY_KEY_SECRET}
      - ENCRYPTION_KEY=${ENCRYPTION_KEY}
      - SENTRY_DSN=${SENTRY_DSN}
      - NODE_ENV=production
    deploy:
      replicas: 2
      resources:
        limits:
          cpus: "2"
          memory: 4G
        reservations:
          cpus: "1"
          memory: 2G
    logging:
      driver: "json-file"
      options:
        max-size: "100m"
        max-file: "5"

  # ── Ollama (Local LLM) ────────────────────────
  ollama:
    image: ollama/ollama:latest
    container_name: rf-ollama
    restart: unless-stopped
    volumes:
      - ollama-models:/root/.ollama
    ports:
      - "11434:11434"
    deploy:
      resources:
        limits:
          cpus: "4"
          memory: 8G
    logging:
      driver: "json-file"
      options:
        max-size: "50m"
        max-file: "3"

  # ── Nginx Reverse Proxy ───────────────────────
  nginx:
    image: nginx:alpine
    container_name: rf-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./ssl:/etc/nginx/ssl:ro
      - nginx-logs:/var/log/nginx
    depends_on:
      - workers
    logging:
      driver: "json-file"
      options:
        max-size: "50m"
        max-file: "3"

  # ── Redis (if self-hosted) ────────────────────
  redis:
    image: redis:7-alpine
    container_name: rf-redis
    restart: unless-stopped
    command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru
    volumes:
      - redis-data:/data
    ports:
      - "6379:6379"
    logging:
      driver: "json-file"
      options:
        max-size: "50m"
        max-file: "3"

  # ── Watchtower (auto-updates) ─────────────────
  watchtower:
    image: containrrr/watchtower
    container_name: rf-watchtower
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - WATCHTOWER_POLL_INTERVAL=300
      - WATCHTOWER_CLEANUP=true
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

volumes:
  ollama-models:
  redis-data:
  nginx-logs:

10.4 Worker Dockerfile#

# Dockerfile.workers
FROM node:20-slim AS base

# Install dependencies for Puppeteer and Playwright
RUN apt-get update && apt-get install -y \
    chromium \
    fonts-liberation \
    libappindicator3-1 \
    libasound2 \
    libatk-bridge2.0-0 \
    libatk1.0-0 \
    libcups2 \
    libdbus-1-3 \
    libdrm2 \
    libgbm1 \
    libgtk-3-0 \
    libnspr4 \
    libnss3 \
    libxcomposite1 \
    libxdamage1 \
    libxfixes3 \
    libxkbcommon0 \
    libxrandr2 \
    xdg-utils \
    libu2f-udev \
    libvulkan1 \
    --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Install dependencies
COPY package.json package-lock.json* ./
COPY prisma ./prisma/
RUN npm ci --only=production

# Generate Prisma client
RUN npx prisma generate

# Copy source
COPY . .

# Build TypeScript
RUN npm run build:workers

ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
ENV NODE_ENV=production

EXPOSE 3000

CMD ["node", "dist/server/queue/main.js"]

10.5 Environment Variable Management#

// src/env.mjs
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";

export const env = createEnv({
  server: {
    // Database
    DATABASE_URL: z.string().url(),

    // Redis
    REDIS_URL: z.string().url(),
    REDIS_TOKEN: z.string().optional(),

    // Auth
    NEXTAUTH_SECRET: z.string().min(32),
    NEXTAUTH_URL: z.string().url(),
    GOOGLE_CLIENT_ID: z.string(),
    GOOGLE_CLIENT_SECRET: z.string(),

    // AI / LLM
    ANTHROPIC_API_KEY: z.string(),
    OPENAI_API_KEY: z.string(),
    OLLAMA_BASE_URL: z.string().url().optional(),

    // Social
    COMPOSIO_API_KEY: z.string(),
    ZERNIO_API_KEY: z.string(),
    FACEBOOK_APP_ID: z.string(),
    FACEBOOK_APP_SECRET: z.string(),

    // Storage
    R2_ENDPOINT: z.string().url(),
    R2_ACCESS_KEY_ID: z.string(),
    R2_SECRET_ACCESS_KEY: z.string(),
    R2_BUCKET_NAME: z.string(),
    R2_PUBLIC_URL: z.string().url(),

    // Email
    RESEND_API_KEY: z.string(),
    AWS_REGION: z.string().optional(),
    AWS_ACCESS_KEY_ID: z.string().optional(),
    AWS_SECRET_ACCESS_KEY: z.string().optional(),

    // Billing
    STRIPE_SECRET_KEY: z.string(),
    STRIPE_WEBHOOK_SECRET: z.string(),
    STRIPE_PUBLISHABLE_KEY: z.string(),
    RAZORPAY_KEY_ID: z.string(),
    RAZORPAY_KEY_SECRET: z.string(),

    // Security
    ENCRYPTION_KEY: z.string().min(32),
    REVALIDATE_SECRET: z.string(),

    // Monitoring
    SENTRY_DSN: z.string().url().optional(),

    // Citation
    TWOCAPTCHA_API_KEY: z.string().optional(),

    // DNS
    CLOUDFLARE_API_TOKEN: z.string(),
    CLOUDFLARE_ZONE_ID: z.string(),

    // App
    NODE_ENV: z.enum(["development", "test", "production"]),
    ROOT_DOMAIN: z.string().default("rankflow.ai"),
  },

  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
    NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string(),
    NEXT_PUBLIC_RAZORPAY_KEY_ID: z.string(),
    NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
    NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
  },

  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    REDIS_URL: process.env.REDIS_URL,
    NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
    NEXTAUTH_URL: process.env.NEXTAUTH_URL,
    GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
    GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
    ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
    OPENAI_API_KEY: process.env.OPENAI_API_KEY,
    RESEND_API_KEY: process.env.RESEND_API_KEY,
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
    RAZORPAY_KEY_SECRET: process.env.RAZORPAY_KEY_SECRET,
    R2_ENDPOINT: process.env.R2_ENDPOINT,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
    NODE_ENV: process.env.NODE_ENV,
  },
});

10.6 CI/CD Pipeline (GitHub Actions)#

# .github/workflows/ci.yml
name: CI / CD

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  NODE_VERSION: "20"

jobs:
  lint-and-typecheck:
    name: Lint & Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "npm"

      - run: npm ci

      - name: Generate Prisma Client
        run: npx prisma generate

      - name: Lint
        run: npm run lint

      - name: Type Check
        run: npm run typecheck

      - name: Test
        run: npm test -- --coverage

  build-and-deploy-web:
    name: Deploy to Vercel
    needs: lint-and-typecheck
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Vercel
        uses: vercel/action-deploy@v1
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}

  build-and-push-workers:
    name: Build & Push Worker Image
    needs: lint-and-typecheck
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and Push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./Dockerfile.workers
          push: true
          tags: |
            ghcr.io/${{ github.repository }}/workers:${{ github.sha }}
            ghcr.io/${{ github.repository }}/workers:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy-workers:
    name: Deploy Workers to VPS
    needs: build-and-push-workers
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /opt/rankflow
            docker compose pull workers
            docker compose up -d --no-deps workers
            docker compose exec -T workers npx prisma migrate deploy
            docker system prune -f

10.7 Monitoring & Alerting#

// src/server/lib/monitoring/sentry.ts
import * as Sentry from "@sentry/nextjs";

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: 0.1, // 10% of transactions
  profilesSampleRate: 0.05, // 5% of profiles
  integrations: [
    Sentry.httpIntegration(),
    Sentry.prismaIntegration(),
  ],
  beforeSend(event) {
    // Filter out PII
    if (event.request?.headers) {
      delete event.request.headers.cookie;
      delete event.request.headers.authorization;
    }
    return event;
  },
});

// src/server/lib/monitoring/health.ts
import { prisma } from "~/server/db";
import { redis } from "~/server/redis";

export interface HealthStatus {
  status: "healthy" | "degraded" | "unhealthy";
  checks: Record<string, { status: "pass" | "fail" | "warn"; responseTime: number; message?: string }>;
  timestamp: string;
  version: string;
}

export async function getHealthStatus(): Promise<HealthStatus> {
  const checks: HealthStatus["checks"] = {};
  let overallStatus: HealthStatus["status"] = "healthy";

  // Database check
  const dbStart = Date.now();
  try {
    await prisma.$queryRaw`SELECT 1`;
    checks.database = { status: "pass", responseTime: Date.now() - dbStart };
  } catch (error) {
    checks.database = { status: "fail", responseTime: Date.now() - dbStart, message: (error as Error).message };
    overallStatus = "unhealthy";
  }

  // Redis check
  const redisStart = Date.now();
  try {
    await redis.ping();
    checks.redis = { status: "pass", responseTime: Date.now() - redisStart };
  } catch (error) {
    checks.redis = { status: "fail", responseTime: Date.now() - redisStart, message: (error as Error).message };
    overallStatus = "unhealthy";
  }

  // LLM providers check
  const llmStart = Date.now();
  try {
    const { multiLLM } = await import("~/server/lib/ai/router");
    // Lightweight check
    checks.llm_providers = { status: "pass", responseTime: Date.now() - llmStart };
  } catch {
    checks.llm_providers = { status: "warn", responseTime: Date.now() - llmStart, message: "Some providers unavailable" };
    if (overallStatus === "healthy") overallStatus = "degraded";
  }

  // Queue depth check
  const queueStart = Date.now();
  try {
    const queueDepths = await Promise.all([
      redis.llen("bull:default:wait"),
      redis.llen("bull:gbp-operations:wait"),
      redis.llen("bull:citations:wait"),
    ]);
    const totalDepth = queueDepths.reduce((s, d) => s + d, 0);
    if (totalDepth > 1000) {
      checks.queues = { status: "warn", responseTime: Date.now() - queueStart, message: `Queue depth: ${totalDepth}` };
      if (overallStatus === "healthy") overallStatus = "degraded";
    } else {
      checks.queues = { status: "pass", responseTime: Date.now() - queueStart };
    }
  } catch (error) {
    checks.queues = { status: "warn", responseTime: Date.now() - queueStart, message: (error as Error).message };
  }

  return {
    status: overallStatus,
    checks,
    timestamp: new Date().toISOString(),
    version: process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? "dev",
  };
}

10.8 Uptime Monitoring Configuration#

# Uptime monitoring via external service (UptimeRobot / BetterUptime)
# Configure via their dashboards:

Monitors:
  - name: "RankFlow Web"
    url: "https://rankflow.ai"
    interval: 60
    timeout: 10
    expected_status: 200

  - name: "RankFlow API Health"
    url: "https://rankflow.ai/api/health"
    interval: 60
    timeout: 10
    expected_status: 200
    expected_body: '{"status":"healthy"}'

  - name: "RankFlow tRPC"
    url: "https://rankflow.ai/api/trpc/practice.getMyPractices"
    interval: 300
    timeout: 15
    expected_status: 401  # Should return 401 without auth

  - name: "VPS Workers"
    url: "https://workers.rankflow.ai/health"
    interval: 60
    timeout: 10
    expected_status: 200

  - name: "VPS Redis"
    url: "tcp://workers.rankflow.ai:6379"
    interval: 120
    type: port

Alerts:
  channels:
    - type: email
      to: "ops@rankflow.ai"
    - type: slack
      webhook: "${SLACK_WEBHOOK_URL}"
    - type: pagerduty
      key: "${PAGERDUTY_KEY}"

11. Security Considerations#

11.1 API Key & Token Encryption#

// src/server/lib/crypto.ts
import crypto from "crypto";

const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
const KEY_LENGTH = 32;

/**
 * Derive encryption key from master key and context.
 * Each type of secret gets a unique derived key.
 */
function deriveKey(masterKey: string, context: string): Buffer {
  return crypto.pbkdf2Sync(masterKey, context, 100000, KEY_LENGTH, "sha256");
}

/**
 * Encrypt a sensitive value (OAuth tokens, API keys, passwords).
 */
export function encrypt(value: string, context: string): string {
  const key = deriveKey(process.env.ENCRYPTION_KEY!, context);
  const iv = crypto.randomBytes(IV_LENGTH);
  const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });

  const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
  const authTag = cipher.getAuthTag();

  // Format: iv:authTag:encrypted (all base64)
  return `${iv.toString("base64")}:${authTag.toString("base64")}:${encrypted.toString("base64")}`;
}

/**
 * Decrypt a value encrypted with encrypt().
 */
export function decrypt(encryptedValue: string, context: string): string {
  const key = deriveKey(process.env.ENCRYPTION_KEY!, context);
  const [ivB64, authTagB64, encryptedB64] = encryptedValue.split(":");

  if (!ivB64 || !authTagB64 || !encryptedB64) {
    throw new Error("Invalid encrypted value format");
  }

  const iv = Buffer.from(ivB64, "base64");
  const authTag = Buffer.from(authTagB64, "base64");
  const encrypted = Buffer.from(encryptedB64, "base64");

  const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
  decipher.setAuthTag(authTag);

  return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
}

/**
 * Hash a password using bcrypt-compatible scrypt.
 */
export async function hashPassword(password: string): Promise<string> {
  const salt = crypto.randomBytes(32).toString("base64");
  const hash = crypto.scryptSync(password, salt, 64).toString("base64");
  return `${salt}:${hash}`;
}

/**
 * Verify a password against its hash.
 */
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
  const [salt, expectedHash] = hash.split(":");
  if (!salt || !expectedHash) return false;
  const computed = crypto.scryptSync(password, salt, 64).toString("base64");
  return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expectedHash));
}

// ─── Prisma Middleware for Auto-Encryption ───────

/**
 * Prisma middleware that automatically encrypts/decrypts sensitive fields.
 */
export function createEncryptionMiddleware() {
  const encryptedFields: Record<string, string[]> = {
    gbpAccount: ["accessToken", "refreshToken"],
    socialAccount: ["accessToken", "refreshToken"],
    citation: ["passwordEncrypted"],
    ownedBlogSite: ["cmsPasswordEncrypted", "apiKeyEncrypted"],
  };

  return async function encryptionMiddleware(
    params: { model?: string; action: string; args: any },
    next: (params: any) => Promise<any>
  ): Promise<any> {
    const model = params.model;
    const fields = model ? encryptedFields[model] : undefined;

    if (!fields) return next(params);

    // Encrypt on create/update
    if (params.action === "create" || params.action === "update" || params.action === "upsert") {
      const data = params.args.data ?? params.args;
      for (const field of fields) {
        if (data[field] && typeof data[field] === "string" && !data[field].includes(":")) {
          data[field] = encrypt(data[field], `${model}:${field}`);
        }
      }
    }

    // Run query
    const result = await next(params);

    // Decrypt on read
    if (result && (params.action === "findUnique" || params.action === "findFirst" || params.action === "findMany")) {
      const results = Array.isArray(result) ? result : [result];
      for (const item of results) {
        if (!item) continue;
        for (const field of fields) {
          if (item[field] && typeof item[field] === "string" && item[field].includes(":")) {
            try {
              item[field] = decrypt(item[field], `${model}:${field}`);
            } catch {
              // If decryption fails, value might already be plaintext
            }
          }
        }
      }
    }

    return result;
  };
}

11.2 Rate Limiting Strategy#

// src/server/lib/ratelimit/index.ts
import { Ratelimit } from "@upstash/ratelimit";
import { redis } from "~/server/redis";

// Different rate limits for different endpoints
export const ratelimits = {
  // Strict: Auth endpoints
  auth: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(5, "1 m"), // 5 per minute
    analytics: true,
    prefix: "ratelimit:auth",
  }),

  // Medium: API mutations
  api: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 per minute
    analytics: true,
    prefix: "ratelimit:api",
  }),

  // Relaxed: Read-only API
  apiRead: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(300, "1 m"), // 300 per minute
    analytics: true,
    prefix: "ratelimit:api:read",
  }),

  // Very strict: AI generation
  ai: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(20, "1 m"), // 20 per minute per user
    analytics: true,
    prefix: "ratelimit:ai",
  }),

  // Strict: GBP API calls (proxied)
  gbp: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(30, "1 m"), // 30 per minute per practice
    analytics: true,
    prefix: "ratelimit:gbp",
  }),

  // Webhook receivers — generous
  webhook: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(500, "1 m"),
    analytics: true,
    prefix: "ratelimit:webhook",
  }),
};

/**
 * Tier-based rate limiting for subscription levels
 */
export function getTierRateLimit(tier: string, operation: string): { requests: number; window: string } {
  const limits: Record<string, Record<string, { requests: number; window: string }>> = {
    FREE: {
      ai_generate: { requests: 10, window: "1 d" },
      gbp_posts: { requests: 5, window: "1 d" },
      social_posts: { requests: 5, window: "1 d" },
      citations: { requests: 3, window: "1 d" },
    },
    STARTER: {
      ai_generate: { requests: 50, window: "1 d" },
      gbp_posts: { requests: 30, window: "1 d" },
      social_posts: { requests: 30, window: "1 d" },
      citations: { requests: 10, window: "1 d" },
    },
    PROFESSIONAL: {
      ai_generate: { requests: 200, window: "1 d" },
      gbp_posts: { requests: 100, window: "1 d" },
      social_posts: { requests: 100, window: "1 d" },
      citations: { requests: 50, window: "1 d" },
    },
    ENTERPRISE: {
      ai_generate: { requests: 1000, window: "1 d" },
      gbp_posts: { requests: 500, window: "1 d" },
      social_posts: { requests: 500, window: "1 d" },
      citations: { requests: 200, window: "1 d" },
    },
  };

  return limits[tier]?.[operation] ?? { requests: 10, window: "1 d" };
}

11.3 DPDPA Compliance (Digital Personal Data Protection Act, India)#

// src/server/lib/compliance/dpdpa.ts
/**
 * DPDPA 2023 Compliance Module
 *
 * Key requirements:
 * 1. Consent Management — explicit, informed, revocable consent
 * 2. Data Minimization — collect only what's necessary
 * 3. Purpose Limitation — use data only for stated purposes
 * 4. Data Retention — delete after purpose fulfilled
 * 5. Data Principal Rights — access, correction, erasure
 * 6. Data Fiduciary obligations — security, breach notification
 * 7. Cross-border transfers — adequacy decisions
 * 8. Children's data — parental consent for <18
 */

import { prisma } from "~/server/db";

// ─── Consent Management ────────────────────────────

export interface ConsentRecord {
  purpose: string;           // e.g., "seo_optimization", "report_delivery"
  dataCategories: string[];  // e.g., ["business_info", "patient_reviews"]
  grantedAt: Date;
  expiresAt?: Date;
  ipAddress?: string;
  withdrawnAt?: Date;
}

export async function recordConsent(params: {
  practiceId: string;
  userId: string;
  purpose: string;
  dataCategories: string[];
  consentGiven: boolean;
  ipAddress?: string;
}): Promise<void> {
  await prisma.practice.update({
    where: { id: params.practiceId },
    data: {
      settings: {
        // Store consent in practice settings JSON
        consent: {
          ...(await getExistingConsent(params.practiceId)),
          [params.purpose]: {
            given: params.consentGiven,
            categories: params.dataCategories,
            grantedAt: new Date().toISOString(),
            ipAddress: params.ipAddress,
          },
        },
      },
    },
  });
}

export async function withdrawConsent(practiceId: string, purpose: string): Promise<void> {
  const existing = await getExistingConsent(practiceId);
  if (existing[purpose]) {
    existing[purpose].withdrawnAt = new Date().toISOString();
    await prisma.practice.update({
      where: { id: practiceId },
      data: { settings: { consent: existing } },
    });
  }
}

export async function checkConsent(practiceId: string, purpose: string): Promise<boolean> {
  const consent = await getExistingConsent(practiceId);
  const record = consent[purpose];
  if (!record) return false;
  if (record.withdrawnAt) return false;
  return record.given === true;
}

async function getExistingConsent(practiceId: string): Promise<Record<string, any>> {
  const practice = await prisma.practice.findUnique({
    where: { id: practiceId },
    select: { settings: true },
  });
  return (practice?.settings as any)?.consent ?? {};
}

// ─── Data Principal Rights ─────────────────────────

export async function exportPracticeData(practiceId: string): Promise<{
  personalData: unknown;
  generatedAt: string;
  format: string;
}> {
  // Collect all data related to a practice
  const [
    practice,
    locations,
    members,
    gbpAccounts,
    socialAccounts,
    citations,
    contentPieces,
    reviews,
    jobs,
    reports,
    auditLogs,
  ] = await Promise.all([
    prisma.practice.findUnique({ where: { id: practiceId } }),
    prisma.location.findMany({ where: { practiceId } }),
    prisma.practiceMember.findMany({ where: { practiceId }, include: { user: true } }),
    prisma.gbpAccount.findMany({ where: { practiceId } }),
    prisma.socialAccount.findMany({ where: { practiceId } }),
    prisma.citation.findMany({ where: { practiceId } }),
    prisma.contentPiece.findMany({ where: { practiceId } }),
    prisma.review.findMany({ where: { location: { practiceId } } }),
    prisma.job.findMany({ where: { practiceId } }),
    prisma.report.findMany({ where: { practiceId } }),
    prisma.auditLog.findMany({ where: { practiceId } }),
  ]);

  return {
    personalData: {
      practice,
      locations,
      members: members.map((m) => ({
        role: m.role,
        joinedAt: m.acceptedAt,
        user: { name: m.user.name, email: m.user.email },
      })),
      gbpAccounts: gbpAccounts.map((a) => ({
        accountEmail: a.accountEmail,
        connectedAt: a.createdAt,
      })),
      socialAccounts: socialAccounts.map((a) => ({
        platform: a.platform,
        accountName: a.accountName,
      })),
      citations,
      contentPieces,
      reviews: reviews.map((r) => ({
        rating: r.rating,
        comment: r.comment,
        reviewDate: r.reviewDate,
      })),
      jobs: jobs.map((j) => ({ type: j.type, status: j.status, createdAt: j.createdAt })),
      reports,
      auditLogs,
    },
    generatedAt: new Date().toISOString(),
    format: "JSON",
  };
}

export async function deletePracticeData(practiceId: string): Promise<void> {
  // Soft delete — anonymize rather than hard delete for audit
  await prisma.$transaction([
    prisma.practice.update({
      where: { id: practiceId },
      data: {
        name: `[DELETED] ${Date.now()}`,
        status: "deleted",
        deletedAt: new Date(),
        customDomain: null,
        wildcardSubdomain: `deleted-${Date.now()}`,
        stripeCustomerId: null,
        razorpayCustomerId: null,
      },
    }),
    // Anonymize user data in jobs
    prisma.job.updateMany({
      where: { practiceId },
      data: { createdBy: null },
    }),
    // Delete sensitive tokens
    prisma.gbpAccount.updateMany({
      where: { practiceId },
      data: {
        accessToken: "[DELETED]",
        refreshToken: "[DELETED]",
        isActive: false,
      },
    }),
    prisma.socialAccount.updateMany({
      where: { practiceId },
      data: {
        accessToken: "[DELETED]",
        refreshToken: "[DELETED]",
        isActive: false,
      },
    }),
  ]);
}

// ─── Data Retention ────────────────────────────────

/**
 * Automated data retention policies
 */
export async function enforceDataRetention(): Promise<{
  deleted: number;
  archived: number;
}> {
  const now = new Date();
  let deleted = 0;
  let archived = 0;

  // Delete completed jobs older than 90 days
  const oldJobs = await prisma.job.deleteMany({
    where: {
      status: { in: ["COMPLETED", "FAILED", "CANCELLED"] },
      completedAt: { lt: new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000) },
    },
  });
  deleted += oldJobs.count;

  // Anonymize audit logs older than 1 year
  const oldLogs = await prisma.auditLog.updateMany({
    where: {
      createdAt: { lt: new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000) },
      ipAddress: { not: null },
    },
    data: { ipAddress: null, userAgent: null },
  });
  archived += oldLogs.count;

  // Delete AI cost logs older than 2 years
  const oldAiLogs = await prisma.aICostLog.deleteMany({
    where: {
      createdAt: { lt: new Date(now.getTime() - 730 * 24 * 60 * 60 * 1000) },
    },
  });
  deleted += oldAiLogs.count;

  // Soft-delete expired trials older than 30 days
  const expiredTrials = await prisma.practice.updateMany({
    where: {
      subscriptionStatus: "EXPIRED",
      subscriptionExpiresAt: { lt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) },
      deletedAt: null,
    },
    data: { deletedAt: now },
  });
  archived += expiredTrials.count;

  return { deleted, archived };
}

// ─── Breach Notification ───────────────────────────

export async function logSecurityEvent(event: {
  type: string;
  severity: "low" | "medium" | "high" | "critical";
  description: string;
  affectedPracticeId?: string;
  metadata?: Record<string, unknown>;
}): Promise<void> {
  // Log to audit system
  await prisma.auditLog.create({
    data: {
      practiceId: event.affectedPracticeId,
      action: "SECURITY",
      entityType: "security_event",
      metadata: {
        severity: event.severity,
        description: event.description,
        ...event.metadata,
      },
    },
  });

  // If critical, send immediate alert
  if (event.severity === "critical") {
    // Send email + SMS alert to admins
    console.error(`[SECURITY] CRITICAL: ${event.description}`);
  }
}

11.4 Input Sanitization & XSS Prevention#

// src/server/lib/sanitize.ts
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";

const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window as any);

/**
 * Sanitize HTML content (e.g., AI-generated landing page content).
 * Allow only safe tags and attributes.
 */
export function sanitizeHtml(dirty: string): string {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: [
      "p", "br", "strong", "em", "u", "h1", "h2", "h3", "h4", "h5", "h6",
      "ul", "ol", "li", "a", "img", "div", "span", "blockquote", "hr",
      "table", "thead", "tbody", "tr", "td", "th",
    ],
    ALLOWED_ATTR: [
      "href", "title", "alt", "src", "width", "height", "class", "id",
      "target", "rel", "style",
    ],
    ALLOW_DATA_ATTR: false,
    FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover"],
  });
}

/**
 * Sanitize plain text — escape HTML entities.
 */
export function escapeHtml(text: string): string {
  const div = window.document.createElement("div");
  div.textContent = text;
  return div.innerHTML;
}

/**
 * Validate and sanitize URL.
 */
export function sanitizeUrl(url: string): string | null {
  try {
    const parsed = new URL(url);
    // Only allow http and https
    if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
      return null;
    }
    // Block known malicious patterns
    const blockedPatterns = [/javascript:/i, /data:/i, /vbscript:/i, /<script/i];
    if (blockedPatterns.some((p) => p.test(url))) {
      return null;
    }
    return parsed.toString();
  } catch {
    return null;
  }
}

/**
 * Rate-limited content validation middleware.
 * Prevents prompt injection in AI inputs.
 */
export function sanitizeAIInput(input: string): string {
  // Remove common prompt injection patterns
  const injectionPatterns = [
    /ignore previous instructions/gi,
    /disregard all prior/gi,
    /system prompt/gi,
    /you are now/gi,
    /\[\[SYS\]\]/gi,
    /<\|system\|>/gi,
    /{{system}}/gi,
    /### INSTRUCTIONS/gi,
  ];

  let sanitized = input;
  for (const pattern of injectionPatterns) {
    sanitized = sanitized.replace(pattern, "");
  }

  // Limit length
  return sanitized.slice(0, 10000).trim();
}

/**
 * Zod schemas with sanitization for common inputs.
 */
export const sanitizedString = (maxLength: number) =>
  z.string()
    .max(maxLength)
    .transform((val) => escapeHtml(val.trim()));

import { z } from "zod";

export const safeTextSchema = z.string()
  .min(1)
  .max(50000)
  .transform((val) => sanitizeHtml(val));

export const safeUrlSchema = z.string()
  .url()
  .transform((val) => sanitizeUrl(val))
  .refine((val) => val !== null, { message: "Invalid or unsafe URL" });

11.5 Security Headers & Configuration#

// src/middleware.ts (security additions)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // Security headers applied to all responses
  const headers = response.headers;

  // Prevent clickjacking
  headers.set("X-Frame-Options", "SAMEORIGIN");

  // Prevent MIME type sniffing
  headers.set("X-Content-Type-Options", "nosniff");

  // XSS protection (legacy but still useful)
  headers.set("X-XSS-Protection", "1; mode=block");

  // Referrer policy
  headers.set("Referrer-Policy", "strict-origin-when-cross-origin");

  // Permissions policy
  headers.set(
    "Permissions-Policy",
    "camera=(), microphone=(), geolocation=(self), interest-cohort=()"
  );

  // HSTS (handled by Cloudflare/NGINX in production)
  if (process.env.NODE_ENV === "production") {
    headers.set(
      "Strict-Transport-Security",
      "max-age=63072000; includeSubDomains; preload"
    );
  }

  // CSP for landing pages (more permissive)
  if (request.nextUrl.pathname.startsWith("/lp/")) {
    headers.set(
      "Content-Security-Policy",
      [
        "default-src 'self'",
        "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
        "style-src 'self' 'unsafe-inline' fonts.googleapis.com",
        "img-src 'self' data: https:",
        "font-src 'self' fonts.gstatic.com",
        "connect-src 'self'",
        "frame-ancestors 'none'",
      ].join("; ")
    );
  } else {
    // CSP for app (more restrictive)
    headers.set(
      "Content-Security-Policy",
      [
        "default-src 'self'",
        "script-src 'self' 'unsafe-eval'", // Needed for Next.js
        "style-src 'self' 'unsafe-inline'",
        "img-src 'self' data: https:",
        "connect-src 'self'",
        "frame-ancestors 'none'",
        "base-uri 'self'",
        "form-action 'self'",
      ].join("; ")
    );
  }

  return response;
}

11.6 Authentication Security#

// src/server/auth/config.ts
import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "~/server/db";
import { verifyPassword } from "~/server/lib/crypto";

export const authConfig: NextAuthOptions = {
  adapter: PrismaAdapter(prisma) as any,
  providers: [
    // Google OAuth (for GBP connection)
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      authorization: {
        params: {
          scope: [
            "openid",
            "email",
            "profile",
            "https://www.googleapis.com/auth/business.manage",
          ].join(" "),
          access_type: "offline",
          prompt: "consent",
        },
      },
    }),

    // Email/Password (for regular login)
    CredentialsProvider({
      name: "credentials",
      credentials: {
        email: { label: "Email", type: "email" },
      password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) return null;

        const user = await prisma.user.findUnique({
          where: { email: credentials.email.toLowerCase().trim() },
        });

        if (!user?.password) return null;

        const valid = await verifyPassword(credentials.password, user.password);
        if (!valid) return null;

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          image: user.image,
          role: user.role,
        };
      },
    }),
  ],

  session: {
    strategy: "jwt",
    maxAge: 30 * 24 * 60 * 60, // 30 days
    updateAge: 24 * 60 * 60, // Update JWT every 24h
  },

  jwt: {
    maxAge: 30 * 24 * 60 * 60,
  },

  callbacks: {
    async jwt({ token, user, account }) {
      if (user) {
        token.role = (user as any).role;
        token.id = user.id;
      }
      // Persist OAuth tokens for GBP integration
      if (account?.provider === "google" && account.refresh_token) {
        token.googleRefreshToken = account.refresh_token;
      }
      return token;
    },

    async session({ session, token }) {
      if (token) {
        (session.user as any).id = token.id;
        (session.user as any).role = token.role;
      }
      return session;
    },

    async signIn({ user, account, profile }) {
      // Block signups from disposable email domains
      if (user.email) {
        const isDisposable = await checkDisposableEmail(user.email);
        if (isDisposable) return false;
      }
      return true;
    },
  },

  pages: {
    signIn: "/auth/signin",
    error: "/auth/error",
    newUser: "/onboarding",
  },

  events: {
    async signIn({ user, account }) {
      // Log sign-in for security audit
      await prisma.auditLog.create({
        data: {
          userId: user.id,
          action: "LOGIN",
          entityType: "user",
          entityId: user.id,
          metadata: { provider: account?.provider },
        },
      });
    },

    async signOut({ token }) {
      if (token.sub) {
        await prisma.auditLog.create({
          data: {
            userId: token.sub,
            action: "LOGOUT",
            entityType: "user",
            entityId: token.sub,
          },
        });
      }
    },
  },
};

async function checkDisposableEmail(email: string): Promise<boolean> {
  const domain = email.split("@")[1]?.toLowerCase();
  if (!domain) return true;

  // Check against known disposable domains (cached in Redis)
  const disposableDomains = new Set([
    "tempmail.com", "throwaway.com", "guerrillamail.com",
    "mailinator.com", "yopmail.com", "sharklasers.com",
    // Add more as needed
  ]);

  return disposableDomains.has(domain);
}

Appendix A: Project Structure#

rankflow-ai/
  ├── src/
  │   ├── app/                          # Next.js App Router
  │   │   ├── (dashboard)/              # Authenticated routes
  │   │   │   ├── layout.tsx
  │   │   │   ├── page.tsx              # Dashboard home
  │   │   │   ├── practice/
  │   │   │   ├── locations/
  │   │   │   ├── gbp/
  │   │   │   ├── social/
  │   │   │   ├── citations/
  │   │   │   ├── content/
  │   │   │   ├── landing-pages/
  │   │   │   ├── reports/
  │   │   │   ├── settings/
  │   │   │   └── billing/
  │   │   ├── lp/                       # Landing page routes
  │   │   │   └── [practiceSlug]/
  │   │   │       └── page.tsx
  │   │   ├── api/                        # API routes
  │   │   │   ├── trpc/
  │   │   │   │   └── [trpc]/
  │   │   │   ├── webhooks/
  │   │   │   │   ├── stripe/
  │   │   │   │   ├── razorpay/
  │   │   │   │   └── zernio/
  │   │   │   ├── cron/
  │   │   │   │   ├── job-scheduler/
  │   │   │   │   ├── gbp-sync/
  │   │   │   │   └── report-dispatcher/
  │   │   │   └── health/
  │   │   ├── auth/                       # Auth pages
  │   │   ├── landing/                    # Marketing site
  │   │   └── layout.tsx
  │   ├── components/                   # React components
  │   │   ├── ui/                         # shadcn/ui components
  │   │   ├── landing-page/
  │   │   ├── dashboard/
  │   │   ├── forms/
  │   │   └── charts/
  │   ├── hooks/                        # React hooks
  │   ├── lib/                          # Utilities
  │   │   ├── utils.ts
  │   │   └── trpc/
  │   │       ├── api.ts
  │   │       └── react.tsx
  │   ├── server/                       # Server-side code
  │   │   ├── api/
  │   │   │   ├── trpc.ts                 # tRPC setup
  │   │   │   ├── root.ts                 # Root router
  │   │   │   └── routers/              # tRPC routers
  │   │   │       ├── auth.ts
  │   │   │       ├── user.ts
  │   │   │       ├── practice.ts
  │   │   │       ├── location.ts
  │   │   │       ├── gbp.ts
  │   │   │       ├── social.ts
  │   │   │       ├── content.ts
  │   │   │       ├── citation.ts
  │   │   │       ├── landing-page.ts
  │   │   │       ├── rank-tracking.ts
  │   │   │       ├── backlink.ts
  │   │   │       ├── competitor.ts
  │   │   │       ├── report.ts
  │   │   │       ├── job.ts
  │   │   │       ├── billing.ts
  │   │   │       ├── ai.ts
  │   │   │       ├── webhook.ts
  │   │   │       └── admin.ts
  │   │   ├── auth.ts                     # NextAuth config
  │   │   ├── db.ts                       # Prisma client
  │   │   ├── redis.ts                    # Redis client
  │   │   ├── queue/
  │   │   │   ├── config.ts               # Queue configuration
  │   │   │   ├── types.ts                # Job type definitions
  │   │   │   ├── worker-factory.ts       # Worker factory
  │   │   │   ├── cron.ts                 # Scheduled jobs
  │   │   │   ├── dlq.ts                  # Dead letter queue
  │   │   │   ├── bootstrap.ts            # Worker bootstrap
  │   │   │   ├── main.ts                 # Entry point
  │   │   │   └── workers/                # Worker implementations
  │   │   │       ├── gbp-worker.ts
  │   │   │       ├── social-worker.ts
  │   │   │       ├── citation-worker.ts
  │   │   │       ├── ai-content-worker.ts
  │   │   │       ├── report-worker.ts
  │   │   │       └── landing-page-worker.ts
  │   │   ├── lib/
  │   │   │   ├── crypto.ts               # Encryption utilities
  │   │   │   ├── ai/
  │   │   │   │   ├── router.ts           # Multi-LLM router
  │   │   │   │   ├── types.ts
  │   │   │   │   ├── providers/
  │   │   │   │   │   ├── anthropic.ts
  │   │   │   │   │   ├── openai.ts
  │   │   │   │   │   └── ollama.ts
  │   │   │   │   └── health-check.ts
  │   │   │   ├── gbp/
  │   │   │   │   ├── client.ts
  │   │   │   │   ├── auth.ts
  │   │   │   │   ├── ratelimit.ts
  │   │   │   │   └── monitor.ts
  │   │   │   ├── social/
  │   │   │   │   ├── types.ts
  │   │   │   │   ├── registry.ts
  │   │   │   │   ├── zernio.ts
  │   │   │   │   ├── error-handler.ts
  │   │   │   │   └── platforms/
  │   │   │   │       ├── facebook.ts
  │   │   │   │       ├── instagram.ts
  │   │   │   │       ├── linkedin.ts
  │   │   │   │       └── twitter.ts
  │   │   │   ├── citations/
  │   │   │   │   ├── types.ts
  │   │   │   │   ├── engine.ts
  │   │   │   │   ├── nap-monitor.ts
  │   │   │   │   ├── registry.ts
  │   │   │   │   ├── tracker.ts
  │   │   │   │   └── plugins/
  │   │   │   │       ├── justdial.ts
  │   │   │   │       ├── practo.ts
  │   │   │   │       ├── lybrate.ts
  │   │   │   │       ├── sulekha.ts
  │   │   │   │       └── google-maps.ts
  │   │   │   ├── reports/
  │   │   │   │   ├── pipeline.ts
  │   │   │   │   ├── charts.ts
  │   │   │   │   ├── email-template.ts
  │   │   │   │   ├── pdf-generator.ts
  │   │   │   │   └── email.ts
  │   │   │   ├── dns/
  │   │   │   │   └── cloudflare.ts
  │   │   │   ├── monitoring/
  │   │   │   │   ├── sentry.ts
  │   │   │   │   └── health.ts
  │   │   │   ├── compliance/
  │   │   │   │   └── dpdpa.ts
  │   │   │   ├── ratelimit/
  │   │   │   │   └── index.ts
  │   │   │   └── sanitize.ts
  │   │   └── middleware/
  │   │       └── auth.ts
  │   └── types/                        # Global types
  ├── prisma/
  │   ├── schema.prisma                 # Full Prisma schema
  │   └── migrations/
  ├── public/
  ├── docker-compose.yml
  ├── Dockerfile.workers
  ├── Dockerfile.ollama
  ├── nginx.conf
  ├── vercel.json
  ├── next.config.js
  ├── tailwind.config.ts
  ├── tsconfig.json
  ├── package.json
  └── .env.example

Appendix B: Technology Stack Summary#

Layer Technology Purpose
Framework Next.js 14+ (App Router) Full-stack React framework
Styling Tailwind CSS + shadcn/ui Utility-first CSS
API tRPC + Zod Type-safe API layer
Database PostgreSQL + Prisma Primary data store
Cache/Queue Redis + BullMQ Caching, sessions, job queue
Auth NextAuth.js v5 Authentication
AI Claude Sonnet + GPT-4 + Ollama Multi-LLM content generation
Social Composio + Zernio Social media integration
Email Resend / AWS SES Transactional & report emails
Storage Cloudflare R2 File storage (PDFs, images)
DNS Cloudflare API Subdomain & custom domain management
Billing Stripe + Razorpay Subscription payments
Hosting Vercel (web) + VPS (workers) Deployment
Monitoring Sentry + UptimeRobot Error tracking & uptime
PDF Playwright PDF generation from HTML
Citations Puppeteer + 2captcha Directory submission automation

Appendix C: Cost Estimates#

Infrastructure (Monthly)#

Service Tier Cost (USD)
Vercel Pro Team plan $20
PostgreSQL (Supabase) 8GB / 2 CPU $25
Redis (Upstash) 10GB $30
VPS (Hetzner) CPX31 (4 vCPU, 16GB) $30
Cloudflare R2 100GB storage + egress $5
Cloudflare Pro DNS + CDN $20
Resend 50k emails/mo $0 (free tier)
Sentry Developer plan $0 (free tier)
Total ~$130/mo

API Costs (Per 100 Practices)#

Service Usage Cost (USD)
Claude Sonnet API 50k requests/mo $200
OpenAI GPT-4 20k requests/mo $150
2captcha 500 solves/mo $50
Total ~$400/mo

End of Technical Specification