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#
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#
### 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.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#
// 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 |
| 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