The current email_log table tracks email sends at a high level (subject, recipient_count, template_used) but has no per-recipient tracking and is unused. As the system grows to multiple events per year and multiple communication channels (email via Brevo, LinkedIn posts, on-the-fly marketing materials), we need proper campaign tracking that answers: who was sent what, through which channel, for which event.
This migration will replace email_log with a richer set of tables.
Groups related outreach for a specific event purpose (e.g., "CFP 2026", "Early Bird Registration 2026").
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PK | |
| name | VARCHAR(200) NOT NULL | e.g., "CFP Invitation — Data & AI Day 2026" |
| event_id | INTEGER NOT NULL FK → events | Every campaign belongs to an event |
| campaign_type | VARCHAR(50) NOT NULL | cfp, registration, reminder, thank_you, sponsor, general |
| description | TEXT | Optional context |
| status | VARCHAR(20) DEFAULT 'draft' | draft, active, completed, cancelled |
| created_at | TIMESTAMPTZ DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ DEFAULT NOW() |
Individual outreach actions within a campaign — an email blast, a LinkedIn post, etc.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PK | |
| campaign_id | INTEGER NOT NULL FK → campaigns | |
| channel | VARCHAR(30) NOT NULL | email, linkedin_post, marketing_material |
| title | VARCHAR(500) | Email subject, LinkedIn post title, or material name |
| brevo_template_id | INTEGER | Email only — links to templates.json |
| post_url | TEXT | LinkedIn only — URL to the published post |
| content_summary | TEXT | LinkedIn post content or material description |
| recipient_count | INTEGER DEFAULT 0 | For emails; 0 for posts/materials |
| status | VARCHAR(20) DEFAULT 'draft' | draft, sending, sent, published, failed |
| sent_at | TIMESTAMPTZ | When it was sent/published |
| created_at | TIMESTAMPTZ DEFAULT NOW() |
Per-recipient tracking for emails. Answers "who was sent what".
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PK | |
| communication_id | INTEGER NOT NULL FK → communications | |
| contact_id | INTEGER NOT NULL FK → contacts | |
| status | VARCHAR(20) DEFAULT 'queued' | queued, sent, delivered, opened, clicked, bounced, failed |
| brevo_message_id | VARCHAR(100) | Brevo per-message tracking ID |
| sent_at | TIMESTAMPTZ | |
| metadata | JSONB | Channel-specific data (Brevo webhook events, etc.) |
| UNIQUE | (communication_id, contact_id) | No duplicate sends |
Track materials created for campaigns/events — social graphics, flyers, banners, etc.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PK | |
| campaign_id | INTEGER FK → campaigns | Optional — asset may exist outside a campaign |
| event_id | INTEGER NOT NULL FK → events | |
| asset_type | VARCHAR(50) NOT NULL | social_graphic, flyer, banner, poster, video, presentation |
| title | VARCHAR(200) NOT NULL | |
| description | TEXT | |
| file_url | TEXT | Link to the file (Brevo media library, cloud storage, etc.) |
| created_at | TIMESTAMPTZ DEFAULT NOW() |
Track where and when materials were shared.
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PK | |
| asset_id | INTEGER NOT NULL FK → marketing_assets | |
| channel | VARCHAR(50) NOT NULL | linkedin, whatsapp, twitter, print, email, website |
| distributed_at | TIMESTAMPTZ DEFAULT NOW() | |
| reach_estimate | INTEGER | Optional — estimated reach/impressions |
| notes | TEXT | e.g., "Posted in SA Data & AI LinkedIn group" |
email_log— replaced bycampaigns+communications+communication_recipients
events
├── campaigns (1:many)
│ ├── communications (1:many)
│ │ └── communication_recipients (1:many) → contacts
│ └── marketing_assets (1:many)
│ └── asset_distributions (1:many)
└── marketing_assets (1:many, direct)
The existing tickets.marketing_source field can reference a campaign name or ID, connecting outreach to actual registrations. For example:
- Send CFP emails (campaign "CFP 2026")
- Speaker registers on Quicket
- Quicket CSV import populates
tickets.marketing_source = 'cfp-2026' - Query: "How many registrations came from the CFP campaign?" becomes answerable
No schema change needed — just a convention for marketing_source values.
| Table | Index | Why |
|---|---|---|
| campaigns | (event_id) | Filter campaigns by event |
| campaigns | (campaign_type) | Filter by purpose |
| campaigns | (status) | Filter active/completed |
| communications | (campaign_id) | All comms for a campaign |
| communications | (channel) | Filter by channel type |
| communications | (sent_at) | Timeline queries |
| communication_recipients | (communication_id, contact_id) UNIQUE | Prevent duplicates |
| communication_recipients | (contact_id) | "What has this person received?" |
| communication_recipients | (status) | Find bounced/failed |
| marketing_assets | (event_id) | All assets for an event |
| marketing_assets | (campaign_id) | All assets for a campaign |
| asset_distributions | (asset_id) | All distributions for an asset |
"Who did we send the CFP invitation to?" → JOIN campaigns → communications → communication_recipients → contacts WHERE campaign_type = 'cfp'
"What outreach have we done for Data & AI Day 2026?" → Query campaigns + communications for the event, across all channels
"Has Jane Smith received any emails about the 2026 event?" → Query communication_recipients JOIN contacts WHERE email = 'jane@...' AND event_id = X
"What marketing materials did we create for 2026?" → Query marketing_assets WHERE event_id = X
"Where did we share the CFP flyer?" → Query asset_distributions JOIN marketing_assets
"Show me a timeline of all comms for the CFP campaign" → Query communications + marketing_assets for campaign, ordered by date
Create as migrations/003_campaign_tracking.sql:
- Create tables in FK dependency order: campaigns → communications → communication_recipients → marketing_assets → asset_distributions
- Drop
email_log - Add indexes
- Add CHECK constraints for enum-like columns (channel, status, campaign_type, asset_type)
- templates.json: No change —
communications.brevo_template_idreferences the same IDs - Import MCP server: No change — imports don't touch campaign tracking
- CLAUDE.md: Update table count (10 → 14, since we add 5 and drop 1)
- DATABASE.md: Update ER diagram and table documentation
After running the migration:
\dt public.*— confirm 14 tables (5 new, 1 dropped)- Insert a test campaign → communication → recipients flow
- Query: "What has contact X been sent?" — verify JOIN works
- Query: "What outreach has been done for event Y?" — verify cross-channel view
- Verify
email_logis dropped