AI Product Description Generator
Module 22 · ai · intermediate
learner.json
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(/ /g, ' ').replace(/&/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, '&').replace(/</g, '<').replace(/>/g, '>');\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, '&').replace(/</g, '<').replace(/>/g, '>');\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": {}
}