Skip to Content
DatabasePostgreSQL Schema

Moved: This document is now at docs/database/postgres.md. The content below is kept for backwards compatibility with existing links.

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.


Base Record Fields

Every table inherits a standard set of fields via Prisma schema composition.

Field Definitions

FieldTypePresent OnNotes
iduuid PKAll recordsAuto-generated, primary key (internal FK use only)
ref_idvarchar(26) UNIQUEAll recordsULID — globally unique across all tables, URL-safe, sortable by creation time. Used in external APIs and URLs.
tenant_iduuid FK → tenantsAll tenant-scoped recordsNot on global records (e.g. global templates)
created_byuuid FK → usersAll recordsUser or agent principal that created the record
created_ontimestamptzAll recordsSet on insert, never changed
updated_byuuid FK → usersMutable recordsNULL if never updated
updated_ontimestamptzMutable recordsNULL if never updated
deleted_byuuid FK → usersSoft-deletable recordsNULL if not deleted
deleted_ontimestamptzSoft-deletable recordsNULL 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

TableMutableSoft DeleteReason
tenantsTenants are suspended/cancelled via status, not deleted
usersSoft delete on deactivation
strategiesVersioned; old versions archived via status, not deleted
goalsRemoved if strategy changes
deliverablesCan be disabled mid-strategy
deliverable_periodsHistorical record — immutable after close
activitiesCan be cancelled
activity_runsImmutable execution audit log
activity_templatesArchived, not hard-deleted
recurring_task_templatesCan be disabled per tenant
agent_configsAlways exists per agent per tenant; disabled via is_enabled
llm_callsImmutable cost audit log
sessionsExpires via TTL

Tenants & Users

tenants

ColumnTypeNotes
iduuid PK
slugvarchar(100) UNIQUEURL-safe: acme-corp
namevarchar(255)
industryvarchar(100)
websitevarchar(500)
planenumfree | pro | agency | enterprise
statusenumtrialing | active | suspended | cancelled
data_privacy_levelenumcloud_ok | local_only
active_platformstext[]['google','meta','linkedin','email']
monthly_spend_cap_usdnumeric(10,4)
current_month_spend_usdnumeric(10,4)Running total
razorpay_customer_idvarchar(255)
razorpay_subscription_idvarchar(255)
deployment_modeenumsaas | on_prem
created_ontimestamptz
updated_ontimestamptz

users

ColumnTypeNotes
iduuid PK
tenant_iduuid FK → tenants
emailvarchar(255) UNIQUE
namevarchar(255)
roleenumadmin | member | reviewer | super_admin
app_accesstext[]['dashboard','dm-portal','manage']
statusenumactive | invited | deactivated
created_ontimestamptz

contracts

One contract record per tenant. Tracks the generated PDF and the tenant’s acceptance.

Flow:

  1. On tenant creation, the master contract template (contracts/master-contract.md) is read, placeholders replaced with tenant details, rendered to PDF via Puppeteer
  2. PDF binary is stored in MongoDB contract_pdfs collection; pdf_ref points to it
  3. On first login, the Dashboard shows a full-screen contract acceptance modal (link to PDF + “I agree” checkbox)
  4. On acceptance: accepted_on, accepted_by, accepted_ip are set; tenants.contract_accepted_on is updated for fast auth checks
ColumnTypeNotes
tenant_iduuid FK → tenants
template_versionvarchar(50)Version of the contract template used (e.g. v1.2) — from template frontmatter
pdf_refvarchar(24)MongoDB ObjectId → contract_pdfs collection
generated_ontimestamptzWhen the PDF was generated
statusenumpending_acceptance | accepted | superseded
accepted_ontimestamptzNULL until accepted
accepted_byuuid FK → usersThe user who clicked “I agree”
accepted_ipvarchar(45)IP address at time of acceptance (legal record)
accepted_user_agenttextBrowser 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):

ColumnTypeNotes
contract_accepted_ontimestamptzNULL = 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.

