Browse documentation

Research

RankFlow AI — Corrected Technical Specification

The scope brief explicitly calls for Next.js + tRPC + Prisma. After evaluating both paths:

docs/research_technical_v3.md
On this page

Skill-Based, Agent-Buildable Local SEO Platform#

Version: 3.0.0 Date: 2025 Status: Production Architecture — Preserves All Scope Brief Features Philosophy: Every feature = one skill. Skills are the single source of truth. Stack: Next.js 14 + TypeScript + tRPC + Prisma + Inngest + BullMQ + Redis + PostgreSQL Infrastructure: Dokploy on AWS EC2 (own everything, no Vercel)


1. Architecture Decisions#

1.1 Why Next.js Monolith (Not FastAPI)#

The scope brief explicitly calls for Next.js + tRPC + Prisma. After evaluating both paths:

Factor Next.js/TS FastAPI/Python
Scope brief alignment ✅ Designed for this ❌ Would rewrite
One codebase ✅ Frontend + API + workers ❌ Two repos
Agent productivity ✅ No context switching ❌ Language switch
tRPC type safety ✅ End-to-end ❌ No equivalent
Job queue ✅ BullMQ (native), Inngest (native) ⚠️ BullMQ Python less mature
Workflow engine ✅ Inngest (best TS SDK) ✅ Restate (best Python SDK)
AI SDK ✅ Vercel AI SDK excellent ✅ LangChain more mature
Browser automation ✅ Playwright native, Hyperbrowser API ✅ Same APIs
Dashboard UI ✅ shadcn/ui + Tailwind ❌ Separate frontend needed
Hiring (India) ✅ More TS/Next.js devs ❌ Fewer Python web devs

Verdict: The scope brief's stack is correct. The JS ecosystem has excellent libraries for every feature. One language = faster agent implementation.

1.2 Why Inngest (Not Restate)#

Feature Inngest Restate
TypeScript SDK maturity ✅ Excellent ⚠️ Newer
Durable execution ✅ Yes ✅ Yes
Self-hosted ✅ Open source ✅ Yes
Next.js integration ✅ Native middleware ❌ Separate service
Community/examples ✅ Larger ⚠️ Smaller

Verdict: Inngest has the best TypeScript SDK for durable workflows. Same core features as Restate.

1.3 Why BullMQ + Inngest (Not Just One)#

  • Inngest: Complex multi-step workflows (onboarding, citation building, monthly reports)
  • BullMQ: Simple background jobs (GBP post publish, social post, review monitor, token refresh)

BullMQ has the best observability dashboard for job queues. Inngest has the best workflow durability. Together they cover all cases.

1.4 Why Dokploy on AWS (Not Vercel)#

Own the infrastructure. It's the moat.

  • 100 client profile URLs = no problem
  • Control DNS, SSL, CDN, caching for directory domain
  • No platform limits, no surprise bills
  • "Your profile is hosted on India's trusted healthcare directory" = retention weapon

2. Infrastructure (Dokploy + AWS)#

2.1 AWS Architecture#

AWS Mumbai (ap-south-1)
│
├── VPC
│ ├── Public Subnet
│ │ └── EC2 t3.xlarge (4 vCPU, 16GB RAM)
│ │ └── Dokploy (Docker Compose)
│ │ ├── nextjs-app (Next.js frontend + API)
│ │ ├── inngest-server (workflow engine)
│ │ ├── bullmq-worker (background jobs)
│ │ ├── postgres (database)
│ │ └── redis (cache + queue)
│ └── Security Groups
│
├── S3 Bucket: rankflow-assets
│ ├── /assets/ (doctor photos, logos)
│ ├── /pdfs/ (monthly reports)
│ └── /backups/ (DB backups)
│
├── Cloudflare
│ ├── DNS: rankflow.ai, directory.com
│ ├── CDN + caching
│ └── SSL (directory domain certificate)
│
└── IAM: dokploy-ec2-role (S3 access)

2.2 Dokploy Services#

Service Type Resource Purpose
rankflow-app App (Docker) 2 vCPU, 4GB Next.js (frontend + API routes)
rankflow-worker App (Docker) 1 vCPU, 2GB BullMQ workers
inngest-server App (Docker) 1 vCPU, 2GB Inngest workflow engine
postgres Database 1 vCPU, 2GB PostgreSQL (Dokploy managed)
redis Database 0.5 vCPU, 1GB Redis (Dokploy managed)

Total EC2: t3.xlarge = ~$130/month S3: ~$5-10/month Cloudflare Pro: ~$20/month Total infrastructure: ~$160/month

2.3 Docker Compose (Dokploy)#

# docker-compose.yml
version: "3.8"

services:
 app:
 build:
 context: .
 dockerfile: Dockerfile
 ports:
 - "3000:3000"
 environment:
 - DATABASE_URL=${DATABASE_URL}
 - REDIS_URL=${REDIS_URL}
 - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
 - INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY}
 - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
 - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
 - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
 - OPENAI_API_KEY=${OPENAI_API_KEY}
 - HYPERBROWSER_API_KEY=${HYPERBROWSER_API_KEY}
 - FIRECRAWL_API_KEY=${FIRECRAWL_API_KEY}
 - DATAFORSEO_LOGIN=${DATAFORSEO_LOGIN}
 - DATAFORSEO_PASSWORD=${DATAFORSEO_PASSWORD}
 - SERPAPI_KEY=${SERPAPI_KEY}
 - COMPOSIO_API_KEY=${COMPOSIO_API_KEY}
 - ZERNIO_API_KEY=${ZERNIO_API_KEY}
 - RESEND_API_KEY=${RESEND_API_KEY}
 - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
 - RAZORPAY_KEY_SECRET=${RAZORPAY_KEY_SECRET}
 - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
 - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
 - AWS_S3_BUCKET=${AWS_S3_BUCKET}
 - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}
 - CLOUDFLARE_ZONE_ID=${CLOUDFLARE_ZONE_ID}
 depends_on:
 - postgres
 - redis
 restart: unless-stopped

 worker:
 build:
 context: .
 dockerfile: Dockerfile.worker
 environment:
 - DATABASE_URL=${DATABASE_URL}
 - REDIS_URL=${REDIS_URL}
 - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
 - INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY}
 - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
 - OPENAI_API_KEY=${OPENAI_API_KEY}
 - HYPERBROWSER_API_KEY=${HYPERBROWSER_API_KEY}
 - FIRECRAWL_API_KEY=${FIRECRAWL_API_KEY}
 - COMPOSIO_API_KEY=${COMPOSIO_API_KEY}
 - ZERNIO_API_KEY=${ZERNIO_API_KEY}
 - RESEND_API_KEY=${RESEND_API_KEY}
 - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
 - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
 - AWS_S3_BUCKET=${AWS_S3_BUCKET}
 depends_on:
 - redis
 restart: unless-stopped

 inngest:
 image: inngest/inngest:latest
 ports:
 - "8288:8288"
 environment:
 - INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
 - INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY}
 - REDIS_URI=${REDIS_URL}
 command: ["inngest", "dev", "-u", "http://app:3000/api/inngest"]
 restart: unless-stopped

 postgres:
 image: postgres:16-alpine
 environment:
 - POSTGRES_USER=rankflow
 - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
 - POSTGRES_DB=rankflow
 volumes:
 - postgres-data:/var/lib/postgresql/data
 restart: unless-stopped

 redis:
 image: redis:7-alpine
 command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru
 volumes:
 - redis-data:/data
 restart: unless-stopped

volumes:
 postgres-data:
 redis-data:

2.4 Environment Variables#

# App
NODE_ENV=production
APP_URL=https://rankflow.ai

# Database
DATABASE_URL=postgresql://rankflow:xxx@postgres:5432/rankflow

# Redis
REDIS_URL=redis://redis:6379

# Auth (Better Auth)
BETTER_AUTH_SECRET=xxx
BETTER_AUTH_URL=https://rankflow.ai

# Google OAuth + GBP
GOOGLE_CLIENT_ID=xxx
GOOGLE_CLIENT_SECRET=xxx

# AI
ANTHROPIC_API_KEY=xxx
OPENAI_API_KEY=xxx

# SEO Data
DATAFORSEO_LOGIN=xxx
DATAFORSEO_PASSWORD=xxx
SERPAPI_KEY=xxx

# Browser Automation
HYPERBROWSER_API_KEY=xxx
FIRECRAWL_API_KEY=xxx

# Social
COMPOSIO_API_KEY=xxx
ZERNIO_API_KEY=xxx

# Storage
AWS_ACCESS_KEY_ID=xxx
AWS_SECRET_ACCESS_KEY=xxx
AWS_S3_BUCKET=rankflow-assets
AWS_REGION=ap-south-1

# Inngest
INNGEST_SIGNING_KEY=xxx
INNGET_EVENT_KEY=xxx

# Email
RESEND_API_KEY=xxx

# Billing
STRIPE_SECRET_KEY=xxx
STRIPE_WEBHOOK_SECRET=xxx
STRIPE_PUBLISHABLE_KEY=xxx
RAZORPAY_KEY_ID=xxx
RAZORPAY_KEY_SECRET=xxx

# DNS
CLOUDFLARE_API_TOKEN=xxx
CLOUDFLARE_ZONE_ID=xxx

# Security
ENCRYPTION_KEY=xxx

# Monitoring
SENTRY_DSN=xxx

3. System Architecture#

3.1 High-Level Diagram#

┌─────────────────────────────────────────────────────────────────────┐
│ CLIENT LAYER │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
│ │ Doctor │ │ Doctor │ │ Public │ │ Admin │ │
│ │ Dashboard │ │ Site │ │ Site │ │ Panel │ │
│ │ (Next.js) │ │ (Next.js) │ │ (Next.js) │ │(Next.js) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────────┘
 │
 ▼ HTTP / tRPC
┌─────────────────────────────────────────────────────────────────────┐
│ API LAYER (Next.js + tRPC) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ tRPC │ │ Next.js │ │ Inngest │ │
│ │ Routers │ │ API Routes │ │ Handler │ │
│ │ (Zod) │ │ (Webhooks) │ │ (Workflows) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ Middleware: Auth (Better Auth), Rate Limit, Request ID, Logging │
└─────────────────────────────────────────────────────────────────────┘
 │
 ▼ Internal
┌─────────────────────────────────────────────────────────────────────┐
│ SKILL HARNESS (TypeScript) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Skill │ │ Inngest │ │ BullMQ │ │ │
│ │ Registry │ │ Workflows │ │ Workers │ │ │
│ │ (loads .md) │ │ (durable) │ │ (jobs) │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Execution │ │ Fallback │ │ Audit │ │
│ │ Engine │ │ Handler │ │ Logger │ │
│ │ (dispatches)│ │ (retries) │ │ (structured)│ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
 │
 ▼ API Calls
┌─────────────────────────────────────────────────────────────────────┐
│ EXTERNAL SERVICES │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Claude │ │Hyper- │ │DataForSEO│ │ Composio │ │ Resend │ │
│ │ Sonnet │ │browser │ │ SerpAPI │ │ Zernio │ │ S3 │ │
│ │ GPT-4o │ │Firecrawl │ │ │ │ │ │ │ │
│ │ (AI) │ │(Browser) │ │ (SEO) │ │ (Social) │ │(Storage) │ │
│ └──────────┐ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ │
│ │Cloudflare│ DNS, CDN, SSL │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────────────┘
 │
 ▼
┌─────────────────────────────────────────────────────────────────────┐
│ DATA LAYER │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │PostgreSQL│ │ Redis │ │ S3 │ │ Inngest │ │
│ │ (Prisma) │ │(BullMQ, │ │ (Assets, │ │ State │ │
│ │ │ │ Cache) │ │ PDFs) │ │ Store │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────────┘

4. The Skill System#

4.1 Skill File Format#

Every skill is a markdown file with YAML frontmatter:

---
id: 04-gbp-post-create
name: Create GBP Post
category: gbp
version: 1.0.0
phase: 3
autonomous: true
approval_required: false
triggers:
 - manual
 - scheduled
 - ai-generated
inputs:
 practice_id:
 type: string
 required: true
 location_id:
 type: string
 required: true
 content:
 type: string
 required: false
 max_length: 1500
 media_urls:
 type: array[string]
 required: false
 max_items: 10
 cta_type:
 type: enum[BOOK, CALL, LEARN_MORE, SIGN_UP, ORDER]
 required: false
 default: BOOK
outputs:
 post_id:
 type: string
 status:
 type: enum[SCHEDULED, PUBLISHED, FAILED]
 published_url:
 type: string
 required: false
dependencies:
 - 03-gbp-connect
 - 21-content-generate
external_apis:
 - google-business-profile
 - claude-sonnet
cost_estimate_usd: 0.05
time_estimate_seconds: 10
retry_policy:
 max_attempts: 3
 backoff: exponential
 initial_delay_seconds: 5
observability:
 metrics:
 - gbp_posts_created_total
 - gbp_post_create_duration_seconds
 - gbp_post_create_cost_usd
 alerts:
 - condition: "status == FAILED"
 severity: warning
 message: "GBP post creation failed"
---

## Purpose
Create and publish a Google Business Profile post for a medical practice.

## Business Context
GBP posts appear in Google Search and Maps results. They drive patient engagement
and improve local SEO. Medical practices should post 1-2x per week.

## Procedure

### Step 1: Validate Prerequisites
- Check practice has active GBP connection (skill 03)
- Check GBP API quota (max 500 posts/day per location)
- Verify location_id belongs to practice

