{"name":"UGC ads on autopilot workflow","nodes":[{"id":"00000001-1111-4222-8333-444455556601","name":"Daily Creator Pipeline Check","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-260,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":24}]}}},{"id":"00000002-1111-4222-8333-444455556602","name":"Read Creator Roster","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[0,140],"parameters":{"operation":"read","documentId":{"__rl":true,"value":"UGC_PIPELINE_SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Creators"},"options":{}}},{"id":"00000003-1111-4222-8333-444455556603","name":"Read Brief Log","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[0,460],"parameters":{"operation":"read","documentId":{"__rl":true,"value":"UGC_PIPELINE_SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=118822","mode":"list","cachedResultName":"Briefs"},"options":{}}},{"id":"00000004-1111-4222-8333-444455556604","name":"Merge Roster And Briefs","type":"n8n-nodes-base.merge","typeVersion":3,"position":[260,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"00000005-1111-4222-8333-444455556605","name":"Fetch Posted Content","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"executeOnce":true,"position":[520,300],"parameters":{"url":"https://business-api.tiktok.com/open_api/v1.3/business/video/list/","sendQuery":true,"queryParameters":{"parameters":[{"name":"business_id","value":"BUSINESS_ID"},{"name":"fields","value":"video_id,create_time,view_count,like_count,share_count,caption,creator_handle"},{"name":"filters","value":"={{ JSON.stringify({ create_time_gte: Math.floor(Date.now()/1000) - 60*86400 }) }}"},{"name":"page_size","value":"100"}]},"options":{}}},{"id":"00000006-1111-4222-8333-444455556606","name":"Build Creator Pipeline State","type":"n8n-nodes-base.code","typeVersion":2,"position":[780,300],"parameters":{"jsCode":"// ---- UGC pipeline state: join briefs -> deliveries -> live posts ----\n// SLA to deliver a brief, by creator tier (days from brief_sent_at)\nconst SLA_DAYS = { A: 5, B: 7, C: 10 };\nconst DAY = 24 * 60 * 60 * 1000;\nconst now = Date.now();\n\n// Live posts pulled from the social API in the previous node.\n// Dedupe by video_id (the API paginates and repeats boundary rows).\nconst seen = new Set();\nconst posts = [];\nfor (const it of $('Fetch Posted Content').all()) {\n  const p = it.json;\n  const id = String(p.video_id || p.id || '');\n  if (!id || seen.has(id)) continue;\n  seen.add(id);\n  posts.push({\n    video_id: id,\n    handle: String(p.creator_handle || p.username || '').toLowerCase().replace('@', ''),\n    brief_id: p.brief_id || null,\n    posted_at: p.create_time ? new Date(p.create_time * 1000).getTime() : null,\n    views: Number(p.view_count || 0),\n    likes: Number(p.like_count || 0),\n    shares: Number(p.share_count || 0),\n  });\n}\nconst postsByBrief = {};\nconst postsByHandle = {};\nfor (const p of posts) {\n  if (p.brief_id) (postsByBrief[p.brief_id] = postsByBrief[p.brief_id] || []).push(p);\n  (postsByHandle[p.handle] = postsByHandle[p.handle] || []).push(p);\n}\n\n// Brief rows come from the merge node - $input here is the social API response.\nconst out = [];\nfor (const item of $('Merge Roster And Briefs').all()) {\n  const r = item.json;\n  const handle = String(r.creator_handle || '').toLowerCase().replace('@', '');\n  const tier = String(r.tier || 'C').toUpperCase();\n  const sla = SLA_DAYS[tier] || 10;\n\n  const sentAt = r.brief_sent_at ? new Date(r.brief_sent_at).getTime() : null;\n  const receivedAt = r.content_received_at ? new Date(r.content_received_at).getTime() : null;\n  if (!sentAt) continue; // brief never actually went out - nothing to chase\n\n  const daysSinceBrief = Math.floor((now - sentAt) / DAY);\n  const livePosts = (postsByBrief[r.brief_id] || []).concat(\n    (postsByHandle[handle] || []).filter(p => !p.brief_id && p.posted_at && p.posted_at >= sentAt)\n  );\n  const posted = livePosts.length > 0;\n  const delivered = Boolean(receivedAt) || posted;\n  const turnaroundDays = delivered && receivedAt ? Math.max(0, Math.round((receivedAt - sentAt) / DAY)) : null;\n\n  let status = 'awaiting_content';\n  if (posted) status = 'posted';\n  else if (delivered) status = 'received_not_posted';\n\n  const daysOverdue = delivered ? 0 : Math.max(0, daysSinceBrief - sla);\n  const overdue = daysOverdue > 0;\n\n  // Chase escalation ladder. Only one nudge per 3-day window - chase_count is\n  // written back to the sheet by the logging branch, so this stays idempotent.\n  const chaseCount = Number(r.chase_count || 0);\n  const lastChase = r.last_chased_at ? new Date(r.last_chased_at).getTime() : 0;\n  const daysSinceChase = lastChase ? Math.floor((now - lastChase) / DAY) : 999;\n  let chaseStage = 0;\n  if (overdue && daysSinceChase >= 3) {\n    if (daysOverdue <= 2) chaseStage = 1;      // friendly nudge\n    else if (daysOverdue <= 5) chaseStage = 2; // firm reminder + new deadline\n    else chaseStage = 3;                       // escalate to a human / reassign brief\n  }\n  const escalate = chaseStage === 3 || chaseCount >= 3;\n\n  // Health score 0-100: on-time delivery weighs most, then posting, then reach.\n  const views = livePosts.reduce((s, p) => s + p.views, 0);\n  const timeliness = delivered\n    ? Math.max(0, 1 - (turnaroundDays === null ? 0 : Math.max(0, turnaroundDays - sla) / sla))\n    : Math.max(0, 1 - daysOverdue / (sla * 2));\n  const score = Math.round(\n    55 * timeliness + 25 * (posted ? 1 : delivered ? 0.5 : 0) + 20 * Math.min(1, views / 50000)\n  );\n\n  out.push({\n    json: {\n      brief_id: r.brief_id,\n      creator_handle: handle,\n      creator_email: r.creator_email,\n      tier,\n      product: r.product,\n      concept: r.concept,\n      brief_sent_at: r.brief_sent_at,\n      sla_days: sla,\n      days_since_brief: daysSinceBrief,\n      days_overdue: daysOverdue,\n      status,\n      delivered,\n      posted,\n      turnaround_days: turnaroundDays,\n      live_posts: livePosts.length,\n      views,\n      overdue,\n      chase_stage: chaseStage,\n      chase_count: chaseCount,\n      escalate,\n      health_score: score,\n      checked_at: new Date(now).toISOString(),\n    },\n  });\n}\nreturn out;"}},{"id":"00000007-1111-4222-8333-444455556607","name":"Brief Overdue?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1040,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.overdue }}","rightValue":true,"operator":{"type":"boolean","operation":"true"}},{"id":"c2","leftValue":"={{ $json.chase_stage }}","rightValue":0,"operator":{"type":"number","operation":"gt"}}]},"options":{}}},{"id":"00000008-1111-4222-8333-444455556608","name":"Compose Chase Message","type":"n8n-nodes-base.code","typeVersion":2,"position":[1300,100],"parameters":{"jsCode":"// ---- Build the chase message for this escalation stage ----\nconst DEADLINE_DAYS = { 1: 3, 2: 2, 3: 1 };\nreturn $input.all().map(item => {\n  const r = item.json;\n  const stage = r.chase_stage || 1;\n  const extra = DEADLINE_DAYS[stage] || 2;\n  const deadline = new Date(Date.now() + extra * 86400000).toISOString().slice(0, 10);\n\n  const bodies = {\n    1: `Hey @${r.creator_handle} - quick nudge on the ${r.product} brief (\"${r.concept}\").\\n` +\n       `It went out ${r.days_since_brief} days ago and we're ${r.days_overdue} day(s) past the ${r.sla_days}-day turnaround.\\n` +\n       `Can you get the raw files over by ${deadline}? Shout if anything is blocking you.`,\n    2: `Hi @${r.creator_handle} - second reminder on the ${r.product} brief (\"${r.concept}\").\\n` +\n       `We're now ${r.days_overdue} days past deadline and this concept is holding a paid slot.\\n` +\n       `New hard deadline: ${deadline}. If you can't make it, reply \"pass\" and we'll reassign with no hard feelings.`,\n    3: `Hi @${r.creator_handle} - the ${r.product} brief is ${r.days_overdue} days overdue and has had ${r.chase_count} reminders.\\n` +\n       `We're pulling it back into the pool on ${deadline} unless we hear from you today.`,\n  };\n\n  return {\n    json: {\n      ...r,\n      chase_deadline: deadline,\n      chase_count: r.chase_count + 1,\n      last_chased_at: new Date().toISOString(),\n      subject: stage >= 3\n        ? `Final notice: ${r.product} UGC brief (${r.brief_id})`\n        : `Nudge ${stage}: ${r.product} UGC brief due`,\n      message: bodies[stage] || bodies[1],\n    },\n  };\n});"}},{"id":"00000009-1111-4222-8333-444455556609","name":"Escalate To Human?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1560,100],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.escalate }}","rightValue":true,"operator":{"type":"boolean","operation":"true"}}]},"options":{}}},{"id":"00000010-1111-4222-8333-444455556610","name":"Alert Producer In Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1820,-60],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ugc-pipeline","mode":"name"},"text":"=🚩 @{{ $json.creator_handle }} is {{ $json.days_overdue }}d overdue on {{ $json.product }} ({{ $json.brief_id }}) after {{ $json.chase_count }} chases. Reassign by {{ $json.chase_deadline }}.","otherOptions":{}}},{"id":"00000011-1111-4222-8333-444455556611","name":"Email Chase To Creator","type":"n8n-nodes-base.gmail","typeVersion":2.1,"position":[1820,240],"parameters":{"sendTo":"={{ $json.creator_email }}","subject":"={{ $json.subject }}","message":"={{ $json.message }}","options":{}}},{"id":"00000012-1111-4222-8333-444455556612","name":"Log Chase Sent","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2080,100],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"UGC_PIPELINE_SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=774411","mode":"list","cachedResultName":"ChaseLog"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"00000013-1111-4222-8333-444455556613","name":"Log On-Track Brief","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1300,560],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"UGC_PIPELINE_SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=553311","mode":"list","cachedResultName":"PipelineSnapshot"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"00000014-1111-4222-8333-444455556614","name":"Aggregate Creator Throughput","type":"n8n-nodes-base.code","typeVersion":2,"position":[1040,780],"parameters":{"jsCode":"// ---- Throughput + reliability per creator (rolling 30 / 90 day view) ----\nconst DAY = 86400000;\nconst now = Date.now();\nconst rows = $input.all().map(i => i.json);\n\nconst byCreator = {};\nfor (const r of rows) {\n  const k = r.creator_handle;\n  if (!byCreator[k]) byCreator[k] = {\n    creator_handle: k, creator_email: r.creator_email, tier: r.tier,\n    briefs_sent: 0, delivered: 0, posted: 0, overdue_now: 0,\n    turnarounds: [], views: 0, sla_days: r.sla_days, first_brief: now, scores: [],\n  };\n  const c = byCreator[k];\n  const sent = new Date(r.brief_sent_at).getTime();\n  c.briefs_sent++;\n  if (r.delivered) c.delivered++;\n  if (r.posted) c.posted++;\n  if (r.overdue) c.overdue_now++;\n  if (r.turnaround_days !== null && r.turnaround_days !== undefined) c.turnarounds.push(r.turnaround_days);\n  c.views += r.views || 0;\n  c.scores.push(r.health_score);\n  if (sent < c.first_brief) c.first_brief = sent;\n}\n\nconst median = a => {\n  if (!a.length) return null;\n  const s = [...a].sort((x, y) => x - y);\n  const m = Math.floor(s.length / 2);\n  return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);\n};\n\nconst out = Object.values(byCreator).map(c => {\n  const activeDays = Math.max(7, Math.round((now - c.first_brief) / DAY));\n  const deliveryRate = c.briefs_sent ? c.delivered / c.briefs_sent : 0;\n  const postRate = c.delivered ? c.posted / c.delivered : 0;\n  const medianTurnaround = median(c.turnarounds);\n  const onTimeRate = c.turnarounds.length\n    ? c.turnarounds.filter(t => t <= c.sla_days).length / c.turnarounds.length\n    : 0;\n\n  // Observed throughput, then projected next 30 days assuming the same\n  // brief cadence and the creator's historical delivery rate.\n  const assetsPer30 = +(c.delivered / activeDays * 30).toFixed(2);\n  const briefsPer30 = +(c.briefs_sent / activeDays * 30).toFixed(2);\n  const projectedNext30 = Math.round(briefsPer30 * deliveryRate * 10) / 10;\n\n  const reliability = Math.round(\n    45 * deliveryRate + 30 * onTimeRate + 15 * postRate + 10 * Math.min(1, c.views / 100000)\n  );\n  const avgHealth = Math.round(c.scores.reduce((s, v) => s + v, 0) / c.scores.length);\n\n  let verdict = 'keep';\n  if (reliability < 50 || c.overdue_now >= 2) verdict = 'review';\n  if (reliability >= 80 && assetsPer30 >= 3) verdict = 'scale';\n\n  return {\n    json: {\n      creator_handle: c.creator_handle,\n      creator_email: c.creator_email,\n      tier: c.tier,\n      briefs_sent: c.briefs_sent,\n      delivered: c.delivered,\n      posted: c.posted,\n      overdue_now: c.overdue_now,\n      delivery_rate: +deliveryRate.toFixed(2),\n      post_rate: +postRate.toFixed(2),\n      on_time_rate: +onTimeRate.toFixed(2),\n      median_turnaround_days: medianTurnaround,\n      assets_per_30d: assetsPer30,\n      briefs_per_30d: briefsPer30,\n      projected_assets_next_30d: projectedNext30,\n      total_views: c.views,\n      reliability_score: reliability,\n      avg_health_score: avgHealth,\n      verdict,\n      reported_at: new Date().toISOString(),\n    },\n  };\n}).sort((a, b) => b.json.reliability_score - a.json.reliability_score);\n\nreturn out;"}},{"id":"00000015-1111-4222-8333-444455556615","name":"Underperforming Creator?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1300,780],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.reliability_score }}","rightValue":50,"operator":{"type":"number","operation":"lt"}}]},"options":{}}},{"id":"00000016-1111-4222-8333-444455556616","name":"Flag Creator For Review","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1560,640],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ugc-pipeline","mode":"name"},"text":"=⚠️ @{{ $json.creator_handle }} (tier {{ $json.tier }}) reliability {{ $json.reliability_score }}/100 — {{ $json.delivered }}/{{ $json.briefs_sent }} delivered, median turnaround {{ $json.median_turnaround_days }}d, {{ $json.overdue_now }} overdue now. Verdict: {{ $json.verdict }}.","otherOptions":{}}},{"id":"00000017-1111-4222-8333-444455556617","name":"Append Creator Scorecard","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1560,920],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"UGC_PIPELINE_SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=990011","mode":"list","cachedResultName":"Scorecards"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"00000018-1111-4222-8333-444455556618","name":"Email Throughput Digest","type":"n8n-nodes-base.gmail","typeVersion":2.1,"position":[1820,920],"parameters":{"sendTo":"creative-ops@brand.com","subject":"=UGC throughput {{ $now.format('yyyy-LL-dd') }} — {{ $json.creator_handle }} top of board","message":"=Top creator: @{{ $json.creator_handle }} · reliability {{ $json.reliability_score }}/100 · {{ $json.assets_per_30d }} assets/30d · projected next 30d: {{ $json.projected_assets_next_30d }} · {{ $json.posted }} posted · {{ $json.total_views }} views. Full scorecard in the Scorecards tab.","options":{}}},{"id":"00000019-1111-4222-8333-444455556619","name":"Notes Intake","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-300,-60],"parameters":{"content":"## 1. INTAKE\nPull the creator roster + every brief that has been sent from the pipeline sheet, then pull the last 60 days of live posts from the TikTok Business API so we know what actually went up.","height":320,"width":460,"color":4}},{"id":"00000020-1111-4222-8333-444455556620","name":"Notes Chase","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1020,-260],"parameters":{"content":"## 2. CHASE OVERDUE CREATORS\nSLA is 5/7/10 days by tier. Overdue briefs get a 3-stage ladder (nudge → firm deadline → final notice), max one touch per 3 days. Stage 3 or 3+ chases escalates to the producer in Slack instead of another email.","height":300,"width":480,"color":3}},{"id":"00000021-1111-4222-8333-444455556621","name":"Notes Reporting","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1020,1060],"parameters":{"content":"## 3. THROUGHPUT REPORTING\nPer creator: delivery rate, on-time rate, median turnaround, assets/30d and a projection for the next 30 days. Reliability < 50 gets flagged for review; everyone lands in the scorecard tab + daily digest.","height":300,"width":480,"color":5}}],"connections":{"Daily Creator Pipeline Check":{"main":[[{"node":"Read Creator Roster","type":"main","index":0},{"node":"Read Brief Log","type":"main","index":0}]]},"Read Creator Roster":{"main":[[{"node":"Merge Roster And Briefs","type":"main","index":0}]]},"Read Brief Log":{"main":[[{"node":"Merge Roster And Briefs","type":"main","index":1}]]},"Merge Roster And Briefs":{"main":[[{"node":"Fetch Posted Content","type":"main","index":0}]]},"Fetch Posted Content":{"main":[[{"node":"Build Creator Pipeline State","type":"main","index":0}]]},"Build Creator Pipeline State":{"main":[[{"node":"Brief Overdue?","type":"main","index":0},{"node":"Aggregate Creator Throughput","type":"main","index":0}]]},"Brief Overdue?":{"main":[[{"node":"Compose Chase Message","type":"main","index":0}],[{"node":"Log On-Track Brief","type":"main","index":0}]]},"Compose Chase Message":{"main":[[{"node":"Escalate To Human?","type":"main","index":0}]]},"Escalate To Human?":{"main":[[{"node":"Alert Producer In Slack","type":"main","index":0}],[{"node":"Email Chase To Creator","type":"main","index":0}]]},"Alert Producer In Slack":{"main":[[{"node":"Log Chase Sent","type":"main","index":0}]]},"Email Chase To Creator":{"main":[[{"node":"Log Chase Sent","type":"main","index":0}]]},"Aggregate Creator Throughput":{"main":[[{"node":"Underperforming Creator?","type":"main","index":0}]]},"Underperforming Creator?":{"main":[[{"node":"Flag Creator For Review","type":"main","index":0}],[{"node":"Append Creator Scorecard","type":"main","index":0}]]},"Flag Creator For Review":{"main":[[{"node":"Append Creator Scorecard","type":"main","index":0}]]},"Append Creator Scorecard":{"main":[[{"node":"Email Throughput Digest","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}