ColumnTypeNotes
tenant_iduuid FK → tenants
statusenumpending | researching | review | completed
research_outputtextRaw research findings from Onboarding Agent
context_file_idvarchar(255)MongoDB ObjectId of generated context skill file
reviewed_byuuid FK → usersWho approved the context file
reviewed_ontimestamptz

strategies

The marketing strategy generated from the context file.

ColumnTypeNotes
tenant_iduuid FK → tenants
versionsmallintIncrements on each new strategy
statusenumdraft | awaiting_approval | active | archived
summarytextStrategy overview narrative
rationaletextWhy these goals and deliverables were chosen
period_monthssmallintStrategy horizon (e.g. 6 months)
start_datedate
end_datedate
approved_byuuid FK → users
approved_ontimestamptz

goals

High-level outcomes the strategy aims to achieve.

ColumnTypeNotes
tenant_iduuid FK → tenants
strategy_iduuid FK → strategies
namevarchar(255)e.g. “Increase organic website traffic”
descriptiontext
metricvarchar(100)e.g. organic_sessions, keyword_rankings, domain_authority
target_valuenumeric(12,2)e.g. 10000 (sessions/month)
baseline_valuenumeric(12,2)Current value at strategy start
current_valuenumeric(12,2)Updated periodically by Data Analyst
periodenummonthly | quarterly | annual
target_datedate
statusenumactive | on_track | at_risk | achieved | paused
prioritysmallint1 = highest

deliverables

Fixed recurring commitments per goal. Defines what is produced each month.

ColumnTypeNotes
tenant_iduuid FK → tenants
goal_iduuid FK → goalsPrimary goal this deliverable serves
strategy_iduuid FK → strategies
typeenumblog_posts | social_posts | backlinks | gbp_posts | website_audit | email_newsletters | ad_copy_sets | performance_reports | landing_pages | video_scripts
namevarchar(255)Human label: “Monthly Blog Posts”
quantity_per_periodsmallinte.g. 10 (posts per month)
periodenummonthly | quarterly
platformstext[]Which platforms this targets
template_idvarchar(255)Activity template that defines the pipeline
is_activeboolean

deliverable_periods

Monthly (or quarterly) tracking record for each deliverable. One row per deliverable per period.

ColumnTypeNotes
deliverable_iduuid FK → deliverables
tenant_iduuid FK → tenants
period_startdatee.g. 2026-04-01
period_enddatee.g. 2026-04-30
target_countsmallintFrom deliverable.quantity_per_period
completed_countsmallintIncrements as activities complete
statusenumnot_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.

ColumnTypeNotes
tenant_iduuid FK → tenants
deliverable_period_iduuid FK → deliverable_periodsWhich monthly deliverable this advances
goal_iduuid FK → goalsDenormalised for quick goal-progress queries
parent_activity_iduuid FK → activitiesFor sub-activities (e.g. one per blog post)
pipeline_stepvarchar(100)Step name from activity template
titlevarchar(500)Human-readable: “Write blog post: The future of B2B SaaS”
typeenumresearch | write | review | approve | publish | audit | report | outreach | other
assignee_typeenumagent | human
assignee_agent_rolevarchar(100)If agent: e.g. copywriter
assignee_user_iduuid FK → usersIf human
statusenumcreated | assigned | in_progress | awaiting_approval | approved | rejected | completed | failed | cancelled
priorityenumlow | normal | high | urgent
is_approval_gatebooleanTrue = this is a human review step
input_datajsonbWhat was passed into this activity
output_summaryjsonbStructured summary of result (not full content — that’s in MongoDB)
output_refvarchar(255)MongoDB ObjectId of full output content
rejection_notetextHuman’s rejection feedback
revision_notetextInstructions for re-run
due_datedate
started_ontimestamptz
completed_ontimestamptz
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).

ColumnTypeNotes
activity_iduuid FK → activities
tenant_iduuid FK → tenants
adapter_typevarchar(50)claude | openai | ollama | webhook
model_idvarchar(100)
session_iduuid FK → sessions
statusenumrunning | completed | failed | timed_out
errortext
input_tokensint
output_tokensint
cost_usdnumeric(10,6)
duration_msint
started_ontimestamptz
ended_ontimestamptz