### Step 2: Generate or Validate Content
- If content not provided, call skill 21-content-generate
- Validate content length ≤ 1500 characters
- Ensure content complies with medical advertising guidelines
- Add CTA if not present

### Step 3: Prepare Media
- If media_urls provided, validate each URL is accessible
- Check file size ≤ 10MB per image, ≤ 100MB per video
- Verify media format (JPEG, PNG, MP4, MOV)

### Step 4: Call GBP API
- Format request per Google Business Profile API spec
- Set languageCode: "en-IN"
- Include searchTerms for SEO
- Handle API errors:
 - 429 (quota exceeded) → queue for next day
 - 401 (auth expired) → trigger skill 03 re-auth
 - 400 (bad request) → flag for human review

### Step 5: Store Result
- Save post to database with status
- Link to content_piece if AI-generated
- Update practice metrics

### Step 6: Audit Log
- Log: practice_id, location_id, content_length, status, cost, duration

## Error Handling

| Error | Cause | Action |
|-------|-------|--------|
| AUTH_EXPIRED | Refresh token invalid | Trigger skill 03, notify user |
| QUOTA_EXCEEDED | Daily limit reached | Queue for next day, notify user |
| CONTENT_REJECTED | Violates GBP policy | Flag for human review |
| MEDIA_TOO_LARGE | File exceeds limit | Compress or reject |
| NETWORK_ERROR | Temporary failure | Retry with backoff |

## Implementation Notes for Coding Agents

### Database Model
```prisma
model GbpPost {
 id String @id @default(cuid())
 practiceId String
 locationId String
 gbpLocationId String
 content String @db.Text
 mediaUrls String[]
 ctaType String?
 status String // SCHEDULED, PUBLISHED, FAILED
 gbpPostId String? // Google's ID
 publishedAt DateTime?
 failedReason String?
 costUsd Decimal? @db.Decimal(10, 6)
 createdAt DateTime @default(now())
 updatedAt DateTime @updatedAt
}

tRPC Router#

// src/server/api/routers/gbp.ts
export const gbpRouter = createTRPCRouter({
 createPost: protectedProcedure
 .input(z.object({
 practiceId: z.string(),
 locationId: z.string(),
 content: z.string().max(1500).optional(),
 mediaUrls: z.array(z.string().url()).max(10).optional(),
 ctaType: z.enum(["BOOK", "CALL", "LEARN_MORE", "SIGN_UP", "ORDER"]).optional(),
 }))
 .mutation(async ({ ctx, input }) => {
 // Validate practice ownership
 // Call harness.executeSkill("04-gbp-post-create", input)
 // Return result
 }),
});

Inngest Workflow#

// src/inngest/functions/gbp-post.ts
export const gbpPostCreate = inngest.createFunction(
 { id: "gbp-post-create", retries: 3 },
 { event: "skill/gbp-post-create" },
 async ({ event, step }) => {
 const { practiceId, locationId, content, mediaUrls, ctaType } = event.data;
 
 // Step 1: Validate
 const practice = await step.run("validate", async () => {
 return await db.practice.findUnique({ where: { id: practiceId } });
 });
 
 // Step 2: Generate content if needed
 const finalContent = content || await step.run("generate", async () => {
 return await ai.generateGbpPost(practice, locationId);
 });
 
 // Step 3: Publish
 const result = await step.run("publish", async () => {
 return await gbpApi.createPost(practice, locationId, finalContent, mediaUrls, ctaType);
 });
 
 // Step 4: Store
 await step.run("store", async () => {
 return await db.gbpPost.create({...});
 });
 
 return { postId: result.id };
 }
);

Acceptance Criteria#

  • Can create a GBP post with provided content
  • Can generate AI content if none provided
  • Respects 1500 character limit
  • Handles auth errors gracefully
  • Handles quota exceeded gracefully
  • Stores audit log entry
  • Updates practice metrics
  • Returns within 30 seconds

### 4.2 Skill Registry

```typescript
// src/harness/registry.ts

import { glob } from "glob";
import matter from "gray-matter";
import { z } from "zod";

const SkillSchema = z.object({
 id: z.string(),
 name: z.string(),
 category: z.string(),
 version: z.string(),
 phase: z.number(),
 autonomous: z.boolean(),
 approval_required: z.boolean(),
 triggers: z.array(z.string()),
 inputs: z.record(z.object({
 type: z.string(),
 required: z.boolean(),
 description: z.string().optional(),
 max_length: z.number().optional(),
 max_items: z.number().optional(),
 default: z.any().optional(),
 })),
 outputs: z.record(z.object({
 type: z.string(),
 required: z.boolean().optional(),
 description: z.string().optional(),
 })),
 dependencies: z.array(z.string()).default([]),
 external_apis: z.array(z.string()).default([]),
 cost_estimate_usd: z.number().optional(),
 time_estimate_seconds: z.number().optional(),
 retry_policy: z.object({
 max_attempts: z.number(),
 backoff: z.enum(["exponential", "fixed"]),
 initial_delay_seconds: z.number(),
 }).optional(),
});

export type Skill = z.infer<typeof SkillSchema> & {
 content: string; // Markdown body
 filePath: string;
};

export class SkillRegistry {
 private skills: Map<string, Skill> = new Map();
 
 async loadAll(skillsDir: string = "./skills"): Promise<void> {
 const files = await glob(`${skillsDir}/**/*.md`);
 
 for (const file of files) {
 const content = await fs.readFile(file, "utf-8");
 const { data, content: body } = matter(content);
 
 const parsed = SkillSchema.parse(data);
 this.skills.set(parsed.id, {
 ...parsed,
 content: body,
 filePath: file,
 });
 }
 }
 
 get(id: string): Skill | undefined {
 return this.skills.get(id);
 }
 
 listAll(): Skill[] {
 return Array.from(this.skills.values());
 }
 
 listByCategory(category: string): Skill[] {
 return this.listAll().filter(s => s.category === category);
 }
 
 listByPhase(phase: number): Skill[] {
 return this.listAll().filter(s => s.phase === phase);
 }
 
 getDependencies(skillId: string): Skill[] {
 const skill = this.get(skillId);
 if (!skill) return [];
 return skill.dependencies.map(id => this.get(id)).filter(Boolean) as Skill[];
 }
 
 validateDependencyGraph(): { valid: boolean; errors: string[] } {
 const errors: string[] = [];
 const visited = new Set<string>();
 const stack = new Set<string>();
 
 const visit = (id: string): boolean => {
 if (stack.has(id)) {
 errors.push(`Circular dependency detected: ${id}`);
 return false;
 }
 if (visited.has(id)) return true;
 
 visited.add(id);
 stack.add(id);
 
 const skill = this.get(id);
 if (skill) {
 for (const dep of skill.dependencies) {
 if (!this.get(dep)) {
 errors.push(`Missing dependency: ${dep} (required by ${id})`);
 }
 visit(dep);
 }
 }
 
 stack.delete(id);
 return true;
 };
 
 for (const skill of this.listAll()) {
 visit(skill.id);
 }
 
 return { valid: errors.length === 0, errors };
 }
}

4.3 Skill Harness#

// src/harness/main.ts

import { SkillRegistry } from "./registry";
import { inngest } from "@/lib/inngest";
import { Queue } from "bullmq";
import { redis } from "@/lib/redis";
import { logger } from "@/lib/logger";

export interface ExecutionContext {
 userId: string;
 practiceId?: string;
 trigger: "manual" | "scheduled" | "ai-generated" | "webhook";
 requestId: string;
}

export interface ExecutionResult {
 status: "SUCCESS" | "FAILED" | "QUEUED" | "WAITING";
 data?: any;
 error?: string;
 jobId?: string;
 durationMs?: number;
 costUsd?: number;
}

export class RankFlowHarness {
 registry: SkillRegistry;
 private jobQueue: Queue;
 
 constructor() {
 this.registry = new SkillRegistry();
 this.jobQueue = new Queue("skill-execution", { connection: redis });
 }
 
 async initialize(): Promise<void> {
 await this.registry.loadAll("./skills");
 const validation = this.registry.validateDependencyGraph();
 if (!validation.valid) {
 logger.error({ errors: validation.errors }, "Skill dependency validation failed");
 throw new Error("Invalid skill dependency graph");
 }
 logger.info({ skillCount: this.registry.listAll().length }, "Skill registry loaded");
 }
 
 async execute(
 skillId: string,
 payload: Record<string, any>,
 context: ExecutionContext
 ): Promise<ExecutionResult> {
 const skill = this.registry.get(skillId);
 if (!skill) {
 return { status: "FAILED", error: `Skill not found: ${skillId}` };
 }
 
 // Check prerequisites
 for (const depId of skill.dependencies) {
 const dep = this.registry.get(depId);
 if (!dep) {
 return { status: "FAILED", error: `Missing dependency: ${depId}` };
 }
 }
 
 // Check if approval required
 if (skill.approval_required && context.trigger !== "manual") {
 // Queue for approval
 const job = await this.jobQueue.add("pending-approval", {
 skillId,
 payload,
 context,
 });
 return { status: "QUEUED", jobId: job.id };
 }
 
 // Log start
 const startTime = Date.now();
 logger.info({
 skillId,
 practiceId: context.practiceId,
 userId: context.userId,
 trigger: context.trigger,
 }, "Skill execution started");
 
 try {
 // Dispatch to Inngest for durable workflows
 // or BullMQ for simple jobs
 if (skill.triggers.includes("scheduled") || skill.dependencies.length > 0) {
 // Complex workflow → Inngest
 const result = await inngest.send({
 name: `skill/${skillId}`,
 data: { ...payload, _context: context },
 });
 
 return {
 status: "QUEUED",
 jobId: result.ids[0],
 };
 } else {
 // Simple job → BullMQ
 const job = await this.jobQueue.add(skillId, {
 payload,
 context,
 }, {
 attempts: skill.retry_policy?.max_attempts || 3,
 backoff: {
 type: skill.retry_policy?.backoff || "exponential",
 delay: (skill.retry_policy?.initial_delay_seconds || 5) * 1000,
 },
 });
 
 return {
 status: "QUEUED",
 jobId: job.id,
 };
 }
 } catch (error) {
 const duration = Date.now() - startTime;
 logger.error({
 skillId,
 error: (error as Error).message,
 durationMs: duration,
 }, "Skill execution failed");
 
 return {
 status: "FAILED",
 error: (error as Error).message,
 durationMs: duration,
 };
 }
 }
 
 async getStatus(jobId: string): Promise<ExecutionResult | null> {
 // Check Inngest run status
 // Check BullMQ job status
 return null;
 }
}

5. Database Schema (Complete)#

This schema preserves every entity from the scope brief:

// prisma/schema.prisma

generator client {
 provider = "prisma-client-js"
}

datasource db {
 provider = "postgresql"
 url = env("DATABASE_URL")
}

// ─── Auth & Users ─────────────────────────────────────────

model User {
 id String @id @default(cuid())
 email String @unique
 name String?
 image String?
 password String? // bcrypt hashed — null for OAuth-only
 role UserRole @default(CLIENT)
 emailVerified DateTime?
 createdAt DateTime @default(now())
 updatedAt DateTime @updatedAt
 
 // Relations
 sessions Session[]
 accounts Account[]
 memberships PracticeMember[]
 auditLogs AuditLog[]
 contentEdits ContentPiece[] @relation("ContentEditor")
 
 @@map("users")
}

enum UserRole {
 ADMIN
 CLIENT
 EDITOR
 VIEWER
}

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])
 @@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)
 
 @@map("sessions")
}

// ─── Practice (Tenant) ────────────────────────────────────

model Practice {
 id String @id @default(cuid())
 name String
 slug String @unique
 type PracticeType @default(CLINIC)
 status PracticeStatus @default(TRIAL)
 
 // Owner
 ownerId String
 
 // Subscription
 tier SubscriptionTier @default(STARTER)
 trialEndsAt DateTime?
 subscriptionEndsAt DateTime?
 stripeCustomerId String?
 stripeSubscriptionId String?
 razorpayCustomerId String?
 razorpaySubscriptionId String?
 
 // Branding
 logoUrl String?
 primaryColor String? @default("#2563eb")
 
 // Site
 profileUrl String? @unique
 profileLayout String @default("standard-profile")
 profilePublished Boolean @default(false)
 autoUpdateEnabled Boolean @default(true)
 lastProfileUpdate DateTime?
 
 // Settings
 settings Json @default("{}")
 
 // Onboarding
 onboardingComplete Boolean @default(false)
 onboardingStep Int @default(0)
 
 createdAt DateTime @default(now())
 updatedAt DateTime @updatedAt
 deletedAt DateTime?
 
 // Relations
 members PracticeMember[]
 locations Location[]
 gbpAccounts GbpAccount[]
 socialAccounts SocialAccount[]
 contentPieces ContentPiece[]
 profileSections ProfileSection[]
 citations Citation[]
 jobs Job[]
 reports Report[]
 leads Lead[]
 invoices Invoice[]
 auditLogs AuditLog[]
 promptTemplates PromptTemplate[]
 
 @@map("practices")
}

enum PracticeType {
 CLINIC
 HOSPITAL
 DIAGNOSTIC_CENTER
 DENTAL_CLINIC
 PHYSIOTHERAPY
 AYURVEDIC_CENTER
 HOMEOPATHY_CLINIC
 CA
 LAWYER
 WEDDING_PHOTOGRAPHER
}

enum PracticeStatus {
 TRIAL
 ACTIVE
 PAST_DUE
 SUSPENDED
 CANCELLED
 EXPIRED
}

enum SubscriptionTier {
 STARTER // Rs 4,000/mo
 STANDARD // Rs 8,000/mo
 PREMIUM // Rs 12,000/mo
 ENTERPRISE // Rs 20,000/mo
}

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])
 @@map("practice_members")
}

