Research
RankFlow AI — Technical Specification
The schema follows these core principles:
docs/research_technical.mdOn this page
- Local SEO Automation Platform for Indian Medical Professionals
- 1. Detailed Database Schema
- 1.1 Schema Design Philosophy
- 1.2 Enum Definitions
- 1.3 Core Models
- 1.4 Index Strategy
- 2. tRPC Router Structure
- 2.1 Router Tree
- 2.2 Router Registration
- 2.3 Base tRPC Setup
- 2.4 Example Router Implementations
- 2.5 Client-Side Hooks
- 3. Job Queue Design
- 3.1 Architecture Overview
- 3.2 BullMQ Configuration
- 3.3 Job TypeScript Interfaces
- 3.4 Worker Implementation Pattern
- 3.5 Job Worker Implementations
- 3.6 Cron Schedule Configuration
- 3.7 Dead Letter Queue Handler
- 3.8 Worker Bootstrapping (VPS Entry Point)
- 4. Multi-LLM Router Specification
- 4.1 Design Goals
- 4.2 Provider Interface
- 4.3 Provider Implementations
- 4.4 Router Core
- 4.5 Provider Health Monitoring
- 5. Landing Page Architecture
- 5.1 Multi-Tenant Landing Page System
- 5.2 Next.js Middleware for Subdomain Routing
- 5.3 Landing Page Route Handler
- 5.4 Template System Design
- 5.5 Section Components
- 5.6 Schema.org Injection
- 5.7 Custom Domain + Wildcard DNS
- 5.8 ISR/SSG Strategy
- 5.9 Template Configuration
- 6. GBP Integration Spec
- 6.1 OAuth Flow Architecture
- 6.2 GBP API Client
- 6.3 Rate Limiting & Quota Management
- 6.4 Suspension Monitoring
- 7. Social Media Integration Spec
- 7.1 Platform Abstraction Layer
- 7.2 Facebook Platform Implementation (via Composio)
- 7.3 Instagram Platform Implementation
- 7.4 Zernio Scheduling Integration
- 7.5 Error Handling & Retry Logic
- 7.6 Platform Registry
- 8. Citation Engine Spec
- 8.1 Plugin Architecture
- 8.2 Puppeteer-based Submission Engine
- 8.3 NAP Monitoring Crawler
- 8.4 India-Specific Medical Directory Plugins
- 8.5 Plugin Registry
- 8.6 Success/Failure Tracking
- 9. Email Report System
- 9.1 Monthly Report Generation Pipeline
- 9.2 SVG Chart Generation
- 9.3 HTML Email Template System
- 9.4 PDF Generation with Playwright
- 9.5 Email Delivery
- 10. Deployment Architecture
- 10.1 Overview
- 10.2 Vercel Configuration
- 10.3 VPS Setup — Docker Compose
- 10.4 Worker Dockerfile
- 10.5 Environment Variable Management
- 10.6 CI/CD Pipeline (GitHub Actions)
- 10.7 Monitoring & Alerting
- 10.8 Uptime Monitoring Configuration
- 11. Security Considerations
- 11.1 API Key & Token Encryption
- 11.2 Rate Limiting Strategy
- 11.3 DPDPA Compliance (Digital Personal Data Protection Act, India)
- 11.4 Input Sanitization & XSS Prevention
- 11.5 Security Headers & Configuration
- 11.6 Authentication Security
- Appendix A: Project Structure
- Appendix B: Technology Stack Summary
- Appendix C: Cost Estimates
- Infrastructure (Monthly)
- API Costs (Per 100 Practices)
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/updatedAtand ties toauditLog - JSON flexibility:
Jsonfields for schema markup, platform-specific metadata, and extensible config - Soft deletes:
deletedAtpattern for all core business entities - Row-level tenancy: Isolation via
practiceIdforeign 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")
}
1.3.9 Rank Tracking & Backlinks#
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#
- Cost optimization: Route simple tasks to cheaper models, complex tasks to premium models
- Reliability: Automatic fallback chain when providers fail
- Rate limiting: Per-provider token/request rate limiting
- Cost tracking: Detailed cost attribution per practice
- 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:
- Subdomain:
{slug}.rankflow.ai(default) - Custom domain:
drsharma.example.com(CNAME) - 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 |
| 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 |
| 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