activity_templates

Pipeline definitions per deliverable type. Used by the Activity Planner to spawn activity pipelines at the start of each deliverable period.

ColumnTypeNotes
deliverable_typevarchar(100)e.g. blog_posts
namevarchar(255)e.g. “Standard Blog Post Pipeline”
tenant_iduuid FK → tenantsNULL = global template; set = tenant-specific override
source_template_iduuid FK → activity_templatesNULL for originals; set if copied from global
stepsjsonbArray of ActivityTemplateStep (see workflow-model.md)
versionsmallintIncremented on edit
is_activeboolean

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.

ColumnTypeNotes
tenant_iduuid FK → tenantsNULL = global template; set = tenant copy
source_template_iduuid FK → recurring_task_templatesNULL for globals; set on tenant copy
namevarchar(255)e.g. “Update Google My Business listing”
descriptiontextWhat to do and why
assignee_typeenumagent | human
agent_rolevarchar(100)NULL if human task
human_roleenumreviewer | admin | NULL
periodicityenumone_time | daily | weekly | monthly | quarterly | yearly
triggerenumon_onboarding | on_strategy_approval | cron | manual
cron_expressionvarchar(100)Only if trigger = cron
prompt_templatetextTask description with {{variable}} placeholders
variablesjsonbArray of TemplateVariable definitions (key, label, type, required)
variable_valuesjsonbFilled in at tenant level; null for global templates
is_activebooleanCan be disabled per tenant

Key behaviour:

  • On tenant creation: all global templates (tenant_id IS NULL) are copied with tenant_id set and source_template_id pointing 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)

Agents & Sessions

agent_configs

Tenant-specific agent configuration.

ColumnTypeNotes
tenant_iduuid FK → tenants
agent_rolevarchar(100)
is_enabledbooleanTenant can disable agents they don’t need
adapter_typeenumclaude | openai | ollama | webhook
model_idvarchar(100)
skill_idstext[]MongoDB ObjectIds of assigned skill files
tool_namestext[]Allowed integrations
max_concurrentsmallint
timeout_msint
max_cost_usd_per_activitynumeric(8,4)
escalation_after_failuressmallintDefault 3

sessions

LLM session continuity records.

ColumnTypeNotes
tenant_iduuid FK → tenants
external_session_idvarchar(255)Claude session ID; null for Ollama/OpenAI
agent_rolevarchar(100)
goal_iduuid FK → goalsSessions scoped to a goal for context continuity
adapter_typevarchar(50)
model_idvarchar(100)
token_countintRunning total
expires_attimestamptzTTL-based expiry

Billing & Costs

llm_calls

Full audit log of every LLM request.

ColumnTypeNotes
tenant_iduuid FK → tenants
activity_run_iduuid FK → activity_runs
adapter_typevarchar(50)
model_idvarchar(100)
prompt_hashvarchar(64)SHA-256 — no raw prompt stored
response_hashvarchar(64)
input_tokensint
output_tokensint
cost_usdnumeric(10,6)
duration_msint
-- Partition by month for scale CREATE TABLE llm_calls (...) PARTITION BY RANGE (created_on);

tool_calls

Every integration API call made by an agent.

ColumnTypeNotes
tenant_iduuid FK → tenants
activity_run_iduuid FK → activity_runs
tool_namevarchar(100)e.g. google_search_console
methodvarchar(100)e.g. getKeywordRankings
inputjsonb
outputjsonbSummary (not full — full in MongoDB)
statusenumsuccess | error
errortext
duration_msint

billing_events

Payment and subscription lifecycle events from Razorpay.

ColumnTypeNotes
tenant_iduuid FK → tenants
event_typevarchar(100)e.g. subscription.activated, payment.failed
razorpay_entity_idvarchar(255)Subscription ID or payment ID
amount_inrintPaise (÷100 for rupees)
planvarchar(50)
payloadjsonbFull Razorpay event payload

Channels & Content

channels