// ─── Location (GBP Entity) ────────────────────────────────

model Location {
 id String @id @default(cuid())
 practiceId String
 name String
 
 // NAP
 businessName String
 address String
 city String
 state String
 postalCode String
 country String @default("IN")
 phone String
 phoneSecondary String?
 email String?
 website String?
 
 // Geo
 latitude Decimal? @db.Decimal(10, 8)
 longitude Decimal? @db.Decimal(11, 8)
 
 // Business
 category String @default("Medical Clinic")
 services String[] @default([])
 businessHours Json @default("{}")
 specialHours Json?
 
 // SEO
 targetKeywords String[] @default([])
 serviceAreas String[] @default([])
 languages String[] @default(["English", "Hindi"])
 
 isPrimary Boolean @default(false)
 isActive Boolean @default(true)
 
 createdAt DateTime @default(now())
 updatedAt DateTime @updatedAt
 deletedAt DateTime?
 
 practice Practice @relation(fields: [practiceId], references: [id], onDelete: Cascade)
 gbpLocation GbpLocation?
 reviews Review[]
 rankTracking RankTracking[]
 
 @@map("locations")
}

// ─── GBP Integration ──────────────────────────────────────

model GbpAccount {
 id String @id @default(cuid())
 practiceId String
 accountEmail String
 googleAccountId String?
 
 // OAuth tokens (encrypted at application layer)
 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])
 @@map("gbp_accounts")
}

model GbpLocation {
 id String @id @default(cuid())
 gbpAccountId String
 locationId String // Internal location ID
 gbpLocationId String // Google's location ID
 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])
 @@map("gbp_locations")
}

model GbpPost {
 id String @id @default(cuid())
 gbpLocationId String
 contentPieceId String?
 
 gbpPostId String? // Google's post ID
 topicType String @default("STANDARD") // STANDARD, OFFER, EVENT
 content String @db.Text
 mediaUrls String[] @default([])
 actionType String? // BOOK, ORDER, SHOP, LEARN_MORE, SIGN_UP, CALL
 actionUrl String?
 
 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
 
 costUsd Decimal? @db.Decimal(10, 6)
 createdAt DateTime @default(now())
 updatedAt DateTime @updatedAt
 
 gbpLocation GbpLocation @relation(fields: [gbpLocationId], references: [id], onDelete: Cascade)
 
 @@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") // EXTERIOR, INTERIOR, TEAM, LOGO, COVER, AT_WORK
 caption String?
 status String @default("PENDING") // PENDING, UPLOADED, FAILED
 uploadedAt DateTime?
 failedReason String?
 createdAt DateTime @default(now())
 
 gbpLocation GbpLocation @relation(fields: [gbpLocationId], references: [id], onDelete: Cascade)
 
 @@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)
 
 @@map("gbp_qa")
}

model GbpInsight {
 id String @id @default(cuid())
 gbpLocationId String
 date DateTime @db.Date
 
 // 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
 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])
 @@map("gbp_insights")
}

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

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?
 replyGeneratedByAI Boolean @default(false)
 replyPublished Boolean @default(false)
 repliedAt DateTime?
 
 reviewDate DateTime
 photos String[] @default([])
 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])
 
 @@map("reviews")
}

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

model SocialAccount {
 id String @id @default(cuid())
 practiceId String
 platform PlatformType
 accountName String
 accountId String? // Platform's account ID
 profileUrl String?
 
 // Tokens (encrypted at application layer)
 accessToken String @db.Text
 refreshToken String? @db.Text
 tokenExpiresAt DateTime?
 tokenScope String[] @default([])
 
 // Composio/Zernio
 composioConnectionId String?
 
 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])
 @@map("social_accounts")
}

enum PlatformType {
 GBP
 FACEBOOK
 INSTAGRAM
 LINKEDIN
 TWITTER
 WHATSAPP
 YOUTUBE
}

model SocialPost {
 id String @id @default(cuid())
 socialAccountId String
 contentPieceId String?
 
 externalPostId String? // Platform's post ID
 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, PENDING_APPROVAL, 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)
 
 @@map("social_posts")
}

// ─── Content (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)
 seoScore Int? @default(0)
 
 // AI tracking
 aiGenerated Boolean @default(false)
 aiProvider String?
 aiModel String?
 aiPrompt String? @db.Text
 aiTokensUsed Int?
 costUsd Decimal? @db.Decimal(10, 6)
 generationTimeMs Int?
 
 // Human editing
 humanEdited Boolean @default(false)
 editedBy String?
 editedAt DateTime?
 
 // Approval workflow
 approvedBy String?
 approvedAt DateTime?
 
 sourceUrl String?
 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])
 editor User? @relation("ContentEditor", fields: [editedBy], references: [id])
 
 @@map("content_pieces")
}

enum ContentType {
 GBP_POST
 GBP_REPLY
 SOCIAL_POST
 DIRECTORY_PROFILE_CONTENT
 FAQ
 SCHEMA_MARKUP
 META_DESCRIPTION
 CITATION_DESCRIPTION
 REVIEW_TEMPLATE
 BLOG_POST
}

enum ContentStatus {
 DRAFT
 PENDING_REVIEW
 APPROVED
 PUBLISHED
 REJECTED
 ARCHIVED
}

// ─── Prompt Templates ─────────────────────────────────────

model PromptTemplate {
 id String @id @default(cuid())
 practiceId String?
 
 taskType String // gbp_post, directory_profile, faq, etc.
 name String
 version Int @default(1)
 isDefault Boolean @default(false)
 
 systemPrompt String @db.Text
 userPromptTemplate String @db.Text
 outputSchema Json? // Expected output structure
 
 modelConfig Json // { provider, model, temperature, maxTokens }
 
 // profile performance tracking
 variant String @default("A")
 performanceScore Decimal? @db.Decimal(5, 2)
 
 createdAt DateTime @default(now())
 updatedAt DateTime @updatedAt
 
 practice Practice? @relation(fields: [practiceId], references: [id], onDelete: Cascade)
 
 @@map("prompt_templates")
}

// ─── Profile Sections (directory profile) ─────────────────────────

model ProfileSection {
 id String @id @default(cuid())
 practiceId String
 
 sectionKey String // hero, bio, services, reviews, faq, map, contact, related-clinics
 sortOrder Int @default(0)
 content String @db.Text
 mediaUrls String[] @default([])
 config Json @default("{}") // Section-specific settings
 isVisible Boolean @default(true)
 
 createdAt DateTime @default(now())
 updatedAt DateTime @updatedAt
 
 practice Practice @relation(fields: [practiceId], references: [id], onDelete: Cascade)
 
 @@unique([practiceId, sectionKey])
 @@map("profile_sections")
}

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

model Citation {
 id String @id @default(cuid())
 practiceId String
 locationId String
 
 directoryName String // justdial, practo, lybrate, sulekha, etc.
 directoryDisplayName String
 directoryUrl String? // The listing URL
 submissionUrl String? // Where we submitted
 category String?
 
 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
 
 screenshotUrl String? // S3 URL of submission screenshot
 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)
 
 @@unique([locationId, directoryName])
 @@map("citations")
}

enum CitationStatus {
 PENDING
 SUBMITTING
 SUBMITTED
 VERIFIED
 FAILED
 NEEDS_UPDATE
 REMOVED
}

enum NAPMatchStatus {
 MATCHED
 MISMATCH_NAME
 MISMATCH_ADDRESS
 MISMATCH_PHONE
 MISMATCH_ALL
 NOT_FOUND
}

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)
 submissionType String @default("FORM") // API, FORM, EMAIL
 signupFlow Json // JSON schema of signup flow
 submissionFields Json // Required fields
 rateLimit Json @default("{}")
 successPatterns String[] @default([])
 failurePatterns String[] @default([])
 createdAt DateTime @default(now())
 updatedAt DateTime @updatedAt
 
 @@map("citation_directories")
}

// ─── Owned Blog Sites (Citation Network) ─────────────────

model OwnedBlogSite {
 id String @id @default(cuid())
 practiceId String? // If linked to a specific practice
 name String
 domain String @unique
 subdomain String? // If hosted as subdomain of rankflow
 
 niche String // "kerala-health", "medical-guide", etc.
 authorityScore Int? @default(0)
 postCount Int @default(0)
 
 isActive Boolean @default(true)
 lastPublishedAt DateTime?
 
 settings Json @default("{}")
 createdAt DateTime @default(now())
 updatedAt DateTime @updatedAt
 
 practice Practice? @relation(fields: [practiceId], references: [id], onDelete: SetNull)
 posts BlogSitePost[]
 
 @@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])
 @@map("blog_site_posts")
}

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

model RankTracking {
 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")
 
 currentRank Int?
 previousRank Int?
 bestRank Int?
 
 searchVolume Int? // Monthly
 difficulty Int? // 0-100
 cpc Decimal? @db.Decimal(8, 2) // 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])
 @@map("rank_tracking")
}

model RankTrackingHistory {
 id String @id @default(cuid())
 rankTrackingId 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
 
 rankTracking RankTracking @relation(fields: [rankTrackingId], references: [id], onDelete: Cascade)
 
 @@map("rank_tracking_history")
}

// ─── 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?
 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[]
 
 @@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])
 @@map("competitor_snapshots")
}

// ─── Jobs / Workflows ─────────────────────────────────────

model Job {
 id String @id @default(cuid())
 practiceId String
 
 skillId String // Which skill was executed
 status JobStatus @default(PENDING)
 
 payload Json // Input data
 result Json? // Output data
 error String?
 errorStack String? @db.Text
 
 inngestRunId String? // Inngest run ID
 bullJobId String? // BullMQ job ID
 
 startedAt DateTime?
 completedAt DateTime?
 failedAt DateTime?
 
 retryCount Int @default(0)
 maxRetries Int @default(3)
 
 costUsd Decimal? @db.Decimal(10, 6)
 durationMs Int?
 
 createdAt DateTime @default(now())
 updatedAt DateTime @updatedAt
 
 practice Practice @relation(fields: [practiceId], references: [id], onDelete: Cascade)
 
 @@index([practiceId, status])
 @@index([skillId, status])
 @@index([createdAt])
 @@map("jobs")
}

enum JobStatus {
 PENDING
 QUEUED
 RUNNING
 COMPLETED
 FAILED
 CANCELLED
 WAITING
 RETRYING
}

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

model Report {
 id String @id @default(cuid())
 practiceId String
 
 name String
 periodStart DateTime
 periodEnd DateTime
 
 status String @default("GENERATING") // GENERATING, READY, FAILED
 sections Json // Structured report data
 summaryText String? @db.Text
 
 scoreOverall Int? @default(0)
 scoreGbp Int? @default(0)
 scoreCitations Int? @default(0)
 scoreReviews Int? @default(0)
 scoreRankings Int? @default(0)
 scoreSeo Int? @default(0)
 
 pdfUrl String? // S3 URL
 emailSentAt DateTime?
 emailRecipients String[] @default([])
 viewedAt DateTime?
 
 createdAt DateTime @default(now())
 updatedAt DateTime @updatedAt
 
 practice Practice @relation(fields: [practiceId], references: [id], onDelete: Cascade)
 
 @@map("reports")
}

// ─── Leads ────────────────────────────────────────────────

model Lead {
 id String @id @default(cuid())
 practiceId String
 
 name String?
 phone String?
 email String?
 message String? @db.Text
 source String // profile, gbp, social, direct
 
 status String @default("NEW") // NEW, CONTACTED, CONVERTED, LOST
 
 createdAt DateTime @default(now())
 updatedAt DateTime @updatedAt
 
 practice Practice @relation(fields: [practiceId], references: [id], onDelete: Cascade)
 
 @@map("leads")
}

// ─── 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)
 
 @@map("invoices")
}

enum PaymentProvider {
 STRIPE
 RAZORPAY
}

// ─── Audit Log ────────────────────────────────────────────

model AuditLog {
 id String @id @default(cuid())
 practiceId String?
 userId String?
 
 action String // CREATE, UPDATE, DELETE, LOGIN, SKILL_EXECUTE, etc.
 entityType String // table name or resource type
 entityId String?
 
 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, createdAt])
 @@index([userId, createdAt])
 @@index([action, createdAt])
 @@map("audit_logs")
}

// ─── Notification ─────────────────────────────────────────

model Notification {
 id String @id @default(cuid())
 userId String
 practiceId 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())
 
 @@index([userId, isRead])
 @@index([practiceId, createdAt])
 @@map("notifications")
}

6. Next.js Monolith Structure#

