Petunia Platform Architecture
Version: 2.0.0 Last Updated: 2025-12-12 Status: Living Document - Ideal State Definition
This document defines the ideal architectural state for Petunia - the "north star" that guides all development decisions. It describes both the current state and the target architecture, serving as the authoritative reference for system design.
Table of Contents
- Executive Summary
- Entity Hierarchy & Relationships
- Multi-Tenant Architecture
- Customer Journey: Contact → Lead → Customer
- Database Schema (133+ Models)
- Knowledge Architecture (4-Tier)
- Async/Queue Architecture
- Webhook Idempotency
- LLM Cost Tracking
- Voice AI Architecture
- Billing & Commission System
- Authentication & Security
- Observability Stack
- API Architecture
- Data Retention & GDPR
- Disaster Recovery
- Demo Portal Configuration
- Cross-Portal Deduplication
- Design Decisions
- Migration Roadmap
Executive Summary
Petunia is a multi-tenant customer engagement platform providing unified inbox management, AI-powered autoresponders, lead management, voice AI, and review aggregation across 6+ platforms (Yelp, Google, Facebook, Apple Maps, Nextdoor, BBB).
Core Architecture Principles
- Portal-First Multi-Tenancy: Portal is the primary tenant boundary where all stakeholders meet
- Company Persistence: Business data (Products, Services) persists across portal lifecycles
- Defense-in-Depth Security: 4-layer isolation (Middleware → Service → Query → RLS)
- Event-Driven Processing: BullMQ + Database-backed queues with automatic failover
- AI Provider Abstraction: Unified interface across Anthropic, OpenAI, Cartesia, ElevenLabs
Technology Stack
| Layer | Technology |
|---|---|
| Frontend | Next.js 15 App Router, React 19, TailwindCSS |
| Backend | Next.js API Routes (415+ routes), Edge Runtime |
| Database | PostgreSQL (Supabase), Prisma ORM |
| Queue | BullMQ (Redis) + Database fallback |
| Vector Store | Pinecone (Platform), Upstash Vector (Tenant) |
| Auth | Supabase Auth, TOTP 2FA, Session Rotation |
| Payments | Stripe (Subscriptions, Connect, Checkout) |
| Voice AI | Cartesia (primary), ElevenLabs, Retell |
| Observability | Sentry, Prometheus, Structured Logging |
| Real-time | WebSocket Server, Supabase Realtime |
Entity Hierarchy & Relationships
Core Entity Chain
User (Authentication)
├── UserCompany ──→ Company (Business Entity)
│ ├── Client ──→ Portal (Tenant Workspace)
│ │ ├── Business (PortalAIConfig) ← Voice AI settings
│ │ ├── Conversations/Messages
│ │ ├── Leads/Contacts
│ │ └── Reviews/Integrations
│ ├── Products (persist across portals)
│ └── Services (persist across portals)
├── UserPortalAccess ──→ Portal (Role-based access)
└── UserClient ──→ Client (Legacy relationship)
Key Entity Definitions
| Entity | Purpose | Lifecycle |
|---|---|---|
| User | Authenticated identity with MFA, preferences | Permanent |
| Company | Business organization, billing entity | Permanent |
| Client | Organization representation, legacy | Permanent |
| Portal | Workspace container (petuniaID), tenant boundary | Can be deactivated |
| Business | Portal AI config (voice settings, greeting) | Tied to Portal |
| Contact | Customer record with purchase history | Portal-scoped |
| Lead | Sales opportunity with pipeline stages | Portal-scoped |
Ideal Entity Relationships (Target State)
// Target: Clean foreign key relationships
model Company {
id String @id
clients Client[] // One Company → Many Clients
products Product[] // Products persist at Company level
services Service[] // Services persist at Company level
}
model Client {
id String @id
companyId String // Required FK (currently broken: default "default")
Company Company @relation(fields: [companyId], references: [id])
portals Portal[] // One Client → Many Portals (typically 1 active)
}
model Portal {
id String @id
petuniaID String @unique // Human-readable identifier
clientId String // Required FK to Client
Client Client @relation(fields: [clientId], references: [id])
AIConfig PortalAIConfig? // 1:1 with voice AI settings
isDemoPortal Boolean @default(false)
}
Join Table Schemas
// User ↔ Company (many-to-many with role)
model UserCompany {
id String @id @default(cuid())
userId String
companyId String
role String @default("member") // owner, admin, member
createdAt DateTime @default(now())
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
@@unique([userId, companyId])
@@index([companyId])
}
// User ↔ Portal (many-to-many with role and permissions)
model UserPortalAccess {
id String @id @default(cuid())
userId String
portalId String
role String @default("viewer") // admin, editor, viewer
createdAt DateTime @default(now())
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Portal Portal @relation(fields: [portalId], references: [id], onDelete: Cascade)
@@unique([userId, portalId])
@@index([portalId])
}
// Conversation ↔ Contact/Lead (many-to-many participants)
model ConversationParticipant {
id String @id @default(cuid())
conversationId String
contactId String? // Either contact or lead
leadId String?
joinedAt DateTime @default(now())
leftAt DateTime?
Conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
Contact Contact? @relation(fields: [contactId], references: [id])
Lead Lead? @relation(fields: [leadId], references: [id])
@@index([conversationId])
@@index([contactId])
@@index([leadId])
}
Source of Truth: See
/prisma/schema.prismafor complete, authoritative schema definitions.
Current Issues (See ARCHITECTURE_PLAN.md)
- P0-1:
Client.companyIdhas no FK constraint, defaults to "default" - P0-8:
Portal.clientIdFK missing for direct linkage - P0-9: Portal→Client→Company chain broken
Multi-Tenant Architecture
Tenant Isolation Model
┌─────────────────────────────────────────────────────────────┐
│ ISOLATION LAYERS │
├─────────────────────────────────────────────────────────────┤
│ Layer 1: MIDDLEWARE │
│ • Session validation (Supabase Auth) │
│ • Rate limiting (Upstash Redis) │
│ • CSRF protection (constant-time comparison) │
├─────────────────────────────────────────────────────────────┤
│ Layer 2: SERVICE (TenantAccessService) │
│ • getTenantContext() validates portal access │
│ • buildPortalScope() adds WHERE clauses │
│ • validateMessageAccess() per-resource checks │
├─────────────────────────────────────────────────────────────┤
│ Layer 3: QUERY (Prisma) │
│ • All queries include portalId filter │
│ • No cross-portal data leakage │
├─────────────────────────────────────────────────────────────┤
│ Layer 4: DATABASE (PostgreSQL RLS) │
│ • Row-Level Security policies on sensitive tables │
│ • Service role bypass for background jobs │
└─────────────────────────────────────────────────────────────┘
Key Files
/proxy.ts- 1060 lines, session validation/lib/services/tenant-access.ts- Core tenant context/lib/utils/portalAccess.ts- Portal listing and roles/supabase/migrations/20251205_apply_core_rls_policies.sql- RLS policies
Access Validation Flow
// Every API request follows this pattern:
1. Middleware validates session
2. getTenantContext(user, petuniaID) validates access
3. buildPortalScope(context) returns { portalId: string }
4. Query includes portalId filter
5. RLS provides database-level backup
Platform Admin Bypass
- Stored in
Admintable withisPlatformAdmin: true - Bypasses UserPortalAccess validation
- 5-minute cache TTL for performance
- No email-based hardcoding (database-driven only)
Customer Journey: Contact → Lead → Customer
Journey Model
CONTACT LEAD CUSTOMER
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ name │ │ contactId │──1:1────→ │ totalValue │
│ email │◄──────────│ score │ │ customerSince│
│ phone │ optional │ journeyStage│ │ lastPurchase │
│ clientId │ │ pipelineId │ │ (calculated) │
│ companyId │ │ stageId │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
│ │
│ ▼
│ ┌─────────────┐
│ │ Pipeline │
│ │ Stages │
│ │ (kanban) │
│ └─────────────┘
│
▼
┌─────────────┐
│Conversation │
│ Messages │
│ (history) │
└─────────────┘
Schema Definition
model Contact {
id String @id
name String
email String?
phone String?
clientId String // Required: belongs to Client
companyId String? // Optional: Company scope
// Customer graduation fields
totalValue Float @default(0) // Lifetime value
lastPurchaseDate DateTime? // Most recent purchase
customerSince DateTime? // When became customer
// Relationships
Lead Lead? // Optional 1:1 to Lead
Conversations Conversation[]
CallRecords CallRecord[]
}
model Lead {
id String @id
contactId String? @unique // Links to Contact
Contact Contact? @relation(...)
// Scoring
score Int @default(0)
journeyStage String? // new, engaged, qualified, customer
// Pipeline
pipelineId String?
pipelineStageId String?
Pipeline LeadPipeline?
PipelineStage LeadPipelineStage?
// Soft delete for merging
isDeleted Boolean @default(false)
}
Pipeline Stages
New → Contacted → Qualified → Proposal → Negotiation → Won/Lost
Customer Graduation
A Contact becomes a Customer when:
totalValue > 0(has made a purchase)customerSinceis set (graduation date)- Journey stage updated to "customer"
Database Schema (133+ Models)
Model Categories
| Category | Count | Key Models |
|---|---|---|
| Auth & Identity | 5 | User, Account, Session, Admin, VerificationToken |
| User Relationships | 13 | UserCompany, UserPortalAccess, UserPreference, OnboardingData |
| Client & Company | 3 | Client, Company, CompanySettings |
| Portal & Business | 3 | Portal, PortalLocation, Business |
| Contacts & Leads | 14 | Contact, Lead, LeadPipeline, LeadPipelineStage, LeadScore |
| Conversations | 9 | Conversation, Message, CallRecord, ConversationParticipant |
| 8 | FacebookConnection, FacebookMessage, FacebookReview | |
| Google/Apple | 6 | GoogleConnection, GoogleReview, AppleMapsReview |
| Yelp | 8 | YelpConnection, YelpMessage, YelpReview, YelpCompetitor |
| Nextdoor/BBB | 6 | NextdoorConnection, BbbConnection, reviews |
| Integrations | 5 | Connection, ConnectionIntegration, CallLogSyncJob |
| Autoresponder | 7 | AutoresponderSettings, KnowledgeBaseEntry, sequences |
| Voice | 3 | VoiceTouchpoint, PhoneNumberMapping, MessagingProvider |
| Products/Sales | 5 | Product, Service, Quote, ConversionLink |
| Commission | 8 | CommissionRule, CommissionTransaction, CommissionEvent |
| Billing | 6 | CreditWallet, Subscription, Invoice, WalletTransaction |
| Analytics | 6 | AnalyticsRollupDaily, PortalAnalyticsSummary, TTFV |
| Audit | 5 | AuditLog, ErrorLog, SettingsAuditLog |
| Feature Flags | 3 | FeatureFlag, FeatureFlagClientOverride |
| Platform | 4 | PlatformTrialConfig, Template, DemoTelemetry |
Critical Model Issues
| Model | Issue | Priority |
|---|---|---|
Client.companyId | String default "default", no FK | P0 |
Lead.clientId | Optional, no FK relation | P4 |
Business | Should be renamed to PortalAIConfig | P2 |
Product/Service | businessId should be companyId | P1 |
Knowledge Architecture (4-Tier)
Tier Overview
┌─────────────────────────────────────────────────────────────┐
│ TIER 1: PLATFORM KNOWLEDGE (Pinecone) │
│ • Petunia's brain - how the platform works │
│ • Documentation, help articles, features │
│ • Index: platform-knowledge │
│ • Shared across all tenants │
└─────────────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ TIER 2: COMPANY KNOWLEDGE (Upstash Vector) │
│ • Business offerings, products, services │
│ • Persists when client gets new portal │
│ • Namespace: company-{companyId} │
│ • Shared across portals within company │
└─────────────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ TIER 3: PORTAL KNOWLEDGE (Upstash Vector) │
│ • Workspace-specific context │
│ • Team members, integrations, workflows │
│ • Namespace: portal-{portalId} │
│ • Portal-scoped, resets with new portal │
└─────────────────────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ TIER 4: CONVERSATION KNOWLEDGE (In-Memory/Redis) │
│ • Real-time conversation context │
│ • Recent messages, current topic, sentiment │
│ • Ephemeral, session-scoped │
└─────────────────────────────────────────────────────────────┘
Retrieval Priority
async function retrieveKnowledge(query: string, context: TenantContext) {
// 1. Check conversation cache (fastest)
const conversationContext = await getConversationContext(context.conversationId);
// 2. Query portal-specific knowledge
const portalKnowledge = await upstash.query({
namespace: `portal-${context.portalId}`,
topK: 3
});
// 3. Query company knowledge (persists across portals)
const companyKnowledge = await upstash.query({
namespace: `company-${context.companyId}`,
topK: 3
});
// 4. Query platform knowledge (Petunia docs)
const platformKnowledge = await pinecone.query({
index: 'platform-knowledge',
topK: 2
});
return mergeAndRank([conversationContext, portalKnowledge, companyKnowledge, platformKnowledge]);
}
Failure Handling
| Scenario | Behavior | Fallback |
|---|---|---|
| Pinecone down/timeout (>3s) | Skip platform tier, continue with tenant tiers | Log to Sentry with knowledge.retrieval.failed tag |
| Upstash Vector timeout (>3s) | Skip that tier (company or portal), continue | Use cached results if available, log failure |
| LLM returns garbage/invalid | Validate response structure, retry once | Return cached response or escalate to human agent |
| All vector stores fail | Proceed with conversation context only | Alert ops, degrade to keyword matching |
| Complete knowledge failure | Return graceful error to user | Escalate to human immediately, notify on-call |
All failures logged to Sentry with knowledge.retrieval.failed tag and include tier, latency, and error details.
Key Files
/lib/services/ai/knowledge-ai.ts- Knowledge retrieval/lib/services/voice/voiceKnowledgeConnector.ts- Voice AI integration
Async/Queue Architecture
Queue System Overview
┌─────────────────────────────────────────────────────────────┐
│ PRIMARY: BullMQ (Redis) │
│ • ttfv-calculation, analytics-rollup, message-processing │
│ • notification, competitor-analysis, email/sms-delivery │
│ • webhook-delivery │
│ • Default: 3 retries, exponential backoff (1s base) │
│ • Retention: completed 1hr, failed 24hr │
└─────────────────────────────────────────────────────────────┘
│
(if Redis unavailable)
▼
┌─────────────────────────────────────────────────────────────┐
│ FALLBACK: Database-Backed Queues (Prisma) │
│ • YelpReviewSyncJob, GoogleReviewSyncJob, CallLogSyncJob │
│ • States: pending → running → completed | dead_letter │
│ • 5 retries, exponential backoff (60s base, 12hr max) │
│ • Stuck job recovery: 15-minute threshold │
└─────────────────────────────────────────────────────────────┘
Cron Jobs (Vercel)
| Endpoint | Schedule | Purpose |
|---|---|---|
/api/health/edge | Every 15 min | Health check |
/api/cron/yelp-reviews | Every 15 min | Yelp review sync |
/api/cron/google-reviews | Every 15 min | Google review sync |
/api/cron/message-status | Every 5 min | Message delivery status |
/api/cron/orphan-cleanup | Every 6 hours | Orphaned record cleanup |
/api/cron/dunning | Daily 8 AM | Payment retry |
/api/cron/usage-warnings | Every 15 min | Usage threshold alerts |
/api/cron/lead-settlement | Daily 6 AM | Lead settlement |
/api/cron/presence-cleanup | Every 5 min | Stale presence cleanup |
/api/cron/data-retention | Daily 3 AM | GDPR data retention |
Retry Policies
| System | Max Attempts | Backoff | Max Delay |
|---|---|---|---|
| BullMQ (default) | 3 | Exponential (1s) | N/A |
| Review Sync Jobs | 5 | Exponential (60s) | 12 hours |
| Webhook Delivery | 2 | Exponential (2x) | 30 seconds |
| withRetry utility | 3 | Exponential (2x) | Configurable |
Key Files
/lib/queue/bullmq-queue.ts- BullMQ implementation/lib/jobs/yelpReviewSyncJobService.ts- Database job pattern/vercel.json- Cron configuration
Webhook Idempotency
Signature Verification by Provider
| Provider | Header | Algorithm |
|---|---|---|
| Yelp | x-yelp-signature | HMAC-SHA256 |
| Facebook/Instagram | x-hub-signature-256 | HMAC-SHA256 |
| Google (Pub/Sub) | Authorization: Bearer | JWT |
| Twilio | x-twilio-signature | HMAC-SHA1 |
| Mailgun | Provider-specific | HMAC |
| Sentry | sentry-hook-signature | HMAC-SHA256 + timestamp |
Deduplication Strategy
// Current: Database-based deduplication
const existingMessage = await prisma.message.findFirst({
where: { externalId: messageId }
});
if (existingMessage) {
return { success: true, isDuplicate: true };
}
// Target: Add unique constraint
@@unique([externalId, providerId, portalId])
Critical Gaps
- No Redis/cache-based idempotency - relies on database lookup
- Race condition vulnerability - no atomic check+insert
- No unique constraint on
(externalId, providerId) - Missing idempotency key header support
Key Files
/app/api/webhooks/*/route.ts- Provider handlers/tests/app/api/webhooks/*-signature.test.ts- Signature tests
LLM Cost Tracking
Platform Usage Tracker
// File: /lib/services/platform-usage-tracker.ts (1,860 lines)
interface UsageTracking {
clientId: string;
provider: 'anthropic' | 'openai' | 'elevenlabs' | 'cartesia';
keySource: 'platform' | 'client';
operationalContext: 'demo' | 'client-facing' | 'internal';
callCount: number;
estimatedCost: number;
metadata: {
inputTokens?: number;
outputTokens?: number;
characters?: number;
audioMinutes?: number;
};
}
Provider Pricing (Nov 2025)
| Provider | Type | Cost |
|---|---|---|
| Anthropic (Claude) | Tokens | $3/1M input, $15/1M output |
| OpenAI (GPT-4 Turbo) | Tokens | $10/1M input, $30/1M output |
| ElevenLabs (Voice) | Characters | $0.30/1k characters |
| Cartesia | Minutes | $0.05/minute |
| Retell | Minutes | $0.10/minute |
| Twilio (SMS) | Messages | $0.0075/message |
Budget Alerts
| Level | Threshold | Action |
|---|---|---|
| Safe | 0-79% | No alert |
| Warning | 80-89% | Email notification |
| Critical | 90-99% | Email + SMS + auto-pause option |
| Exceeded | 100%+ | Services paused |
Model Selection & Fallback
// Primary: Claude 3 Haiku
// Fallback chain:
1. Anthropic (Claude) if available
2. OpenAI (GPT-4 Turbo) if Anthropic fails
3. Regex/pattern matching if both fail
// Confidence: 0.95 (AI) → 0.60 (regex)
Voice AI Architecture
Provider Hierarchy
┌─────────────────────────────────────────────────────────────┐
│ CARTESIA (Primary) │
│ • Sonic 3.0 TTS - ultra-realistic voices │
│ • Cartesia Line - real-time conversational AI │
│ • Cartesia Ink - STT (Ink-Whisper model) │
└─────────────────────────────────────────────────────────────┘
(automatic failover)
▼
┌─────────────────────────────────────────────────────────────┐
│ ELEVENLABS (Fallback) │
│ • v3 TTS and Conversational AI │
│ • Multiple voice options (Rachel, Bella, Dorothy) │
│ • 30-minute cache for frequent phrases │
└─────────────────────────────────────────────────────────────┘
│
(client integration only)
▼
┌─────────────────────────────────────────────────────────────┐
│ RETELL (Client Integration) │
│ • Full telephony support │
│ • Agent creation and management │
│ • Not platform default │
└─────────────────────────────────────────────────────────────┘
Inbound Call Flow
1. Incoming call → handleInboundCall()
2. Agent config resolution (5-min cache):
a. Business.voiceAIAgentId (custom)
b. Platform env var (CARTESIA_AGENT_ID)
c. Default: 'default-agent'
3. Transfer number resolution:
a. Business.voiceAITransferNumber
b. RINGCENTRAL/TWILIO_PHONE_NUMBER
4. Greeting generation:
a. Business.voiceAIGreeting (custom)
b. voiceKnowledgeConnector.generateInboundGreeting()
c. Default: "Thank you for calling..."
5. Response: TwiML with greeting + action
Business Model Voice Config (Target: PortalAIConfig)
model Business { // TODO: Rename to PortalAIConfig
portalId String? @unique
voiceAIEnabled Boolean @default(true)
voiceAIAgentId String?
voiceAITransferNumber String?
voiceAIGreeting String?
voiceAIProvider String? @default("cartesia")
// Move to Company:
products Product[] // Should be Company.products
services Service[] // Should be Company.services
}
Key Files
/lib/services/voice/index.ts- Provider initialization/lib/services/voice/unifiedVoiceService.ts- Unified interface/lib/services/voice/inboundCallHandler.ts- Call flow/lib/services/voice/voiceKnowledgeConnector.ts- Knowledge integration
Billing & Commission System
Wallet Model
┌─────────────────────────────────────────────────────────────┐
│ CREDIT WALLET (Prepaid) │
│ • leadCredits - Prepaid lead purchases │
│ • smsCredits - SMS message credits │
│ • voiceCredits - Voice AI minutes │
│ • aiCredits - AI/LLM token credits │
│ • Trust ladder: immediate → monthly billing │
└─────────────────────────────────────────────────────────────┘
Commission System
Lead → Appointment → Closure → Commission
│ │ │ │
▼ ▼ ▼ ▼
2% 5% 7% Revenue-based
Stripe Integration
- Subscriptions: Base monthly fee
- Stripe Connect: OAuth payment attribution
- Webhooks: Wallet top-ups, payment failures
- Customer Portal: Self-service billing management
Key Files
/docs/features/BILLING_SYSTEM.md- Comprehensive documentation/lib/services/platform-usage-tracker.ts- Usage tracking/app/api/payments/*- Payment API routes
Authentication & Security
Auth Stack
┌─────────────────────────────────────────────────────────────┐
│ SUPABASE AUTH │
│ • SSR-compatible cookie-based authentication │
│ • OAuth PKCE flow (Google, Facebook) │
│ • Email verification required │
└─────────────────────────────────────────────────────────────┘
+
┌─────────────────────────────────────────────────────────────┐
│ SESSION MANAGEMENT │
│ • JWT-based with HMAC-SHA256 signing │
│ • Automatic rotation (24hr interval) │
│ • Force rotation on privilege change │
│ • Max session age: 7 days │
└─────────────────────────────────────────────────────────────┘
+
┌─────────────────────────────────────────────────────────────┐
│ 2FA (TOTP) │
│ • HMAC-SHA1, 6-digit codes, 30-second period │
│ • ±30 second time window tolerance │
│ • 20-byte (160-bit) secrets │
└─────────────────────────────────────────────────────────────┘
Rate Limiting
| Tier | Limit | Purpose |
|---|---|---|
| auth | 50/15min | Login/signup |
| otp | 3/15min | 2FA attempts |
| api | 100/min | General API |
| heavy | 10/min | Resource-intensive |
| webhook | 1000/min | Webhook handlers |
Security Headers
- CSP: Restrictive default-src, specific allowlists
- HSTS: 1 year, includeSubDomains, preload
- X-Frame-Options: DENY
- Permissions-Policy: Restrictive camera/mic/geo
Encryption
- At-rest: AES-256-GCM with PBKDF2 (100K iterations)
- Tenant isolation: HKDF-derived keys per tenant
- Sensitive fields: Auto-detection and encryption
Key Files
/proxy.ts- Auth validation, rate limiting/lib/auth/sessionRotation.ts- Session management/lib/utils/totp.ts- TOTP implementation/docs/features/ENCRYPTION.md- Encryption documentation
Observability Stack
Three Pillars
┌─────────────────────────────────────────────────────────────┐
│ LOGGING (Structured) │
│ • 19 pre-configured module loggers │
│ • JSON context formatting │
│ • Performance timers │
│ • File: /lib/utils/logging.ts │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ METRICS (Prometheus) │
│ • HTTP: request_duration, requests_total, active_connections│
│ • Database: query_duration, connection_pool │
│ • Queue: jobs_total, job_duration, jobs_active/waiting/failed│
│ • Business: leads_created, reviews_processed │
│ • SLO: webhook/payment request duration │
│ • File: /lib/services/monitoring/prometheusMetrics.ts │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ TRACING (Sentry) │
│ • 10% sample rate (production) │
│ • Prisma database query tracking │
│ • Data scrubbing (passwords, tokens, API keys) │
│ • Profiling: 10% sample rate │
│ • Files: /sentry.*.config.ts │
└─────────────────────────────────────────────────────────────┘
Error Tracking
- Server-side: Queue-based batch processing (50 errors/5 seconds)
- Client-side: Browser capture with rate limiting
- Alerting: Slack, Discord, Email with threshold-based triggers
- Storage:
ErrorLogtable with fingerprint deduplication
Health Endpoints
| Endpoint | Purpose |
|---|---|
/api/health | Full component health |
/api/metrics | Prometheus metrics |
/api/diagnostics | System diagnostics |
/api/ping | Liveness probe |
/api/ready | Readiness probe |
API Architecture
Route Structure
/app/api/
├── auth/ # Authentication (signin, signup, 2fa)
├── users/ # User management
├── inbox/ # Conversations, messages
├── leads/ # Lead management
├── contacts/ # Contact management
├── reviews/ # Review aggregation
├── connections/ # Integration management
├── voice/ # Voice AI endpoints
├── communications/ # Voice calls, SMS
├── payments/ # Billing, subscriptions
├── platform/ # Platform admin
├── settings/ # User/portal settings
├── webhooks/ # External webhooks
├── cron/ # Scheduled jobs
└── health/ # Health checks
API Response Standard
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: unknown;
};
meta?: {
timestamp: string;
requestId: string;
pagination?: {
page: number;
pageSize: number;
total: number;
hasMore: boolean;
};
};
}
Versioning Strategy
- Current: Per-route version metadata (
@versionJSDoc) - OpenAPI: Static specs at
/docs/openapi.yaml - Target: URL-based versioning (
/api/v1/) for major changes - Deprecation: Not yet implemented (needs headers)
Key Files
/docs/openapi.yaml- API specification/lib/types/api.ts- Response types
Data Retention & GDPR
Retention Policies
| Data Type | Retention | Action |
|---|---|---|
| Behavior analytics | 90 days | Hard delete |
| Session data | 30 days | Hard delete |
| ML training data | 180 days | Hard delete |
| Audit logs | 365 days | Archive |
| Profile responses | Indefinite | Anonymize |
| Aggregated metrics | Indefinite | Keep |
GDPR Compliance
// Implemented: /lib/services/gdpr-compliance.ts
- exportUserData() // Article 20 - Data Portability
- deleteUserData() // Article 17 - Right to Erasure
- anonymizeUserData() // Alternative to deletion
- trackConsent() // Consent management
- generatePrivacyReport()
Audit Trail
AuditLogtable: 85+ event types- Auth, data access, security, admin actions
- IP address, user agent, metadata
- 365-day retention
Key Files
/lib/compliance/gdpr.ts- GDPR/CCPA/HIPAA classes/lib/services/gdpr-compliance.ts- Implementation/app/api/cron/data-retention/route.ts- Cleanup job
Disaster Recovery
Current State
| Aspect | Status |
|---|---|
| Database backups | Supabase automatic |
| RTO/RPO targets | NOT DOCUMENTED |
| Restore procedures | NOT DOCUMENTED |
| Cross-region failover | NOT IMPLEMENTED |
| Backup testing | NOT SCHEDULED |
Target State
| Aspect | Target |
|---|---|
| RTO | 4 hours |
| RPO | 1 hour |
| Backup testing | Quarterly drills |
| Cross-region | Secondary region for failover |
Rollback Procedures
Documented in /docs/PRE_DEPLOYMENT_CHECKLIST.md:
- Vercel rollback via CLI/dashboard
- Git revert to previous commit
- Emergency database fix procedures
Key Files
/docs/PRE_DEPLOYMENT_CHECKLIST.md- Deployment procedures/scripts/db-safety-check.sh- Migration safety/docs/security/incident-log.md- Incident tracking
Demo Portal Configuration
Canonical Identifier
export const DEMO_PETUNIA_ID = 'PID-DEMO-001';
export const DEMO_COMPANY_ID = 'demo-company';
export const DEMO_PORTAL_ID = 'demo-portal';
Detection Methods
// Method 1: PetuniaID check (preferred)
isDemoPortalByPetuniaID(petuniaID) // petuniaID === 'PID-DEMO-001'
// Method 2: Boolean flag
portal.isDemoPortal === true
// Method 3: ID check
isDemoPortal(id) // matches demo-company, demo-portal, or PID-DEMO-001
Demo Data Isolation
- All metrics queries exclude
isDemoPortal: false - Demo portals get simulated data from
/lib/services/monitoring/demoMetrics.ts - Storage isolated with
demo_prefix - Features restricted: data export, integration setup, billing
Demo User
- Email:
demo@example.com - Password:
password123 - Flag:
isDemoAdmin: true - Session: 4 hours
- Data reset: 24 hours
Key Files
/docs/DEMO.md- Demo documentation/lib/demo/DemoDataIsolation.ts- Isolation logic/lib/services/monitoring/demoMetrics.ts- Demo metrics
Cross-Portal Deduplication
Current Implementation
Scope: Single portal/client only (NO cross-portal deduplication)
// Detection algorithm (basic matching)
function isDuplicate(a, b): boolean {
// Email matching (case-insensitive)
if (normalizeEmail(a.email) === normalizeEmail(b.email)) return true;
// Phone matching (digits-only)
if (normalizePhone(a.phone) === normalizePhone(b.phone)) return true;
// Name matching (when contact info missing)
if (namesMatch(a.name, b.name) && (!a.email || !a.phone)) return true;
return false;
}
Merge Operation
// File: /lib/services/lead/leadService.prisma.ts
async mergeLeads(businessId, primaryLeadId, duplicateLeadIds) {
// 1. Move activities to primary
// 2. Move conversation participants
// 3. Soft delete duplicates (isDeleted = true)
// 4. Log merge activity
}
Limitations
- No database-level unique constraints on email/phone
- No cross-portal contact linking
- Race condition possible during concurrent creation
- No automatic duplicate detection triggers
Target State
- Add
@@unique([email, clientId])constraint - Add
@@unique([phone, clientId])constraint - Consider cross-company contact linking for enterprise
Design Decisions
Why Portal-First Architecture?
Decision: Portal is the primary tenant boundary, not Company.
Rationale:
- Fresh Start Capability: If a company needs a fresh start, they get a new Portal (old deactivated)
- Workspace Isolation: Each Portal is a complete workspace with its own integrations, team, settings
- AI Config Binding: Voice AI configuration is Portal-specific (different numbers, greetings, agents)
- Typical Usage: 1 Client = 1 active Portal (never 2 active simultaneously)
Consequence: Products/Services should persist at Company level, AI config at Portal level.
Why Business Model Split?
Decision: Split Business into PortalAIConfig (stays at Portal) and move Products/Services to Company.
Rationale:
- Products/Services persist: When client gets new portal, their product catalog shouldn't reset
- AI config is workspace-specific: Different portals might have different voice greetings, agents
- Clean separation: Business data vs. operational configuration
Why BullMQ + Database Fallback?
Decision: Use BullMQ as primary queue with database-backed fallback.
Rationale:
- Resilience: System continues without Redis using database as queue
- Feature parity: Both systems support retry, backoff, dead letter
- Observability: Both have metrics and health checks
- Graceful degradation: Production continues even if Redis fails
Why 4-Tier Knowledge Architecture?
Decision: Separate knowledge into Platform, Company, Portal, and Conversation tiers.
Rationale:
- Platform knowledge: Petunia's capabilities, shared across all
- Company knowledge: Products/services persist across portals
- Portal knowledge: Workspace-specific context, team, integrations
- Conversation: Real-time context, ephemeral
Why Prepaid Wallet for Billing?
Decision: Prepaid wallet model instead of post-paid.
Rationale:
- No bill shock: Customers know exactly what they'll spend
- Trust ladder: New customers start immediate billing, graduate to monthly
- Cash flow: Revenue collected upfront
- Simplicity: Single wallet for all usage types
Affiliate Partner Model
Decision: Deferred to Phase 2.
Target Implementation: UserAffiliate join table with commission tracking, linking Users to affiliate program with referral codes, commission rates, and payout tracking.
Rationale: Core entity relationships (User→Company→Client→Portal) must be stabilized before adding affiliate layer. Will be implemented after P0-P1 migrations complete.
Migration Roadmap
See ARCHITECTURE_PLAN.md for the complete 50+ item migration plan with priorities P0-P5.
Quick Reference
| Priority | Count | Focus |
|---|---|---|
| P0 | 9 | Critical: FK constraints, data integrity, encryption |
| P1 | 10 | High: Rate limiting, model migration, indexes |
| P2 | 8 | Medium: Type safety, model renaming, access control |
| P3 | 8 | Standard: Cleanup, standardization, observability |
| P4 | 8 | Lower: Schema refinement, validation, optimization |
| P5 | 5 | Documentation: Webhook, monitoring, voice AI docs |
Appendix: File Reference
Core Architecture Files
| Category | Key Files |
|---|---|
| Schema | /prisma/schema.prisma |
| Auth | /proxy.ts, /lib/auth/* |
| Tenant | /lib/services/tenant-access.ts |
| Queue | /lib/queue/bullmq-queue.ts, /lib/jobs/* |
| Voice | /lib/services/voice/* |
| Billing | /lib/services/platform-usage-tracker.ts |
| GDPR | /lib/services/gdpr-compliance.ts |
| Demo | /lib/demo/DemoDataIsolation.ts |
| Observability | /lib/utils/logging.ts, /lib/services/monitoring/* |
Documentation
| Document | Purpose |
|---|---|
/docs/ARCHITECTURE.md | This document (ideal state) |
/docs/ARCHITECTURE_PLAN.md | Migration todos |
/docs/DEMO.md | Demo portal |
/docs/features/BILLING_SYSTEM.md | Billing |
/docs/features/ENCRYPTION.md | Security |
/TESTING.md | Test guidelines |
This document is the authoritative reference for Petunia's architecture. All development decisions should align with the patterns and principles documented here.