OAuth-connected external platforms per tenant. Stores encrypted credentials used by agents to publish content.

ColumnTypeNotes
tenant_iduuid FK → tenants
platformenumlinkedin | facebook | instagram | x_twitter | tiktok | wordpress | webflow | google_business_profile | google_search_console | google_analytics | mailchimp | klaviyo | google_ads | meta_ads
channel_typeenumsocial | cms | analytics | local | email | ads
namevarchar(255)Display name: “Acme Corp LinkedIn Page”
external_idvarchar(500)Platform’s internal ID: page ID, account ID, workspace ID
external_urlvarchar(1000)URL to the connected page/profile/site
access_tokentextEncrypted OAuth access token (AES-256-GCM)
refresh_tokentextEncrypted OAuth refresh token (AES-256-GCM)
token_expires_attimestamptzWhen access_token expires; NULL if does not expire
scopestext[]OAuth scopes granted: ['w_member_social', 'r_basicprofile']
metadatajsonbPlatform-specific info: { pageId, followersCount, avatarUrl }
statusenumconnected | expired | revoked | error
last_verified_attimestamptzLast successful token validation
error_messagetextLast error if status = error
scoresmallint0–100. NULL until first score calculated.
score_breakdownjsonbComponent scores: { completeness: 70, activity: 60, engagement: 45 }
score_calculated_attimestamptz
score_previoussmallintScore 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.

ColumnTypeNotes
tenant_iduuid FK → tenants
channel_iduuid FK → channels
scoresmallint0–100
score_breakdownjsonbComponent scores with values and max values
scoring_criteriajsonbWhat was evaluated (platform-specific rubric)
scored_ontimestamptz
periodvarchar(7)2026-04 — YYYY-MM, for monthly tracking

blog_requests

A request to create a blog post. Content is not created until the request is approved.

ColumnTypeNotes
tenant_iduuid FK → tenants
deliverable_iduuid FK → deliverablesNULL if ad-hoc request
deliverable_period_iduuid FK → deliverable_periodsNULL if ad-hoc
activity_iduuid FK → activitiesThe approval activity for this request
requested_by_typeenumagent | human
requested_by_idvarchar(255)Agent role or user ref_id
titlevarchar(500)Proposed post title
topic_brieftextWhat the post should cover
target_keywordvarchar(255)Primary SEO keyword
secondary_keywordstext[]
search_intentenuminformational | commercial | navigational | transactional
target_audiencetextWho this is for
target_channel_iduuid FK → channelsWhich WordPress/CMS to publish to
target_word_countinte.g. 1500
notestextCreative direction, references, don’ts
priorityenumnormal | high | urgent
statusenumdraft | pending_approval | approved | rejected | in_progress | completed
rejection_notestextReviewer 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.

ColumnTypeNotes
tenant_iduuid FK → tenants
blog_request_iduuid FK → blog_requests
channel_iduuid FK → channelsTarget CMS channel (WordPress, Webflow)
titlevarchar(500)Final title (may differ from request)
slugvarchar(500)URL slug
meta_descriptionvarchar(160)
focus_keywordvarchar(255)
content_refvarchar(24)MongoDB ObjectId — full article content
word_countint
statusenumdraft | writing | review | approved | scheduled | published | rejected
scheduled_fortimestamptzNULL = publish immediately on approval
published_ontimestamptz
published_urlvarchar(1000)URL after WordPress publish
cms_post_idvarchar(255)WordPress post ID / Webflow item ID

social_media_requests

A request to create social post(s). One request can target multiple channels.

ColumnTypeNotes
tenant_iduuid FK → tenants
deliverable_iduuid FK → deliverablesNULL if ad-hoc
deliverable_period_iduuid FK → deliverable_periodsNULL if ad-hoc
activity_iduuid FK → activitiesApproval activity
requested_by_typeenumagent | human
requested_by_idvarchar(255)
topicvarchar(500)What the post is about
content_brieftextKey messages, angle, tone direction
post_typeenumstandard | carousel | reel | story | article (LinkedIn) | gbp_update
target_channel_idsuuid[]FK → channels — which platforms to post to
scheduled_fortimestamptzRequested publish date/time
notestextHashtag direction, visual notes, don’ts
priorityenumnormal | high | urgent
statusenumdraft | pending_approval | approved | rejected | in_progress | completed
rejection_notestext