rankflow/
├── apps/
│ └── web/ # Next.js 14 monolith
│ ├── src/
│ │ ├── app/
│ │ │ ├── layout.tsx # Root layout
│ │ │ ├── page.tsx # Marketing landing
│ │ │ │
│ │ │ ├── (client)/ # Client dashboard (authenticated)
│ │ │ │ ├── layout.tsx # Dashboard shell
│ │ │ │ ├── page.tsx # Dashboard home
│ │ │ │ ├── practice/
│ │ │ │ ├── locations/
│ │ │ │ ├── gbp/
│ │ │ │ │ ├── page.tsx # GBP overview
│ │ │ │ │ ├── posts/
│ │ │ │ │ ├── reviews/
│ │ │ │ │ └── insights/
│ │ │ │ ├── social/
│ │ │ │ ├── citations/
│ │ │ │ ├── content/
│ │ │ │ ├── profile/
│ │ │ │ ├── reports/
│ │ │ │ ├── leads/
│ │ │ │ ├── settings/
│ │ │ │ └── billing/
│ │ │ │
│ │ │ ├── (admin)/ # Admin dashboard (ADMIN role)
│ │ │ │ ├── layout.tsx # Admin shell
│ │ │ │ ├── page.tsx # Admin KPI overview
│ │ │ │ ├── clients/
│ │ │ │ ├── clients/[id]/
│ │ │ │ ├── domains/
│ │ │ │ ├── websites/
│ │ │ │ ├── social-connections/
│ │ │ │ ├── jobs/
│ │ │ │ ├── content/
│ │ │ │ ├── billing/
│ │ │ │ └── reports/
│ │ │ │
│ │ │ ├── profile/ # Public directory profiles
│ │ │ │ └── [profileSlug]/
│ │ │ │ └── page.tsx # Dynamic profile renderer
│ │ │ │
│ │ │ ├── auth/
│ │ │ │ ├── signin/
│ │ │ │ ├── signup/
│ │ │ │ └── callback/
│ │ │ │
│ │ │ └── api/
│ │ │ ├── trpc/
│ │ │ │ └── [trpc]/
│ │ │ │ └── route.ts
│ │ │ ├── inngest/
│ │ │ │ └── route.ts # Inngest handler
│ │ │ ├── webhooks/
│ │ │ │ ├── stripe/
│ │ │ │ ├── razorpay/
│ │ │ │ ├── composio/
│ │ │ │ ├── zernio/
│ │ │ │ └── google/
│ │ │ └── health/
│ │ │
│ │ ├── components/
│ │ │ ├── ui/ # shadcn/ui components
│ │ │ ├── dashboard/ # Dashboard-specific
│ │ │ ├── admin/ # Admin-specific
│ │ │ ├── profile/ # Profile section components
│ │ │ │ ├── ProfileHero.tsx
│ │ │ │ ├── ProfileBio.tsx
│ │ │ │ ├── ProfileServices.tsx
│ │ │ │ ├── ProfileReviews.tsx
│ │ │ │ ├── ProfileFAQ.tsx
│ │ │ │ ├── ProfileContact.tsx
│ │ │ │ ├── ProfileCTA.tsx
│ │ │ │ ├── ProfileProfileReviewsWidget.tsx
│ │ │ │ └── ProfileSchema.tsx
│ │ │ └── forms/
│ │ │
│ │ ├── lib/
│ │ │ ├── api.ts # tRPC client
│ │ │ ├── auth.ts # Better Auth client
│ │ │ ├── redis.ts # Redis client
│ │ │ ├── inngest.ts # Inngest client
│ │ │ ├── logger.ts # Pino logger
│ │ │ ├── crypto.ts # Encryption
│ │ │ └── utils.ts
│ │ │
│ │ ├── server/
│ │ │ ├── api/
│ │ │ │ ├── trpc.ts # tRPC setup
│ │ │ │ ├── root.ts # Root router
│ │ │ │ └── routers/
│ │ │ │ ├── auth.ts
│ │ │ │ ├── practice.ts
│ │ │ │ ├── location.ts
│ │ │ │ ├── gbp.ts
│ │ │ │ ├── social.ts
│ │ │ │ ├── content.ts
│ │ │ │ ├── citation.ts
│ │ │ │ ├── directoryProfile.ts
│ │ │ │ ├── report.ts
│ │ │ │ ├── lead.ts
│ │ │ │ ├── billing.ts
│ │ │ │ ├── skill.ts
│ │ │ │ └── admin.ts
│ │ │ │
│ │ │ ├── inngest/
│ │ │ │ ├── client.ts # Inngest client setup
│ │ │ │ └── functions/
│ │ │ │ ├── onboarding.ts
│ │ │ │ ├── gbp-post.ts
│ │ │ │ ├── gbp-review.ts
│ │ │ │ ├── citation-build.ts
│ │ │ │ ├── directory-profile-generate.ts
│ │ │ │ ├── directory-profile-refresh.ts
│ │ │ │ ├── content-generate.ts
│ │ │ │ ├── report-generate.ts
│ │ │ │ └── token-refresh.ts
│ │ │ │
│ │ │ ├── bullmq/
│ │ │ │ ├── queue.ts # Queue setup
│ │ │ │ ├── worker.ts # Worker setup
│ │ │ │ └── processors/
│ │ │ │ ├── gbp-post-publish.ts
│ │ │ │ ├── social-post-publish.ts
│ │ │ │ ├── review-monitor.ts
│ │ │ │ ├── nap-check.ts
│ │ │ │ └── email-send.ts
│ │ │ │
│ │ │ ├── harness/
│ │ │ │ ├── main.ts # RankFlowHarness
│ │ │ │ ├── registry.ts # SkillRegistry
│ │ │ │ ├── executor.ts # Skill execution
│ │ │ │ ├── audit.ts # Audit logging
│ │ │ │ └── metrics.ts # Metrics collection
│ │ │ │
│ │ │ ├── services/
│ │ │ │ ├── ai/
│ │ │ │ │ ├── router.ts # Multi-LLM router
│ │ │ │ │ ├── prompts.ts # Prompt templates
│ │ │ │ │ └── providers/
│ │ │ │ │ ├── anthropic.ts
│ │ │ │ │ └── openai.ts
│ │ │ │ ├── gbp/
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── auth.ts
│ │ │ │ │ └── ratelimit.ts
│ │ │ │ ├── social/
│ │ │ │ │ ├── composio.ts
│ │ │ │ │ ├── zernio.ts
│ │ │ │ │ └── platforms.ts
│ │ │ │ ├── seo/
│ │ │ │ │ ├── dataforseo.ts
│ │ │ │ │ ├── serpapi.ts
│ │ │ │ │ └── schema.ts
│ │ │ │ ├── browser/
│ │ │ │ │ ├── hyperbrowser.ts
│ │ │ │ │ └── firecrawl.ts
│ │ │ │ ├── storage/
│ │ │ │ │ └── s3.ts
│ │ │ │ ├── email/
│ │ │ │ │ ├── resend.ts
│ │ │ │ │ └── templates.ts
│ │ │ │ ├── dns/
│ │ │ │ │ └── cloudflare.ts
│ │ │ │ └── pdf/
│ │ │ │ └── playwright.ts
│ │ │ │
│ │ │ └── db.ts # Prisma client
│ │ │
│ │ └── types/
│ │ └── index.ts
│ │
│ ├── skills/ # Skill definitions (source of truth)
│ │ ├── 01-practice-onboard.md
│ │ ├── 02-asset-collect.md
│ │ ├── 03-gbp-connect.md
│ │ ├── 04-gbp-post-create.md
│ │ ├── 05-gbp-post-schedule.md
│ │ ├── 06-gbp-review-monitor.md
│ │ ├── 07-gbp-review-reply.md
│ │ ├── 08-gbp-insights-fetch.md
│ │ ├── 09-gbp-qa-manage.md
│ │ ├── 10-social-connect.md
│ │ ├── 11-social-post-create.md
│ │ ├── 12-social-post-schedule.md
│ │ ├── 13-citation-submit.md
│ │ ├── 14-citation-verify-nap.md
│ │ ├── 15-citation-monitor.md
│ │ ├── 16-citation-delete.md
│ │ ├── 17-owned-site-create.md
│ │ ├── 18-owned-site-publish.md
│ │ ├── 19-directory-profile-generate.md
│ │ ├── 20-directory-profile-publish.md
│ │ ├── 21-directory-profile-refresh.md
│ │ ├── 22-profile-layout-standard.md
│ │ ├── 23-profile-layout-premium.md
│ │ ├── 24-profile-layout-enhanced.md
│ │ ├── 25-schema-generate.md
│ │ ├── 26-keyword-track.md
│ │ ├── 27-seo-audit.md
│ │ ├── 28-competitor-track.md
│ │ ├── 29-content-generate.md
│ │ ├── 30-content-approve.md
│ │ ├── 31-prompt-manage.md
│ │ ├── 32-report-generate.md
│ │ ├── 33-report-email.md
│ │ ├── 34-lead-capture.md
│ │ ├── 35-lead-notify.md
│ │ ├── 36-billing-subscribe.md
│ │ ├── 37-billing-invoice.md
│ │ ├── 38-billing-cancel.md
│ │ ├── 39-admin-kpi.md
│ │ └── 40-admin-job-monitor.md
│ │
│ ├── public/
│ │ └── templates/ # Profile layout assets
│ │
│ ├── prisma/
│ │ └── schema.prisma
│ │
│ ├── tests/
│ │ ├── unit/
│ │ ├── integration/
│ │ └── skills/ # Skill acceptance tests
│ │
│ ├── Dockerfile
│ ├── Dockerfile.worker
│ ├── docker-compose.yml
│ ├── next.config.js
│ ├── tailwind.config.ts
│ ├── tsconfig.json
│ └── package.json
│
├── packages/
│ └── shared-types/ # Shared TypeScript types (if needed)
│
├── docs/
│ ├── architecture.md
│ └── api-reference.md
│
└── README.md

7. tRPC API Layer#

7.1 Base Setup#

// src/server/api/trpc.ts

import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { getServerSession } from "@/lib/auth";
import { db } from "@/server/db";
import { redis } from "@/lib/redis";

export const createTRPCContext = async (opts: { headers: Headers }) => {
 const session = await getServerSession();
 
 return {
 db,
 redis,
 session,
 ...opts,
 };
};

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;

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

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

const resolvePractice = t.middleware(async ({ ctx, next }) => {
 const practiceId = ctx.headers.get("x-practice-id");
 if (!practiceId) throw new TRPCError({ code: "BAD_REQUEST", message: "Practice ID required" });
 
 const membership = await ctx.db.practiceMember.findUnique({
 where: { practiceId_userId: { practiceId, userId: ctx.session!.user.id } },
 include: { practice: true },
 });
 
 if (!membership) throw new TRPCError({ code: "FORBIDDEN" });
 
 return next({ ctx: { ...ctx, practice: membership.practice, membership } });
});

export const practiceProcedure = protectedProcedure.use(resolvePractice);

const requireAdmin = t.middleware(({ ctx, next }) => {
 if (ctx.session!.user.role !== "ADMIN") {
 throw new TRPCError({ code: "FORBIDDEN", message: "Admin required" });
 }
 return next({ ctx });
});

export const adminProcedure = protectedProcedure.use(requireAdmin);

7.2 Skill Router#

// src/server/api/routers/skill.ts

import { z } from "zod";
import { createTRPCRouter, protectedProcedure, practiceProcedure, adminProcedure } from "@/server/api/trpc";
import { harness } from "@/server/harness/main";

export const skillRouter = createTRPCRouter({
 list: protectedProcedure.query(async () => {
 return harness.registry.listAll().map(s => ({
 id: s.id,
 name: s.name,
 category: s.category,
 phase: s.phase,
 autonomous: s.autonomous,
 approvalRequired: s.approval_required,
 }));
 }),
 
 get: protectedProcedure
 .input(z.object({ id: z.string() }))
 .query(async ({ input }) => {
 return harness.registry.get(input.id);
 }),
 
 execute: practiceProcedure
 .input(z.object({
 skillId: z.string(),
 payload: z.record(z.any()),
 async: z.boolean().default(false),
 }))
 .mutation(async ({ ctx, input }) => {
 const result = await harness.execute(input.skillId, {
 ...input.payload,
 practice_id: ctx.practice.id,
 }, {
 userId: ctx.session.user.id,
 practiceId: ctx.practice.id,
 trigger: "manual",
 requestId: crypto.randomUUID(),
 });
 
 return result;
 }),
 
 executeAdmin: adminProcedure
 .input(z.object({
 skillId: z.string(),
 practiceId: z.string(),
 payload: z.record(z.any()),
 }))
 .mutation(async ({ ctx, input }) => {
 return harness.execute(input.skillId, {
 ...input.payload,
 practice_id: input.practiceId,
 }, {
 userId: ctx.session.user.id,
 practiceId: input.practiceId,
 trigger: "manual",
 requestId: crypto.randomUUID(),
 });
 }),
 
 getStatus: protectedProcedure
 .input(z.object({ jobId: z.string() }))
 .query(async ({ input }) => {
 return harness.getStatus(input.jobId);
 }),
});

8. Inngest Workflow Engine#

8.1 Client Setup#

// src/lib/inngest.ts

import { Inngest } from "inngest";

export const inngest = new Inngest({
 id: "rankflow",
 eventKey: process.env.INNGEST_EVENT_KEY,
});

8.2 Example: Onboarding Workflow#

// src/server/inngest/functions/onboarding.ts

import { inngest } from "@/lib/inngest";
import { db } from "@/server/db";
import { ai } from "@/server/services/ai/router";
import { gbpAuth } from "@/server/services/gbp/auth";
import { profile } from "@/server/services/directoryProfile/generator";
import { email } from "@/server/services/email/resend";

