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
| 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 | |
slug | varchar(100) UNIQUE | URL-safe: acme-corp |
name | varchar(255) | |
industry | varchar(100) | |
website | varchar(500) | |
plan | enum | free | pro | agency | 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 |
razorpay_customer_id | varchar(255) | |
razorpay_subscription_id | varchar(255) | |
deployment_mode | enum | saas | on_prem |
created_on | timestamptz | |
updated_on | timestamptz |
users
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
tenant_id | uuid FK → tenants | |
email | varchar(255) UNIQUE | |
name | varchar(255) | |
role | enum | admin | member | reviewer | super_admin |
app_access | text[] | ['dashboard','dm-portal','manage'] |
status | enum | active | invited | deactivated |
created_on | timestamptz |
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_ref | varchar(24) | MongoDB ObjectId → contract_pdfs collection |
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)
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 |
Billing & Costs
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 |
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 |
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 |
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.