Skip to content

In n8n, copy this file and paste it onto the canvas, or use Import from File.

{
  "name": "22 · AI Product Description Generator",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 7
            }
          ]
        }
      },
      "id": "m22-daily-7-am",
      "name": "Daily 7 AM",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "pd/generate",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m22-generate-api",
      "name": "Generate API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        180
      ],
      "webhookId": "course-22-generate"
    },
    {
      "parameters": {
        "httpMethod": "GET",
        "path": "pd/review",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m22-review-api",
      "name": "Review API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        400
      ],
      "webhookId": "course-22-review"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "pd/review/decide",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m22-decide-api",
      "name": "Decide API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        580
      ],
      "webhookId": "course-22-decide"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "s1",
              "name": "brand_name",
              "value": "Juniper & Oak",
              "type": "string"
            },
            {
              "id": "s2",
              "name": "voice",
              "value": "Warm, calm and specific. Short sentences. Talks about how the piece feels and fits into a home, not hype.",
              "type": "string"
            },
            {
              "id": "s3",
              "name": "audience",
              "value": "Homeowners and renters furnishing a cozy, natural-looking home",
              "type": "string"
            },
            {
              "id": "s4",
              "name": "banned_phrases",
              "value": "={{ ['best ever', 'miracle', 'guaranteed', 'world-class', 'revolutionary', 'cure', 'eco-friendly', 'non-toxic', 'hypoallergenic', 'game-changer'] }}",
              "type": "array"
            },
            {
              "id": "s5",
              "name": "max_words",
              "value": 120,
              "type": "number"
            },
            {
              "id": "s6",
              "name": "min_existing_words",
              "value": 30,
              "type": "number"
            },
            {
              "id": "s7",
              "name": "batch_size",
              "value": 10,
              "type": "number"
            },
            {
              "id": "s8",
              "name": "max_tags",
              "value": 10,
              "type": "number"
            },
            {
              "id": "s9",
              "name": "use_ai",
              "value": true,
              "type": "boolean"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "m22-settings-edit-me",
      "name": "Settings (edit me)",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        240,
        280
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r1",
                    "leftValue": "={{ $('Daily 7 AM').isExecuted || $('Generate API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "generate"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r2",
                    "leftValue": "={{ $('Review API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "review"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r3",
                    "leftValue": "={{ $('Decide API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "decide"
            }
          ]
        },
        "options": {}
      },
      "id": "m22-route",
      "name": "Route",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        460,
        280
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $('Generate API').isExecuted && $('Generate API').first().json.body.source !== 'shopify' }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m22-products-from-api",
      "name": "Products from API?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        700,
        60
      ]
    },
    {
      "parameters": {
        "authentication": "accessToken",
        "resource": "product",
        "operation": "getAll",
        "returnAll": false,
        "limit": 250,
        "additionalFields": {
          "published_status": "any"
        }
      },
      "id": "m22-get-shopify-products",
      "name": "Get Shopify Products",
      "type": "n8n-nodes-base.shopify",
      "typeVersion": 1,
      "position": [
        920,
        180
      ],
      "onError": "continueRegularOutput",
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "// Normalize products (API body or Shopify) and pick the ones that need copy, up to the batch size.\nconst s = $('Settings (edit me)').first().json;\nconst viaApi = $('Generate API').isExecuted;\nconst body = viaApi ? $('Generate API').first().json.body ?? {} : {};\nconst fromShopify = $('Get Shopify Products').isExecuted;\n\nconst strip = (html) => String(html ?? '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\\s+/g, ' ').trim();\nconst words = (t) => (t ? t.split(/\\s+/).length : 0);\nconst toList = (t) => (Array.isArray(t) ? t : String(t ?? '').split(',')).map((x) => String(x).trim()).filter(Boolean);\n\nlet raw = [], source_error = null;\nif (fromShopify) {\n  const items = $('Get Shopify Products').all().map((i) => i.json);\n  if (items.some((p) => p.id)) raw = items.filter((p) => p.id).map((p) => ({\n    id: String(p.id), title: p.title, product_type: p.product_type, vendor: p.vendor, tags: p.tags, body_html: p.body_html,\n    // Shopify keeps specs in options/variants; take what's there (Exercise 2 adds metafields).\n    attributes: Object.fromEntries((p.options ?? []).filter((o) => o.name !== 'Title').map((o) => [o.name.toLowerCase(), (o.values ?? []).join(', ')])),\n    price: p.variants?.[0]?.price,\n  }));\n  else source_error = String(items[0]?.error?.message ?? items[0]?.error ?? 'Shopify returned no products (check the Shopify credential)').slice(0, 300);\n} else raw = body.products ?? [];\n\nconst all = raw.map((p) => {\n  const existing = strip(p.body_html);\n  return {\n    product_id: String(p.id ?? p.product_id ?? ''),\n    title: String(p.title ?? '').trim(),\n    product_type: p.product_type ?? '',\n    vendor: p.vendor ?? '',\n    tags: toList(p.tags),\n    attributes: p.attributes ?? {},\n    price: p.price ?? null,\n    existing_text: existing,\n    existing_words: words(existing),\n    draft: p.draft ?? null, // copy written elsewhere: skip the AI, just run the checks\n  };\n}).filter((p) => p.product_id && p.title);\n\nconst force = body.force === true;\nconst drafts = $getWorkflowStaticData('global').drafts ?? {};\nconst inReview = (p) => ['pending', 'rejected'].includes(drafts[p.product_id]?.state); // don't overwrite a reviewer's queue\nconst needs = all.filter((p) => force || p.draft || (!inReview(p) && (p.existing_words < s.min_existing_words || /\\b(todo|tbd|lorem ipsum)\\b/i.test(p.existing_text))));\nconst batchSize = Number(body.batch_size ?? s.batch_size);\nconst batch = needs.slice(0, batchSize);\nconst meta = {\n  _run: {\n    products_seen: all.length, needing_copy: needs.length, in_batch: batch.length, remaining: needs.length - batch.length,\n    skipped_in_review: all.filter((p) => !needs.includes(p) && inReview(p)).map((p) => ({ product_id: p.product_id, title: p.title, state: drafts[p.product_id].state })),\n    skipped_has_copy: all.filter((p) => !needs.includes(p) && !inReview(p)).map((p) => ({ product_id: p.product_id, title: p.title, words: p.existing_words })),\n    source: fromShopify ? 'shopify' : 'api', source_error, dry_run: body.dry_run === true, via_api: viaApi,\n    use_ai: s.use_ai && body.ai !== false,\n  },\n};\nif (!batch.length) return [{ json: { nothing_to_do: true, ...meta } }];\nreturn batch.map((p, i) => ({ json: { ...p, use_ai: meta._run.use_ai && !p.draft, ...(i === 0 ? meta : {}) } }));\n"
      },
      "id": "m22-pick-products",
      "name": "Pick Products",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1140,
        60
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.use_ai === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m22-use-ai",
      "name": "Use AI?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1360,
        60
      ]
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "=Write product copy for {{ $('Settings (edit me)').first().json.brand_name }}.\n\nBrand voice: {{ $('Settings (edit me)').first().json.voice }}\nAudience: {{ $('Settings (edit me)').first().json.audience }}\n\nProduct: {{ $json.title }}\nType: {{ $json.product_type || 'not given' }}\nFacts: {{ JSON.stringify($json.attributes) }}\nPrice: {{ $json.price ?? 'not given' }}\nCurrent description (may be empty): {{ $json.existing_text || '(none)' }}\n\nRules:\n- Use ONLY the facts above. Never invent materials, sizes, numbers, certifications or awards.\n- description: 2–3 sentences, benefit-led, max 70 words, plain text.\n- bullets: 3–5 short lines built from the facts.\n- seo_title: max 60 characters, includes the product name.\n- meta_description: max 155 characters, one sentence.\n- tags: 3–6 lowercase shopping keywords.\n- Never use: {{ $('Settings (edit me)').first().json.banned_phrases.join(', ') }}.",
        "hasOutputParser": true
      },
      "id": "m22-write-with-ai",
      "name": "Write with AI",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.9,
      "position": [
        1580,
        -80
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5-mini",
          "cachedResultName": "gpt-5-mini"
        },
        "options": {}
      },
      "id": "m22-openai-chat-model",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "typeVersion": 1.3,
      "position": [
        1520,
        120
      ]
    },
    {
      "parameters": {
        "schemaType": "fromJson",
        "jsonSchemaExample": "{\n  \"description\": \"A soft, washed-linen cover in muted sage that makes any sofa feel lived-in and relaxed.\",\n  \"bullets\": [\n    \"Material: 100% European linen\",\n    \"Size: 20 x 20 in\",\n    \"Care: machine wash cold\"\n  ],\n  \"seo_title\": \"Sage Linen Throw Pillow Cover | Juniper & Oak\",\n  \"meta_description\": \"A washed European linen pillow cover in sage, 20 x 20 in, for a calm, lived-in look.\",\n  \"tags\": [\n    \"linen\",\n    \"pillow cover\",\n    \"sage\"\n  ]\n}"
      },
      "id": "m22-copy-format",
      "name": "Copy Format",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "typeVersion": 1.3,
      "position": [
        1680,
        120
      ]
    },
    {
      "parameters": {},
      "id": "m22-join-branches",
      "name": "Join Branches",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [
        1820,
        60
      ]
    },
    {
      "parameters": {
        "jsCode": "// Turn the AI output (or a template / supplied draft) into final copy, then run the guardrails.\nconst s = $('Settings (edit me)').first().json;\nconst esc = (t) => String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\nconst cut = (t, n) => { t = String(t ?? '').replace(/\\s+/g, ' ').trim(); if (t.length <= n) return t; const c = t.slice(0, n + 1); return c.slice(0, c.lastIndexOf(' ')).replace(/[,;:.\\-–]+$/, '') ; };\nconst cap = (t) => t.charAt(0).toUpperCase() + t.slice(1);\n\nfunction template(p) {\n  const a = p.attributes;\n  const bits = [a.material && `made from ${a.material}`, a.color && `in ${a.color}`].filter(Boolean).join(' ');\n  const lead = `${p.title}${bits ? ', ' + bits : ''}.`;\n  const use = p.product_type ? ` A simple, well-made ${p.product_type.toLowerCase()} from ${s.brand_name}.` : ` From ${s.brand_name}.`;\n  return {\n    description: lead + use,\n    bullets: Object.entries(a).filter(([, v]) => v).map(([k, v]) => `${cap(k)}: ${v}`).slice(0, 5),\n    seo_title: `${p.title} | ${s.brand_name}`,\n    meta_description: `${lead} Shop ${p.title.toLowerCase()} at ${s.brand_name}.`,\n    tags: [p.product_type, a.material, a.color].filter((t) => t && !/\\d/.test(t) && String(t).length <= 25).map((t) => String(t).toLowerCase()),\n  };\n}\n\nreturn $input.all().map((item, i) => {\n  if (item.json.nothing_to_do) return item;\n  const fromAi = !item.json.product_id; // the AI chain outputs only { output } or { error }\n  const p = fromAi ? $('Pick Products').itemMatching(i).json : item.json;\n  const ai = fromAi ? item.json.output : null;\n  let c, source, ai_error;\n  if (p.draft) { c = p.draft; source = 'supplied'; }\n  else if (ai?.description) { c = ai; source = 'ai'; }\n  else { c = template(p); source = 'template'; if (fromAi) ai_error = String(item.json.error?.message ?? item.json.error ?? 'no AI output').slice(0, 200); }\n\n  const description = String(c.description ?? '').trim();\n  const bullets = (c.bullets ?? []).map((b) => String(b).trim()).filter(Boolean).slice(0, 6);\n  const issues = [];\n\n  // 1. Banned claims (legal risk / off-brand)\n  const fullText = [description, ...bullets, c.seo_title, c.meta_description].join(' ');\n  for (const phrase of s.banned_phrases) if (new RegExp(`\\\\b${phrase}\\\\b`, 'i').test(fullText)) issues.push(`banned phrase: \"${phrase}\"`);\n  // 2. Numbers that aren't in the product data (invented specs)\n  const facts = JSON.stringify([p.title, p.attributes, p.price, p.existing_text]).toLowerCase();\n  for (const n of new Set(fullText.match(/\\d+(?:[.,]\\d+)?/g) ?? [])) if (!facts.includes(n.toLowerCase())) issues.push(`number not in product data: ${n}`);\n  // 3. Length\n  const wc = [description, ...bullets].join(' ').split(/\\s+/).filter(Boolean).length;\n  if (wc > s.max_words) issues.push(`too long: ${wc} words (max ${s.max_words})`);\n  if (wc < 15) issues.push(`too short: ${wc} words`);\n  if (Object.keys(p.attributes).length === 0 && !p.existing_text) issues.push('thin product data: add material, size or other attributes');\n\n  const seo_title = cut(c.seo_title || p.title, 60);\n  const meta_description = cut(c.meta_description || description, 155);\n  const tags = [...new Set([...p.tags, ...(c.tags ?? [])].map((t) => String(t).trim().toLowerCase()).filter(Boolean))].slice(0, s.max_tags);\n  const body_html = `<p>${esc(description)}</p>` + (bullets.length ? `\\n<ul>\\n${bullets.map((b) => `  <li>${esc(b)}</li>`).join('\\n')}\\n</ul>` : '');\n\n  return {\n    json: {\n      product_id: p.product_id, title: p.title, source, ai_error,\n      status: issues.length ? 'needs_review' : 'ready', issues,\n      copy: { description, bullets, body_html, seo_title, meta_description, tags, word_count: wc },\n      original_html: p.existing_text,\n      ...(p._run ? { _run: p._run } : {}),\n    },\n  };\n});\n"
      },
      "id": "m22-check-copy",
      "name": "Check Copy",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2040,
        60
      ]
    },
    {
      "parameters": {
        "jsCode": "// Put the drafts in the review queue (nothing is published until someone approves). Dry runs save nothing.\nconst db = $getWorkflowStaticData('global');\ndb.drafts ??= {};\nconst items = $input.all().map((i) => i.json);\nconst run = $('Pick Products').first().json._run;\nconst now = new Date().toISOString();\nconst drafts = items.filter((d) => d.product_id);\nif (!run.dry_run) for (const d of drafts) {\n  const prev = db.drafts[d.product_id];\n  db.drafts[d.product_id] = { ...d, _run: undefined, state: 'pending', created_at: now, version: (prev?.version ?? 0) + 1 };\n}\nreturn [{\n  json: {\n    ...run,\n    drafts: drafts.map(({ _run, ...d }) => d),\n    ready: drafts.filter((d) => d.status === 'ready').length,\n    needs_review: drafts.filter((d) => d.status === 'needs_review').length,\n    review_url: '/webhook/pd/review',\n  },\n}];\n"
      },
      "id": "m22-queue-for-review",
      "name": "Queue for Review",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2260,
        60
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.via_api }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m22-called-via-api",
      "name": "Called via API?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2480,
        -40
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "m22-respond-drafts",
      "name": "Respond Drafts",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        2700,
        -40
      ]
    },
    {
      "parameters": {},
      "id": "m22-notify-reviewer-exercise-1",
      "name": "Notify Reviewer (Exercise 1)",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2700,
        160
      ]
    },
    {
      "parameters": {
        "jsCode": "// The review queue: pending drafts first, with their issues.\nconst q = $('Review API').first().json.query ?? {};\nconst all = Object.values($getWorkflowStaticData('global').drafts ?? {});\nconst state = q.state || 'pending';\nconst list = all.filter((d) => state === 'all' || d.state === state)\n  .sort((a, b) => (a.status === b.status ? 0 : a.status === 'needs_review' ? -1 : 1));\nconst one = q.product_id ? all.find((d) => d.product_id === String(q.product_id)) : null;\nreturn [{ json: q.product_id ? (one ? { found: true, ...one } : { found: false, product_id: q.product_id })\n  : { found: true, state, count: list.length, counts: all.reduce((m, d) => ({ ...m, [d.state]: (m[d.state] ?? 0) + 1 }), {}), drafts: list } }];\n"
      },
      "id": "m22-review-queue",
      "name": "Review Queue",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        400
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {
          "responseCode": "={{ $json.found ? 200 : 404 }}"
        }
      },
      "id": "m22-respond-review",
      "name": "Respond Review",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        920,
        400
      ]
    },
    {
      "parameters": {
        "jsCode": "// Approve (optionally with edits) or reject one draft. Approved drafts go on to Shopify.\nconst b = $('Decide API').first().json.body ?? {};\nconst db = $getWorkflowStaticData('global');\nconst d = (db.drafts ?? {})[String(b.product_id ?? '')];\nif (!d) return [{ json: { found: false, product_id: b.product_id ?? null, publish: false } }];\nif (!['approve', 'reject'].includes(b.decision)) return [{ json: { found: true, error: 'decision must be approve or reject', publish: false } }];\nif (d.state === 'published' && b.decision === 'approve') return [{ json: { found: true, product_id: d.product_id, state: 'published', note: 'already published', publish: false } }];\n\nif (b.decision === 'reject') {\n  d.state = 'rejected'; d.note = String(b.note ?? ''); d.decided_at = new Date().toISOString();\n  return [{ json: { found: true, product_id: d.product_id, state: 'rejected', note: d.note, publish: false } }];\n}\nconst esc = (t) => String(t ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\nconst e = b.edits ?? {};\nconst description = e.description ?? d.copy.description;\nconst bullets = e.bullets ?? d.copy.bullets;\nconst body_html = `<p>${esc(description)}</p>` + (bullets.length ? `\\n<ul>\\n${bullets.map((x) => `  <li>${esc(x)}</li>`).join('\\n')}\\n</ul>` : '');\nconst final = { ...d.copy, description, bullets, body_html, tags: e.tags ?? d.copy.tags };\nd.approved_copy = final; d.edited = Object.keys(e).length > 0;\nreturn [{ json: { found: true, product_id: d.product_id, publish: true, body_html, tags: final.tags.join(', '), edited: d.edited, dry_run: b.dry_run === true } }];\n"
      },
      "id": "m22-apply-decision",
      "name": "Apply Decision",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        600
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.publish && !$json.dry_run }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m22-publish",
      "name": "Publish?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        920,
        600
      ]
    },
    {
      "parameters": {
        "authentication": "accessToken",
        "resource": "product",
        "operation": "update",
        "productId": "={{ $json.product_id }}",
        "updateFields": {
          "body_html": "={{ $json.body_html }}",
          "tags": "={{ $json.tags }}"
        }
      },
      "id": "m22-update-shopify-product",
      "name": "Update Shopify Product",
      "type": "n8n-nodes-base.shopify",
      "typeVersion": 1,
      "position": [
        1140,
        540
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Mark the draft published only if Shopify returned the product (with its id).\nconst db = $getWorkflowStaticData('global');\nconst item = $input.first().json;\nconst x = $('Apply Decision').first().json;\nconst d = db.drafts[x.product_id];\nif (x.dry_run) return [{ json: { found: true, product_id: x.product_id, published: false, dry_run: true, body_html: x.body_html, tags: x.tags } }];\nconst ok = Boolean(item.id) && String(item.id) === String(x.product_id);\nif (ok) { d.state = 'published'; d.published_at = new Date().toISOString(); d.last_error = null; }\nelse d.last_error = String(item.error?.message ?? item.error ?? 'Shopify returned no product (check the Shopify credential)').slice(0, 300);\nreturn [{ json: { found: true, product_id: x.product_id, published: ok, state: d.state, edited: x.edited, error: ok ? null : d.last_error } }];\n"
      },
      "id": "m22-record-publish",
      "name": "Record Publish",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1360,
        540
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "m22-respond-published",
      "name": "Respond Published",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1580,
        540
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.publish && $json.dry_run }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m22-dry-run-approve",
      "name": "Dry Run Approve?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1140,
        720
      ]
    },
    {
      "parameters": {
        "jsCode": "// Mark the draft published only if Shopify returned the product (with its id).\nconst db = $getWorkflowStaticData('global');\nconst item = $input.first().json;\nconst x = $('Apply Decision').first().json;\nconst d = db.drafts[x.product_id];\nif (x.dry_run) return [{ json: { found: true, product_id: x.product_id, published: false, dry_run: true, body_html: x.body_html, tags: x.tags } }];\nconst ok = Boolean(item.id) && String(item.id) === String(x.product_id);\nif (ok) { d.state = 'published'; d.published_at = new Date().toISOString(); d.last_error = null; }\nelse d.last_error = String(item.error?.message ?? item.error ?? 'Shopify returned no product (check the Shopify credential)').slice(0, 300);\nreturn [{ json: { found: true, product_id: x.product_id, published: ok, state: d.state, edited: x.edited, error: ok ? null : d.last_error } }];\n"
      },
      "id": "m22-preview-publish",
      "name": "Preview Publish",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1360,
        700
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "m22-respond-preview",
      "name": "Respond Preview",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1580,
        700
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {
          "responseCode": "={{ !$json.found ? 404 : $json.error ? 400 : 200 }}"
        }
      },
      "id": "m22-respond-decision",
      "name": "Respond Decision",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1360,
        860
      ]
    }
  ],
  "connections": {
    "Daily 7 AM": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Review API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Decide API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings (edit me)": {
      "main": [
        [
          {
            "node": "Route",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route": {
      "main": [
        [
          {
            "node": "Products from API?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Review Queue",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Apply Decision",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Products from API?": {
      "main": [
        [
          {
            "node": "Pick Products",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Get Shopify Products",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Shopify Products": {
      "main": [
        [
          {
            "node": "Pick Products",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick Products": {
      "main": [
        [
          {
            "node": "Use AI?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Use AI?": {
      "main": [
        [
          {
            "node": "Write with AI",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Join Branches",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "Write with AI",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Copy Format": {
      "ai_outputParser": [
        [
          {
            "node": "Write with AI",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Write with AI": {
      "main": [
        [
          {
            "node": "Join Branches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Join Branches": {
      "main": [
        [
          {
            "node": "Check Copy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Copy": {
      "main": [
        [
          {
            "node": "Queue for Review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Queue for Review": {
      "main": [
        [
          {
            "node": "Called via API?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Called via API?": {
      "main": [
        [
          {
            "node": "Respond Drafts",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Notify Reviewer (Exercise 1)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Review Queue": {
      "main": [
        [
          {
            "node": "Respond Review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apply Decision": {
      "main": [
        [
          {
            "node": "Publish?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Publish?": {
      "main": [
        [
          {
            "node": "Update Shopify Product",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Dry Run Approve?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Shopify Product": {
      "main": [
        [
          {
            "node": "Record Publish",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record Publish": {
      "main": [
        [
          {
            "node": "Respond Published",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dry Run Approve?": {
      "main": [
        [
          {
            "node": "Preview Publish",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond Decision",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Preview Publish": {
      "main": [
        [
          {
            "node": "Respond Preview",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}