On approval: One SocialPost record created per channel in target_channel_ids.


social_posts

One record per platform per approved social media request.

ColumnTypeNotes
tenant_iduuid FK → tenants
social_media_request_iduuid FK → social_media_requests
channel_iduuid FK → channelsWhich specific platform/page
platformvarchar(50)Denormalised from channel for query convenience
content_refvarchar(24)MongoDB ObjectId — post copy + hashtags
char_countintFor validation against platform limits
statusenumdraft | writing | review | approved | scheduled | published | rejected | failed
scheduled_fortimestamptz
published_ontimestamptz
published_urlvarchar(1000)URL of the published post
platform_post_idvarchar(255)Platform’s post ID (for analytics, editing, deletion)
engagement_snapshotjsonbLatest metrics: { likes, comments, shares, impressions }
last_engagement_ontimestamptzWhen engagement was last fetched

Tracks all backlinks built for a tenant. Periodically checked for live/indexed status via DataForSEO.

ColumnTypeNotes
tenant_iduuid FK → tenants
activity_iduuid FK → activitiesThe outreach activity that produced this backlink
deliverable_iduuid FK → deliverablesThe backlinks deliverable this belongs to
target_urlvarchar(1000)The page on the linking site
source_urlvarchar(1000)The page on the client’s site being linked to
anchor_textvarchar(500)
domain_authoritysmallintDA of the linking domain at time of discovery
link_typeenumdofollow | nofollow | ugc | sponsored
outreach_statusenumprospecting | outreach_sent | responded | agreed | published | rejected | no_response
outreach_sent_ontimestamptz
link_published_ontimestamptz
last_checked_ontimestamptzLast DataForSEO check
is_livebooleanDataForSEO: link currently live?
is_indexedbooleanDataForSEO: linking page indexed by Google?
dataforseo_check_resultjsonbFull DataForSEO response payload
notestextManual 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.

ColumnTypeNotes
tenant_iduuid FK → tenants
sourceenumwebsite_form | facebook_lead_ad | google_lead_form | manual | import | other
source_channel_iduuid FK → channelsNULL for manual/import
source_campaign_iduuid FK → ad_campaignsNULL unless from a paid campaign
first_namevarchar(255)
last_namevarchar(255)
emailvarchar(255)
phonevarchar(50)
companyvarchar(255)
job_titlevarchar(255)
messagetextMessage from form or lead ad answers
source_urlvarchar(1000)Landing page or ad URL
utm_sourcevarchar(255)
utm_mediumvarchar(255)
utm_campaignvarchar(255)
statusenumnew | contacted | qualified | disqualified | converted
disqualification_reasontext
assigned_touuid FK → users
enrichment_datajsonb{ linkedinUrl, companySize, industry, website }
notestext
last_activity_ontimestamptz
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.

ColumnTypeNotes
tenant_iduuid FK → tenants
lead_iduuid FK → leads
typeenumnote | email_sent | email_received | call | status_change | enrichment
descriptiontext
actor_typeenumhuman | agent | system
actor_idvarchar(255)User ref_id or agent role
metadatajsonbe.g. { emailSubject, fromStatus, toStatus }

ad_campaigns

Synced from Google Ads and Meta Ads. Upserted on each sync run.

ColumnTypeNotes
tenant_iduuid FK → tenants
channel_iduuid FK → channelsThe connected Google Ads or Meta Ads channel
platformenumgoogle_ads | meta_ads
external_campaign_idvarchar(255)Google Ads campaign ID or Meta campaign ID
namevarchar(500)Campaign name from platform
statusenumactive | paused | removed
campaign_typevarchar(100)SEARCH, DISPLAY, VIDEO (Google) / AWARENESS, CONVERSIONS (Meta)
budget_amountnumeric(12,2)Daily or lifetime budget
budget_currencyvarchar(10)
total_spendnumeric(12,2)Lifetime spend (from last sync)
performance_snapshotjsonb{ impressions, clicks, ctr, cpc, conversions, roas, spend }
last_synced_ontimestamptz
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.