export const onboardingWorkflow = inngest.createFunction(
 {
 id: "onboarding-pipeline",
 retries: 3,
 concurrency: { limit: 5 },
 },
 { event: "skill/01-practice-onboard" },
 async ({ event, step }) => {
 const { practice_id, user_id } = event.data;
 
 // Step 1: Create practice record
 const practice = await step.run("create-practice", async () => {
 return await db.practice.findUnique({ where: { id: practice_id } });
 });
 
 // Step 2: Generate initial profile content
 const siteContent = await step.run("generate-profile", async () => {
 return await directoryProfile.generate(practice_id);
 });
 
 // Step 3: Wait for GBP connection (can wait for days)
 await step.run("notify-gbp-auth", async () => {
 await email.send({
 to: practice.owner.email,
 subject: "Connect your Google Business Profile",
 body: `Please connect your GBP: ${await gbpAuth.getAuthUrl(practice_id)}`,
 });
 });
 
 // Wait for OAuth callback event
 const oauthResult = await step.waitForEvent("gbp/oauth-callback", {
 timeout: "7d",
 match: "data.practice_id",
 });
 
 // Step 4: Generate first GBP post
 await step.run("create-first-post", async () => {
 const content = await ai.generate({
 task: "gbp_post",
 context: { practice, location: practice.locations[0] },
 prompt: "Welcome post for new medical practice",
 });
 
 // Queue via BullMQ for immediate publish
 // ...
 });
 
 // Step 5: Schedule weekly posts
 await step.run("schedule-posts", async () => {
 // Create recurring schedule
 // ...
 });
 
 // Step 6: Wait 1 day, then send welcome report
 await step.sleep("1d");
 
 await step.run("send-welcome-report", async () => {
 const report = await generateWelcomeReport(practice_id);
 await email.send({
 to: practice.owner.email,
 subject: "Your RankFlow Welcome Report",
 body: report,
 });
 });
 
 return { practice_id, status: "onboarded" };
 }
);

8.3 Example: Citation Builder Workflow#

// src/server/inngest/functions/citation-build.ts

export const citationBuilder = inngest.createFunction(
 {
 id: "citation-builder",
 retries: 3,
 concurrency: { limit: 3 }, // Conservative — directory sites are slow
 },
 { event: "skill/13-citation-submit" },
 async ({ event, step }) => {
 const { practice_id, location_id } = event.data;
 
 // Step 1: Get practice and location
 const [practice, location] = await step.run("get-data", async () => {
 return await Promise.all([
 db.practice.findUnique({ where: { id: practice_id } }),
 db.location.findUnique({ where: { id: location_id } }),
 ]);
 });
 
 // Step 2: Get active directories
 const directories = await step.run("get-directories", async () => {
 return await db.citationDirectory.findMany({ where: { isActive: true } });
 });
 
 // Step 3: Generate unique descriptions for each directory
 const descriptions = await step.run("generate-descriptions", async () => {
 return await Promise.all(
 directories.map(async (dir) => ({
 directoryId: dir.id,
 description: await ai.generate({
 task: "citation_description",
 context: { practice, location, directory: dir },
 }),
 }))
 );
 });
 
 // Step 4: Submit to each directory (parallel with concurrency limit)
 const results = await Promise.all(
 directories.map(async (dir, i) => {
 return await step.run(`submit-${dir.name}`, async () => {
 try {
 if (dir.submissionType === "API") {
 return await submitViaApi(dir, practice, location, descriptions[i]);
 } else {
 return await submitViaBrowser(dir, practice, location, descriptions[i]);
 }
 } catch (error) {
 return { success: false, error: (error as Error).message };
 }
 });
 })
 );
 
 // Step 5: Store results
 await step.run("store-results", async () => {
 await db.citation.createMany({
 data: results.map((r, i) => ({
 practiceId: practice_id,
 locationId: location_id,
 directoryName: directories[i].name,
 status: r.success ? "SUBMITTED" : "FAILED",
 submittedAt: r.success ? new Date() : null,
 errorMessage: r.error || null,
 })),
 });
 });
 
 // Step 6: Schedule NAP verification (1 week later)
 await step.sleep("7d");
 
 await step.run("schedule-verification", async () => {
 await inngest.send({
 name: "skill/14-citation-verify-nap",
 data: { practice_id, location_id },
 });
 });
 
 return { submitted: results.filter(r => r.success).length, failed: results.filter(r => !r.success).length };
 }
);

async function submitViaBrowser(dir: any, practice: any, location: any, description: any) {
 const { HyperbrowserClient } = await import("@/server/services/browser/hyperbrowser");
 const client = new HyperbrowserClient();
 
 const session = await client.createSession();
 
 try {
 await client.navigate(session.id, dir.signupFlow.steps[0].url);
 
 // Fill form fields
 for (const field of dir.signupFlow.steps[1].formFields) {
 const value = getFieldValue(field.field, practice, location, description);
 await client.fillForm(session.id, field.selector, value);
 }
 
 // Solve CAPTCHA if present
 if (dir.requiresCaptcha) {
 await client.solveCaptcha(session.id);
 }
 
 // Submit
 await client.click(session.id, "button[type='submit']");
 
 // Capture result
 const screenshot = await client.screenshot(session.id);
 const screenshotUrl = await uploadToS3(screenshot, `citations/${practice.id}/${dir.name}.png`);
 
 return { success: true, screenshotUrl };
 } finally {
 await client.closeSession(session.id);
 }
}

9. BullMQ Job Queue#

9.1 Queue Setup#

// src/server/bullmq/queue.ts

import { Queue } from "bullmq";
import { redis } from "@/lib/redis";

export const queues = {
 gbpPostPublish: new Queue("gbp-post-publish", { connection: redis }),
 socialPostPublish: new Queue("social-post-publish", { connection: redis }),
 reviewMonitor: new Queue("review-monitor", { connection: redis }),
 napCheck: new Queue("nap-check", { connection: redis }),
 emailSend: new Queue("email-send", { connection: redis }),
 tokenRefresh: new Queue("token-refresh", { connection: redis }),
};

export async function scheduleRecurringJobs() {
 // GBP post scheduler — 2-3x per week per client
 await queues.gbpPostPublish.add(
 "schedule-posts",
 {},
 { repeat: { pattern: "0 9 * * 1,3,5" } } // Mon, Wed, Fri at 9 AM
 );
 
 // Review monitor — daily
 await queues.reviewMonitor.add(
 "check-reviews",
 {},
 { repeat: { pattern: "0 8 * * *" } } // Daily at 8 AM
 );
 
 // NAP check — monthly
 await queues.napCheck.add(
 "check-nap",
 {},
 { repeat: { pattern: "0 2 1 * *" } } // 1st of month at 2 AM
 );
 
 // Token refresh — daily
 await queues.tokenRefresh.add(
 "refresh-tokens",
 {},
 { repeat: { pattern: "0 3 * * *" } } // Daily at 3 AM
 );
}

9.2 Worker Setup#

// src/server/bullmq/worker.ts

import { Worker } from "bullmq";
import { redis } from "@/lib/redis";
import { logger } from "@/lib/logger";

export function createWorkers() {
 const workers = [
 new Worker("gbp-post-publish", async (job) => {
 logger.info({ jobId: job.id }, "Processing GBP post publish");
 // Implementation in processor file
 }, { connection: redis, concurrency: 3 }),
 
 new Worker("social-post-publish", async (job) => {
 logger.info({ jobId: job.id }, "Processing social post publish");
 }, { connection: redis, concurrency: 5 }),
 
 new Worker("review-monitor", async (job) => {
 logger.info({ jobId: job.id }, "Processing review monitor");
 }, { connection: redis, concurrency: 2 }),
 
 new Worker("nap-check", async (job) => {
 logger.info({ jobId: job.id }, "Processing NAP check");
 }, { connection: redis, concurrency: 2 }),
 
 new Worker("email-send", async (job) => {
 logger.info({ jobId: job.id }, "Processing email send");
 }, { connection: redis, concurrency: 5 }),
 
 new Worker("token-refresh", async (job) => {
 logger.info({ jobId: job.id }, "Processing token refresh");
 }, { connection: redis, concurrency: 1 }),
 ];
 
 return workers;
}

10. AI Content Engine#

10.1 Multi-LLM Router#

// src/server/services/ai/router.ts

import { generateText, streamText } from "ai";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createOpenAI } from "@ai-sdk/openai";
import { db } from "@/server/db";
import { logger } from "@/lib/logger";

const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });

interface GenerateOptions {
 task: string;
 system?: string;
 prompt: string;
 model?: "claude-sonnet" | "claude-haiku" | "gpt-4o" | "gpt-4o-mini";
 maxTokens?: number;
 temperature?: number;
 jsonMode?: boolean;
}

interface GenerateResult {
 text: string;
 model: string;
 provider: string;
 tokensUsed: number;
 costUsd: number;
 latencyMs: number;
}

const MODEL_CONFIGS = {
 "claude-sonnet": { provider: "anthropic", model: "claude-sonnet-4-20250514", inputCost: 0.003, outputCost: 0.015 },
 "claude-haiku": { provider: "anthropic", model: "claude-3-5-haiku-20241022", inputCost: 0.00025, outputCost: 0.00125 },
 "gpt-4o": { provider: "openai", model: "gpt-4o", inputCost: 0.0025, outputCost: 0.01 },
 "gpt-4o-mini": { provider: "openai", model: "gpt-4o-mini", inputCost: 0.00015, outputCost: 0.0006 },
};

const TASK_MODELS: Record<string, string> = {
 gbp_post: "claude-haiku",
 directory_profile_content: "claude-sonnet",
 faq: "claude-haiku",
 citation_description: "gpt-4o-mini",
 social_caption: "gpt-4o-mini",
 review_reply: "claude-haiku",
 schema_markup: "claude-sonnet",
 seo_audit: "claude-sonnet",
 meta_description: "gpt-4o-mini",
};

export async function generate(options: GenerateOptions): Promise<GenerateResult> {
 const startTime = Date.now();
 const modelKey = options.model || TASK_MODELS[options.task] || "claude-sonnet";
 const config = MODEL_CONFIGS[modelKey];
 
 if (!config) {
 throw new Error(`Unknown model: ${modelKey}`);
 }
 
 const model = config.provider === "anthropic"
 ? anthropic(config.model)
 : openai(config.model);
 
 try {
 const result = await generateText({
 model,
 system: options.system,
 prompt: options.prompt,
 maxTokens: options.maxTokens || 1024,
 temperature: options.temperature || 0.7,
 });
 
 const latency = Date.now() - startTime;
 const inputTokens = result.usage.promptTokens;
 const outputTokens = result.usage.completionTokens;
 const costUsd = (inputTokens / 1000) * config.inputCost + (outputTokens / 1000) * config.outputCost;
 
 // Log cost
 logger.info({
 task: options.task,
 model: modelKey,
 provider: config.provider,
 tokensUsed: inputTokens + outputTokens,
 costUsd,
 latencyMs: latency,
 }, "AI generation completed");
 
 return {
 text: result.text,
 model: config.model,
 provider: config.provider,
 tokensUsed: inputTokens + outputTokens,
 costUsd,
 latencyMs: latency,
 };
 } catch (error) {
 logger.error({ task: options.task, model: modelKey, error: (error as Error).message }, "AI generation failed");
 
 // Fallback to next model
 const fallback = modelKey === "claude-sonnet" ? "gpt-4o" : "claude-haiku";
 if (fallback !== modelKey) {
 logger.info({ from: modelKey, to: fallback }, "Falling back to alternative model");
 return generate({ ...options, model: fallback });
 }
 
 throw error;
 }
}

export async function generateWithTemplate(
 practiceId: string,
 taskType: string,
 variables: Record<string, any>
): Promise<GenerateResult> {
 // Load prompt template from DB
 const template = await db.promptTemplate.findFirst({
 where: { practiceId: { in: [practiceId, null] }, taskType, isDefault: true },
 orderBy: { version: "desc" },
 });
 
 if (!template) {
 throw new Error(`No prompt template found for task: ${taskType}`);
 }
 
 // Replace variables in template
 let prompt = template.userPromptTemplate;
 for (const [key, value] of Object.entries(variables)) {
 prompt = prompt.replace(new RegExp(`{{${key}}}`, "g"), String(value));
 }
 
 return generate({
 task: taskType,
 system: template.systemPrompt,
 prompt,
 model: (template.modelConfig as any)?.model,
 maxTokens: (template.modelConfig as any)?.maxTokens,
 temperature: (template.modelConfig as any)?.temperature,
 });
}

11. Directory Profile System#

11.1 Profile Layouts#

// src/lib/profile-layouts.ts

export interface SiteTemplate {
 id: string;
 name: string;
 vertical: string; // doctor, dentist, clinic, ca, lawyer
 sections: string[];
 defaultConfig: {
 primaryColor: string;
 secondaryColor: string;
 fontFamily: string;
 borderRadius: string;
 buttonStyle: string;
 };
}

export const templates: Record<string, SiteTemplate> = {
 "standard-profile": {
 id: "standard-profile",
 name: "Medical Modern",
 vertical: "doctor",
 sections: ["hero", "bio", "services", "reviews", "faq", "map", "contact", "related-clinics"],
 defaultConfig: {
 primaryColor: "#2563eb",
 secondaryColor: "#f8fafc",
 fontFamily: "Inter, system-ui, sans-serif",
 borderRadius: "0.5rem",
 buttonStyle: "solid",
 },
 },
 "premium-profile": {
 id: "premium-profile",
 name: "Dental Clean",
 vertical: "dentist",
 sections: ["hero", "bio", "services", "gallery", "reviews", "faq", "map", "contact"],
 defaultConfig: {
 primaryColor: "#06b6d4",
 secondaryColor: "#ecfeff",
 fontFamily: "DM Sans, system-ui, sans-serif",
 borderRadius: "9999px",
 buttonStyle: "outline",
 },
 },
 "enhanced-profile": {
 id: "enhanced-profile",
 name: "Clinic Premium",
 vertical: "clinic",
 sections: ["hero", "bio", "services", "reviews", "faq", "map", "contact", "related-clinics"],
 defaultConfig: {
 primaryColor: "#0f172a",
 secondaryColor: "#f1f5f9",
 fontFamily: "Poppins, system-ui, sans-serif",
 borderRadius: "0.75rem",
 buttonStyle: "solid",
 },
 },
 "standard-profile": {
 id: "standard-profile",
 name: "CA Professional",
 vertical: "ca",
 sections: ["hero", "about", "services", "testimonials", "faq", "contact", "cta"],
 defaultConfig: {
 primaryColor: "#059669",
 secondaryColor: "#ecfdf5",
 fontFamily: "Inter, system-ui, sans-serif",
 borderRadius: "0.25rem",
 buttonStyle: "solid",
 },
 },
 "premium-profile": {
 id: "premium-profile",
 name: "Lawyer Authority",
 vertical: "lawyer",
 sections: ["hero", "about", "services", "testimonials", "faq", "contact", "cta"],
 defaultConfig: {
 primaryColor: "#7c3aed",
 secondaryColor: "#f5f3ff",
 fontFamily: "Merriweather, Georgia, serif",
 borderRadius: "0.25rem",
 buttonStyle: "outline",
 },
 },
};

