Campaigns — Data Model
Related: API Routes | Workflow & Permissions | Prisma Schema
Model Index
| # | Model | Purpose | Campaign Types |
|---|---|---|---|
| 1 | Campaign (extended) | Core campaign record | All |
| 2 | CampaignExternalMapping | Links to external platform campaign ID | paid_ads |
| 3 | CampaignMetrics | Daily performance snapshots per channel | All |
| 4 | CampaignAudience | Audience segments (CRM, platform, custom list) | All |
| 5 | CampaignSequence + CampaignSequenceStep | Email drip sequence and steps | email_marketing |
| 6 | ReviewCampaign | Review request drip configuration | review_generation |
| 7 | Activity.campaignId | FK linking generated content to campaign | All |
| 8 | AdGroup | Google Ads ad groups | paid_ads (Google) |
| 9 | CampaignKeyword | Bidding keywords with metrics | paid_ads (Google) |
| 10 | SearchTermReport | Raw search queries from Google Ads | paid_ads (Google) |
| 11 | SearchTermClassification | AI + DM classification per search term | paid_ads (Google) |
| 12 | NegativeKeyword | Negative keywords at campaign / ad group level | paid_ads (Google) |
| 13 | MetaAdSet | Meta ad sets with targeting + fatigue data | paid_ads (Meta) |
| 14 | MetaAd | Individual Meta ad creatives | paid_ads (Meta) |
| 15 | LinkedInAd | LinkedIn ad creatives | paid_ads (LinkedIn) |
| 16 | LinkedInDemographicBreakdown | Per-dimension performance breakdown | paid_ads (LinkedIn) |
| 17 | CampaignOptimizationRecommendation | AI recommendations driving the Optimizations panel | paid_ads, seo_outreach, review_generation |
| 18 | BacklinkHealth | HTTP health status of acquired backlinks | seo_outreach |
| 19 | ReviewMetrics | Weekly review velocity snapshots per platform | review_generation |
1. Campaign (extended)
The existing Campaign model currently handles backlink outreach only. It is generalised to support all campaign types.
New / changed fields:
| Field | Type | Notes |
|---|---|---|
type | CampaignType enum | paid_ads | email_marketing | social_media | seo_outreach | review_generation |
channel | String[] | e.g. ["google_ads", "meta_ads"] |
goal | CampaignGoal enum | awareness | traffic | leads | sales | retention | reviews |
status | CampaignStatus enum | Full lifecycle — see Workflow |
budget | Decimal? | Optional; relevant for paid_ads campaigns |
budgetCurrency | String? | ISO currency code |
startDate | DateTime? | Campaign start |
endDate | DateTime? | Campaign end |
deliverablePeriodId | String? | Optional FK to DeliverablePeriod |
brief | Text? | AI-generated brief stored after campaign-brief-writer runs |
autoPilotEnabled | Boolean | Default false; paid_ads only — enables AI optimisation suggestions to be auto-queued (still requires double-approval) |
Kept for backward compatibility: activityId, totalEmails, existing emails[] and backlinks[] relations.
Note on
source: Add asourcefield (imported | created) to distinguish campaigns imported from a connected platform (Flow A) from campaigns created directly in Leadmetrics.
2. CampaignExternalMapping
Stores the link between a Leadmetrics Campaign record and the corresponding campaign(s) on external ad platforms. A single Leadmetrics campaign can map to one external campaign per platform (e.g. the same campaign tracks one Google Ads campaign and one Meta campaign).
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
campaignId | String | FK → Campaign |
connectedChannelId | String | FK → ConnectedChannel (identifies the platform and ad account) |
externalCampaignId | String | The platform’s own campaign ID (e.g. Google Ads campaign ID, Meta campaign ID) |
externalCampaignName | String | Name as it appears on the platform (synced on import) |
platform | String | google_ads | meta_ads | linkedin_ads |
lastSyncedAt | DateTime? | Timestamp of the last successful metrics pull |
createdAt | DateTime |
Unique index on (campaignId, platform) — one mapping per platform per campaign.
This model is populated during Flow A (Import) and read during Flow B (Metrics Sync).
3. CampaignMetrics
Daily / weekly performance snapshots pulled from connected channel APIs.
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
campaignId | String | FK → Campaign |
date | DateTime | Snapshot date |
channel | String | Platform the metrics are from |
impressions | Int? | |
clicks | Int? | |
conversions | Int? | |
spend | Decimal? | Paid ads only |
revenue | Decimal? | |
ctr | Float? | Click-through rate |
cpc | Decimal? | Cost per click |
roas | Float? | Return on ad spend |
openRate | Float? | Email only |
unsubscribes | Int? | Email only |
reach | Int? | Social only |
engagement | Int? | Social only |
Unique index on (campaignId, date, channel) to prevent duplicate snapshots.
Metrics are populated by the google-ads-insights and meta-ads-insights workers during Flow B (Metrics Sync), using CampaignExternalMapping.externalCampaignId to scope the API call to the specific campaign rather than the whole ad account.
4. CampaignAudience
Audience segments attached to a campaign. Can be CRM-derived, platform-native, or custom.
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
campaignId | String | FK → Campaign |
name | String | Human-readable segment name |
segmentType | String | crm_segment | platform_audience | custom_list |
filters | Json? | Age range, location, interests, lead tags, etc. |
estimatedSize | Int? | Estimated audience reach |
platformAudienceId | String? | External ID on Google / Meta / LinkedIn |
createdAt | DateTime |
5. CampaignSequence + CampaignSequenceStep
Email drip sequences for email_marketing and review_generation campaigns.
CampaignSequence:
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
campaignId | String | FK → Campaign |
name | String | e.g. “Welcome Sequence” |
triggerType | String | on_subscribe | on_date | manual |
status | String | draft | active | paused | completed |
createdAt | DateTime |
CampaignSequenceStep:
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
sequenceId | String | FK → CampaignSequence |
stepOrder | Int | 1-based ordering |
delayDays | Int | Days after previous step (0 = immediate) |
emailSubject | String | |
emailBody | Text | |
status | String | draft | approved | active |
sentCount | Int | |
openCount | Int | |
clickCount | Int |
6. ReviewCampaign
Configuration for GBP review generation drip campaigns.
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
tenantId | String | FK → Tenant |
campaignId | String | FK → Campaign |
channel | String | email | sms |
sequence | Json | Array of { delayDays, messageTemplate } steps |
contactListId | String? | FK to leads / contacts query |
status | String | draft | active | paused | completed |
startedAt | DateTime? | |
completedAt | DateTime? |
7. Activity.campaignId (new FK)
Add an optional campaignId FK on Activity so that all content generated for a campaign (ad copy, emails, social posts) can be retrieved via the Campaign Content tab without joining through DeliverablePeriod.
campaignId String?
campaign Campaign? @relation(fields: [campaignId], references: [id], onDelete: SetNull)8. AdGroup
Represents a Google Ads ad group within a campaign. Synced from the platform via GAQL.
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
campaignId | String | FK → Campaign |
externalAdGroupId | String | Google Ads ad group ID |
name | String | Ad group name as on the platform |
status | String | enabled | paused | removed (mirrored from platform) |
defaultBidMicros | BigInt? | Default CPC bid in micros (1,000,000 = $1.00) |
lastSyncedAt | DateTime? | |
createdAt | DateTime |
Unique index on (campaignId, externalAdGroupId).
9. CampaignKeyword
Keywords actively bidding within a campaign/ad group, synced from the platform. Distinct from the SEO-focused Keyword model.
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
campaignId | String | FK → Campaign |
adGroupId | String? | FK → AdGroup |
externalKeywordId | String | Google Ads criterion ID |
keyword | String | Keyword text |
matchType | String | broad | phrase | exact |
status | String | enabled | paused | removed |
bidMicros | BigInt? | Keyword-level CPC bid override |
qualityScore | Int? | 1–10 as reported by Google |
impressions | Int? | From last sync period |
clicks | Int? | From last sync period |
ctr | Float? | |
avgCpc | Decimal? | |
conversions | Float? | |
lastSyncedAt | DateTime? | |
createdAt | DateTime |
Unique index on (campaignId, externalKeywordId).
10. SearchTermReport
Actual search queries that triggered ads, synced from search_term_view via GAQL. This is the raw data before classification.
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
campaignId | String | FK → Campaign |
adGroupId | String? | FK → AdGroup |
searchTerm | String | The actual query typed by the user |
matchType | String | How it matched: broad | phrase | exact | broad_match_modifier |
impressions | Int | |
clicks | Int | |
cost | Decimal | Spend for this term |
conversions | Float | |
ctr | Float | |
avgCpc | Decimal | |
syncDate | DateTime | Date range end of this sync batch |
createdAt | DateTime |
Unique index on (campaignId, searchTerm, syncDate) to avoid duplicate rows per sync.
11. SearchTermClassification
Stores the AI-generated classification for each search term and tracks the human review decision. This is the HITL gate before pushing negative keywords to the platform.
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
searchTermReportId | String | FK → SearchTermReport (unique — one classification per term) |
campaignId | String | FK → Campaign (denormalised for query convenience) |
aiClassification | String | AI suggestion: add_as_keyword | add_as_negative | watch | irrelevant |
aiRationale | String? | Brief reason from the AI |
dmDecision | String? | DM override: same enum values — null until reviewed |
status | String | pending | dm_reviewed | pushed | skipped |
pushedAt | DateTime? | When the negative keyword was pushed to Google Ads |
createdAt | DateTime |
12. NegativeKeyword
Negative keywords at campaign or ad group level, both manually added and pushed from SearchTermClassification.
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
campaignId | String | FK → Campaign |
adGroupId | String? | FK → AdGroup (null = campaign-level negative) |
keyword | String | Negative keyword text |
matchType | String | broad | phrase | exact |
externalCriterionId | String? | Google Ads criterion ID after push |
source | String | manual | search_term_classification |
status | String | pending_push | active | removed |
createdAt | DateTime |
Unique index on (campaignId, keyword, matchType) to prevent duplicates.
13. MetaAdSet
Ad sets within a Meta Ads campaign. Each ad set has its own audience targeting, budget, and schedule. Synced from the Meta Marketing API.
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
campaignId | String | FK → Campaign |
externalAdSetId | String | Meta’s ad set ID |
name | String | Ad set name |
status | String | active | paused | deleted | archived |
dailyBudgetCents | Int? | Daily budget in cents |
lifetimeBudgetCents | Int? | Lifetime budget in cents |
targeting | Json? | Meta targeting spec (age, gender, geo, interests, custom audiences) |
placements | Json? | Placement config (automatic vs. manual placement list) |
impressions | Int? | From last sync period |
clicks | Int? | |
spend | Decimal? | |
reach | Int? | Unique people reached |
frequency | Float? | Avg times a person has seen the ads — key fatigue indicator |
ctr | Float? | |
cpm | Decimal? | Cost per 1,000 impressions |
cpc | Decimal? | |
conversions | Float? | |
roas | Float? | |
lastSyncedAt | DateTime? | |
createdAt | DateTime |
Unique index on (campaignId, externalAdSetId).
Fatigue rule of thumb: frequency > 3.0 for cold (lookalike/interest) audiences or > 7.0 for warm (retargeting) audiences signals creative burnout — flag for refresh.
14. MetaAd
Individual ad creatives within a Meta ad set. Performance is tracked at this level to identify winning and fatigued creatives.
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
campaignId | String | FK → Campaign |
adSetId | String | FK → MetaAdSet |
externalAdId | String | Meta’s ad ID |
name | String | |
status | String | active | paused | deleted | archived |
format | String | image | video | carousel | collection | lead | instant_experience |
creativePreviewUrl | String? | Thumbnail / preview image URL |
headline | String? | Primary text / headline used |
impressions | Int? | |
clicks | Int? | |
spend | Decimal? | |
frequency | Float? | Ad-level frequency — more granular than ad set |
ctr | Float? | |
cpc | Decimal? | |
conversions | Float? | |
roas | Float? | |
fatigueFlag | Boolean | Set to true by meta-ads-insights when frequency is high + CTR is declining |
lastSyncedAt | DateTime? | |
createdAt | DateTime |
Unique index on (campaignId, externalAdId).
15. LinkedInAd
Individual ad creatives within a LinkedIn Ads campaign. LinkedIn’s hierarchy is Campaign Group → Campaign → Ad. We track at ad level for creative performance.
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
campaignId | String | FK → Campaign |
externalAdId | String | LinkedIn’s creative ID |
name | String | |
status | String | active | paused | archived | draft |
format | String | sponsored_content | message_ad | text_ad | dynamic_ad | lead_gen_form |
headline | String? | |
introText | String? | Introductory text (Sponsored Content) |
leadGenFormId | String? | LinkedIn Lead Gen Form ID if applicable |
impressions | Int? | |
clicks | Int? | |
spend | Decimal? | |
frequency | Float? | LinkedIn computes this — fatigue happens fast on small B2B audiences |
ctr | Float? | |
cpc | Decimal? | |
cpm | Decimal? | |
conversions | Float? | |
leadGenFormCompletions | Int? | For Lead Gen Form ads only |
leadGenFormCompletionRate | Float? | Form completions / ad clicks |
fatigueFlag | Boolean | Set by linkedin-ads-insights when frequency is high |
lastSyncedAt | DateTime? | |
createdAt | DateTime |
Unique index on (campaignId, externalAdId).
16. LinkedInDemographicBreakdown
LinkedIn’s unique strength is demographic targeting. This model stores performance broken down by audience dimension — used to identify which job functions, seniority levels, industries, and company sizes are converting.
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
campaignId | String | FK → Campaign |
syncDate | DateTime | Date range end of this sync batch |
dimensionType | String | job_function | seniority | industry | company_size | job_title | company |
dimensionValue | String | e.g. "Engineering", "Director", "1-10 employees" |
impressions | Int? | |
clicks | Int? | |
spend | Decimal? | |
conversions | Float? | |
ctr | Float? | |
cpc | Decimal? | |
createdAt | DateTime |
Unique index on (campaignId, syncDate, dimensionType, dimensionValue).
This data feeds the AI recommendation to tighten or expand LinkedIn audience segments (e.g. “Director and VP seniority converting at 3× the rate of Manager — recommend excluding Manager”).
17. CampaignOptimizationRecommendation
Stores every AI-generated optimisation recommendation for a campaign — whether from a scheduled weekly scan or a manual trigger. This is the single model that drives the Optimizations panel in the UI. Nothing is pushed to a platform until the recommendation reaches applied status via the HITL approval chain.
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
campaignId | String | FK → Campaign |
platform | String | meta_ads | linkedin_ads | google_ads |
type | String | See recommendation types below |
title | String | Short human-readable title, e.g. “Refresh fatigued ad — Summer Promo V1” |
rationale | Text | AI explanation of why this is recommended + supporting data |
suggestedAction | Json | Structured payload describing the change (pause ad set ID, new budget amount, etc.) |
estimatedImpact | String? | AI estimate of expected improvement, e.g. “~15% CTR recovery” |
priority | String | high | medium | low — set by the optimizer worker based on spend/impact |
status | String | pending | dm_reviewing | dm_approved | client_review | client_approved | applied | skipped | expired |
dmNote | String? | DM comments when approving or skipping |
clientNote | String? | Client comments when approving or rejecting |
appliedAt | DateTime? | When the change was pushed to the platform |
expiresAt | DateTime? | Stale recommendations expire after 14 days if not actioned |
sourceJobId | String? | BullMQ job ID that generated this recommendation |
createdAt | DateTime |
Recommendation types:
| Type | Platform | Description |
|---|---|---|
creative_refresh | Meta, LinkedIn | Fatigued ad — generate new variant |
pause_ad | Meta, LinkedIn | Pause a specific underperforming or fatigued ad |
pause_ad_set | Meta | Pause a whole ad set (all audience exhausted or ROAS < 0) |
budget_shift | Meta | Move daily budget from a low-ROAS ad set to a high-ROAS one |
pause_placement | Meta | Disable a specific placement (e.g. Audience Network) within an ad set |
audience_tighten | Narrow targeting to top-converting demographic segment | |
audience_exclude | LinkedIn, Meta | Add an exclusion (converted leads, competitor employees) |
bid_adjust | Google, LinkedIn | Change CPC bid for keyword or campaign |
refresh_contact_list | Meta, LinkedIn | Re-upload updated CRM audience list for custom audience |
lead_form_refresh | Lead Gen Form copy is stale or completion rate dropped — refresh | |
audience_expand | Meta | Cold audience exhausted — suggest new interest or lookalike |
refresh_outreach_template | SEO Outreach | Low reply rate on an outreach email step — rewrite copy |
add_follow_up_step | SEO Outreach | Sequence ended without a reply — AI drafts an additional follow-up step |
reprioritize_prospects | SEO Outreach | Domain authority scores have shifted — reorder the target prospect list |
flag_dead_backlink | SEO Outreach | A previously acquired backlink has gone dead — re-engage the domain |
suggest_disavow | SEO Outreach | Suspicious / spammy backlink pattern detected — add domain to disavow file |
refresh_review_sequence_step | Review Generation | Low open or click rate on a drip step — rewrite subject line or body |
re_engage_non_responders | Review Generation | Contacts who completed the sequence without leaving a review — send a different angle |
adjust_send_schedule | Review Generation | Send time analysis shows a higher-converting window — adjust drip schedule |
shift_review_platform | Review Generation | One review platform is lagging behind — redirect the next outreach batch |
flag_negative_review_pattern | Review Generation | Multiple recent reviews share the same negative theme — flag for tenant action |
Unique constraint: one pending or dm_reviewing recommendation per (campaignId, platform, type, suggestedAction.targetId) to prevent duplicate recommendations for the same object.
18. BacklinkHealth
Tracks the live status of each backlink acquired through an seo_outreach campaign. A weekly background job performs HTTP HEAD checks on each URL and updates the status. Dead or nofollowed backlinks surface as flag_dead_backlink recommendations.
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
campaignId | String | FK → Campaign |
domain | String | Root domain of the linking site (e.g. techcrunch.com) |
url | String | Full URL of the page containing the link |
anchorText | String? | Anchor text used for the link |
status | String | unchecked | live | dead | nofollow | redirected | blocked |
httpStatusCode | Int? | Last HTTP response code (200, 301, 404, etc.) |
isDoFollow | Boolean? | Whether the link is dofollow — checked via rel attribute scan |
domainAuthority | Int? | DA/DR score at last check (sourced from third-party API or manual entry) |
acquiredAt | DateTime? | When the backlink was first confirmed live |
lastCheckedAt | DateTime? | Timestamp of the last health check |
createdAt | DateTime |
Unique index on (campaignId, url).
Health check logic: A HEAD request to
urlis made. If HTTP status ≠ 2xx →dead. If redirect chain lands on a different domain →redirected. If the page is reachable but the link can no longer be found (requires full GET + HTML parse) →dead. Ifrel="nofollow"is present →nofollow. Statusblockedis set when the domain returns 403/rate-limit consistently.
19. ReviewMetrics
Weekly snapshot of reviews received per platform for a review_generation campaign. Used to track review velocity, star rating trends, and negative theme patterns.
| Field | Type | Notes |
|---|---|---|
id | String | CUID |
campaignId | String | FK → Campaign |
platform | String | google | trustpilot | g2 | yelp | tripadvisor | other |
weekStartDate | DateTime | Monday of the reporting week |
newReviewCount | Int | Reviews received during this week |
avgRating | Float? | Average star rating for new reviews (1–5) |
totalReviewCount | Int? | Cumulative total on the platform (synced from connected channel or manually entered) |
negativeCount | Int? | Reviews with rating ≤ 2 this week |
sentimentScore | Float? | AI-computed sentiment score across new review text (−1 negative → +1 positive) |
keyThemes | Json? | Array of recurring themes extracted from review text by AI — e.g. ["slow response", "great onboarding"] |
createdAt | DateTime |
Unique index on (campaignId, platform, weekStartDate).
Populated by the
review-campaign-optimizerworker or manually.keyThemesandsentimentScoreare AI-generated — they feed theflag_negative_review_patternrecommendation type.
Periodic optimization activities, thresholds, and the weekly scheduler are documented in the Periodic Optimization Automation section of workflow.md.
Provider API method extensions required to support these models are documented in api.md — Provider Extensions.