Database Schema
Complete documentation of all database models, relationships, and design decisions.
Overview
MetaPilot uses 20+ database tables across 10 Django apps. All primary keys are UUIDs (not auto-incrementing integers) for security and distributed system compatibility.
┌─────────────────────────────────────────────────────────────┐
│ MULTI-TENANCY LAYER │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │
│ │ agencies │───▶│ tenants │◀───│ users │ │
│ └──────────┘ └────┬─────┘ └──────────────────────┘ │
│ │ │
│ ┌────────────────────┼────────────────────────────────┐ │
│ │ TENANT-SCOPED DATA │ │
│ │ ┌──────────┐ ┌──────────┐ ┌────────────────────┐ │ │
│ │ │ contacts │ │campaigns │ │ scheduler_jobs │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────────┬───────────┘ │ │
│ │ │ │ │ │ │
│ │ ┌────▼─────────┐ │ ┌───────▼──────────┐ │ │
│ │ │conversations │ │ │job_recipients │ │ │
│ │ └────┬─────────┘ │ └──────────────────┘ │ │
│ │ │ │ │ │
│ │ ┌────▼─────┐ ┌────▼──────────────┐ │ │
│ │ │ messages │ │scheduled_messages │ │ │
│ │ └──────────┘ └───────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
1. Users App
users
Table
usersThe custom User model replaces Django's default. Uses email as the login identifier (not username).
| Column | Type | Description |
|---|---|---|
| UUID | Primary key |
| VARCHAR(254) | Unique, indexed. Login identifier |
| VARCHAR(128) | PBKDF2 hashed password |
| VARCHAR(150) | Optional |
| VARCHAR(150) | Optional |
| VARCHAR(20) | Optional phone number |
| VARCHAR(20) | |
| UUID FK → agencies | Only for AGENCY_ADMIN users |
| UUID FK → tenants | NULL for SUPER_ADMIN and AGENCY_ADMIN |
| BOOLEAN | Soft delete / account disable |
| BOOLEAN | Django admin access |
| TIMESTAMP | Auto-set on creation |
| TIMESTAMP | Auto-set on update |
Role Hierarchy:
SUPER_ADMIN → Full platform access. No tenant restriction.
└── AGENCY_ADMIN → Manages clients under their agency.
└── TENANT_ADMIN → Manages own tenant (contacts, campaigns, etc.)
└── TENANT_USER → Limited access within tenant.
2. Tenants App
agencies
Table
agenciesOptional reseller layer. An agency manages multiple clients.
| Column | Type | Description |
|---|---|---|
| UUID | Primary key |
| VARCHAR(255) | Agency business name |
| VARCHAR(50) | URL-safe identifier, unique |
| VARCHAR(254) | Primary contact email |
| VARCHAR(20) | Phone number |
| VARCHAR(20) | |
| DECIMAL(5,2) | Commission rate for billing |
tenants
Table
tenantsThe core multi-tenancy entity. Each tenant = one business = one WhatsApp number.
| Column | Type | Description |
|---|---|---|
| UUID | Primary key |
| VARCHAR(255) | Business name |
| VARCHAR(50) | Unique URL identifier |
| VARCHAR(20) | ECOMMERCE, SERVICE, SAAS, etc. |
| UUID FK → agencies | Parent agency (optional) |
| VARCHAR(20) | ACTIVE, SUSPENDED, PENDING |
| VARCHAR(20) | FREE, STARTER, PRO, ENTERPRISE |
| INTEGER | Default: 1000 |
| INTEGER | Default: 5 |
| INTEGER | API calls per minute. Default: 60 |
| VARCHAR(64) | Auto-generated 32-byte token |
| BOOLEAN | Feature toggle |
| BOOLEAN | Feature toggle |
| BOOLEAN | Feature toggle |
Indexes:
- — Fast filtering by status
tenants_status - — Fast lookup of agency's clients
tenants_agency - — Combined filter for agency dashboard
tenants_agency_status
Auto-generated webhook URL:
{WEBHOOK_BASE_URL}/api/wa-chatbot/webhook/{tenant_id}/
tenant_configs
Table
tenant_configsEncrypted storage for API credentials. This is how each tenant's WhatsApp keys are stored securely.
| Column | Type | Description |
|---|---|---|
| UUID | Primary key |
| UUID FK → tenants | Which tenant owns this config |
| VARCHAR(50) | META_WHATSAPP, TWILIO, SENDGRID, CUSTOM |
| VARCHAR(100) | e.g., |
| TEXT | Fernet-encrypted (AES-256-CBC) value |
| BOOLEAN | Can be deactivated without deleting |
Unique constraint: — one config per key per provider per tenant.
(tenant, provider, key_name)Encryption flow:
python
# Storing a value
config.set_value("EAAGm0PX4...")
# Internally: Fernet(FERNET_KEY).encrypt(b"EAAGm0PX4...").decode()
# Retrieving a value
token = config.get_value()
# Internally: Fernet(FERNET_KEY).decrypt(encrypted_bytes).decode()
audit_logs
Table
audit_logsTracks all important user actions for security and compliance.
| Column | Type | Description |
|---|---|---|
| UUID | Primary key |
| VARCHAR(100) | e.g., |
| UUID FK → users | Who did it |
| UUID FK → tenants | Which tenant context |
| UUID FK → agencies | Which agency context |
| JSONB | Additional context (any key-value data) |
| INET | Client IP address |
| TEXT | Browser/client identifier |
| TIMESTAMP | When it happened |
feature_flags
Table
feature_flagsPer-tenant feature toggles for progressive rollouts.
| Column | Type | Description |
|---|---|---|
| UUID FK → tenants | Which tenant |
| VARCHAR(100) | e.g., |
| BOOLEAN | ON/OFF |
Unique:
(tenant, feature_name)data_deletion_requests
Table
data_deletion_requestsGDPR compliance. Tracks data deletion requests with status.
| Statuses | Description |
|---|---|
| PENDING | Request received, not started |
| PROCESSING | Deletion in progress |
| COMPLETED | Data successfully deleted |
| FAILED | Deletion failed (will retry) |
3. Messaging App
contacts
Table
contactsWhatsApp contacts for each tenant.
| Column | Type | Description |
|---|---|---|
| UUID | Primary key |
| UUID FK → tenants | Owner tenant |
| VARCHAR(20) | WhatsApp number (E.164 format) |
| VARCHAR(255) | Display name |
| VARCHAR(254) | Optional email |
| JSONB | Array of strings for segmentation: |
| JSONB | Arbitrary key-value data |
| BOOLEAN | Opt-in status |
| BOOLEAN | Blocked by the business |
| UUID FK → contact_imports | Which import batch |
Unique: — same phone can exist in different tenants.
(tenant, phone)conversations
Table
conversationsRepresents a WhatsApp conversation thread with one contact.
| Column | Type | Description |
|---|---|---|
| UUID | Primary key |
| UUID FK → contacts | Who is this conversation with |
| VARCHAR(100) | Meta's conversation ID |
| VARCHAR(20) | ACTIVE, ARCHIVED, BLOCKED |
| UUID FK → users | Agent assignment |
| TIMESTAMP | For sorting |
messages
Table
messagesIndividual WhatsApp messages (both inbound and outbound).
| Column | Type | Description |
|---|---|---|
| UUID | Primary key |
| UUID FK → conversations | Parent thread |
| VARCHAR(100) | Meta's message ID (for status tracking) |
| VARCHAR(10) | INBOUND or OUTBOUND |
| VARCHAR(20) | TEXT, IMAGE, TEMPLATE, etc. |
| VARCHAR(20) | PENDING → SENT → DELIVERED → READ / FAILED |
| TEXT | Message text body |
| JSONB | Full message payload (template params, media, etc.) |
| URL | Media attachment URL |
| VARCHAR(50) | Meta error code if failed |
| TEXT | Error description |
| TIMESTAMP | When sent |
| TIMESTAMP | When delivered |
| TIMESTAMP | When read |
media_assets
Table
media_assetsUploaded files for campaigns (images, videos, documents).
| Column | Type | Description |
|---|---|---|
| BYTEA | Binary file content stored in PostgreSQL |
| VARCHAR(100) | MIME type for HTTP serving |
| VARCHAR(64) | Random token for unauthenticated access |
Why store files in PostgreSQL? Simplicity. No S3 setup needed. Files ≤5MB are stored as binary. The lets Meta's servers fetch header images without auth.
public_tokencontact_imports
Table
contact_importsTracks CSV/XLSX import jobs with result counts.
| Column | Type | Description |
|---|---|---|
| INTEGER | Rows in uploaded file |
| INTEGER | Successfully imported |
| INTEGER | Skipped (already existed) |
| INTEGER | Failed rows |
| JSONB | Array of error details |
| JSONB | Tags auto-applied to imported contacts |
4. Campaigns App
campaigns
Table
campaignsMarketing campaigns that target contacts by tags.
Key fields: , (standard/carousel), , , (JSON), (JSON for carousel cards).
template_nametemplate_typetarget_tagstarget_allheader_datacards_jsoncampaign_messages
Table
campaign_messagesIndividual messages within a campaign, each with independent scheduling. Links to for execution tracking.
scheduler_jobsscheduled_messages
Table
scheduled_messagesLegacy per-recipient tracking. Links campaign → contact with status.
message_results
Table
message_resultsDetailed result log for each send attempt.
5. Scheduler App
scheduler_jobs
Table
scheduler_jobsCentral scheduling entity. See Scheduler Engine for detailed explanation.
Key fields:
- — MD5 deduplication hash (unique)
job_hash - — Server ID for distributed locking
claimed_by - — 1 (highest) to 10 (lowest)
priority - Template data: ,
template_type,header_datacards_json - Stats: ,
total_recipients,sent_countfailed_count
Indexes (4 composite):
- — Finding due jobs
(status, scheduled_time) - — Tenant-scoped queries
(tenant, scheduled_time) - — Retry scheduling
(status, next_retry_at) - — Distributed lock tracking
(status, claimed_by)
scheduler_job_recipients
Table
scheduler_job_recipientsPer-recipient tracking with error isolation. Each recipient has independent status.
Unique: — prevents duplicate sends.
(job, phone_number)6. Templates App
whatsapp_templates
Table
whatsapp_templatesTemplates created by Super Admin and assigned to clients (M2M relationship).
cached_meta_templates
Table
cached_meta_templatesTemplates fetched from Meta's Graph API and cached locally. Includes internal classification fields not provided by Meta: , , .
industryfeature_groupuse_caseUnique:
(tenant, meta_template_id, language)7. Inbox App
inbox_conversations
Table
inbox_conversationsDenormalized for fast inbox list rendering. Stores , , to avoid expensive JOINs.
last_messagelast_message_timeunread_countUnique:
(tenant, customer_phone)inbox_messages
Table
inbox_messagesFull message content stored as (JSONB). Deduplication via .
content_jsonmeta_message_id8. Analytics App
campaign_stats
— 1:1 with campaigns. Aggregated delivery/read rates.
campaign_statsmessage_analytics
— 1:1 with messages. Latency tracking.
message_analyticsclient_quotas
— 1:1 with tenants. Daily/monthly message limits.
client_quotasrate_limit_logs
— API rate limit tracking per endpoint.
rate_limit_logs9. Notifications App
notifications
Table
notificationsRole-based notifications with priority levels (LOW, MEDIUM, HIGH, URGENT).
5 indexes for fast queries on different access patterns.
Key Design Decisions
-
UUIDs everywhere — Prevents ID guessing attacks. Safe for distributed systems.
-
JSONB for flexible data — Tags, metadata, template params, carousel cards — all stored as JSONB. PostgreSQL indexes JSONB efficiently.
-
Soft deletes —flags instead of
is_active. Data is never truly lost.DELETE -
Denormalized inbox —is duplicated data, but it eliminates a JOIN on every inbox list query. Worth the trade-off for real-time performance.
InboxConversation.last_message -
Fernet encryption — API keys encrypted at rest. Database dump alone doesn't expose secrets.
-
Composite indexes — Every multi-tenant query is indexed:,
(tenant, created_at), etc.(tenant, status)