11.2 Dynamic Profile Renderer#

// src/app/profile/[city]/[profileSlug]/page.tsx

import { notFound } from "next/navigation";
import { Metadata } from "next";
import { db } from "@/server/db";
import { ProfileSchema } from "@/components/profile/ProfileSchema";
import { profileComponents } from "@/components/profile/sections";

interface Props {
 params: { city: string; profileSlug: string };
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
 const practice = await getProfileBySlug(params.city, params.profileSlug);
 if (!practice) return { title: "Not Found" };
 
 return {
 title: practice.profileSections.find(s => s.sectionKey === "hero")?.content?.match(/<h1[^>]*>(.*?)<\/h1>/)?.[1] || practice.name,
 description: practice.seoDescription,
 robots: practice.profilePublished ? "index, follow" : "noindex, nofollow",
 alternates: { canonical: `https://directory.com/clinics/${params.city}/${params.profileSlug}` },
 openGraph: {
 title: practice.name,
 description: practice.seoDescription,
 type: "website",
 },
 };
}

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

export default async function ProfilePage({ params }: Props) {
 const practice = await getProfileBySlug(params.city, params.profileSlug);
 if (!practice || !practice.profilePublished) {
 notFound();
 }
 
 const sections = practice.profileSections
 .filter(s => s.isVisible)
 .sort((a, b) => a.sortOrder - b.sortOrder);
 
 const template = templates[practice.profileLayout] || templates["standard-profile"];
 
 return (
 <div
 style={{
 "--primary": template.defaultConfig.primaryColor,
 "--secondary": template.defaultConfig.secondaryColor,
 } as React.CSSProperties}
 className="min-h-screen"
 >
 <ProfileSchema profile={practice} />
 
 {sections.map((section) => {
 const Component = profileComponents[section.sectionKey];
 if (!Component) return null;
 
 return (
 <section
 key={section.id}
 id={section.sectionKey}
 className={`section-${section.sectionKey}`}
 >
 <Component
 content={section.content}
 mediaUrls={section.mediaUrls}
 config={section.config}
 practice={practice}
 />
 </section>
 );
 })}
 </div>
 );
}

async function getProfileBySlug(city: string, slug: string) {
 return db.practice.findUnique({
 where: { citySlug: city, slug, deletedAt: null },
 include: {
 profileSections: true,
 locations: { where: { isPrimary: true }, take: 1 },
 gbpAccounts: { where: { isActive: true } },
 },
 });
}

11.3 Directory Profile Refresh Workflow#

// src/server/inngest/functions/directory-profile-refresh.ts

export const siteEvolution = inngest.createFunction(
 {
 id: "directory-profile-refresh",
 retries: 3,
 },
 { cron: "0 2 * * 1" }, // Every Monday at 2 AM
 async ({ step }) => {
 // Get all practices with auto-update enabled
 const practices = await step.run("get-practices", async () => {
 return await db.practice.findMany({
 where: { profilePublished: true, autoUpdateEnabled: true },
 include: { locations: true },
 });
 });
 
 for (const practice of practices) {
 await step.run(`evolve-${practice.id}`, async () => {
 // 1. Get latest SEO data
 const rankings = await db.rankTracking.findMany({
 where: { practiceId: practice.id },
 include: { history: { orderBy: { checkedAt: "desc" }, take: 1 } },
 });
 
 // 2. Get GBP insights
 const insights = await db.gbpInsight.findMany({
 where: { gbpLocation: { gbpAccount: { practiceId: practice.id } } },
 orderBy: { date: "desc" },
 take: 7,
 });
 
 // 3. AI plans updates
 const updatePlan = await ai.generate({
 task: "directory_profile_refresh",
 system: "You are an SEO expert. Given current rankings and GBP data, suggest specific content updates for a medical practice website.",
 prompt: `Practice: ${practice.name}
Rankings: ${JSON.stringify(rankings)}
GBP Insights: ${JSON.stringify(insights)}

Suggest 2-3 specific content updates. Return JSON: { "updates": [{ "section": "hero|about|services|faq", "reason": "...", "newContent": "..." }] }`,
 jsonMode: true,
 });
 
 // 4. Apply updates
 const plan = JSON.parse(updatePlan.text);
 for (const update of plan.updates) {
 await db.profileSection.updateMany({
 where: { practiceId: practice.id, sectionKey: update.section },
 data: { content: update.newContent },
 });
 }
 
 // 5. Update last update timestamp
 await db.practice.update({
 where: { id: practice.id },
 data: { lastProfileUpdate: new Date() },
 });
 
 // 6. Revalidate profile
 await revalidateProfile(practice.slug);
 
 // 7. Notify doctor
 await email.send({
 to: practice.owner.email,
 subject: "Your directory profile has been refreshed",
 body: `We've updated your directory profile based on the latest SEO data. Changes: ${plan.updates.map((u: any) => u.reason).join("; ")}`,
 });
 });
 }
 
 return { evolved: practices.length };
 }
);

12. GBP Management Pipeline#

12.1 OAuth Flow#

// src/server/services/gbp/auth.ts

import { google } from "googleapis";
import { encrypt, decrypt } from "@/lib/crypto";

const oauth2Client = new google.auth.OAuth2(
 process.env.GOOGLE_CLIENT_ID,
 process.env.GOOGLE_CLIENT_SECRET,
 `${process.env.APP_URL}/api/webhooks/google/oauth-callback`
);

export async function getAuthUrl(practiceId: string, redirectUri?: string): Promise<string> {
 const state = Buffer.from(JSON.stringify({ practiceId })).toString("base64");
 
 return oauth2Client.generateAuthUrl({
 access_type: "offline",
 scope: [
 "https://www.googleapis.com/auth/business.manage",
 "https://www.googleapis.com/auth/userinfo.email",
 "https://www.googleapis.com/auth/userinfo.profile",
 ],
 prompt: "consent",
 state,
 include_granted_scopes: true,
 });
}

export async function handleCallback(code: string, state: string) {
 const { practiceId } = JSON.parse(Buffer.from(state, "base64").toString());
 
 const { tokens } = await oauth2Client.getToken(code);
 
 // Get user info
 oauth2Client.setCredentials(tokens);
 const oauth2 = google.oauth2({ version: "v2", auth: oauth2Client });
 const { data: userInfo } = await oauth2.userinfo.get();
 
 // Store tokens (encrypted)
 await db.gbpAccount.create({
 data: {
 practiceId,
 accountEmail: userInfo.email!,
 accessToken: encrypt(tokens.access_token!),
 refreshToken: encrypt(tokens.refresh_token!),
 tokenExpiresAt: new Date(Date.now() + (tokens.expiry_date || 3600 * 1000)),
 scope: (tokens.scope || "").split(" ").filter(Boolean),
 },
 });
 
 // Trigger sync workflow
 await inngest.send({
 name: "gbp/sync-locations",
 data: { practice_id: practiceId },
 });
 
 return { success: true, email: userInfo.email };
}

export async function getClient(accountId: string) {
 const account = await db.gbpAccount.findUnique({ where: { id: accountId } });
 if (!account) throw new Error("GBP account not found");
 
 // Decrypt tokens
 const accessToken = decrypt(account.accessToken);
 const refreshToken = decrypt(account.refreshToken);
 
 // Refresh if expiring within 5 minutes
 if (account.tokenExpiresAt.getTime() - Date.now() < 5 * 60 * 1000) {
 oauth2Client.setCredentials({ refresh_token: refreshToken });
 const { tokens } = await oauth2Client.refreshAccessToken();
 
 await db.gbpAccount.update({
 where: { id: accountId },
 data: {
 accessToken: encrypt(tokens.access_token!),
 ...(tokens.refresh_token ? { refreshToken: encrypt(tokens.refresh_token) } : {}),
 tokenExpiresAt: new Date(Date.now() + (tokens.expiry_date || 3600 * 1000)),
 },
 });
 
 oauth2Client.setCredentials({ access_token: tokens.access_token });
 } else {
 oauth2Client.setCredentials({ access_token: accessToken });
 }
 
 return google.mybusinessbusinessinformation({ version: "v1", auth: oauth2Client });
}

12.2 Rate Limiting#

// src/server/services/gbp/ratelimit.ts

import { redis } from "@/lib/redis";

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 }> {
 const limit = QUOTA_LIMITS[operation];
 const today = new Date().toISOString().slice(0, 10);
 const dailyKey = `gbp:quota:${operation}:${accountId}:${today}`;
 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);
 
 return { allowed, remaining };
}

13. Social Media Integration#

13.1 Composio Integration#

// src/server/services/social/composio.ts

import { Composio } from "composio-core";

const composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });

export async function initiateConnection(platform: string, redirectUri: string, metadata: Record<string, any>) {
 const connection = await composio.connectedAccounts.initiate({
 appName: platform,
 redirectUri,
 metadata,
 });
 
 return {
 authUrl: connection.redirectUrl,
 connectionId: connection.connectedAccountId,
 };
}

export async function handleCallback(connectionId: string) {
 const connection = await composio.connectedAccounts.get({
 connectedAccountId: connectionId,
 });
 
 return {
 status: connection.status,
 appName: connection.appName,
 entityId: connection.entityId,
 };
}

export async function publishFacebookPost(connectionId: string, message: string, mediaUrls?: string[]) {
 const action = await composio.getAction({ actionName: "FACEBOOK_PUBLISH_POST" });
 
 return await action.execute({
 connectedAccountId: connectionId,
 input: {
 message,
 ...(mediaUrls ? { photos: mediaUrls } : {}),
 },
 });
}

export async function publishInstagramPost(connectionId: string, caption: string, imageUrl: string) {
 const action = await composio.getAction({ actionName: "INSTAGRAM_PUBLISH_POST" });
 
 return await action.execute({
 connectedAccountId: connectionId,
 input: {
 caption,
 image_url: imageUrl,
 },
 });
}

13.2 Zernio Scheduling#

// src/server/services/social/zernio.ts

export async function schedulePosts(posts: Array<{
 platform: string;
 connectionId: string;
 content: string;
 mediaUrls?: string[];
 scheduledFor: string;
}>) {
 const response = await fetch("https://api.zernio.com/v1/schedule/batch", {
 method: "POST",
 headers: {
 Authorization: `Bearer ${process.env.ZERNIO_API_KEY}`,
 "Content-Type": "application/json",
 },
 body: JSON.stringify({
 posts,
 webhook_url: `${process.env.APP_URL}/api/webhooks/zernio`,
 }),
 });
 
 if (!response.ok) {
 throw new Error(`Zernio scheduling failed: ${await response.text()}`);
 }
 
 return response.json();
}

14. Citation Network Manager#

14.1 Hyperbrowser for Form Submissions#

// src/server/services/browser/hyperbrowser.ts

export class HyperbrowserClient {
 private apiKey: string;
 private baseUrl: string;
 
 constructor() {
 this.apiKey = process.env.HYPERBROWSER_API_KEY!;
 this.baseUrl = "https://app.hyperbrowser.ai/api/v1";
 }
 
 async createSession(): Promise<{ id: string }> {
 const response = await fetch(`${this.baseUrl}/sessions`, {
 method: "POST",
 headers: { Authorization: `Bearer ${this.apiKey}` },
 body: JSON.stringify({ browser: "chrome", headless: true }),
 });
 
 const data = await response.json();
 return { id: data.session_id };
 }
 
 async navigate(sessionId: string, url: string): Promise<void> {
 await fetch(`${this.baseUrl}/sessions/${sessionId}/navigate`, {
 method: "POST",
 headers: { Authorization: `Bearer ${this.apiKey}` },
 body: JSON.stringify({ url }),
 });
 }
 
 async fillForm(sessionId: string, selector: string, value: string): Promise<void> {
 await fetch(`${this.baseUrl}/sessions/${sessionId}/fill`, {
 method: "POST",
 headers: { Authorization: `Bearer ${this.apiKey}` },
 body: JSON.stringify({ selector, value }),
 });
 }
 
 async click(sessionId: string, selector: string): Promise<void> {
 await fetch(`${this.baseUrl}/sessions/${sessionId}/click`, {
 method: "POST",
 headers: { Authorization: `Bearer ${this.apiKey}` },
 body: JSON.stringify({ selector }),
 });
 }
 
 async screenshot(sessionId: string): Promise<Buffer> {
 const response = await fetch(`${this.baseUrl}/sessions/${sessionId}/screenshot`, {
 headers: { Authorization: `Bearer ${this.apiKey}` },
 });
 
 return Buffer.from(await response.arrayBuffer());
 }
 
 async closeSession(sessionId: string): Promise<void> {
 await fetch(`${this.baseUrl}/sessions/${sessionId}`, {
 method: "DELETE",
 headers: { Authorization: `Bearer ${this.apiKey}` },
 });
 }
}

