PostgreSQL Schema
Engine: PostgreSQL 16
ORM: Prisma ORM (type-safe schema with migrations and auto-generated client)
Package: packages/db — schema, migrations, and the shared db singleton all live here
Which data goes here vs MongoDB? See Database Overview for the decision guide. ER diagrams for all tables: See SQL Entity Relations.
Base Record Fields
Every table inherits a standard set of fields via Prisma schema composition.
Field Definitions
| Field | Type | Present On | Notes |
|---|---|---|---|
id | uuid PK | All records | Auto-generated, primary key (internal FK use only) |
ref_id | varchar(26) UNIQUE | All records | ULID — globally unique across all tables, URL-safe, sortable by creation time. Used in external APIs and URLs. |
tenant_id | uuid FK → tenants | All tenant-scoped records | Not on global records (e.g. global templates) |
created_by | uuid FK → users | All records | User or agent principal that created the record |
created_on | timestamptz | All records | Set on insert, never changed |
updated_by | uuid FK → users | Mutable records | NULL if never updated |
updated_on | timestamptz | Mutable records | NULL if never updated |
deleted_by | uuid FK → users | Soft-deletable records | NULL if not deleted |
deleted_on | timestamptz | Soft-deletable records | NULL if active; set on soft delete. Standard queries filter WHERE deleted_on IS NULL. |
ref_id (ULID): Exposed in APIs, URLs, and webhooks. Sortable by creation time without a secondary sort column. The internal id (UUID) is used only for PostgreSQL foreign keys.
Prisma Schema
model BaseRecord {
id String @id @default(cuid())
refId String @unique
tenantId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
}Which Records Get Which Fields
| Table | Mutable | Soft Delete | Reason |
|---|---|---|---|
tenants | ✅ | ❌ | Tenants are suspended/cancelled via status, not deleted |
users | ✅ | ✅ | Soft delete on deactivation |
strategies | ✅ | ❌ | Versioned; old versions archived via status, not deleted |
goals | ✅ | ✅ | Removed if strategy changes |
deliverables | ✅ | ✅ | Can be disabled mid-strategy |
deliverable_periods | ✅ | ❌ | Historical record — immutable after close |
activities | ✅ | ✅ | Can be cancelled |
activity_runs | ❌ | ❌ | Immutable execution audit log |
activity_templates | ✅ | ✅ | Archived, not hard-deleted |
recurring_task_templates | ✅ | ✅ | Can be disabled per tenant |
agent_configs | ✅ | ❌ | Always exists per agent per tenant; disabled via is_enabled |
llm_calls | ❌ | ❌ | Immutable cost audit log |
sessions | ✅ | ❌ | Expires via TTL |
Tenants & Users
tenants
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
ref_id | varchar(26) UNIQUE | ULID |
slug | varchar(100) UNIQUE | URL-safe slug: acme-corp |
name | varchar(255) | Brand / Display Name — shown throughout the platform |
legal_name | varchar(500) | Legal company name — appears on invoices and contract PDFs |
industry | varchar(100) | |
website | varchar(500) | |
country | varchar(100) | Country selected at registration (Step 1) |
state | varchar(100) | State/province (Step 3) |
pan_number | varchar(10) | Indian PAN number — optional, for GST invoicing |
has_gst | boolean | Whether the tenant has a GST registration (Step 3) |
gst_number | varchar(20) | GST number — required if has_gst = true |
plan | enum | starter | professional | enterprise |
status | enum | trialing | active | suspended | cancelled |
data_privacy_level | enum | cloud_ok | local_only |
active_platforms | text[] | ['google','meta','linkedin','email'] |
monthly_spend_cap_usd | numeric(10,4) | |
current_month_spend_usd | numeric(10,4) | Running total |
billing_name | varchar(255) | Billing address name (pre-filled from brand name at registration) |
billing_address | text | Street address |
billing_city | varchar(100) | |
billing_postal_code | varchar(20) | |
billing_state | varchar(100) | |
billing_country | varchar(100) | |
razorpay_customer_id | varchar(255) | |
razorpay_subscription_id | varchar(255) | |
deployment_mode | enum | saas | on_prem |
contract_accepted_on | timestamptz | NULL = contract not yet accepted; checked on every request |
created_on | timestamptz | |
updated_on | timestamptz |
users
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
ref_id | varchar(26) UNIQUE | ULID |
tenant_id | uuid FK → tenants | |
email | varchar(255) UNIQUE | Login identity |
title | varchar(10) | Mr. | Mrs. | Ms. | Dr. | Prof. — collected at registration |
first_name | varchar(255) | |
last_name | varchar(255) | |
name | varchar(511) GENERATED | first_name || ' ' || last_name — display name, computed column |
phone | varchar(50) | E.164 format: +919876543210. Collected at registration. |
role | enum | admin | member | reviewer | super_admin |
app_access | text[] | ['dashboard','dm-portal','manage'] |
status | enum | active | invited | deactivated |
created_on | timestamptz |
registration_sessions
Persists partial state for the 4-step self-registration wizard. The tenant and user records are not created until Step 4 (payment) succeeds — until then, all collected data lives here.
Why a separate table: Registration takes multiple page loads across multiple steps, possibly across multiple sessions (user closes tab and returns). Data must be persisted without creating a real tenant account prematurely.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
ref_id | varchar(26) UNIQUE | ULID |
session_token | varchar(64) UNIQUE | Cryptographically random — sent as cookie for session resumption |
current_step | smallint | Last completed step: 1–4 |
contact_data | jsonb | Step 1: { country, title, firstName, lastName, email, phone, passwordHash } |
company_data | jsonb | Step 2: { legalName, brandName, panNumber } |
location_data | jsonb | Step 3: { state, hasGst, gstNumber } |
plan_selected | varchar(50) | 'starter' | 'professional' |
billing_data | jsonb | Step 4: { name, address, city, postalCode, state, country } |
created_on | timestamptz | |
expires_at | timestamptz | 24 hours after creation — cron deletes expired sessions |
Lifecycle:
- Row created when user submits Step 1 (Contact);
session_tokenset inHttpOnlycookie - Each step submission updates
contact_data/company_data/location_dataand incrementscurrent_step - Returning user (same browser): cookie re-identifies session → resumed at
current_step - Payment success (Razorpay webhook):
contact_data,company_data,location_data,billing_dataextracted →tenants+usersrecords created →registration_sessionsrow deleted - Expired rows: cron job deletes rows where
expires_at < NOW()
Security: passwordHash in contact_data is bcrypt-hashed before storage. panNumber and gstNumber are stored as plaintext in JSONB (non-sensitive Indian tax IDs). session_token is compared with crypto.timingSafeEqual.
CREATE INDEX idx_registration_sessions_token
ON registration_sessions(session_token);
CREATE INDEX idx_registration_sessions_expiry
ON registration_sessions(expires_at);contracts
One contract record per tenant. Tracks the generated PDF and the tenant’s acceptance.
Flow:
- On tenant creation, the master contract template (
contracts/master-contract.md) is read, placeholders replaced with tenant details, rendered to PDF via Puppeteer - PDF binary is stored in MongoDB
contract_pdfscollection;pdf_refpoints to it - On first login, the Dashboard shows a full-screen contract acceptance modal (link to PDF + “I agree” checkbox)
- On acceptance:
accepted_on,accepted_by,accepted_ipare set;tenants.contract_accepted_onis updated for fast auth checks
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
template_version | varchar(50) | Version of the contract template used (e.g. v1.2) — from template frontmatter |
pdf_s3_key | varchar(500) | S3 object key → {tenantId}/contracts/{refId}/v{templateVersion}.pdf |
generated_on | timestamptz | When the PDF was generated |
status | enum | pending_acceptance | accepted | superseded |
accepted_on | timestamptz | NULL until accepted |
accepted_by | uuid FK → users | The user who clicked “I agree” |
accepted_ip | varchar(45) | IP address at time of acceptance (legal record) |
accepted_user_agent | text | Browser user agent at time of acceptance |
Acceptance gate: The auth middleware checks tenants.contract_accepted_on IS NOT NULL. If NULL, all Dashboard routes redirect to the contract acceptance screen. Only the contract acceptance endpoint is accessible until accepted.
Tenants table additions (to support fast auth-level gate):
| Column | Type | Notes |
|---|---|---|
contract_accepted_on | timestamptz | NULL = not yet accepted. Checked on every request by auth middleware. |
Contract supersession: If the master contract template is updated (new version), existing accepted contracts remain valid. Tenants are not asked to re-accept unless the platform decides to force re-acceptance (managed manually by super admin in Manage app).
Onboarding & Strategy
onboarding_runs
Tracks the Onboarding Agent’s research run for a tenant.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
status | enum | pending | researching | review | completed |
research_output | text | Raw research findings from Onboarding Agent |
context_file_id | varchar(255) | MongoDB ObjectId of generated context skill file |
reviewed_by | uuid FK → users | Who approved the context file |
reviewed_on | timestamptz |
strategies
The marketing strategy generated from the context file.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
version | smallint | Increments on each new strategy |
status | enum | draft | awaiting_approval | active | archived |
summary | text | Strategy overview narrative |
rationale | text | Why these goals and deliverables were chosen |
period_months | smallint | Strategy horizon (e.g. 6 months) |
start_date | date | |
end_date | date | |
approved_by | uuid FK → users | |
approved_on | timestamptz |
goals
High-level outcomes the strategy aims to achieve.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
strategy_id | uuid FK → strategies | |
name | varchar(255) | e.g. “Increase organic website traffic” |
description | text | |
metric | varchar(100) | e.g. organic_sessions, keyword_rankings, domain_authority |
target_value | numeric(12,2) | e.g. 10000 (sessions/month) |
baseline_value | numeric(12,2) | Current value at strategy start |
current_value | numeric(12,2) | Updated periodically by Data Analyst |
period | enum | monthly | quarterly | annual |
target_date | date | |
status | enum | active | on_track | at_risk | achieved | paused |
priority | smallint | 1 = highest |
deliverables
Fixed recurring commitments per goal. Defines what is produced each month.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
goal_id | uuid FK → goals | Primary goal this deliverable serves |
strategy_id | uuid FK → strategies | |
type | enum | blog_posts | social_posts | backlinks | gbp_posts | website_audit | email_newsletters | ad_copy_sets | performance_reports | landing_pages | video_scripts |
name | varchar(255) | Human label: “Monthly Blog Posts” |
quantity_per_period | smallint | e.g. 10 (posts per month) |
period | enum | monthly | quarterly |
platforms | text[] | Which platforms this targets |
template_id | varchar(255) | Activity template that defines the pipeline |
is_active | boolean |
deliverable_periods
Monthly (or quarterly) tracking record for each deliverable. One row per deliverable per period.
| Column | Type | Notes |
|---|---|---|
deliverable_id | uuid FK → deliverables | |
tenant_id | uuid FK → tenants | |
period_start | date | e.g. 2026-04-01 |
period_end | date | e.g. 2026-04-30 |
target_count | smallint | From deliverable.quantity_per_period |
completed_count | smallint | Increments as activities complete |
status | enum | not_started | in_progress | completed | at_risk | missed |
Activities
activities
The primary work unit. Every piece of work — agent task or human review — is an activity.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
deliverable_period_id | uuid FK → deliverable_periods | Which monthly deliverable this advances |
goal_id | uuid FK → goals | Denormalised for quick goal-progress queries |
parent_activity_id | uuid FK → activities | For sub-activities (e.g. one per blog post) |
pipeline_step | varchar(100) | Step name from activity template |
title | varchar(500) | Human-readable: “Write blog post: The future of B2B SaaS” |
type | enum | research | write | review | approve | publish | audit | report | outreach | other |
assignee_type | enum | agent | human |
assignee_agent_role | varchar(100) | If agent: e.g. copywriter |
assignee_user_id | uuid FK → users | If human |
status | enum | created | assigned | in_progress | awaiting_approval | approved | rejected | completed | failed | cancelled |
priority | enum | low | normal | high | urgent |
is_approval_gate | boolean | True = this is a human review step |
input_data | jsonb | What was passed into this activity |
output_summary | jsonb | Structured summary of result (not full content — that’s in MongoDB) |
output_ref | varchar(255) | MongoDB ObjectId of full output content |
rejection_note | text | Human’s rejection feedback |
revision_note | text | Instructions for re-run |
due_date | date | |
started_on | timestamptz | |
completed_on | timestamptz |
CREATE INDEX ON activities(tenant_id, status);
CREATE INDEX ON activities(deliverable_period_id, status);
CREATE INDEX ON activities(assignee_user_id, status) WHERE assignee_type = 'human';
CREATE INDEX ON activities(assignee_agent_role, status) WHERE assignee_type = 'agent';
CREATE INDEX ON activities(parent_activity_id);
CREATE INDEX ON activities(goal_id, status);activity_runs
Each execution attempt of an agent activity (retries create new runs).
| Column | Type | Notes |
|---|---|---|
activity_id | uuid FK → activities | |
tenant_id | uuid FK → tenants | |
adapter_type | varchar(50) | claude | openai | ollama | webhook |
model_id | varchar(100) | |
session_id | uuid FK → sessions | |
status | enum | running | completed | failed | timed_out |
error | text | |
input_tokens | int | |
output_tokens | int | |
cost_usd | numeric(10,6) | |
duration_ms | int | |
started_on | timestamptz | |
ended_on | timestamptz |
activity_templates
Pipeline definitions per deliverable type. Used by the Activity Planner to spawn activity pipelines at the start of each deliverable period.
| Column | Type | Notes |
|---|---|---|
deliverable_type | varchar(100) | e.g. blog_posts |
name | varchar(255) | e.g. “Standard Blog Post Pipeline” |
tenant_id | uuid FK → tenants | NULL = global template; set = tenant-specific override |
source_template_id | uuid FK → activity_templates | NULL for originals; set if copied from global |
steps | jsonb | Array of ActivityTemplateStep (see workflow-model.md) |
version | smallint | Incremented on edit |
is_active | boolean |
recurring_task_templates
Predefined standalone tasks that recur on a schedule, not tied to a deliverable pipeline. Global templates are copied to each tenant on creation.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | NULL = global template; set = tenant copy |
source_template_id | uuid FK → recurring_task_templates | NULL for globals; set on tenant copy |
name | varchar(255) | e.g. “Update Google My Business listing” |
description | text | What to do and why |
assignee_type | enum | agent | human |
agent_role | varchar(100) | NULL if human task |
human_role | enum | reviewer | admin | NULL |
periodicity | enum | one_time | daily | weekly | monthly | quarterly | yearly |
trigger | enum | on_onboarding | on_strategy_approval | cron | manual |
cron_expression | varchar(100) | Only if trigger = cron |
prompt_template | text | Task description with {{variable}} placeholders |
variables | jsonb | Array of TemplateVariable definitions (key, label, type, required) |
variable_values | jsonb | Filled in at tenant level; null for global templates |
is_active | boolean | Can be disabled per tenant |
Key behaviour:
- On tenant creation: all global templates (
tenant_id IS NULL) are copied withtenant_idset andsource_template_idpointing to the original - After copy, tenant admin fills in required
variable_values(prompted during onboarding or setup checklist) - Activities are auto-created based on trigger + periodicity
- Changes to a global template do NOT propagate to existing tenant copies (tenants own their copy)
approvals
Content and strategy approvals that route through the HITL pipeline. Each approval is linked to an activity and expires after 72 hours if unresolved, triggering escalation.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
activity_id | uuid FK → activities | The activity this approval is for |
type | enum | content_review | strategy_review | brand_direction | content_direction | hire_agent |
risk_level | enum | low | medium | high | critical |
status | enum | pending | approved | rejected | revision_requested | expired |
title | varchar(500) | Short label shown in the approval queue |
content_ref | varchar(255) | MongoDB ObjectId of the content being reviewed (nullable — some approvals are structural) |
reviewer_id | uuid FK → users | NULL until assigned |
reviewed_by | uuid FK → users | NULL until resolved |
reviewer_notes | text | Free-text feedback from reviewer |
chosen_option | varchar(255) | For multi-option approvals (e.g. pick A or B direction) |
expires_at | timestamptz | Auto-expires after 72 h if unresolved — triggers escalation |
resolved_at | timestamptz | |
sent_to_client_at | timestamptz | When reviewer forwarded to client for sign-off |
CREATE INDEX idx_approvals_pending
ON approvals(tenant_id, expires_at)
WHERE status = 'pending';
CREATE INDEX idx_approvals_activity
ON approvals(activity_id);Agents & Sessions
agent_configs
Tenant-specific agent configuration.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
agent_role | varchar(100) | |
is_enabled | boolean | Tenant can disable agents they don’t need |
adapter_type | enum | claude | openai | ollama | webhook |
model_id | varchar(100) | |
skill_ids | text[] | MongoDB ObjectIds of assigned skill files |
tool_names | text[] | Allowed integrations |
max_concurrent | smallint | |
timeout_ms | int | |
max_cost_usd_per_activity | numeric(8,4) | |
escalation_after_failures | smallint | Default 3 |
sessions
LLM session continuity records.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
external_session_id | varchar(255) | Claude session ID; null for Ollama/OpenAI |
agent_role | varchar(100) | |
goal_id | uuid FK → goals | Sessions scoped to a goal for context continuity |
adapter_type | varchar(50) | |
model_id | varchar(100) | |
token_count | int | Running total |
expires_at | timestamptz | TTL-based expiry |
llm_providers
LLM provider configurations — stores connection details and API credentials for all AI providers available on the platform. Referenced by agent configs and the Security section.
| Column | Type | Notes |
|---|---|---|
name | varchar(100) | Display name: “Anthropic Production”, “OpenAI Backup” |
provider_type | enum | anthropic | openai | ollama | azure_openai | custom |
base_url | varchar(500) | API base URL — required for Ollama and Azure |
api_key | text | Encrypted AES-256-GCM. NULL for Ollama (no key needed) |
default_model | varchar(100) | e.g. claude-sonnet-4-6 |
available_models | text[] | Models exposed to agent configs |
is_active | boolean | Only active providers are offered in agent config dropdowns |
is_global_default | boolean | The platform-level default for new tenants (at most one per provider_type) |
tenant_id | uuid FK → tenants | NULL = global/shared provider; set = tenant-owned private key |
monthly_spend_usd | numeric(12,4) | Cached sum of llm_calls.cost_usd for the current month |
monthly_spend_reset_at | timestamptz | When the monthly spend cache was last reset |
health_status | enum | ok | degraded | error | unknown |
last_health_check_at | timestamptz |
CREATE INDEX idx_llm_providers_active
ON llm_providers(provider_type, is_active)
WHERE is_active = true;Billing & Costs
credit_balances
Live credit position per tenant. One row per tenant, updated atomically on every reservation, consumption, release, and monthly reset.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants UNIQUE | |
credits_monthly | int | Monthly allocation for the tenant’s plan |
credits_available | int | Allocation + top-ups remaining (includes reserved) |
credits_reserved | int | Held for in-progress runs; not yet consumed |
credits_consumed | int | Consumed this billing period |
credits_topup | int | Remaining top-up credits (consumed first; expire end of month) |
period_start | date | e.g. 2026-04-01 |
period_end | date | e.g. 2026-04-30 |
next_reset_at | timestamptz | Next monthly reset timestamp |
Effective available balance = credits_available - credits_reserved.
credit_ledger
Immutable append-only log of every credit movement. Source of truth — credit_balances is a materialised view of this table.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
type | varchar(20) | allocated | topup | reserved | consumed | released | refunded | adjusted |
credits | int | Negative for reserved/consumed; positive for allocated/released/refunded |
activity_run_id | uuid FK → activity_runs | Null for allocation and top-up entries |
deliverable_type | varchar(100) | Null for allocation and top-up entries |
topup_order_id | text | Razorpay order ID — top-up entries only |
balance_after | int | Available balance snapshot at time of entry |
actor | text | system | admin:{userId} | support:{userId} |
note | text | Human note for refund/adjustment entries |
CREATE INDEX credit_ledger_tenant_idx ON credit_ledger(tenant_id, created_at DESC);
CREATE INDEX credit_ledger_run_idx ON credit_ledger(activity_run_id);credit_topup_orders
Top-up bundle purchases via Razorpay.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
razorpay_order_id | text UNIQUE | |
credits | int | Credits in this bundle |
amount_inr | int | Razorpay amount in paise |
status | varchar(20) | pending | paid | failed | refunded |
paid_at | timestamptz | Set on payment.captured webhook |
expires_at | timestamptz | End of current billing month |
llm_calls
Full audit log of every LLM request.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
activity_run_id | uuid FK → activity_runs | |
adapter_type | varchar(50) | |
model_id | varchar(100) | |
prompt_hash | varchar(64) | SHA-256 — no raw prompt stored |
response_hash | varchar(64) | |
input_tokens | int | |
output_tokens | int | |
cost_usd | numeric(10,6) | |
duration_ms | int |
-- Partition by month for scale
CREATE TABLE llm_calls (...)
PARTITION BY RANGE (created_on);tool_calls
Every integration API call made by an agent.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
activity_run_id | uuid FK → activity_runs | |
tool_name | varchar(100) | e.g. google_search_console |
method | varchar(100) | e.g. getKeywordRankings |
input | jsonb | |
output | jsonb | Summary (not full — full in MongoDB) |
status | enum | success | error |
error | text | |
duration_ms | int |
billing_events
Payment and subscription lifecycle events from Razorpay.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
event_type | varchar(100) | e.g. subscription.activated, payment.failed |
razorpay_entity_id | varchar(255) | Subscription ID or payment ID |
amount_inr | int | Paise (÷100 for rupees) |
plan | varchar(50) | |
payload | jsonb | Full Razorpay event payload |
reports
Performance reports generated by the Report Writer agent or manually uploaded, covering a specific deliverable period.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
campaign_id | uuid FK → campaigns | |
deliverable_period_id | uuid FK → deliverable_periods | The period this report covers |
activity_id | uuid FK → activities | The Report Writer activity that generated this |
title | varchar(500) | e.g. “March 2026 Performance Report — Acme Corp” |
type | enum | monthly_performance | quarterly_review | ad_audit | seo_audit | custom |
status | enum | draft | ready | delivered | archived |
content_ref | varchar(255) | MongoDB ObjectId of full report Markdown content |
period_start | date | First day of the reporting period |
period_end | date | Last day of the reporting period |
delivered_at | timestamptz | When sent to the client |
delivered_by | uuid FK → users | DM reviewer who sent it |
delivery_channel | varchar(50) | email | portal | both |
CREATE INDEX idx_reports_tenant_period
ON reports(tenant_id, period_start DESC);Channels & Content
channels
OAuth-connected external platforms per tenant. Stores encrypted credentials used by agents to publish content.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
platform | enum | linkedin | facebook | instagram | x_twitter | tiktok | wordpress | webflow | google_business_profile | google_search_console | google_analytics | mailchimp | klaviyo | google_ads | meta_ads |
channel_type | enum | social | cms | analytics | local | email | ads |
name | varchar(255) | Display name: “Acme Corp LinkedIn Page” |
external_id | varchar(500) | Platform’s internal ID: page ID, account ID, workspace ID |
external_url | varchar(1000) | URL to the connected page/profile/site |
access_token | text | Encrypted OAuth access token (AES-256-GCM) |
refresh_token | text | Encrypted OAuth refresh token (AES-256-GCM) |
token_expires_at | timestamptz | When access_token expires; NULL if does not expire |
scopes | text[] | OAuth scopes granted: ['w_member_social', 'r_basicprofile'] |
metadata | jsonb | Platform-specific info: { pageId, followersCount, avatarUrl } |
status | enum | connected | expired | revoked | error |
last_verified_at | timestamptz | Last successful token validation |
error_message | text | Last error if status = error |
score | smallint | 0–100. NULL until first score calculated. |
score_breakdown | jsonb | Component scores: { completeness: 70, activity: 60, engagement: 45 } |
score_calculated_at | timestamptz | |
score_previous | smallint | Score from previous calculation (for delta display) |
Token security: Access/refresh tokens encrypted AES-256-GCM. Key managed via Doppler. Decrypted in memory only for API calls.
Token refresh cron: Runs hourly. Refreshes tokens expiring within 1 hour. On failure: status = 'expired', alert activity created.
CREATE UNIQUE INDEX idx_channels_tenant_platform
ON channels(tenant_id, platform, external_id);
CREATE INDEX idx_channels_expiry
ON channels(token_expires_at)
WHERE status = 'connected';channel_scores
Historical score records per channel per scoring period.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
channel_id | uuid FK → channels | |
score | smallint | 0–100 |
score_breakdown | jsonb | Component scores with values and max values |
scoring_criteria | jsonb | What was evaluated (platform-specific rubric) |
scored_on | timestamptz | |
period | varchar(7) | 2026-04 — YYYY-MM, for monthly tracking |
email_templates
Transactional email templates used by the system for notifications, reports, and client communications. Supports global system templates and per-tenant overrides.
| Column | Type | Notes |
|---|---|---|
name | varchar(255) | e.g. “Monthly Report Delivery” |
slug | varchar(100) | UNIQUE. Used in code: email_templates.monthly_report |
subject | varchar(500) | Email subject line — supports {{variable}} placeholders |
body_html | text | HTML body — supports {{variable}} placeholders |
body_text | text | Plain-text fallback |
variables | jsonb | Array of { key, description, required } — documents what placeholders exist |
category | enum | approval | report_delivery | onboarding | billing | alert | custom |
tenant_id | uuid FK → tenants | NULL = global/system template; set = tenant-customised version |
source_template_id | uuid FK → email_templates | NULL for globals; set for tenant overrides |
is_active | boolean | |
send_via | enum | sendgrid | ses | smtp |
CREATE UNIQUE INDEX idx_email_templates_slug_tenant
ON email_templates(slug, COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid));integrations
API-key based third-party tool and data integrations (SEMrush, DataForSEO, etc.). Distinct from OAuth channels — these are tool/data integrations, not publishing channels.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
integration_type | enum | semrush | dataforseo | ahrefs | google_ads_api | meta_ads_api | razorpay | sendgrid | twilio | custom |
name | varchar(255) | Display label: “SEMrush Production Key” |
api_key | text | Encrypted AES-256-GCM |
api_secret | text | Encrypted AES-256-GCM (if required by provider) |
extra_config | jsonb | Provider-specific extra config (account IDs, endpoints, etc.) |
is_active | boolean | |
status | enum | connected | error | quota_exceeded | expired |
last_verified_at | timestamptz | Last successful health-check call |
error_message | text |
CREATE UNIQUE INDEX idx_integrations_tenant_type
ON integrations(tenant_id, integration_type);blog_requests
A request to create a blog post. Content is not created until the request is approved.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
deliverable_id | uuid FK → deliverables | NULL if ad-hoc request |
deliverable_period_id | uuid FK → deliverable_periods | NULL if ad-hoc |
activity_id | uuid FK → activities | The approval activity for this request |
requested_by_type | enum | agent | human |
requested_by_id | varchar(255) | Agent role or user ref_id |
title | varchar(500) | Proposed post title |
topic_brief | text | What the post should cover |
target_keyword | varchar(255) | Primary SEO keyword |
secondary_keywords | text[] | |
search_intent | enum | informational | commercial | navigational | transactional |
target_audience | text | Who this is for |
target_channel_id | uuid FK → channels | Which WordPress/CMS to publish to |
target_word_count | int | e.g. 1500 |
notes | text | Creative direction, references, don’ts |
priority | enum | normal | high | urgent |
status | enum | draft | pending_approval | approved | rejected | in_progress | completed |
rejection_notes | text | Reviewer feedback if rejected |
On approval: A BlogPost record is auto-created with status = 'draft' and linked to this request.
blog_posts
The actual blog post content entity, created when a BlogRequest is approved.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
blog_request_id | uuid FK → blog_requests | |
channel_id | uuid FK → channels | Target CMS channel (WordPress, Webflow) |
title | varchar(500) | Final title (may differ from request) |
slug | varchar(500) | URL slug |
meta_description | varchar(160) | |
focus_keyword | varchar(255) | |
content_ref | varchar(24) | MongoDB ObjectId — full article content |
word_count | int | |
status | enum | draft | writing | review | approved | scheduled | published | rejected |
scheduled_for | timestamptz | NULL = publish immediately on approval |
published_on | timestamptz | |
published_url | varchar(1000) | URL after WordPress publish |
cms_post_id | varchar(255) | WordPress post ID / Webflow item ID |
social_media_requests
A request to create social post(s). One request can target multiple channels.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
deliverable_id | uuid FK → deliverables | NULL if ad-hoc |
deliverable_period_id | uuid FK → deliverable_periods | NULL if ad-hoc |
activity_id | uuid FK → activities | Approval activity |
requested_by_type | enum | agent | human |
requested_by_id | varchar(255) | |
topic | varchar(500) | What the post is about |
content_brief | text | Key messages, angle, tone direction |
post_type | enum | standard | carousel | reel | story | article (LinkedIn) | gbp_update |
target_channel_ids | uuid[] | FK → channels — which platforms to post to |
scheduled_for | timestamptz | Requested publish date/time |
notes | text | Hashtag direction, visual notes, don’ts |
priority | enum | normal | high | urgent |
status | enum | draft | pending_approval | approved | rejected | in_progress | completed |
rejection_notes | text |
On approval: One SocialPost record created per channel in target_channel_ids.
social_posts
One record per platform per approved social media request.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
social_media_request_id | uuid FK → social_media_requests | |
channel_id | uuid FK → channels | Which specific platform/page |
platform | varchar(50) | Denormalised from channel for query convenience |
content_ref | varchar(24) | MongoDB ObjectId — post copy + hashtags |
char_count | int | For validation against platform limits |
status | enum | draft | writing | review | approved | scheduled | published | rejected | failed |
scheduled_for | timestamptz | |
published_on | timestamptz | |
published_url | varchar(1000) | URL of the published post |
platform_post_id | varchar(255) | Platform’s post ID (for analytics, editing, deletion) |
engagement_snapshot | jsonb | Latest metrics: { likes, comments, shares, impressions } |
last_engagement_on | timestamptz | When engagement was last fetched |
SEO & Backlinks
backlinks
Tracks all backlinks built for a tenant. Periodically checked for live/indexed status via DataForSEO.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
activity_id | uuid FK → activities | The outreach activity that produced this backlink |
deliverable_id | uuid FK → deliverables | The backlinks deliverable this belongs to |
target_url | varchar(1000) | The page on the linking site |
source_url | varchar(1000) | The page on the client’s site being linked to |
anchor_text | varchar(500) | |
domain_authority | smallint | DA of the linking domain at time of discovery |
link_type | enum | dofollow | nofollow | ugc | sponsored |
outreach_status | enum | prospecting | outreach_sent | responded | agreed | published | rejected | no_response |
outreach_sent_on | timestamptz | |
link_published_on | timestamptz | |
last_checked_on | timestamptz | Last DataForSEO check |
is_live | boolean | DataForSEO: link currently live? |
is_indexed | boolean | DataForSEO: linking page indexed by Google? |
dataforseo_check_result | jsonb | Full DataForSEO response payload |
notes | text | Manual notes from DM reviewer |
Periodic status checks: Cron runs daily per tenant. On link going down: creates alert activity for DM reviewer.
CREATE INDEX idx_backlinks_tenant_status
ON backlinks(tenant_id, outreach_status, is_live);
CREATE INDEX idx_backlinks_check_schedule
ON backlinks(last_checked_on, is_live)
WHERE outreach_status = 'published';CRM
leads
Contacts captured from any source.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
source | enum | website_form | facebook_lead_ad | google_lead_form | manual | import | other |
source_channel_id | uuid FK → channels | NULL for manual/import |
source_campaign_id | uuid FK → ad_campaigns | NULL unless from a paid campaign |
first_name | varchar(255) | |
last_name | varchar(255) | |
email | varchar(255) | |
phone | varchar(50) | |
company | varchar(255) | |
job_title | varchar(255) | |
message | text | Message from form or lead ad answers |
source_url | varchar(1000) | Landing page or ad URL |
utm_source | varchar(255) | |
utm_medium | varchar(255) | |
utm_campaign | varchar(255) | |
status | enum | new | contacted | qualified | disqualified | converted |
disqualification_reason | text | |
assigned_to | uuid FK → users | |
enrichment_data | jsonb | { linkedinUrl, companySize, industry, website } |
notes | text | |
last_activity_on | timestamptz |
CREATE INDEX idx_leads_tenant_status
ON leads(tenant_id, status, created_on);
CREATE INDEX idx_leads_source
ON leads(tenant_id, source, source_campaign_id);lead_activities
Timeline of touchpoints and actions on a lead.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
lead_id | uuid FK → leads | |
type | enum | note | email_sent | email_received | call | status_change | enrichment |
description | text | |
actor_type | enum | human | agent | system |
actor_id | varchar(255) | User ref_id or agent role |
metadata | jsonb | e.g. { emailSubject, fromStatus, toStatus } |
Paid Advertising
ad_campaigns
Synced from Google Ads and Meta Ads. Upserted on each sync run.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
channel_id | uuid FK → channels | The connected Google Ads or Meta Ads channel |
platform | enum | google_ads | meta_ads |
external_campaign_id | varchar(255) | Google Ads campaign ID or Meta campaign ID |
name | varchar(500) | Campaign name from platform |
status | enum | active | paused | removed |
campaign_type | varchar(100) | SEARCH, DISPLAY, VIDEO (Google) / AWARENESS, CONVERSIONS (Meta) |
budget_amount | numeric(12,2) | Daily or lifetime budget |
budget_currency | varchar(10) | |
total_spend | numeric(12,2) | Lifetime spend (from last sync) |
performance_snapshot | jsonb | { impressions, clicks, ctr, cpc, conversions, roas, spend } |
last_synced_on | timestamptz |
CREATE UNIQUE INDEX idx_ad_campaigns_external
ON ad_campaigns(tenant_id, platform, external_campaign_id);ad_keywords
Keywords in Google Ads ad groups. Synced from Google Ads API.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
channel_id | uuid FK → channels | |
ad_campaign_id | uuid FK → ad_campaigns | |
external_keyword_id | varchar(255) | Google Ads criterion ID |
text | varchar(500) | Keyword text |
match_type | enum | exact | phrase | broad |
status | enum | active | paused | removed |
bid_amount | numeric(10,4) | CPC bid |
performance_snapshot | jsonb | { impressions, clicks, ctr, cpc, conversions, quality_score } |
last_synced_on | timestamptz |
search_term_reports
Raw search terms from Google Ads search term reports.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
channel_id | uuid FK → channels | |
ad_campaign_id | uuid FK → ad_campaigns | |
search_term | varchar(500) | The actual search query |
impressions | int | |
clicks | int | |
conversions | numeric(10,2) | |
cost | numeric(10,4) | |
ctr | numeric(6,4) | Click-through rate |
report_date_range | daterange | e.g. last 30 days |
synced_on | timestamptz |
search_term_classifications
Agent-classified search terms, pending human review before being pushed to Google Ads.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
search_term_report_id | uuid FK → search_term_reports | |
activity_id | uuid FK → activities | The classification activity |
classification | enum | keyword | negative_keyword | watch | skip |
recommended_match_type | enum | exact | phrase | broad | NULL |
agent_reasoning | text | Why the agent classified it this way |
status | enum | pending_review | approved | rejected | pushed |
override_classification | enum | NULL if human accepted; set if overridden |
reviewed_by | uuid FK → users | |
reviewed_on | timestamptz | |
pushed_on | timestamptz | |
push_result | jsonb | API response from Google Ads |
ad_optimization_recommendations
Agent-generated optimization recommendations. Pending human review before being applied.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
channel_id | uuid FK → channels | |
ad_campaign_id | uuid FK → ad_campaigns | |
platform | enum | google_ads | meta_ads |
activity_id | uuid FK → activities | The recommendation activity |
type | enum | bid_adjustment | budget_change | pause_keyword | enable_keyword | pause_ad | audience_update | creative_refresh | add_keyword | add_negative |
title | varchar(500) | e.g. “Increase bid for ‘seo services london’ by 15%“ |
reasoning | text | Agent’s explanation with supporting data |
impact_estimate | jsonb | { impressions_delta, spend_delta, conv_delta } |
change_payload | jsonb | Exact API call payload to apply the change |
priority | enum | low | medium | high |
status | enum | pending_review | approved | rejected | snoozed | pushed | failed |
snooze_until | timestamptz | |
reviewed_by | uuid FK → users | |
reviewed_on | timestamptz | |
pushed_on | timestamptz | |
push_result | jsonb | API response from platform |
Notifications
Related: Notifications Service
notifications
In-app notification records shown in the Dashboard notification centre. One row per notification per user.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
user_id | uuid FK → users | Null = broadcast to all admins |
type | varchar(100) | e.g. approval_required, credits_exhausted |
channel | varchar(20) | web (this table is web-only; other channels log to notification_dispatch_log) |
message | text | Human-readable notification body |
ref_id | uuid | Optional — ID of the referenced entity (activity, approval, etc.) |
ref_type | varchar(50) | Optional — entity type e.g. approval, activity, report |
read | boolean | Default false; toggled when user marks as read |
read_at | timestamptz |
CREATE INDEX notifications_user_unread_idx
ON notifications(user_id, tenant_id, read, created_at DESC)
WHERE read = false;notification_providers
Per-tenant notification provider configuration. One row per channel per tenant. If no verified row exists for a channel, the platform default provider is used automatically.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
channel | varchar(20) | email | sms | whatsapp |
provider | varchar(50) | smtp | sendgrid | ses | msg91 | twilio | whatsapp_business_api |
config | jsonb | Encrypted at rest — SMTP credentials, API keys, sender IDs |
is_active | boolean | Default true; set false to revert to platform default without deleting config |
verified_at | timestamptz | Null = not yet tested. Unverified rows are ignored — platform default is used |
last_error | text | Populated on failed test connection; cleared on success |
ALTER TABLE notification_providers
ADD CONSTRAINT notification_providers_tenant_channel_unique UNIQUE (tenant_id, channel);notification_dispatch_log
Append-only audit log of every outbound notification dispatch attempt across all channels (email, SMS, WhatsApp, web).
| Column | Type | Notes |
|---|---|---|
notification_job_id | uuid | BullMQ job ID |
tenant_id | uuid FK → tenants | |
channel | varchar(20) | email | sms | whatsapp | web |
type | varchar(100) | Notification type slug |
recipients_count | int | Count after dev-filter applied |
provider | varchar(50) | e.g. sendgrid, twilio, whatsapp_biz |
status | varchar(30) | sent | failed | partial | skipped_dev_filter |
provider_response | jsonb | Provider response body (truncated to 1 KB) |
error | text | Error message if status = failed |
duration_ms | int | |
attempt | int | BullMQ attempt number |
CREATE INDEX notification_dispatch_tenant_idx
ON notification_dispatch_log(tenant_id, created_at DESC);
CREATE INDEX notification_dispatch_job_idx
ON notification_dispatch_log(notification_job_id);Knowledge Base (RAG)
Related: RAG Architecture
rag_datasets
Dataset containers — one per knowledge domain (e.g. client_docs, website_content, published_content, competitor_content). Each dataset maps to one Qdrant collection named ds_{ref_id}.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
ref_id | varchar(26) UNIQUE | ULID — also used as the Qdrant collection name suffix |
name | text | e.g. client_docs, website_content |
description | text | |
embedding_provider | text | openai | ollama | azure_openai | … Immutable after creation |
embedding_model | text | e.g. text-embedding-3-small, nomic-embed-text. Immutable after creation |
vector_size | int | Derived from model at creation time. Immutable after creation |
chunk_size | int | Default chunk size in characters (default: 512) |
chunk_overlap | int | Default chunk overlap (default: 64) |
parse_type | text | NAIVE | MARKDOWN | MANUAL — default chunking strategy |
parser_engine | text | NODE_NATIVE | DOCLING — default parser |
allowed_agent_roles | text[] | Which agent roles can query this dataset |
qdrant_collection | text | Qdrant collection name: ds_{ref_id} |
status | text | active | building | error |
total_files | int | Cached count of indexed files |
total_chunks | int | Cached count of indexed chunks across all files |
created_by | uuid FK → users | |
created_on | timestamptz | |
updated_on | timestamptz | |
deleted_on | timestamptz |
CREATE INDEX rag_datasets_tenant_idx
ON rag_datasets(tenant_id);rag_files
Individual files uploaded or crawled into a dataset. Each file is processed through the ingestion pipeline (parse → chunk → embed → index into Qdrant).
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
dataset_id | uuid FK → rag_datasets | |
file_name | text | Original filename |
mime_type | text | e.g. application/pdf, text/markdown |
file_size_bytes | int | |
storage_path | text | Path on disk or object storage key |
chunk_size | int | Per-file override — NULL uses dataset default |
chunk_overlap | int | Per-file override — NULL uses dataset default |
parse_type | text | Per-file override — NULL uses dataset default |
parser_engine | text | Per-file override — NULL uses dataset default |
status | text | pending | parsing | embedding | indexed | error | disabled |
error_message | text | Set if status = error |
chunks_count | int | Number of chunks indexed into Qdrant |
enabled | boolean | If false, file vectors are excluded from all search queries |
bull_job_id | text | BullMQ job reference for status polling |
source | text | upload | website_crawl | published_content | competitor_research |
created_by | uuid FK → users | |
created_on | timestamptz | |
updated_on | timestamptz | |
deleted_on | timestamptz |
CREATE INDEX rag_files_dataset_idx
ON rag_files(dataset_id);
CREATE INDEX rag_files_tenant_idx
ON rag_files(tenant_id);rag_crawl_jobs
Website crawl run records. Tracks progress and results for each crawl triggered against a dataset.
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid FK → tenants | |
dataset_id | uuid FK → rag_datasets | |
start_url | text | The URL crawling begins from |
max_pages | int | Maximum pages to crawl (default: 200) |
max_depth | int | Maximum link depth from start URL (default: 3) |
url_path_filter | text | Optional path prefix filter — e.g. /blog crawls only that section |
status | text | queued | running | completed | failed |
pages_crawled | int | Count of pages fetched |
pages_indexed | int | Count of pages successfully ingested into Qdrant |
pages_failed | int | Count of pages that failed during ingestion |
error_message | text | Set if status = failed |
scheduled_for | timestamptz | NULL = immediate; set = deferred crawl |
completed_at | timestamptz | |
created_on | timestamptz |
CREATE INDEX rag_crawl_jobs_dataset_idx
ON rag_crawl_jobs(dataset_id);Critical Indexes
-- Activity queue for agents
CREATE INDEX idx_activities_agent_queue
ON activities(tenant_id, assignee_agent_role, status)
WHERE assignee_type = 'agent' AND status IN ('created', 'assigned');
-- DM Portal human queue
CREATE INDEX idx_activities_human_queue
ON activities(tenant_id, assignee_user_id, status)
WHERE assignee_type = 'human' AND status IN ('created', 'awaiting_approval');
-- Deliverable period progress
CREATE INDEX idx_deliverable_periods_active
ON deliverable_periods(tenant_id, status)
WHERE status IN ('not_started', 'in_progress', 'at_risk');
-- Goal progress tracking
CREATE INDEX idx_goals_active
ON goals(tenant_id, strategy_id, status);
-- Cost aggregation
CREATE INDEX idx_llm_calls_cost
ON llm_calls(tenant_id, created_on);Useful Queries
Monthly deliverable progress for a tenant
SELECT
d.name,
d.type,
dp.target_count,
dp.completed_count,
dp.status,
ROUND((dp.completed_count::numeric / dp.target_count) * 100, 1) AS pct_complete
FROM deliverable_periods dp
JOIN deliverables d ON d.id = dp.deliverable_id
WHERE dp.tenant_id = :tenantId
AND dp.period_start = DATE_TRUNC('month', CURRENT_DATE)
ORDER BY dp.status, d.name;Human’s pending activity queue
SELECT
a.id, a.title, a.type, a.priority,
a.is_approval_gate,
d.name AS deliverable_name,
g.name AS goal_name,
a.due_date
FROM activities a
JOIN deliverable_periods dp ON dp.id = a.deliverable_period_id
JOIN deliverables d ON d.id = dp.deliverable_id
JOIN goals g ON g.id = a.goal_id
WHERE a.tenant_id = :tenantId
AND a.assignee_user_id = :userId
AND a.status IN ('created', 'awaiting_approval')
ORDER BY a.is_approval_gate DESC, a.priority, a.due_date;Goal progress across all goals for a strategy
SELECT
g.name,
g.metric,
g.baseline_value,
g.current_value,
g.target_value,
ROUND(((g.current_value - g.baseline_value) / NULLIF(g.target_value - g.baseline_value, 0)) * 100, 1) AS progress_pct,
g.status
FROM goals g
WHERE g.strategy_id = :strategyId
ORDER BY g.priority;Performance
Required Indexes
Every tenant-scoped table must have tenant_id as the leading column in its primary access indexes. The following indexes are mandatory — Prisma migrations fail CI if they are absent.
Composite indexes (high-traffic query paths):
-- Activity list: tenant + status filter (approval queue, activity list)
CREATE INDEX idx_activities_tenant_status
ON activities(tenant_id, status);
-- Activity list: tenant + deliverable (deliverable detail panel)
CREATE INDEX idx_activities_tenant_deliverable
ON activities(tenant_id, deliverable_id);
-- Activity list: tenant + assignee (human task inbox)
CREATE INDEX idx_activities_tenant_assignee
ON activities(tenant_id, assignee_user_id)
WHERE assignee_user_id IS NOT NULL;
-- LLM cost rollup: tenant + date (cost dashboard aggregation)
CREATE INDEX idx_llm_calls_tenant_created
ON llm_calls(tenant_id, created_on DESC);
-- Campaign list: tenant + status (campaign overview)
CREATE INDEX idx_campaigns_tenant_status
ON campaigns(tenant_id, status);
-- Goal progress: tenant + strategy (strategy detail)
CREATE INDEX idx_goals_tenant_strategy
ON goals(tenant_id, strategy_id);Partial indexes (hot sub-sets only):
-- Pending approvals only (resolved/expired approvals are cold)
CREATE INDEX idx_approvals_pending
ON approvals(tenant_id, expires_at)
WHERE status = 'pending';
-- Active + running agents only (terminated agents never queried by workers)
CREATE INDEX idx_agent_configs_active
ON agent_configs(tenant_id, role)
WHERE status NOT IN ('terminated', 'paused');
-- Undeleted activities (soft-deleted records excluded from all worker queries)
CREATE INDEX idx_activities_active
ON activities(tenant_id, status, created_on DESC)
WHERE deleted_on IS NULL;Connection Pooling (PgBouncer)
All application services connect to PostgreSQL via PgBouncer in transaction mode, not directly:
apps (Fastify, Next.js)
│ POSTGRES_URL=postgresql://.../leadmetrics (points to PgBouncer)
▼
PgBouncer :5433 ← pool size: 25 per app instance, transaction mode
│
▼
PostgreSQL :5432 ← max_connections: 100PgBouncer is added to Docker Compose as a separate service in production/staging. In local development, apps connect directly to PostgreSQL (acceptable because only one developer connects at a time).
Pool size guidance: pool_size = (num_app_instances × 10). A 3-instance API deployment uses pool_size 30.
Cursor Pagination (No OFFSET)
All list endpoints use cursor-based pagination. OFFSET is banned — it causes full table scans when offset is large. Cursors are ULID-based (already sorted by creation time):
-- ✅ Correct: cursor pagination
SELECT * FROM activities
WHERE tenant_id = :tenantId
AND deleted_on IS NULL
AND ref_id > :cursor -- ULID lexicographic order = creation order
ORDER BY ref_id ASC
LIMIT :limit;
-- ❌ Wrong: OFFSET pagination
SELECT * FROM activities
WHERE tenant_id = :tenantId
OFFSET 200 LIMIT 25; -- full scan to skip 200 rowsQuery Constraints
These rules are enforced by ESLint and enforced in code review:
| Rule | Enforcement |
|---|---|
No SELECT * — specify columns explicitly | ESLint no-select-star custom rule |
| No raw SQL strings — Prisma parameterised queries only | ESLint no-raw-sql custom rule |
No N+1 — use Prisma include relations for eager loading | Code review + integration test coverage |
All tenant-scoped queries must include where: { tenantId } | Prisma middleware (enforced globally) |
| Slow queries (> 100 ms) must add an index | pg_stat_statements monitoring alert |
Slow Query Monitoring
pg_stat_statements is enabled in all environments:
-- Top 10 slowest queries (run weekly in production)
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;Queries exceeding 100 ms mean execution time trigger a Grafana alert. The alert includes the query text and an EXPLAIN ANALYZE plan for diagnosis.
Security
Encrypted Columns
Sensitive values are encrypted at the application layer with AES-256-GCM before storage. The encryption key is injected via Doppler (ENCRYPTION_KEY) and never stored in the database:
| Table | Column | What it holds |
|---|---|---|
channels | access_token, refresh_token | OAuth tokens for publishing platforms |
llm_providers | api_key | Anthropic, OpenAI API keys |
agent_configs | env_secrets (JSONB) | Per-agent env vars (e.g. BRAND_API_KEY) |
Columns containing encrypted values are typed TEXT in PostgreSQL (not a custom type). The application decrypts on read; the DB user cannot read the plaintext. If the database is exfiltrated, these values remain ciphertext.
Tenant Isolation — Schema Enforcement
tenant_id is NOT NULL with a foreign key to tenants(id) on every tenant-scoped table. This is enforced in the Prisma schema — any migration that omits this FK fails the CI schema validator:
// ✅ Correct — notNull() enforced in base schema
tenantId: uuid('tenant_id').references(() => tenants.id).notNull(),
// ❌ Wrong — nullable FK allows orphaned records
tenantId: uuid('tenant_id').references(() => tenants.id),Database User Privileges
The application database user has only the privileges it needs:
| User | Tables | Privileges |
|---|---|---|
app_user | All application tables | SELECT, INSERT, UPDATE, DELETE |
app_user | audit_logs (MongoDB) | INSERT only — see MongoDB docs |
migrator_user | All tables | SELECT, INSERT, UPDATE, DELETE, CREATE, DROP |
readonly_user | All tables | SELECT only (used by Grafana dashboards) |
The application never runs DROP TABLE, TRUNCATE, or DDL statements — only migrator_user can.
Immutable Records
Tables marked immutable in the schema (e.g. llm_calls, activity_runs) have UPDATE and DELETE revoked from app_user at the database level:
REVOKE UPDATE, DELETE ON llm_calls FROM app_user;
REVOKE UPDATE, DELETE ON activity_runs FROM app_user;This enforces append-only semantics at the DB layer, independent of application code.
Raw SQL / SQL Injection Prevention
Rule: never write inline SQL. Always use Prisma ORM methods.
Raw SQL bypasses Prisma’s type safety and parameterization, opening the door to SQL injection if a variable is ever interpolated directly into the query string.
Use Prisma ORM — not raw queries
// ✅ Correct — Prisma ORM (safe, type-checked)
await db.emailTemplate.deleteMany({ where: { slug: { startsWith: prefix } } });
await db.tenant.findMany({ where: { status: "active" } });
// ❌ Wrong — raw SQL with string interpolation
await db.$executeRaw`DELETE FROM "email_template" WHERE slug LIKE ${"prefix" + ts + "-%"}`;
await db.$executeRawUnsafe(`SELECT * FROM tenants WHERE status = '${status}'`);When raw SQL is unavoidable
A small number of operations have no ORM equivalent (e.g. PostgreSQL DDL: CREATE DATABASE, DROP DATABASE). In those cases:
- Use CLI tools instead —
dropdb,createdb,psql -cviaexecSync. No Prisma raw query needed. - If you must use
$queryRaw/$executeRaw, use the backtick tagged-template form only. Values must be passed as${}placeholders — Prisma sends them as bound parameters, not string interpolation. - Never use
$queryRawUnsafeor$executeRawUnsafe— these skip parameterization entirely.
Banned patterns
| Pattern | Why banned |
|---|---|
db.$executeRawUnsafe(...) | No parameterization — direct injection risk |
db.$queryRawUnsafe(...) | Same |
db.$executeRaw`... ${"a" + variable + "b"}` | String concat inside placeholder — defeats parameterization |
`SELECT ... WHERE x = '${userInput}'` | Classic injection vector |
The production codebase had all four of these patterns removed in April 2026. If a new raw query is needed, use the Prisma ORM equivalent or open a discussion before writing raw SQL.