{"name":"Winning product finder workflow","nodes":[{"id":"a1b2c3d4-0001-4a11-8b22-c33344445555","name":"Daily Product Scan","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-260,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":24}]}}},{"id":"a1b2c3d4-0002-4a11-8b22-c33344445555","name":"Fetch Supplier Product Feed","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[0,180],"parameters":{"url":"https://api.productfeed.io/v3/catalog/trending","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"product_id,product_title,category,supplier_id,supplier_price_usd,shipping_cost_usd,retail_price_usd,shipping_days_avg,ship_from_country,active_sellers_count,ad_count_active,orders_last_30d,orders_prev_30d,rating,review_count,image_count,has_video"},{"name":"window","value":"last_30d"},{"name":"min_orders_last_30d","value":"120"},{"name":"limit","value":"250"}]},"options":{}}},{"id":"a1b2c3d4-0003-4a11-8b22-c33344445555","name":"Fetch Search Trend Signals","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[0,460],"parameters":{"url":"https://api.trendsignals.io/v1/interest/batch","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"keyword,interest_now,interest_prev,trend_index_now,trend_index_30d_ago"},{"name":"geo","value":"US,GB,DE,AU"},{"name":"compare_window","value":"30d"}]},"options":{}}},{"id":"a1b2c3d4-0004-4a11-8b22-c33344445555","name":"Join Feed With Trends","type":"n8n-nodes-base.merge","typeVersion":3,"position":[260,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"a1b2c3d4-0005-4a11-8b22-c33344445555","name":"Normalise & Dedupe Candidates","type":"n8n-nodes-base.code","typeVersion":2,"position":[520,300],"parameters":{"jsCode":"// ---- Normalise the raw feed + trend payloads into one candidate row ----\n// Feed rows come from the supplier catalogue API, trend rows from the\n// keyword/trends API. Merge-by-position pairs them, but we defend against\n// missing halves so a partial trends response never kills the run.\nconst BANNED_CATEGORIES = ['weapons', 'tobacco', 'adult', 'prescription', 'counterfeit'];\nconst BANNED_KEYWORDS = ['airpod', 'rolex', 'nike', 'louis vuitton', 'disney', 'pokemon'];\n\nconst num = (v, d = 0) => {\n  const n = typeof v === 'string' ? parseFloat(v.replace(/[^0-9.\\-]/g, '')) : Number(v);\n  return Number.isFinite(n) ? n : d;\n};\nconst slug = (s) => String(s || '')\n  .toLowerCase()\n  .replace(/\\b(new|hot|2026|free shipping|dropship|wholesale|for men|for women)\\b/g, '')\n  .replace(/[^a-z0-9]+/g, ' ')\n  .trim()\n  .split(' ')\n  .filter(Boolean)\n  .sort()\n  .slice(0, 6)\n  .join('-');\n\nconst rows = $input.all().map((i) => i.json);\nconst candidates = [];\n\nfor (const r of rows) {\n  const p = r.product || r;\n  const t = r.trend || r;\n\n  const title = p.product_title || p.title || '';\n  const category = String(p.category || 'uncategorised').toLowerCase();\n  const supplierPrice = num(p.supplier_price_usd ?? p.cost_usd);\n  const retailPrice = num(p.retail_price_usd ?? p.suggested_retail_usd);\n  const shipFee = num(p.shipping_cost_usd, 0);\n\n  // hard rejects before we waste scoring cycles or LLM tokens on them\n  if (!title || supplierPrice <= 0 || retailPrice <= 0) continue;\n  if (retailPrice <= supplierPrice + shipFee) continue;\n  if (BANNED_CATEGORIES.some((c) => category.includes(c))) continue;\n  if (BANNED_KEYWORDS.some((k) => title.toLowerCase().includes(k))) continue;\n\n  candidates.push({\n    product_id: p.product_id || p.id || slug(title),\n    dedupe_key: slug(title) + '|' + (p.supplier_id || 'na'),\n    product_title: title.trim(),\n    category,\n    supplier_id: p.supplier_id || 'unknown',\n    supplier_country: p.ship_from_country || 'CN',\n    supplier_price_usd: supplierPrice,\n    shipping_cost_usd: shipFee,\n    retail_price_usd: retailPrice,\n    shipping_days_avg: num(p.shipping_days_avg, 20),\n    active_sellers_count: num(p.active_sellers_count, 250),\n    ad_count_active: num(p.ad_count_active, 0),\n    orders_last_30d: num(p.orders_last_30d),\n    orders_prev_30d: num(p.orders_prev_30d),\n    rating: num(p.rating, 0),\n    review_count: num(p.review_count, 0),\n    image_count: num(p.image_count, 0),\n    has_video: Boolean(p.has_video),\n    trend_index_now: num(t.trend_index_now ?? t.interest_now, 0),\n    trend_index_30d_ago: num(t.trend_index_30d_ago ?? t.interest_prev, 0),\n    trend_keyword: t.keyword || title.split(' ').slice(0, 3).join(' ').toLowerCase(),\n    pulled_at: new Date().toISOString(),\n  });\n}\n\n// ---- dedupe: same product listed by several suppliers / with junk titles ----\n// keep the variant with the most 30d orders, tie-break on the cheaper landed cost\nconst best = new Map();\nfor (const c of candidates) {\n  const prev = best.get(c.dedupe_key);\n  if (!prev) { best.set(c.dedupe_key, { ...c, duplicate_listings: 1 }); continue; }\n  prev.duplicate_listings += 1;\n  const better =\n    c.orders_last_30d > prev.orders_last_30d ||\n    (c.orders_last_30d === prev.orders_last_30d &&\n      c.supplier_price_usd + c.shipping_cost_usd < prev.supplier_price_usd + prev.shipping_cost_usd);\n  if (better) best.set(c.dedupe_key, { ...c, duplicate_listings: prev.duplicate_listings });\n}\n\nreturn [...best.values()].map((c) => ({ json: c }));"}},{"id":"a1b2c3d4-0006-4a11-8b22-c33344445555","name":"Score Product Opportunity","type":"n8n-nodes-base.code","typeVersion":2,"position":[780,300],"parameters":{"jsCode":"// ---- Winning-product score: margin, shipping, saturation, trend velocity ----\n// Every sub-score is 0-100 so the weights below are readable and tunable.\n// Weights sum to 1.00. Trend velocity and margin carry the most weight because\n// they are what actually decide whether a test can be scaled profitably.\nconst W = { margin: 0.32, shipping: 0.16, saturation: 0.22, trend: 0.30 };\nconst SCORE_FLOOR = 72;          // below this it is not worth a validation slot\nconst TARGET_CPA_MULTIPLE = 0.35; // assume ads eat 35% of revenue on a good test\nconst clamp = (n, lo = 0, hi = 100) => Math.max(lo, Math.min(hi, n));\n\nconst out = [];\n\nfor (const item of $input.all()) {\n  const p = item.json;\n\n  // 1) MARGIN --------------------------------------------------------------\n  const landedCost = p.supplier_price_usd + p.shipping_cost_usd;\n  const grossProfit = p.retail_price_usd - landedCost;\n  const marginPct = grossProfit / p.retail_price_usd;\n  const markup = p.retail_price_usd / landedCost;\n  // 65%+ margin = 100, 20% = 0, linear between; bonus for a 3x+ markup which\n  // is what keeps you alive once ad costs bite.\n  let marginScore = clamp(((marginPct - 0.20) / (0.65 - 0.20)) * 100);\n  if (markup >= 3) marginScore = clamp(marginScore + 8);\n  if (grossProfit < 12) marginScore = clamp(marginScore - 25); // too thin to buy traffic\n\n  // 2) SHIPPING ------------------------------------------------------------\n  // <=7 days is a 100, 25+ days is a 0. Domestic warehouses get a lift.\n  const days = p.shipping_days_avg;\n  let shippingScore = clamp(((25 - days) / (25 - 7)) * 100);\n  if (['US', 'DE', 'GB', 'PL', 'CZ'].includes(p.supplier_country)) shippingScore = clamp(shippingScore + 15);\n\n  // 3) SATURATION ----------------------------------------------------------\n  // Log scale: 10 sellers is fine, 100 is crowded, 1000 is dead. Active ad\n  // count is the sharper signal, so it gets weighted double inside the blend.\n  const sellerPenalty = clamp((Math.log10(Math.max(p.active_sellers_count, 1)) / 3) * 100);\n  const adPenalty = clamp((Math.log10(Math.max(p.ad_count_active, 1)) / 2.7) * 100);\n  const saturationScore = clamp(100 - (sellerPenalty + adPenalty * 2) / 3);\n\n  // 4) TREND VELOCITY ------------------------------------------------------\n  // Blend search-interest delta with real order-volume delta. Search tells you\n  // where demand is going, orders tell you it already converts.\n  const prevTrend = Math.max(p.trend_index_30d_ago, 1);\n  const trendDelta = (p.trend_index_now - prevTrend) / prevTrend;      // e.g. 0.45 = +45%\n  const prevOrders = Math.max(p.orders_prev_30d, 1);\n  const orderDelta = (p.orders_last_30d - prevOrders) / prevOrders;\n  const blendedVelocity = trendDelta * 0.55 + orderDelta * 0.45;\n  // +80% MoM or better = 100, flat = 30, -30% = 0. Declining products die fast.\n  let trendScore = clamp(30 + (blendedVelocity / 0.8) * 70);\n  if (p.trend_index_now < 15) trendScore = clamp(trendScore - 20); // no real search volume yet\n  if (blendedVelocity < -0.15) trendScore = clamp(trendScore - 15); // actively rolling over\n\n  // 5) CREATIVE / TRUST modifiers -----------------------------------------\n  let modifier = 0;\n  if (p.has_video) modifier += 3;\n  if (p.image_count >= 6) modifier += 2;\n  if (p.rating >= 4.6 && p.review_count >= 200) modifier += 4;\n  if (p.rating > 0 && p.rating < 4.2) modifier -= 8; // refund magnet\n  if (p.review_count < 30) modifier -= 4;            // unproven supplier\n\n  const score = Math.round(\n    clamp(\n      marginScore * W.margin +\n        shippingScore * W.shipping +\n        saturationScore * W.saturation +\n        trendScore * W.trend +\n        modifier\n    )\n  );\n\n  // ---- pacing projection: what a validation test would actually look like --\n  const contributionPerUnit = grossProfit - p.retail_price_usd * TARGET_CPA_MULTIPLE;\n  const dailyOrdersNow = p.orders_last_30d / 30;\n  // project the next 30 days forward at half the observed velocity (haircut\n  // for regression to the mean), floor at zero.\n  const projectedDailyOrders = Math.max(dailyOrdersNow * (1 + blendedVelocity * 0.5), 0);\n  const projected30dUnits = Math.round(projectedDailyOrders * 30);\n  const projected30dProfit = Math.round(projected30dUnits * contributionPerUnit);\n  const breakevenRoas = p.retail_price_usd / Math.max(grossProfit, 0.01);\n  const testBudgetUsd = Math.max(60, Math.round((p.retail_price_usd * 0.35 * 20) / 10) * 10);\n\n  const flags = [];\n  if (marginScore < 45) flags.push('thin-margin');\n  if (shippingScore < 40) flags.push('slow-shipping');\n  if (saturationScore < 40) flags.push('saturated');\n  if (trendScore < 40) flags.push('flat-or-declining');\n  if (p.duplicate_listings > 4) flags.push('many-duplicate-listings');\n\n  const tier = score >= 85 ? 'A-hero' : score >= SCORE_FLOOR ? 'B-test' : score >= 60 ? 'C-watch' : 'D-reject';\n\n  out.push({\n    json: {\n      ...p,\n      landed_cost_usd: Number(landedCost.toFixed(2)),\n      gross_profit_usd: Number(grossProfit.toFixed(2)),\n      margin_pct: Number((marginPct * 100).toFixed(1)),\n      markup_x: Number(markup.toFixed(2)),\n      margin_score: Math.round(marginScore),\n      shipping_score: Math.round(shippingScore),\n      saturation_score: Math.round(saturationScore),\n      trend_score: Math.round(trendScore),\n      trend_delta_pct: Number((trendDelta * 100).toFixed(1)),\n      order_delta_pct: Number((orderDelta * 100).toFixed(1)),\n      blended_velocity_pct: Number((blendedVelocity * 100).toFixed(1)),\n      score,\n      tier,\n      flags: flags.join(','),\n      breakeven_roas: Number(breakevenRoas.toFixed(2)),\n      contribution_per_unit_usd: Number(contributionPerUnit.toFixed(2)),\n      projected_30d_units: projected30dUnits,\n      projected_30d_profit_usd: projected30dProfit,\n      recommended_test_budget_usd: testBudgetUsd,\n      score_floor: SCORE_FLOOR,\n    },\n  });\n}\n\nreturn out;"}},{"id":"a1b2c3d4-0007-4a11-8b22-c33344445555","name":"Score Above Floor?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1040,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.score }}","rightValue":72,"operator":{"type":"number","operation":"gte"}},{"id":"c2","leftValue":"={{ $json.gross_profit_usd }}","rightValue":12,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"a1b2c3d4-0008-4a11-8b22-c33344445555","name":"Log Rejected Candidates","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1300,540],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Rejected"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"a1b2c3d4-0009-4a11-8b22-c33344445555","name":"Drop Slow Shipping","type":"n8n-nodes-base.filter","typeVersion":2,"position":[1300,140],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"f1","leftValue":"={{ $json.shipping_days_avg }}","rightValue":14,"operator":{"type":"number","operation":"lte"}},{"id":"f2","leftValue":"={{ $json.saturation_score }}","rightValue":35,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"a1b2c3d4-0010-4a11-8b22-c33344445555","name":"Rank & Cap Weekly Shortlist","type":"n8n-nodes-base.code","typeVersion":2,"position":[1560,140],"parameters":{"jsCode":"// ---- Rank, diversify and cap the shortlist ----\n// The team can only realistically validate ~15 products a week, and shipping\n// 8 phone cases in one batch teaches you nothing. Cap 3 per category.\nconst MAX_QUEUE = 15;\nconst MAX_PER_CATEGORY = 3;\nconst MIN_PROJECTED_PROFIT = 900; // 30d contribution must justify the test spend\n\nconst scored = $input\n  .all()\n  .map((i) => i.json)\n  .filter((p) => p.projected_30d_profit_usd >= MIN_PROJECTED_PROFIT)\n  .sort((a, b) => b.score - a.score || b.projected_30d_profit_usd - a.projected_30d_profit_usd);\n\nconst perCategory = {};\nconst queue = [];\nconst benched = [];\n\nfor (const p of scored) {\n  const used = perCategory[p.category] || 0;\n  if (queue.length >= MAX_QUEUE || used >= MAX_PER_CATEGORY) {\n    benched.push(p.product_title);\n    continue;\n  }\n  perCategory[p.category] = used + 1;\n  queue.push({\n    ...p,\n    rank: queue.length + 1,\n    priority: queue.length < 5 ? 'P1' : queue.length < 10 ? 'P2' : 'P3',\n    queue_batch: new Date().toISOString().slice(0, 10),\n    benched_count: 0,\n  });\n}\n\n// stamp the run-level context onto every row so downstream nodes/Slack can use it\nconst totalBudget = queue.reduce((s, p) => s + p.recommended_test_budget_usd, 0);\nreturn queue.map((p) => ({\n  json: {\n    ...p,\n    queue_size: queue.length,\n    benched_count: benched.length,\n    batch_test_budget_usd: totalBudget,\n    batch_projected_profit_usd: queue.reduce((s, x) => s + x.projected_30d_profit_usd, 0),\n  },\n}));"}},{"id":"a1b2c3d4-0011-4a11-8b22-c33344445555","name":"Loop Over Shortlist","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[1820,140],"parameters":{"batchSize":1,"options":{}}},{"id":"a1b2c3d4-0012-4a11-8b22-c33344445555","name":"Write Research Brief","type":"@n8n/n8n-nodes-langchain.openAi","typeVersion":1.8,"position":[2080,380],"parameters":{"modelId":{"__rl":true,"value":"gpt-4o-mini","mode":"list"},"messages":{"values":[{"role":"system","content":"You are a dropshipping product researcher. Given a scored product candidate you write a tight validation brief a media buyer can execute today. Sections, in order: 1) Why it scored (one line per sub-score, quote the numbers). 2) Target customer and the exact pain. 3) Three angles with a hook line each. 4) Two competitor checks to run before spending. 5) The single kill criterion for the test. Max 220 words, no fluff, no emoji."},{"content":"=Product: {{ $json.product_title }} ({{ $json.category }})\nScore {{ $json.score }} / tier {{ $json.tier }} / flags: {{ $json.flags }}\nMargin {{ $json.margin_pct }}% at ${{ $json.retail_price_usd }} retail on ${{ $json.landed_cost_usd }} landed ({{ $json.markup_x }}x markup)\nSub-scores - margin {{ $json.margin_score }}, shipping {{ $json.shipping_score }}, saturation {{ $json.saturation_score }}, trend {{ $json.trend_score }}\nShipping {{ $json.shipping_days_avg }} days from {{ $json.supplier_country }}, {{ $json.active_sellers_count }} active sellers, {{ $json.ad_count_active }} live ads\nSearch trend {{ $json.trend_delta_pct }}% MoM, orders {{ $json.order_delta_pct }}% MoM ({{ $json.orders_last_30d }} orders last 30d)\nBreakeven ROAS {{ $json.breakeven_roas }}, test budget ${{ $json.recommended_test_budget_usd }}, projected 30d profit ${{ $json.projected_30d_profit_usd }}"}]},"options":{}}},{"id":"a1b2c3d4-0013-4a11-8b22-c33344445555","name":"Build Validation Record","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[2340,380],"parameters":{"assignments":{"assignments":[{"id":"a1","name":"product","value":"={{ $('Loop Over Shortlist').item.json.product_title }}","type":"string"},{"id":"a2","name":"category","value":"={{ $('Loop Over Shortlist').item.json.category }}","type":"string"},{"id":"a3","name":"rank","value":"={{ $('Loop Over Shortlist').item.json.rank }}","type":"number"},{"id":"a4","name":"priority","value":"={{ $('Loop Over Shortlist').item.json.priority }}","type":"string"},{"id":"a5","name":"score","value":"={{ $('Loop Over Shortlist').item.json.score }}","type":"number"},{"id":"a6","name":"margin_pct","value":"={{ $('Loop Over Shortlist').item.json.margin_pct }}","type":"number"},{"id":"a7","name":"breakeven_roas","value":"={{ $('Loop Over Shortlist').item.json.breakeven_roas }}","type":"number"},{"id":"a8","name":"test_budget_usd","value":"={{ $('Loop Over Shortlist').item.json.recommended_test_budget_usd }}","type":"number"},{"id":"a9","name":"projected_30d_profit_usd","value":"={{ $('Loop Over Shortlist').item.json.projected_30d_profit_usd }}","type":"number"},{"id":"a10","name":"status","value":"to-validate","type":"string"},{"id":"a11","name":"research_brief","value":"={{ $json.message.content }}","type":"string"}]},"options":{}}},{"id":"a1b2c3d4-0014-4a11-8b22-c33344445555","name":"Push To Validation Queue","type":"n8n-nodes-base.airtable","typeVersion":2.1,"position":[2600,380],"parameters":{"operation":"create","base":{"__rl":true,"value":"appPRODUCTQUEUE","mode":"id"},"table":{"__rl":true,"value":"tblValidationQueue","mode":"id"},"columns":{"mappingMode":"autoMapInputData","value":{}},"options":{}}},{"id":"a1b2c3d4-0015-4a11-8b22-c33344445555","name":"Notify Sourcing Team","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2080,-60],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#product-research","mode":"name"},"text":"=🛒 Winning product shortlist for {{ $now.format('yyyy-LL-dd') }}\n{{ $('Rank & Cap Weekly Shortlist').first().json.queue_size }} products queued ({{ $('Rank & Cap Weekly Shortlist').first().json.benched_count }} benched by the category cap).\nTop pick: {{ $('Rank & Cap Weekly Shortlist').first().json.product_title }} — score {{ $('Rank & Cap Weekly Shortlist').first().json.score }}, {{ $('Rank & Cap Weekly Shortlist').first().json.margin_pct }}% margin, trend {{ $('Rank & Cap Weekly Shortlist').first().json.trend_delta_pct }}% MoM.\nBatch test budget ${{ $('Rank & Cap Weekly Shortlist').first().json.batch_test_budget_usd }} → projected 30d contribution ${{ $('Rank & Cap Weekly Shortlist').first().json.batch_projected_profit_usd }}.\nBriefs are in Airtable, ready to validate.","otherOptions":{}}},{"id":"a1b2c3d4-0090-4a11-8b22-c33344445555","name":"Note Ingest","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-300,-60],"parameters":{"content":"## 1. INGEST\nPull the supplier catalogue (orders, price, shipping, seller counts) and the search-trend feed, join them by position, then normalise + dedupe. Banned categories and trademarked keywords are dropped here before anything else runs.","height":320,"width":520,"color":4}},{"id":"a1b2c3d4-0091-4a11-8b22-c33344445555","name":"Note Scoring","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[740,-60],"parameters":{"content":"## 2. SCORE & GATE\n0-100 sub-scores for margin, shipping speed, saturation and trend velocity, blended 32/16/22/30 with creative + trust modifiers.\nScore >= 72 AND >= $12 gross profit passes; everything else is logged to the rejected sheet so the thresholds stay auditable.","height":320,"width":520,"color":5}},{"id":"a1b2c3d4-0092-4a11-8b22-c33344445555","name":"Note Queue","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1800,-300],"parameters":{"content":"## 3. SHORTLIST → VALIDATION QUEUE\nRank by score, cap 3 per category and 15 per batch, require $900+ projected 30d contribution. Each survivor gets an LLM research brief (angles, competitor checks, kill criterion) and lands in Airtable as `to-validate`. Slack gets the batch summary when the loop finishes.","height":320,"width":560,"color":6}}],"connections":{"Daily Product Scan":{"main":[[{"node":"Fetch Supplier Product Feed","type":"main","index":0},{"node":"Fetch Search Trend Signals","type":"main","index":0}]]},"Fetch Supplier Product Feed":{"main":[[{"node":"Join Feed With Trends","type":"main","index":0}]]},"Fetch Search Trend Signals":{"main":[[{"node":"Join Feed With Trends","type":"main","index":1}]]},"Join Feed With Trends":{"main":[[{"node":"Normalise & Dedupe Candidates","type":"main","index":0}]]},"Normalise & Dedupe Candidates":{"main":[[{"node":"Score Product Opportunity","type":"main","index":0}]]},"Score Product Opportunity":{"main":[[{"node":"Score Above Floor?","type":"main","index":0}]]},"Score Above Floor?":{"main":[[{"node":"Drop Slow Shipping","type":"main","index":0}],[{"node":"Log Rejected Candidates","type":"main","index":0}]]},"Drop Slow Shipping":{"main":[[{"node":"Rank & Cap Weekly Shortlist","type":"main","index":0}]]},"Rank & Cap Weekly Shortlist":{"main":[[{"node":"Loop Over Shortlist","type":"main","index":0}]]},"Loop Over Shortlist":{"main":[[{"node":"Notify Sourcing Team","type":"main","index":0}],[{"node":"Write Research Brief","type":"main","index":0}]]},"Write Research Brief":{"main":[[{"node":"Build Validation Record","type":"main","index":0}]]},"Build Validation Record":{"main":[[{"node":"Push To Validation Queue","type":"main","index":0}]]},"Push To Validation Queue":{"main":[[{"node":"Loop Over Shortlist","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}