14.2 Firecrawl for NAP Verification#

// src/server/services/browser/firecrawl.ts

export class FirecrawlClient {
 private apiKey: string;
 
 constructor() {
 this.apiKey = process.env.FIRECRAWL_API_KEY!;
 }
 
 async scrape(url: string, extract?: Record<string, any>): Promise<any> {
 const response = await fetch("https://api.firecrawl.dev/v1/scrape", {
 method: "POST",
 headers: {
 Authorization: `Bearer ${this.apiKey}`,
 "Content-Type": "application/json",
 },
 body: JSON.stringify({
 url,
 formats: ["markdown"],
 onlyMainContent: true,
 extract,
 }),
 });
 
 return response.json();
 }
 
 async verifyNap(url: string, expected: { name: string; address: string; phone: string }): Promise<{
 nameMatch: boolean;
 addressMatch: boolean;
 phoneMatch: boolean;
 }> {
 const result = await this.scrape(url, {
 schema: {
 type: "object",
 properties: {
 business_name: { type: "string" },
 address: { type: "string" },
 phone: { type: "string" },
 },
 },
 });
 
 const extracted = result.data?.extract || {};
 
 return {
 nameMatch: fuzzyMatch(expected.name, extracted.business_name || ""),
 addressMatch: fuzzyMatch(expected.address, extracted.address || ""),
 phoneMatch: normalizePhone(expected.phone) === normalizePhone(extracted.phone || ""),
 };
 }
}

function fuzzyMatch(expected: string, found: string): boolean {
 const normExpected = expected.toLowerCase().replace(/[^a-z0-9]/g, "");
 const normFound = found.toLowerCase().replace(/[^a-z0-9]/g, "");
 return normFound.includes(normExpected) || normExpected.includes(normFound);
}

function normalizePhone(phone: string): string {
 return phone.replace(/\D/g, "");
}

15. Client Dashboard#

15.1 Dashboard Pages (All from Scope Brief)#

Route Content Skill
/dashboard Overview: Maps position, citation count, review score, social activity 39-admin-kpi (client view)
/dashboard/gbp GBP status, recent posts, review inbox, Q&A 04, 06, 07, 08, 09
/dashboard/social Connected accounts, scheduled posts, post history 10, 11, 12
/dashboard/citations 30 citation URLs, NAP consistency, live/removed 13, 14, 15
/dashboard/profile Profile preview, view analytics 19, 20, 21
/dashboard/reports Monthly report history, PDF download 32, 33
/dashboard/leads Lead inbox, contact form submissions 34, 35
/dashboard/settings Business info, plan details, billing 01, 36, 37
/dashboard/content Content approval queue (for medical clients) 29, 30

15.2 Content Approval Workflow#

// src/app/(client)/dashboard/content/page.tsx

export default async function ContentApprovalPage() {
 const session = await getServerSession();
 const practice = await getPracticeForUser(session.user.id);
 
 const pendingContent = await db.contentPiece.findMany({
 where: {
 practiceId: practice.id,
 status: "PENDING_REVIEW",
 },
 orderBy: { createdAt: "desc" },
 });
 
 return (
 <div className="space-y-6">
 <h1 className="text-2xl font-bold">Content Approval</h1>
 <p className="text-muted-foreground">
 AI-generated content waiting for your approval. All content is queued for 24 hours before auto-publishing.
 </p>
 
 {pendingContent.map((content) => (
 <ContentCard key={content.id} content={content} />
 ))}
 </div>
 );
}

function ContentCard({ content }: { content: ContentPiece }) {
 return (
 <Card>
 <CardHeader>
 <CardTitle>{content.title || content.type}</CardTitle>
 <CardDescription>
 {content.aiGenerated ? "AI-generated" : "Manual"} • {content.type} • 
 Auto-publishes in {getTimeUntilAutoPublish(content.createdAt)}
 </CardDescription>
 </CardHeader>
 <CardContent>
 <div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: content.content }} />
 </CardContent>
 <CardFooter className="flex gap-2">
 <form action={approveContent}>
 <input type="hidden" name="contentId" value={content.id} />
 <Button type="submit" variant="default">Approve</Button>
 </form>
 <form action={rejectContent}>
 <input type="hidden" name="contentId" value={content.id} />
 <Button type="submit" variant="outline">Reject</Button>
 </form>
 <Button variant="ghost">Edit</Button>
 </CardFooter>
 </Card>
 );
}

16. Admin Dashboard#

16.1 Admin Pages (All from Scope Brief)#

Route Content Skill
/admin KPI overview: clients, MRR, churn, active jobs, citation health 39-admin-kpi
/admin/clients All clients table, CRUD 01-practice-onboard
/admin/clients/[id] Client detail: profile, accounts, citations, profile, jobs All
/admin/directory Directory overview: profile URLs, DNS status, SSL 20-directory-profile-publish
/admin/directory-profiles Directory profiles: URL, DA, content count, health 17-owned-site-create
/admin/social-connections All social accounts across clients 10-social-connect
/admin/jobs Job queue monitor: running, queued, failed, completed 40-admin-job-monitor
/admin/content AI content review: approve/reject, prompt management 31-prompt-manage
/admin/billing Subscriptions, invoices, payments, failed charges 36, 37, 38
/admin/reports System-wide analytics: revenue, churn, citation success, API costs 39-admin-kpi

16.2 Job Monitor#

// src/app/(admin)/admin/jobs/page.tsx

export default async function JobMonitorPage() {
 const jobs = await db.job.findMany({
 orderBy: { createdAt: "desc" },
 take: 100,
 include: { practice: { select: { name: true } } },
 });
 
 const stats = {
 pending: jobs.filter(j => j.status === "PENDING").length,
 running: jobs.filter(j => j.status === "RUNNING").length,
 completed: jobs.filter(j => j.status === "COMPLETED").length,
 failed: jobs.filter(j => j.status === "FAILED").length,
 };
 
 return (
 <div className="space-y-6">
 <h1 className="text-2xl font-bold">Job Monitor</h1>
 
 <div className="grid grid-cols-4 gap-4">
 <StatCard label="Pending" value={stats.pending} color="yellow" />
 <StatCard label="Running" value={stats.running} color="blue" />
 <StatCard label="Completed" value={stats.completed} color="green" />
 <StatCard label="Failed" value={stats.failed} color="red" />
 </div>
 
 <DataTable
 columns={["Job ID", "Skill", "Practice", "Status", "Duration", "Cost", "Actions"]}
 rows={jobs.map(j => ({
 id: j.id,
 skill: j.skillId,
 practice: j.practice?.name,
 status: j.status,
 duration: j.durationMs ? `${(j.durationMs / 1000).toFixed(1)}s` : "—",
 cost: j.costUsd ? `$${j.costUsd}` : "—",
 actions: j.status === "FAILED" ? <RetryButton jobId={j.id} /> : null,
 }))}
 />
 </div>
 );
}

17. Billing & Subscriptions#

17.1 Plans (from Scope Brief)#

Plan Price Citations Social Posts directory profile GBP Posts Support
Starter Rs 4,000/mo 15 4/mo 4/mo Email
Standard Rs 8,000/mo 25 8/mo 8/mo Chat
Premium Rs 12,000/mo 30 12/mo ✅ + directory profile URL 12/mo Priority
Enterprise Rs 20,000/mo 30 + owned sites 20/mo ✅ + premium placement 20/mo Dedicated

17.2 Subscription Lifecycle#

Trial (7 days) → Active → Past_due (3 retries over 7 days) → Cancelled
 ↓
 Grace period (7 days)
 ↓
 Citations removed, profile removed

17.3 Implementation#

// src/server/api/routers/billing.ts

export const billingRouter = createTRPCRouter({
 createSubscription: practiceProcedure
 .input(z.object({
 plan: z.enum(["STARTER", "STANDARD", "PREMIUM", "ENTERPRISE"]),
 paymentMethod: z.enum(["stripe", "razorpay"]),
 }))
 .mutation(async ({ ctx, input }) => {
 const planPrices = {
 STARTER: 400000, // Rs 4,000 in paise
 STANDARD: 800000,
 PREMIUM: 1200000,
 ENTERPRISE: 2000000,
 };
 
 if (input.paymentMethod === "stripe") {
 // Create Stripe subscription
 const subscription = await stripe.subscriptions.create({
 customer: ctx.practice.stripeCustomerId!,
 items: [{ price: planPrices[input.plan] }],
 trial_period_days: 7,
 });
 
 await db.practice.update({
 where: { id: ctx.practice.id },
 data: {
 tier: input.plan,
 subscriptionStatus: "TRIAL",
 stripeSubscriptionId: subscription.id,
 trialEndsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
 },
 });
 } else {
 // Create Razorpay subscription
 // ...
 }
 
 return { success: true };
 }),
 
 handleWebhook: publicProcedure
 .input(z.any())
 .mutation(async ({ ctx, input }) => {
 // Stripe/Razorpay webhook handling
 // Update practice status based on payment events
 }),
});

18. Email Reports & PDF Generation#

18.1 Monthly Report Email#

// src/server/services/email/templates.ts

export function buildMonthlyReportEmail(data: ReportData): string {
 return `<!DOCTYPE html>
<html>
<head>
 <meta charset="UTF-8">
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
 <title>Monthly SEO Report — ${data.practiceName}</title>
 <style>
 @media only screen and (max-width: 600px) {
 .container { width: 100% !important; }
 .metric-card { width: 100% !important; display: block !important; }
 }
 </style>
</head>
<body style="margin:0;padding:0;background-color:#f3f4f6;font-family:Inter,system-ui,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;">
 
 <!-- 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;">${data.practiceName}</h1>
 <p style="color:#bfdbfe;margin:0;font-size:14px;">Monthly SEO Report — ${data.period}</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;">${data.scoreOverall}</span>
 </div>
 <p style="margin:0;font-size:14px;color:#6b7280;">Overall SEO Score</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>
 <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 #2563eb;">
 <p style="margin:0 0 5px;font-size:12px;color:#6b7280;font-weight:500;">GBP</p>
 <p style="margin:0 0 3px;font-size:28px;font-weight:800;color:#2563eb;">${data.scoreGbp}</p>
 <p style="margin:0;font-size:11px;color:#6b7280;">${data.gbpViews} Views</p>
 </div>
 </td>
 <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 #10b981;">
 <p style="margin:0 0 5px;font-size:12px;color:#6b7280;font-weight:500;">Citations</p>
 <p style="margin:0 0 3px;font-size:28px;font-weight:800;color:#10b981;">${data.scoreCitations}</p>
 <p style="margin:0;font-size:11px;color:#6b7280;">${data.citationsVerified} Verified</p>
 </div>
 </td>
 </tr>
 <tr>
 <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 #f59e0b;">
 <p style="margin:0 0 5px;font-size:12px;color:#6b7280;font-weight:500;">Reviews</p>
 <p style="margin:0 0 3px;font-size:28px;font-weight:800;color:#f59e0b;">${data.scoreReviews}</p>
 <p style="margin:0;font-size:11px;color:#6b7280;">${data.reviewAvg}/5.0</p>
 </div>
 </td>
 <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 #8b5cf6;">
 <p style="margin:0 0 5px;font-size:12px;color:#6b7280;font-weight:500;">Rankings</p>
 <p style="margin:0 0 3px;font-size:28px;font-weight:800;color:#8b5cf6;">${data.scoreRankings}</p>
 <p style="margin:0;font-size:11px;color:#6b7280;">${data.keywordsTop10} in Top 10</p>
 </div>
 </td>
 </tr>
 </table>
 </td>
 </tr>
 
 <!-- CTA -->
 <tr>
 <td style="padding:30px 40px;text-align:center;background:#f9fafb;">
 <a href="${data.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>
 </td>
 </tr>
 
 </table>
 </td>
 </tr>
 </table>
</body>
</html>`;
}

18.2 PDF Generation (Playwright)#

// src/server/services/pdf/playwright.ts

import { chromium } from "playwright";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";

const s3 = new S3Client({
 region: process.env.AWS_REGION,
 credentials: {
 accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
 secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
 },
});

