Campaigns — Workflow & Permissions
Related: Data Model | API Routes | HITL
Status Lifecycle
draft
│
▼
dm_review ← DM team reviews brief, content, audience
│
▼
dm_approved ← DM approves; ready for client
│
▼
client_review ← Client sees campaign summary; reviews brief + creatives
│
▼
client_approved ← Client approves; campaign ready to launch
│
▼
active ← Campaign is live on connected channels
│
├──► paused ← Manually paused by DM (can resume → active)
│
▼
completed ← End date passed or manually marked done
│
▼
archived ← Soft-deleted; no further editsAllowed transitions:
| From | To | Who can trigger |
|---|---|---|
draft | dm_review | admin, member |
dm_review | dm_approved | admin, member |
dm_review | draft | admin, member (send back for revisions) |
dm_approved | client_review | admin, member |
dm_approved | dm_review | admin, member (reopen for edits) |
client_review | client_approved | reviewer (client), admin |
client_review | dm_review | reviewer (client) — requests changes |
client_approved | active | admin, member |
active | paused | admin, member |
paused | active | admin, member |
active | completed | admin, member (or automatic on endDate) |
| any | archived | admin only |
Role Gates
Campaigns reuse the existing TenantMember role system.
| Action | admin | member | reviewer (client) |
|---|---|---|---|
| Create campaign | ✅ | ✅ | ❌ |
| Edit campaign (draft → dm_approved) | ✅ | ✅ | ❌ |
| Submit for client review | ✅ | ✅ | ❌ |
| View campaign detail | ✅ | ✅ | ✅ (client_review only) |
| Approve / reject (client review) | ✅ | ❌ | ✅ |
| Launch (client_approved → active) | ✅ | ✅ | ❌ |
| Pause / resume | ✅ | ✅ | ❌ |
| Enable auto-pilot | ✅ | ❌ | ❌ |
| Archive | ✅ | ❌ | ❌ |
| View performance metrics | ✅ | ✅ | ✅ (own campaigns) |
| Trigger agent (brief, audience, content) | ✅ | ✅ | ❌ |
Auto-Pilot Mode
Auto-pilot is an opt-in feature for paid_ads, seo_outreach, and review_generation campaigns. When enabled, AI optimizer workers may propose optimisation batches automatically on a schedule (e.g. weekly).
Safeguards:
- Only
adminusers can enable auto-pilot (autoPilotEnabledflag on Campaign). - Each batch of AI-proposed changes still enters the normal approval workflow:
dm_review → dm_approved → client_review → client_approved. - No change is pushed to Google Ads / Meta APIs until
client_approved. - A clear audit log entry is written for every auto-pilot suggestion, approval, and platform push.
Auto-pilot does not mean automatic execution. It means automatic suggestion generation on a schedule, not automatic platform changes.
Notifications
Status transitions should trigger notifications (to be confirmed as part of the notification system design — see Open Questions):
| Transition | Notify |
|---|---|
draft → dm_review | All DM team members |
dm_approved → client_review | Client users (reviewer role) |
client_review → client_approved | DM team members who submitted for review |
client_review → dm_review (rejected) | DM team with client note |
client_approved → active | Client users + DM team |
active → paused | DM team + client users |
completed | DM team + client users |
Periodic Optimization Automation
This describes the full lifecycle of a weekly automated optimization check for paid_ads campaigns on Meta and LinkedIn.
Scheduler Design
A BullMQ cron job runs once per week (e.g. Sunday 02:00 UTC) and enqueues one campaign-optimizer-runner job per active paid_ads, seo_outreach, or review_generation campaign. This runner does the threshold checks in code, then enqueues the AI workers for campaigns with hits.
Weekly BullMQ cron (Sunday 02:00 UTC)
│
├── Query: all active paid_ads campaigns with autoPilotEnabled = true
│ OR seo_outreach campaigns (always eligible)
│ OR review_generation campaigns (always eligible)
│
└── For each campaign:
└── Enqueue campaign-optimizer-runner
│
▼
Read stored data per campaign type:
paid_ads → MetaAdSet, MetaAd, LinkedInAd,
LinkedInDemographicBreakdown, CampaignMetrics
seo_outreach → CampaignSequenceStep stats, BacklinkHealth
review_gen → CampaignSequenceStep stats, ReviewMetrics
│
▼
Run threshold checks (in-process, no AI):
│
├── Meta: frequency > 3.0 cold? → creative_refresh candidate
├── Meta: any adset ROAS > 2× avg? → budget_shift candidate
├── Meta: placement CPM > 2× avg? → pause_placement candidate
├── Meta: campaign frequency > 4.0? → audience_expand candidate
├── LinkedIn: ad frequency > 2.0? → creative_refresh candidate
├── LinkedIn: demographic CTR outlier? → audience_tighten candidate
├── LinkedIn: CPC up > 20% WoW? → bid_adjust candidate
├── LinkedIn: form completion rate down > 15%? → lead_form_refresh
├── SEO: sequence step open rate < 20%? → refresh_outreach_template
├── SEO: final step sent 7+ days ago, 0 replies? → add_follow_up_step
├── SEO: BacklinkHealth status → dead/nofollow? → flag_dead_backlink
├── Review: sequence step open rate < 20%? → refresh_review_sequence_step
├── Review: review velocity drop > 30%? → re_engage_non_responders
└── Review: negative review rate > 25%? → flag_negative_review_pattern
│
▼
For each campaign type with threshold hits:
├── Enqueue meta-ads-optimizer (if Meta hits)
├── Enqueue linkedin-ads-optimizer (if LinkedIn hits)
├── Enqueue seo-outreach-optimizer (if SEO hits)
└── Enqueue review-campaign-optimizer (if Review Gen hits)
│
▼
Write CampaignOptimizationRecommendation records
(status: pending)
│
▼
For creative_refresh / refresh_outreach_template /
refresh_review_sequence_step type:
└── Also enqueue the relevant writer worker
Linked via Activity.campaignIdPer-Campaign-Type Thresholds and Activities
Meta Ads
Weekly (automated threshold scan):
| Activity | What’s checked | Threshold | Recommendation type |
|---|---|---|---|
| Creative fatigue detection | MetaAd.frequency AND week-over-week CTR trend | frequency > 3.0 (cold audience) / > 7.0 (warm/retargeting) + CTR declining | creative_refresh |
| Fatigued ad pause | Same as above but severe (frequency > 5.0 cold / > 10.0 warm) | Hard threshold — no CTR condition required | pause_ad |
| Ad set budget rebalance | Compare MetaAdSet.roas across ad sets in same campaign | Any ad set with ROAS > 2× campaign avg is budget-constrained | budget_shift |
| Placement performance review | Per-placement CPM vs campaign avg | CPM > 2× avg OR CTR < 50% avg for a specific placement | pause_placement |
| Frequency cap alert | Campaign-level aggregate frequency | > 4.0 cold / > 8.0 warm | audience_expand |
| Zero-conversion ad set | Ad sets spending with 0 conversions for 7 days | 0 conversions + spend > $50 equivalent | pause_ad_set |
Monthly (partially manual, partially automated):
| Activity | How |
|---|---|
| Custom audience refresh | Re-upload updated CRM lead/customer list to Meta. Triggered manually from Audience tab → “Refresh Custom Audience” button. Platform: POST /act_{id}/customaudiences |
| Lookalike audience rebuild | When source custom audience grows > 20%, rebuild lookalike. Flagged by optimizer as refresh_contact_list recommendation |
| Attribution window review | Manual DM check — verify 7-day click window still matches sales cycle length. No automation, surfaced as a reminder in Performance tab |
| Campaign Budget Optimization (CBO) check | Review if CBO is bottlenecking high-ROAS ad sets. Surfaced in budget_shift recommendation rationale |
LinkedIn Ads
Weekly (automated threshold scan):
| Activity | What’s checked | Threshold | Recommendation type |
|---|---|---|---|
| Creative fatigue detection | LinkedInAd.frequency AND week-over-week CTR trend | frequency > 2.0 (B2B audiences are much smaller — fatigue hits faster) | creative_refresh |
| Demographic opportunity | LinkedInDemographicBreakdown CTR / conversion outliers | Any dimension value with CTR > 2× campaign avg or disproportionate conversions | audience_tighten |
| CPC spike monitoring | Week-over-week CPC change | CPC rises > 20% week-over-week | bid_adjust |
| Lead Gen Form drop | LinkedInAd.leadGenFormCompletionRate week-over-week change | Drop > 15% week-over-week | lead_form_refresh |
| Zero-conversion ad | Spend with no conversions for 7 days | 0 conversions + spend > equivalent of $100 | pause_ad |
Monthly (partially manual, partially automated):
| Activity | How |
|---|---|
| Audience exclusion refresh | Sync converted leads from CRM into a LinkedIn Matched Audience exclusion list. Triggered from Audience tab. Recommendation type: audience_exclude |
| Matched audience / contact list refresh | Re-upload updated CRM list. Recommendation type: refresh_contact_list |
| Bid strategy review | Manual DM review — compare maximum delivery vs target cost performance. Surfaced as bid_adjust recommendation with strategy rationale |
| Ad format rotation | When all active creatives are fatigued, optimizer suggests trying a different format (e.g. Sponsored Content → Message Ad → Lead Gen Form) |
| InMail sender profile check | LinkedIn Message Ad open rates are heavily influenced by the sender’s profile completeness. Manual check; noted in lead_form_refresh rationale if open rate is low |
SEO Outreach
Weekly (automated threshold scan):
| Activity | What’s checked | Threshold | Recommendation type |
|---|---|---|---|
| Outreach email reply rate | CampaignSequenceStep.clickCount / sentCount (used as a reply proxy for email-tracked outreach) | Open rate < 20% or click rate < 3% for any active step | refresh_outreach_template |
| Sequence gap detection | Active outreach sequences where all steps are sent but 0 replies after the final step | Final step sent > 7 days ago, 0 replies recorded | add_follow_up_step |
| Backlink health check | BacklinkHealth.status for all acquired backlinks | Any status change to dead, nofollow, or redirected | flag_dead_backlink |
Monthly (partially manual, partially automated):
| Activity | How |
|---|---|
| Domain authority rescore | Fetch updated DA/DR for the target prospect list. If high-priority domains drop significantly, reorder the queue. Recommendation type: reprioritize_prospects |
| Disavow file review | AI scans new backlinks pointing to the tenant’s domain (via Google Search Console integration or manual import) for spammy patterns. Recommendation type: suggest_disavow |
| Prospect list refresh | Add new link-building targets based on fresh competitor backlink gap analysis. DM-triggered from the campaign Audience tab |
| Bounce / deliverability cleanup | Identify hard-bounced outreach contacts from the email provider and remove from future steps |
Note on “replies”: Email-based backlink outreach reply tracking requires either an email provider webhook (e.g. Mailchimp reply tracking) or manual DM entry. Until that is integrated, the
clickCounton the “Check our website” CTA link in the email is used as a proxy for engagement.
Review Generation
Weekly (automated threshold scan):
| Activity | What’s checked | Threshold | Recommendation type |
|---|---|---|---|
| Drip step open rate | CampaignSequenceStep.openCount / sentCount | Open rate < 20% for any active step | refresh_review_sequence_step |
| Drip step click rate | CampaignSequenceStep.clickCount / openCount | Click-to-open rate < 10% (suggests CTA or body is weak) | refresh_review_sequence_step |
| Review velocity drop | ReviewMetrics.newReviewCount week-over-week | Count drops > 30% vs prior week | re_engage_non_responders |
| Negative review spike | ReviewMetrics.negativeCount / newReviewCount | > 25% of new reviews are ≤ 2 stars | flag_negative_review_pattern |
| Platform imbalance | Compare ReviewMetrics.totalReviewCount across platforms for the same campaign | One platform has < 30% of the count of the leading platform | shift_review_platform |
Monthly (partially manual, partially automated):
| Activity | How |
|---|---|
| New customer auto-enroll | Contacts added to the CRM in the past month who haven’t yet been enrolled in the drip are identified and batch-enrolled. Triggered automatically if the ReviewCampaign has status: active and the contact list query is dynamic |
| Send time optimization | review-campaign-optimizer analyzes click-to-review conversion rates by send hour/day of week across all steps and recommends the best send window. Recommendation type: adjust_send_schedule |
| Review platform expansion | If the campaign is only targeting Google Reviews but the tenant has a Trustpilot or G2 profile, optimizer surfaces this as a shift_review_platform recommendation |
| Sequence refresh | If average open rate across all steps drops below 15% for 4 consecutive weeks, optimizer recommends a full sequence rewrite. Enqueues review-campaign-writer for new draft |
Recommendation HITL Flow
CampaignOptimizationRecommendation created (status: pending)
│
▼
DM notified (in-app + email) — "X new optimisation tasks for [Campaign Name]"
│
▼
DM opens Optimizations panel in Campaign Detail
│
├── Review recommendation (rationale, estimated impact, suggested action)
│
├── Skip → status: skipped / dmNote recorded
│ Recommendation expires after 14 days if not actioned
│
└── Approve → status: dm_approved
│
▼
Determine if client approval required:
│
├── Budget or bid change only AND amount < threshold ($X) → admin can apply directly
│ └── status: client_approved (system actor)
│
└── Creative change, pause, or audience change → client_review required
│
▼
Client notified → reviews in Campaign Detail (reviewer role)
│
├── Reject → status: dm_reviewing / clientNote recorded
│ DM notified to revise
│
└── Approve → status: client_approved
│
▼
Platform action queued (BullMQ job)
│
▼
Action pushed via Meta Marketing API / LinkedIn Ads API
│
▼
status: applied / appliedAt stamped
Audit log entry writtenRole Gates for Optimizations
| Action | admin | member | reviewer (client) |
|---|---|---|---|
| View Optimizations panel | ✅ | ✅ | ✅ (own campaigns) |
| Skip a recommendation | ✅ | ✅ | ❌ |
| DM-approve a recommendation | ✅ | ✅ | ❌ |
| Apply directly (budget/bid only) | ✅ | ❌ | ❌ |
| Client-approve a recommendation | ✅ | ❌ | ✅ |
| Client-reject a recommendation | ✅ | ❌ | ✅ |
| Trigger manual optimization scan | ✅ | ✅ | ❌ |
Notifications for Optimizations
| Event | Notify |
|---|---|
| New recommendations generated (weekly scan) | DM team members for that tenant |
| Recommendation sent to client review | Client (reviewer role) |
| Client rejects a recommendation | DM team with client note |
| Recommendation applied to platform | DM team + client |
| Recommendation expired (14 days old, still pending) | DM team reminder |
Audit Log
Every status transition, approval, rejection, and platform push must be written to the tenant audit log with:
entityType: "campaign"entityId: campaign.idaction: e.g.campaign.status_changed,campaign.approved,campaign.optimisation_appliedactor: userId who performed the actionpayload:{ from, to, note? }for status changes
For optimization recommendations, additionally log:
entityType: "campaign_optimization_recommendation"entityId: recommendation.id- Actions:
recommendation.created,recommendation.dm_approved,recommendation.skipped,recommendation.client_approved,recommendation.client_rejected,recommendation.applied,recommendation.expired