{"name":"Q4 ads prep workflow","nodes":[{"id":"b6f0a001-4a1c-4b2d-8e3f-9c0d1e2f0001","name":"Weekly Q4 Readiness Check","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-160,300],"parameters":{"rule":{"interval":[{"field":"days","daysInterval":7}]}}},{"id":"b6f0a002-4a1c-4b2d-8e3f-9c0d1e2f0002","name":"Fetch Meta Campaign Setup","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[100,100],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"level","value":"campaign"},{"name":"fields","value":"campaign_id,campaign_name,spend,impressions,frequency,purchase_roas,actions,action_values,daily_budget,estimated_audience_size,creative_count"},{"name":"date_preset","value":"last_7d"},{"name":"filtering","value":"[{\"field\":\"campaign.effective_status\",\"operator\":\"IN\",\"value\":[\"ACTIVE\"]}]"},{"name":"limit","value":"200"}]},"options":{}}},{"id":"b6f0a003-4a1c-4b2d-8e3f-9c0d1e2f0003","name":"Fetch Google Campaign Setup","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[100,300],"parameters":{"method":"POST","url":"https://googleads.googleapis.com/v18/customers/CUSTOMER_ID/googleAds:search","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ query: \"SELECT campaign.id, campaign.name, campaign.status, campaign_budget.amount_micros, metrics.cost_micros, metrics.conversions, metrics.conversions_value, metrics.impressions FROM campaign WHERE campaign.status = 'ENABLED' AND segments.date DURING LAST_7_DAYS\" }) }}","options":{}}},{"id":"b6f0a004-4a1c-4b2d-8e3f-9c0d1e2f0004","name":"Load Q4 Checklist","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[100,500],"parameters":{"operation":"read","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Q4 Checklist"},"options":{}}},{"id":"b6f0a005-4a1c-4b2d-8e3f-9c0d1e2f0005","name":"Merge Platform Feeds","type":"n8n-nodes-base.merge","typeVersion":3,"position":[360,200],"parameters":{"mode":"append","options":{}}},{"id":"b6f0a006-4a1c-4b2d-8e3f-9c0d1e2f0006","name":"Normalize Campaign Records","type":"n8n-nodes-base.code","typeVersion":2,"position":[620,200],"parameters":{"jsCode":"// Fold Meta + Google campaign setup rows into ONE shape the Q4 checklist can score.\n// Meta gives spend/daily_budget in account currency, Google gives *_micros.\nconst MICROS = 1e6;\nconst rows = $input.all().map(x => x.json);\n\nconst norm = rows.map(r => {\n  const isGoogle = r.campaign && r.campaign.resource_name !== undefined;\n\n  if (isGoogle) {\n    const c = r.campaign, m = r.metrics || {}, b = r.campaign_budget || {};\n    return {\n      platform: 'google',\n      campaign_id: String(c.id),\n      campaign_name: c.name,\n      status: c.status,\n      daily_budget: Number(b.amount_micros || 0) / MICROS,\n      spend_7d: Number(m.cost_micros || 0) / MICROS,\n      conversions_7d: Number(m.conversions || 0),\n      conv_value_7d: Number(m.conversions_value || 0),\n      impressions_7d: Number(m.impressions || 0),\n      audience_size: Number(r.audience_estimate || 0),\n      creative_count: Number(r.asset_group_asset_count || 0),\n      primary_sku: r.primary_sku || null,\n      landing_url: c.final_url || null\n    };\n  }\n\n  const roasArr = Array.isArray(r.purchase_roas) ? r.purchase_roas : [];\n  const purchases = (r.actions || []).find(a => a.action_type === 'purchase');\n  const revenue = (r.action_values || []).find(a => a.action_type === 'purchase');\n  return {\n    platform: 'meta',\n    campaign_id: String(r.campaign_id),\n    campaign_name: r.campaign_name,\n    status: r.effective_status || r.status,\n    daily_budget: Number(r.daily_budget || 0) / 100, // Meta returns minor units\n    spend_7d: Number(r.spend || 0),\n    conversions_7d: purchases ? Number(purchases.value) : 0,\n    conv_value_7d: revenue ? Number(revenue.value) : 0,\n    impressions_7d: Number(r.impressions || 0),\n    roas_7d: roasArr.length ? Number(roasArr[0].value) : 0,\n    frequency_7d: Number(r.frequency || 0),\n    audience_size: Number(r.estimated_audience_size || 0),\n    creative_count: Number(r.creative_count || (r.ads && r.ads.data ? r.ads.data.length : 0)),\n    primary_sku: r.primary_sku || null,\n    landing_url: r.landing_url || null\n  };\n});\n\n// Same product can run on both platforms - keep both rows but dedupe exact repeats\n// (paged API calls overlap on the boundary item).\nconst seen = new Set();\nconst deduped = norm.filter(r => {\n  const key = r.platform + ':' + r.campaign_id;\n  if (seen.has(key)) return false;\n  seen.add(key);\n  return true;\n});\n\n// Derived efficiency + a 7d run-rate we reuse for the Q4 pacing projection.\nreturn deduped.map(r => {\n  const roas = r.roas_7d != null && r.roas_7d > 0\n    ? r.roas_7d\n    : (r.spend_7d > 0 ? r.conv_value_7d / r.spend_7d : 0);\n  return { json: {\n    ...r,\n    roas_7d: Number(roas.toFixed(2)),\n    cpa_7d: r.conversions_7d > 0 ? Number((r.spend_7d / r.conversions_7d).toFixed(2)) : null,\n    daily_run_rate: Number((r.spend_7d / 7).toFixed(2))\n  } };\n});"}},{"id":"b6f0a007-4a1c-4b2d-8e3f-9c0d1e2f0007","name":"Fetch Inventory Cover","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[620,500],"parameters":{"url":"https://brand.myshopify.com/admin/api/2024-10/inventory_levels.json","sendQuery":true,"queryParameters":{"parameters":[{"name":"location_ids","value":"LOCATION_ID"},{"name":"limit","value":"250"}]},"options":{}}},{"id":"b6f0a008-4a1c-4b2d-8e3f-9c0d1e2f0008","name":"Combine Setup With Inventory","type":"n8n-nodes-base.merge","typeVersion":3,"position":[880,300],"parameters":{"mode":"chooseBranch","useDataOfInput":1,"options":{}}},{"id":"b6f0a009-4a1c-4b2d-8e3f-9c0d1e2f0009","name":"Score Q4 Readiness","type":"n8n-nodes-base.code","typeVersion":2,"position":[1140,300],"parameters":{"jsCode":"// ---- Q4 PEAK READINESS SCORECARD -------------------------------------------\n// Each campaign is checked against the Q4 checklist row for its tier.\n// Every check is pass/fail + weighted; total 100. Below 80 = not peak-ready.\nconst checklist = $('Load Q4 Checklist').all().map(x => x.json);\nconst inventory = $('Fetch Inventory Cover').all().map(x => x.json);\n\nconst invBySku = {};\nfor (const it of inventory) {\n  const sku = it.sku || it.variant_sku;\n  if (!sku) continue;\n  invBySku[sku] = {\n    on_hand: Number(it.inventory_quantity || 0),\n    inbound: Number(it.incoming || 0),\n    units_per_day: Number(it.velocity_units_per_day || 0)\n  };\n}\n\nconst defaults = {\n  tier: 'default',\n  min_daily_budget: 150,      // must be able to absorb CPM inflation\n  peak_budget_multiple: 2.5,  // Q4 budget must be >= 2.5x current run rate\n  min_audience_size: 500000,  // Meta/Google reach floor before frequency blows up\n  min_creative_count: 4,      // 4 live creatives per campaign minimum\n  max_frequency: 2.6,\n  min_roas: 1.8,\n  min_inventory_days: 45      // must cover BFCM + shipping cutoff\n};\nconst rulesFor = (name) => {\n  const n = String(name || '').toLowerCase();\n  const row = checklist.find(c =>\n    c.tier && n.includes(String(c.tier).toLowerCase())\n  );\n  return { ...defaults, ...(row || {}) };\n};\n\nconst WEIGHTS = { budget: 25, pacing: 20, audience: 15, creative: 20, inventory: 15, efficiency: 5 };\n\nconst out = $input.all().map(x => x.json).map(c => {\n  const r = rulesFor(c.campaign_name);\n  const inv = invBySku[c.primary_sku] || { on_hand: 0, inbound: 0, units_per_day: 0 };\n\n  // Pacing projection: what Q4 spend looks like if we hold the current daily budget\n  // for the 62-day Oct 15 -> Dec 15 peak window, vs the planned peak budget.\n  const PEAK_DAYS = 62;\n  const requiredDaily = Number((c.daily_run_rate * Number(r.peak_budget_multiple)).toFixed(2));\n  const projectedSpend = Number((c.daily_budget * PEAK_DAYS).toFixed(0));\n  const requiredSpend = Number((requiredDaily * PEAK_DAYS).toFixed(0));\n  const budgetHeadroom = requiredDaily > 0\n    ? Number((c.daily_budget / requiredDaily).toFixed(2)) : 0;\n\n  // Inventory cover in days: (on hand + inbound) / velocity scaled for the peak multiple.\n  const peakVelocity = Math.max(inv.units_per_day * Number(r.peak_budget_multiple), 0.01);\n  const inventoryDays = Math.round((inv.on_hand + inv.inbound) / peakVelocity);\n\n  const checks = [\n    { key: 'budget',     label: 'Daily budget above Q4 floor',\n      ok: c.daily_budget >= Number(r.min_daily_budget),\n      detail: '$' + c.daily_budget + ' vs floor $' + r.min_daily_budget },\n    { key: 'pacing',     label: 'Budget covers projected peak demand',\n      ok: budgetHeadroom >= 1,\n      detail: 'projected $' + projectedSpend + ' vs required $' + requiredSpend +\n              ' (headroom ' + budgetHeadroom + 'x)' },\n    { key: 'audience',   label: 'Audience big enough for peak frequency',\n      ok: c.audience_size >= Number(r.min_audience_size) &&\n          (c.frequency_7d || 0) <= Number(r.max_frequency),\n      detail: c.audience_size + ' reach, freq ' + (c.frequency_7d || 0) },\n    { key: 'creative',   label: 'Enough live creatives to rotate',\n      ok: c.creative_count >= Number(r.min_creative_count),\n      detail: c.creative_count + ' live vs ' + r.min_creative_count + ' required' },\n    { key: 'inventory',  label: 'Inventory covers the peak window',\n      ok: inventoryDays >= Number(r.min_inventory_days),\n      detail: inventoryDays + ' days cover vs ' + r.min_inventory_days + ' required' },\n    { key: 'efficiency', label: 'ROAS above Q4 breakeven',\n      ok: c.roas_7d >= Number(r.min_roas),\n      detail: 'ROAS ' + c.roas_7d + ' vs ' + r.min_roas }\n  ];\n\n  const score = checks.reduce((s, ch) => s + (ch.ok ? WEIGHTS[ch.key] : 0), 0);\n  const failed = checks.filter(ch => !ch.ok);\n\n  // Severity: inventory + budget gaps cannot be fixed in the last week -> P0.\n  const p0Keys = ['inventory', 'budget', 'pacing'];\n  const severity = failed.some(f => p0Keys.includes(f.key)) ? 'P0'\n    : failed.length ? 'P1' : 'none';\n\n  return { json: {\n    ...c,\n    tier: r.tier,\n    readiness_score: score,\n    is_q4_ready: score >= 80 && !failed.some(f => p0Keys.includes(f.key)),\n    severity,\n    inventory_days_cover: inventoryDays,\n    required_daily_budget: requiredDaily,\n    projected_peak_spend: projectedSpend,\n    required_peak_spend: requiredSpend,\n    budget_headroom: budgetHeadroom,\n    failed_checks: failed.map(f => f.key),\n    checks\n  } };\n});\n\n// Worst first so the report reads top-down.\nreturn out.sort((a, b) => a.json.readiness_score - b.json.readiness_score);"}},{"id":"b6f0a010-4a1c-4b2d-8e3f-9c0d1e2f0010","name":"Campaign Peak Ready?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1400,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c-ready","leftValue":"={{ $json.is_q4_ready }}","rightValue":true,"operator":{"type":"boolean","operation":"true"}}]},"options":{}}},{"id":"b6f0a011-4a1c-4b2d-8e3f-9c0d1e2f0011","name":"Log Ready Campaigns","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1660,80],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=111","mode":"list","cachedResultName":"Q4 Ready"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"b6f0a012-4a1c-4b2d-8e3f-9c0d1e2f0012","name":"Expand Failed Checks Into Gaps","type":"n8n-nodes-base.code","typeVersion":2,"position":[1660,520],"parameters":{"jsCode":"// Explode each failing campaign into one actionable gap row per failed check,\n// with the concrete fix and the days left to do it.\nconst DEADLINE = new Date(new Date().getFullYear() + '-10-15T00:00:00Z'); // peak on-ramp\nconst daysLeft = Math.max(0, Math.ceil((DEADLINE - new Date()) / 86400000));\n\nconst FIX = {\n  budget: c => 'Raise daily budget to at least $' + c.required_daily_budget +\n               ' (currently $' + c.daily_budget + ').',\n  pacing: c => 'Peak plan is short $' + (c.required_peak_spend - c.projected_peak_spend) +\n               ' over 62 days. Re-forecast or reallocate from a lower-tier campaign.',\n  audience: c => 'Broaden targeting or add a lookalike - reach ' + c.audience_size +\n               ' will hit fatigue at peak frequency.',\n  creative: c => 'Brief ' + Math.max(1, 4 - c.creative_count) +\n               ' more creatives (need 4 live for rotation).',\n  inventory: c => 'Only ' + c.inventory_days_cover + ' days of cover on ' +\n               (c.primary_sku || 'primary SKU') + '. Raise the PO or cap spend.',\n  efficiency: c => 'ROAS ' + c.roas_7d + ' is below Q4 breakeven - fix the offer/LP before scaling.'\n};\n\nconst rows = [];\nfor (const item of $input.all()) {\n  const c = item.json;\n  for (const check of c.checks.filter(ch => !ch.ok)) {\n    rows.push({ json: {\n      campaign_id: c.campaign_id,\n      campaign: c.campaign_name,\n      platform: c.platform,\n      tier: c.tier,\n      readiness_score: c.readiness_score,\n      gap: check.key,\n      gap_label: check.label,\n      evidence: check.detail,\n      fix: FIX[check.key] ? FIX[check.key](c) : check.label,\n      severity: ['inventory', 'budget', 'pacing'].includes(check.key) ? 'P0' : 'P1',\n      days_to_deadline: daysLeft,\n      blocking: ['inventory', 'budget', 'pacing', 'creative'].includes(check.key)\n    } });\n  }\n}\nreturn rows;"}},{"id":"b6f0a013-4a1c-4b2d-8e3f-9c0d1e2f0013","name":"Keep Blocking Gaps","type":"n8n-nodes-base.filter","typeVersion":2,"position":[1920,520],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c-blocking","leftValue":"={{ $json.blocking }}","rightValue":true,"operator":{"type":"boolean","operation":"true"}}]},"options":{}}},{"id":"b6f0a014-4a1c-4b2d-8e3f-9c0d1e2f0014","name":"Assign Gap Owners","type":"n8n-nodes-base.code","typeVersion":2,"position":[2180,520],"parameters":{"jsCode":"// Route each gap to the team that actually owns the fix, and set a due date\n// that back-dates from the Oct 15 on-ramp by the lead time each fix needs.\nconst OWNERS = {\n  budget:     { owner: 'Media Buying', slack: '@maya',  lead_time_days: 2 },\n  pacing:     { owner: 'Media Buying', slack: '@maya',  lead_time_days: 3 },\n  audience:   { owner: 'Media Buying', slack: '@dan',   lead_time_days: 5 },\n  creative:   { owner: 'Creative',     slack: '@lena',  lead_time_days: 10 },\n  inventory:  { owner: 'Supply Chain', slack: '@tom',   lead_time_days: 21 },\n  efficiency: { owner: 'CRO',          slack: '@priya', lead_time_days: 7 }\n};\n\nconst rows = $input.all().map(x => x.json);\n\n// One task per campaign+gap; if two platforms hit the same gap on the same SKU\n// the creative/inventory work is shared, so collapse those into one owner task.\nconst shared = new Set(['creative', 'inventory']);\nconst byKey = new Map();\nfor (const r of rows) {\n  const key = shared.has(r.gap) ? r.gap + '|' + r.campaign.replace(/\\s*[-|].*$/, '')\n                                : r.gap + '|' + r.campaign_id;\n  const prev = byKey.get(key);\n  if (prev) {\n    prev.campaigns.push(r.campaign);\n    prev.readiness_score = Math.min(prev.readiness_score, r.readiness_score);\n    continue;\n  }\n  const o = OWNERS[r.gap] || { owner: 'Growth', slack: '@ira', lead_time_days: 5 };\n  const slipDays = r.days_to_deadline - o.lead_time_days;\n  byKey.set(key, {\n    ...r,\n    campaigns: [r.campaign],\n    owner: o.owner,\n    owner_slack: o.slack,\n    lead_time_days: o.lead_time_days,\n    slack_days: slipDays,\n    at_risk: slipDays < 0,\n    // P0 that no longer fits its lead time is escalated to P0-LATE.\n    priority: r.severity === 'P0' && slipDays < 0 ? 'P0-LATE' : r.severity,\n    due_date: new Date(Date.now() + Math.max(0, slipDays) * 86400000)\n      .toISOString().slice(0, 10)\n  });\n}\n\nconst tasks = [...byKey.values()].map(t => ({\n  ...t,\n  campaigns: [...new Set(t.campaigns)].join(', ')\n}));\n\nconst rank = { 'P0-LATE': 0, P0: 1, P1: 2 };\ntasks.sort((a, b) => (rank[a.priority] - rank[b.priority]) || (a.slack_days - b.slack_days));\n\nconst p0 = tasks.filter(t => t.priority.startsWith('P0')).length;\nreturn tasks.map((t, idx) => ({ json: {\n  ...t,\n  rank: idx + 1,\n  p0_count: p0,\n  summary: '[' + t.priority + '] ' + t.gap_label + ' - ' + t.campaigns +\n           ' (owner ' + t.owner + ', due ' + t.due_date + ')'\n} }));"}},{"id":"b6f0a015-4a1c-4b2d-8e3f-9c0d1e2f0015","name":"Any P0 Blockers?","type":"n8n-nodes-base.if","typeVersion":2,"position":[2440,520],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c-p0","leftValue":"={{ $json.p0_count }}","rightValue":0,"operator":{"type":"number","operation":"gt"}}]},"options":{}}},{"id":"b6f0a016-4a1c-4b2d-8e3f-9c0d1e2f0016","name":"Alert Ads Team In Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2700,320],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#q4-warroom","mode":"name"},"text":"=🚨 Q4 blocker {{ $json.rank }}/{{ $json.p0_count }} — {{ $json.summary }}\nEvidence: {{ $json.evidence }}\nFix: {{ $json.fix }}\nOwner: {{ $json.owner_slack }} · slack days left: {{ $json.slack_days }}","otherOptions":{}}},{"id":"b6f0a017-4a1c-4b2d-8e3f-9c0d1e2f0017","name":"Append Gaps To Tracker","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2960,520],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=222","mode":"list","cachedResultName":"Q4 Gaps"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"b6f0a018-4a1c-4b2d-8e3f-9c0d1e2f0018","name":"Build Gap Report HTML","type":"n8n-nodes-base.code","typeVersion":2,"position":[3220,520],"parameters":{"jsCode":"// Build the HTML gap report for the weekly Q4 readiness email.\nconst tasks = $input.all().map(x => x.json);\nconst p0 = tasks.filter(t => t.priority.startsWith('P0'));\nconst late = tasks.filter(t => t.at_risk);\n\nconst row = t => '<tr>' +\n  '<td>' + t.priority + '</td>' +\n  '<td>' + t.campaigns + '</td>' +\n  '<td>' + t.gap_label + '</td>' +\n  '<td>' + t.evidence + '</td>' +\n  '<td>' + t.fix + '</td>' +\n  '<td>' + t.owner + '</td>' +\n  '<td>' + t.due_date + '</td></tr>';\n\nconst html = '<h2>Q4 readiness gap report</h2>' +\n  '<p><b>' + tasks.length + '</b> open gaps, <b>' + p0.length + '</b> blocking, <b>' +\n  late.length + '</b> already past their lead time.</p>' +\n  '<table border=\"1\" cellpadding=\"6\" cellspacing=\"0\">' +\n  '<tr><th>Pri</th><th>Campaign</th><th>Gap</th><th>Evidence</th><th>Fix</th>' +\n  '<th>Owner</th><th>Due</th></tr>' +\n  tasks.map(row).join('') + '</table>';\n\nreturn [{ json: { html, total_gaps: tasks.length, p0_count: p0.length, late_count: late.length } }];"}},{"id":"b6f0a019-4a1c-4b2d-8e3f-9c0d1e2f0019","name":"Email Q4 Gap Report","type":"n8n-nodes-base.gmail","typeVersion":2.1,"position":[3480,520],"parameters":{"sendTo":"growth@brand.com","subject":"=Q4 readiness: {{ $json.p0_count }} blockers, {{ $json.total_gaps }} open gaps ({{ $now.format('yyyy-LL-dd') }})","message":"={{ $json.html }}","options":{}}},{"id":"b6f0a020-4a1c-4b2d-8e3f-9c0d1e2f0020","name":"Note Collect","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[60,-60],"parameters":{"content":"## 1. COLLECT\nPull live Meta + Google campaign setup, the Q4 checklist tiers from Sheets, and Shopify inventory cover. Everything is normalised into one shape before scoring.","height":320,"width":460,"color":4}},{"id":"b6f0a021-4a1c-4b2d-8e3f-9c0d1e2f0021","name":"Note Score","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1100,20],"parameters":{"content":"## 2. SCORE\nSix weighted checks per campaign (budget 25, pacing 20, creative 20, audience 15, inventory 15, ROAS 5). 62-day peak projection vs required spend. Score >= 80 with no P0 fail = peak ready.","height":240,"width":460,"color":3}},{"id":"b6f0a022-4a1c-4b2d-8e3f-9c0d1e2f0022","name":"Note Report","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[2160,760],"parameters":{"content":"## 3. GAP REPORT + OWNERS\nEach failed check becomes a task, deduped where creative/inventory work is shared, routed to the owning team and back-dated from the Oct 15 on-ramp by its lead time. P0s past lead time escalate to P0-LATE.","height":260,"width":520,"color":5}}],"connections":{"Weekly Q4 Readiness Check":{"main":[[{"node":"Fetch Meta Campaign Setup","type":"main","index":0},{"node":"Fetch Google Campaign Setup","type":"main","index":0},{"node":"Load Q4 Checklist","type":"main","index":0}]]},"Fetch Meta Campaign Setup":{"main":[[{"node":"Merge Platform Feeds","type":"main","index":0}]]},"Fetch Google Campaign Setup":{"main":[[{"node":"Merge Platform Feeds","type":"main","index":1}]]},"Merge Platform Feeds":{"main":[[{"node":"Normalize Campaign Records","type":"main","index":0}]]},"Load Q4 Checklist":{"main":[[{"node":"Fetch Inventory Cover","type":"main","index":0}]]},"Normalize Campaign Records":{"main":[[{"node":"Combine Setup With Inventory","type":"main","index":0}]]},"Fetch Inventory Cover":{"main":[[{"node":"Combine Setup With Inventory","type":"main","index":1}]]},"Combine Setup With Inventory":{"main":[[{"node":"Score Q4 Readiness","type":"main","index":0}]]},"Score Q4 Readiness":{"main":[[{"node":"Campaign Peak Ready?","type":"main","index":0}]]},"Campaign Peak Ready?":{"main":[[{"node":"Log Ready Campaigns","type":"main","index":0}],[{"node":"Expand Failed Checks Into Gaps","type":"main","index":0}]]},"Expand Failed Checks Into Gaps":{"main":[[{"node":"Keep Blocking Gaps","type":"main","index":0}]]},"Keep Blocking Gaps":{"main":[[{"node":"Assign Gap Owners","type":"main","index":0}]]},"Assign Gap Owners":{"main":[[{"node":"Any P0 Blockers?","type":"main","index":0}]]},"Any P0 Blockers?":{"main":[[{"node":"Alert Ads Team In Slack","type":"main","index":0}],[{"node":"Append Gaps To Tracker","type":"main","index":0}]]},"Alert Ads Team In Slack":{"main":[[{"node":"Append Gaps To Tracker","type":"main","index":0}]]},"Append Gaps To Tracker":{"main":[[{"node":"Build Gap Report HTML","type":"main","index":0}]]},"Build Gap Report HTML":{"main":[[{"node":"Email Q4 Gap Report","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}