export async function generatePdf(html: string, fileName: string): Promise<string> {
 const browser = await chromium.launch({ headless: true });
 
 try {
 const page = await browser.newPage();
 await page.setContent(html, { waitUntil: "networkidle" });
 await page.waitForTimeout(2000); // Wait for fonts
 
 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;">
 RankFlow AI — Monthly SEO Report
 </div>`,
 footerTemplate: `<div style="font-size:9px;color:#6b7280;width:100%;text-align:center;padding:10px 40px;">
 <span class="pageNumber"></span> of <span class="totalPages"></span>
 </div>`,
 });
 
 // Upload to S3
 await s3.send(new PutObjectCommand({
 Bucket: process.env.AWS_S3_BUCKET,
 Key: `reports/${fileName}`,
 Body: pdfBuffer,
 ContentType: "application/pdf",
 }));
 
 return `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/reports/${fileName}`;
 } finally {
 await browser.close();
 }
}

19. Observability & Auditability#

19.1 Structured Logging (Pino)#

// src/lib/logger.ts

import pino from "pino";

export const logger = pino({
 level: process.env.LOG_LEVEL || "info",
 base: { service: "rankflow" },
 redact: [
 "req.headers.authorization",
 "req.headers.cookie",
 "password",
 "accessToken",
 "refreshToken",
 "token",
 ],
 transport: process.env.NODE_ENV === "development"
 ? { target: "pino-pretty", options: { colorize: true } }
 : undefined,
});

19.2 Health Checks#

// src/app/api/health/route.ts

import { NextResponse } from "next/server";
import { db } from "@/server/db";
import { redis } from "@/lib/redis";

export async function GET() {
 const checks: Record<string, { status: "pass" | "fail" | "warn"; responseTime: number; message?: string }> = {};
 let overallStatus: "healthy" | "degraded" | "unhealthy" = "healthy";
 
 // Database
 const dbStart = Date.now();
 try {
 await db.$queryRaw`SELECT 1`;
 checks.database = { status: "pass", responseTime: Date.now() - dbStart };
 } catch (e) {
 checks.database = { status: "fail", responseTime: Date.now() - dbStart, message: (e as Error).message };
 overallStatus = "unhealthy";
 }
 
 // Redis
 const redisStart = Date.now();
 try {
 await redis.ping();
 checks.redis = { status: "pass", responseTime: Date.now() - redisStart };
 } catch (e) {
 checks.redis = { status: "fail", responseTime: Date.now() - redisStart, message: (e as Error).message };
 overallStatus = "unhealthy";
 }
 
 // Inngest
 const inngestStart = Date.now();
 try {
 // Check Inngest connectivity
 checks.inngest = { status: "pass", responseTime: Date.now() - inngestStart };
 } catch (e) {
 checks.inngest = { status: "warn", responseTime: Date.now() - inngestStart, message: (e as Error).message };
 if (overallStatus === "healthy") overallStatus = "degraded";
 }
 
 return NextResponse.json({
 status: overallStatus,
 checks,
 timestamp: new Date().toISOString(),
 version: process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) || "dev",
 });
}

19.3 Audit Logging#

// src/server/harness/audit.ts

import { db } from "@/server/db";
import { logger } from "@/lib/logger";

export async function logAudit(event: {
 practiceId?: string;
 userId?: string;
 action: string;
 entityType: string;
 entityId?: string;
 oldValue?: any;
 newValue?: any;
 metadata?: Record<string, any>;
 ipAddress?: string;
 userAgent?: string;
}) {
 // Store in database
 await db.auditLog.create({
 data: {
 practiceId: event.practiceId,
 userId: event.userId,
 action: event.action,
 entityType: event.entityType,
 entityId: event.entityId,
 oldValue: event.oldValue,
 newValue: event.newValue,
 metadata: event.metadata || {},
 ipAddress: event.ipAddress,
 userAgent: event.userAgent,
 },
 });
 
 // Also log to structured logger
 logger.info({
 event: "audit",
 practiceId: event.practiceId,
 userId: event.userId,
 action: event.action,
 entityType: event.entityType,
 entityId: event.entityId,
 }, "Audit event");
}

20. Security & Compliance#

20.1 Token Encryption#

// src/lib/crypto.ts

import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";

const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;

function getKey(): Buffer {
 return scryptSync(process.env.ENCRYPTION_KEY!, "rankflow-salt", 32);
}

export function encrypt(value: string): string {
 const key = getKey();
 const iv = randomBytes(IV_LENGTH);
 const cipher = createCipheriv(ALGORITHM, key, iv);
 
 const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
 const authTag = cipher.getAuthTag();
 
 return `${iv.toString("base64")}:${authTag.toString("base64")}:${encrypted.toString("base64")}`;
}

export function decrypt(encryptedValue: string): string {
 const key = getKey();
 const [ivB64, authTagB64, encryptedB64] = encryptedValue.split(":");
 
 const iv = Buffer.from(ivB64, "base64");
 const authTag = Buffer.from(authTagB64, "base64");
 const encrypted = Buffer.from(encryptedB64, "base64");
 
 const decipher = createDecipheriv(ALGORITHM, key, iv);
 decipher.setAuthTag(authTag);
 
 return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
}

20.2 DPDPA Compliance#

// src/server/lib/compliance/dpdpa.ts

export async function exportPracticeData(practiceId: string) {
 const [
 practice,
 locations,
 members,
 gbpAccounts,
 socialAccounts,
 citations,
 contentPieces,
 reviews,
 jobs,
 reports,
 auditLogs,
 ] = await Promise.all([
 db.practice.findUnique({ where: { id: practiceId } }),
 db.location.findMany({ where: { practiceId } }),
 db.practiceMember.findMany({ where: { practiceId }, include: { user: true } }),
 db.gbpAccount.findMany({ where: { practiceId } }),
 db.socialAccount.findMany({ where: { practiceId } }),
 db.citation.findMany({ where: { practiceId } }),
 db.contentPiece.findMany({ where: { practiceId } }),
 db.review.findMany({ where: { location: { practiceId } } }),
 db.job.findMany({ where: { practiceId } }),
 db.report.findMany({ where: { practiceId } }),
 db.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.skillId, status: j.status, createdAt: j.createdAt })),
 reports,
 auditLogs,
 },
 generatedAt: new Date().toISOString(),
 format: "JSON",
 };
}

export async function deletePracticeData(practiceId: string) {
 await db.$transaction([
 db.practice.update({
 where: { id: practiceId },
 data: {
 name: `[DELETED] ${Date.now()}`,
 status: "CANCELLED",
 deletedAt: new Date(),
 profileUrl: null,
 stripeCustomerId: null,
 razorpayCustomerId: null,
 },
 }),
 db.gbpAccount.updateMany({
 where: { practiceId },
 data: { accessToken: "[DELETED]", refreshToken: "[DELETED]", isActive: false },
 }),
 db.socialAccount.updateMany({
 where: { practiceId },
 data: { accessToken: "[DELETED]", refreshToken: "[DELETED]", isActive: false },
 }),
 ]);
}

21. The 40 Skills (Complete Registry)#

Skill Dependency Graph#

01-practice-onboard
 ├── 02-asset-collect
 ├── 03-gbp-connect
 │ ├── 04-gbp-post-create
 │ │ └── 05-gbp-post-schedule
 │ ├── 06-gbp-review-monitor
 │ │ └── 07-gbp-review-reply
 │ ├── 08-gbp-insights-fetch
 │ └── 09-gbp-qa-manage
 ├── 10-social-connect
 │ ├── 11-social-post-create
 │ │ └── 12-social-post-schedule
 ├── 13-citation-submit
 │ ├── 14-citation-verify-nap
 │ ├── 15-citation-monitor
 │ └── 16-citation-delete
 ├── 17-owned-site-create
 │ └── 18-owned-site-publish
 ├── 19-directory-profile-generate
 │ ├── 20-directory-profile-publish
 │ └── 21-directory-profile-refresh
 │ ├── 25-schema-generate
 │ ├── 26-keyword-track
 │ ├── 27-seo-audit
 │ └── 28-competitor-track
 ├── 22-profile-layout-standard
 ├── 23-profile-layout-premium
 ├── 24-profile-layout-enhanced
 ├── 29-content-generate
 │ └── 30-content-approve
 ├── 31-prompt-manage
 ├── 32-report-generate
 │ └── 33-report-email
 ├── 34-lead-capture
 │ └── 35-lead-notify
 ├── 36-billing-subscribe
 │ ├── 37-billing-invoice
 │ └── 38-billing-cancel
 └── 39-admin-kpi
 └── 40-admin-job-monitor

Full Skill List#

# ID Name Category Phase Autonomous Approval External APIs
01 practice-onboard Practice Onboarding onboarding 1
02 asset-collect Asset Collection onboarding 1 S3
03 gbp-connect GBP Connect gbp 3 Google OAuth
04 gbp-post-create GBP Post Create gbp 3 GBP API, Claude
05 gbp-post-schedule GBP Post Schedule gbp 3 GBP API
06 gbp-review-monitor GBP Review Monitor gbp 3 GBP API
07 gbp-review-reply GBP Review Reply gbp 3 ✅ (negative) GBP API, Claude
08 gbp-insights-fetch GBP Insights Fetch gbp 3 GBP API
09 gbp-qa-manage GBP Q&A Manage gbp 3 GBP API, Claude
10 social-connect Social Connect social 3 Composio
11 social-post-create Social Post Create social 3 Composio, Claude
12 social-post-schedule Social Post Schedule social 3 Zernio
13 citation-submit Citation Submit citation 4 Hyperbrowser
14 citation-verify-nap Citation Verify NAP citation 4 Firecrawl
15 citation-monitor Citation Monitor citation 4 Firecrawl
16 citation-delete Citation Delete citation 4 Hyperbrowser
17 owned-site-create Owned Site Create citation 4
18 owned-site-publish Owned Site Publish citation 4
19 directory-profile-generate Directory Profile Generate directory 2 Claude, S3
20 directory-profile-publish Directory Profile Publish directory 2 Cloudflare
21 directory-profile-refresh Directory Profile Refresh directory 5 DataForSEO, Claude
22 profile-layout-standard Profile Layout: Standard directory 2
23 profile-layout-premium Profile Layout: Premium directory 2
24 profile-layout-enhanced Profile Layout: Enhanced directory 2
25 schema-generate Schema.org Generate seo 2 Claude
26 keyword-track Keyword Track seo 5 DataForSEO, SerpAPI
27 seo-audit SEO Audit seo 5 DataForSEO, Claude
28 competitor-track Competitor Track seo 5 DataForSEO
29 content-generate Content Generate content 2 Claude
30 content-approve Content Approve content 5
31 prompt-manage Prompt Manage content 2
32 report-generate Report Generate report 5 DataForSEO, Claude
33 report-email Report Email report 5 Resend
34 lead-capture Lead Capture lead 2
35 lead-notify Lead Notify lead 2 Resend
36 billing-subscribe Billing Subscribe billing 1 Stripe, Razorpay
37 billing-invoice Billing Invoice billing 1 Stripe, Razorpay
38 billing-cancel Billing Cancel billing 1 Stripe, Razorpay
39 admin-kpi Admin KPI admin 5
40 admin-job-monitor Admin Job Monitor admin 5

22. Phased Build Plan#

Phase 0: Foundation (Weeks 1-2)#

Deliverable Skills Status Gate
Monorepo setup Repo created, CI/CD passing
Prisma schema All prisma migrate dev succeeds
Auth system 01 Admin can log in, RBAC works
Database deployed Postgres running on Dokploy
CI/CD on GitHub Actions Push to main auto-deploys

Phase 1: Core Platform (Weeks 3-5)#

Deliverable Skills Status Gate
Client onboarding flow 01, 02 New client can sign up, create practice
Admin client management 39 Admin can CRUD clients
Plan/subscription system 36, 37, 38 Stripe/Razorpay webhooks working
Directory profile ISR 19, 20, 22-24 Client gets profile URL, profile renders
Single domain DNS 20 directory.com resolves
Asset upload 02 S3 upload working

Phase 2: AI Engine (Weeks 6-8)#

Deliverable Skills Status Gate
Multi-LLM router 29, 31 Claude + GPT-4o fallback working
Prompt template system 31 Templates in DB, versioned
Content generation 29 AI generates content in < 10s
Citation descriptions 29 30 unique descriptions generated
directory profile content 19 Bio, services, FAQs, schema generated
Schema generation 25 JSON-LD valid per Google
Job queue (Inngest + BullMQ) All Jobs execute, retry, log

Phase 3: GBP + Social (Weeks 9-11)#

Deliverable Skills Status Gate
GBP OAuth + API 03 Client can connect GBP
GBP post publish 04, 05 Weekly posts auto-publish
Review monitoring 06 Daily review poll working
Review auto-reply 07 Positive reviews auto-replied
Composio social auth 10 Client can connect IG/FB
Social post publish 11, 12 Posts scheduled and published
Content approval gate 30 Medical clients see approval queue

Phase 4: Citation Network (Weeks 12-14)#

Deliverable Skills Status Gate
Citation site management 13-16 5 test clients have 20+ live citations
Submission engine 13 API + form-based submissions
NAP monitoring 14, 15 95%+ consistency
Owned blog site creation 17, 18 5 sites live
Hyperbrowser integration 13 Form submissions working
Firecrawl integration 14 NAP verification working

Phase 5: Reporting + Polish (Weeks 15-16)#

Deliverable Skills Status Gate
Monthly PDF email reports 32, 33 Report auto-generated and emailed
Client email alerts 35 GBP drop, citation removed alerts
Admin analytics dashboard 39 System KPIs visible
Content approval workflow 30 Full approval flow working
Review approval gate 07 Negative reviews queued
Vernacular content 29 Malayalam content generated

Phase 6: Beta Launch (Weeks 17-18)#

Deliverable Skills Status Gate
Bug fixes All Zero critical bugs
Performance optimization All < 2s page load
10 Kerala beta clients All All active, using platform
Feedback collection NPS survey, support tickets

Total: 18 weeks (4.5 months)


23. Appendix: Cost Projections#

Infrastructure (Monthly)#

Service Tier Cost (USD)
EC2 t3.xlarge (Dokploy) 4 vCPU, 16GB $130
S3 (assets + PDFs) 100GB $5
Cloudflare Pro DNS + CDN $20
Total Infrastructure ~$155/mo

API Costs (Per 100 Clients)#

Service Usage Cost (USD)
Claude Sonnet 30k requests/mo $150
GPT-4o 10k requests/mo $50
DataForSEO 50k API calls/mo $100
SerpAPI 10k calls/mo $50
Hyperbrowser 500 sessions/mo $50
Firecrawl 2k scrapes/mo $10
Composio 100 connections $30
Zernio 500 schedules/mo $30
Resend 50k emails/mo $0 (free tier)
Total API ~$470/mo

Total Monthly Cost @ 100 Clients#

Category Cost
Infrastructure $155
APIs $470
Total ~$625/mo
Per client ~$6.25/mo

At Rs 8,000 ARPU ($95), gross margin = ~93%.


End of Corrected Technical Specification