ColumnTypeNotes
tenant_iduuid FK → tenants
channel_iduuid FK → channels
ad_campaign_iduuid FK → ad_campaigns
external_keyword_idvarchar(255)Google Ads criterion ID
textvarchar(500)Keyword text
match_typeenumexact | phrase | broad
statusenumactive | paused | removed
bid_amountnumeric(10,4)CPC bid
performance_snapshotjsonb{ impressions, clicks, ctr, cpc, conversions, quality_score }
last_synced_ontimestamptz

search_term_reports

Raw search terms from Google Ads search term reports.

ColumnTypeNotes
tenant_iduuid FK → tenants
channel_iduuid FK → channels
ad_campaign_iduuid FK → ad_campaigns
search_termvarchar(500)The actual search query
impressionsint
clicksint
conversionsnumeric(10,2)
costnumeric(10,4)
ctrnumeric(6,4)Click-through rate
report_date_rangedaterangee.g. last 30 days
synced_ontimestamptz

search_term_classifications

Agent-classified search terms, pending human review before being pushed to Google Ads.

ColumnTypeNotes
tenant_iduuid FK → tenants
search_term_report_iduuid FK → search_term_reports
activity_iduuid FK → activitiesThe classification activity
classificationenumkeyword | negative_keyword | watch | skip
recommended_match_typeenumexact | phrase | broad | NULL
agent_reasoningtextWhy the agent classified it this way
statusenumpending_review | approved | rejected | pushed
override_classificationenumNULL if human accepted; set if overridden
reviewed_byuuid FK → users
reviewed_ontimestamptz
pushed_ontimestamptz
push_resultjsonbAPI response from Google Ads

ad_optimization_recommendations

Agent-generated optimization recommendations. Pending human review before being applied.

ColumnTypeNotes
tenant_iduuid FK → tenants
channel_iduuid FK → channels
ad_campaign_iduuid FK → ad_campaigns
platformenumgoogle_ads | meta_ads
activity_iduuid FK → activitiesThe recommendation activity
typeenumbid_adjustment | budget_change | pause_keyword | enable_keyword | pause_ad | audience_update | creative_refresh | add_keyword | add_negative
titlevarchar(500)e.g. “Increase bid for ‘seo services london’ by 15%“
reasoningtextAgent’s explanation with supporting data
impact_estimatejsonb{ impressions_delta, spend_delta, conv_delta }
change_payloadjsonbExact API call payload to apply the change
priorityenumlow | medium | high
statusenumpending_review | approved | rejected | snoozed | pushed | failed
snooze_untiltimestamptz
reviewed_byuuid FK → users
reviewed_ontimestamptz
pushed_ontimestamptz
push_resultjsonbAPI response from platform

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: 100

PgBouncer 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 rows

Query Constraints

These rules are enforced by ESLint and enforced in code review:

RuleEnforcement
No SELECT * — specify columns explicitlyESLint no-select-star custom rule
No raw SQL strings — Prisma parameterised queries onlyESLint no-raw-sql custom rule
No N+1 — use Prisma include relations for eager loadingCode review + integration test coverage
All tenant-scoped queries must include where: { tenantId }Prisma middleware (enforced globally)
Slow queries (> 100 ms) must add an indexpg_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:

TableColumnWhat it holds
channelsaccess_token, refresh_tokenOAuth tokens for publishing platforms
llm_providersapi_keyAnthropic, OpenAI API keys
agent_configsenv_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:

UserTablesPrivileges
app_userAll application tablesSELECT, INSERT, UPDATE, DELETE
app_useraudit_logs (MongoDB)INSERT only — see MongoDB docs
migrator_userAll tablesSELECT, INSERT, UPDATE, DELETE, CREATE, DROP
readonly_userAll tablesSELECT 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.

© 2026 Leadmetrics — Internal use only