Proposal Generator
Module 24 · business · intermediate
learner.json
In n8n, copy this file and paste it onto the canvas, or use Import from File.
{
"name": "24 · Proposal Generator",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "prop/create",
"responseMode": "responseNode",
"options": {}
},
"id": "m24-create-api",
"name": "Create API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
180
],
"webhookId": "course-24-create"
},
{
"parameters": {
"httpMethod": "GET",
"path": "prop/view",
"responseMode": "responseNode",
"options": {}
},
"id": "m24-view-api",
"name": "View API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
420
],
"webhookId": "course-24-view"
},
{
"parameters": {
"httpMethod": "POST",
"path": "prop/accept",
"responseMode": "responseNode",
"options": {}
},
"id": "m24-accept-api",
"name": "Accept API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
600
],
"webhookId": "course-24-accept"
},
{
"parameters": {
"httpMethod": "GET",
"path": "prop/status",
"responseMode": "responseNode",
"options": {}
},
"id": "m24-status-api",
"name": "Status API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
780
],
"webhookId": "course-24-status"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "s1",
"name": "business_name",
"value": "Brightline Studio",
"type": "string"
},
{
"id": "s2",
"name": "sender_name",
"value": "Sam Ortiz",
"type": "string"
},
{
"id": "s3",
"name": "owner_email",
"value": "sam@brightline.example",
"type": "string"
},
{
"id": "s4",
"name": "public_base_url",
"value": "http://localhost:5678",
"type": "string"
},
{
"id": "s5",
"name": "currency",
"value": "usd",
"type": "string"
},
{
"id": "s6",
"name": "services",
"value": "={{ [{\"key\": \"website\",\"name\": \"Website design & build\",\"description\": \"A fast, mobile-first website built to turn visitors into enquiries.\",\"tiers\": [{\"name\": \"Starter\",\"price\": 4500,\"weeks\": 4,\"deliverables\": [\"up to 5 pages\",\"mobile-first design\",\"contact form\",\"basic SEO setup\"]},{\"name\": \"Growth\",\"price\": 8500,\"weeks\": 6,\"deliverables\": [\"up to 12 pages\",\"CMS for your team\",\"on-page SEO\",\"analytics and conversion tracking\"]},{\"name\": \"Scale\",\"price\": 15000,\"weeks\": 10,\"deliverables\": [\"custom design system\",\"up to 30 pages\",\"CRM and booking integrations\",\"performance budget\"]}]},{\"key\": \"seo\",\"name\": \"SEO foundation\",\"description\": \"Technical and on-page SEO so the right people can find you.\",\"tiers\": [{\"name\": \"Essentials\",\"price\": 1500,\"weeks\": 3,\"deliverables\": [\"technical audit\",\"fixes for top issues\",\"keyword map for 10 pages\"]},{\"name\": \"Plus\",\"price\": 3000,\"weeks\": 5,\"deliverables\": [\"everything in Essentials\",\"content briefs for 6 articles\",\"local SEO setup\"]}]},{\"key\": \"automation\",\"name\": \"Workflow automation\",\"description\": \"n8n workflows that take repetitive admin off your team's plate.\",\"tiers\": [{\"name\": \"Single workflow\",\"price\": 1800,\"weeks\": 2,\"deliverables\": [\"one workflow end to end\",\"error alerts\",\"handover video\"]},{\"name\": \"Ops package\",\"price\": 4800,\"weeks\": 5,\"deliverables\": [\"up to 4 workflows\",\"shared error handling\",\"team training session\"]}]},{\"key\": \"branding\",\"name\": \"Brand identity\",\"description\": \"A clear, consistent look that makes you recognisable.\",\"tiers\": [{\"name\": \"Refresh\",\"price\": 2500,\"weeks\": 3,\"deliverables\": [\"logo refinement\",\"colour and type system\",\"one-page guidelines\"]},{\"name\": \"Full identity\",\"price\": 6000,\"weeks\": 6,\"deliverables\": [\"new logo suite\",\"brand guidelines\",\"templates for social and documents\"]}]}] }}",
"type": "array"
},
{
"id": "s7",
"name": "bundle_min_services",
"value": 3,
"type": "number"
},
{
"id": "s8",
"name": "bundle_discount_percent",
"value": 10,
"type": "number"
},
{
"id": "s9",
"name": "tax_rate",
"value": 0,
"type": "number"
},
{
"id": "s10",
"name": "deposit_percent",
"value": 50,
"type": "number"
},
{
"id": "s11",
"name": "valid_days",
"value": 14,
"type": "number"
},
{
"id": "s12",
"name": "tone",
"value": "Plain, confident and specific. Short paragraphs. No jargon, no hype.",
"type": "string"
},
{
"id": "s13",
"name": "banned_promises",
"value": "={{ ['guarantee', 'guaranteed', '#1', 'first page of google', 'double your', '100%'] }}",
"type": "array"
},
{
"id": "s14",
"name": "use_ai",
"value": true,
"type": "boolean"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "m24-settings-edit-me",
"name": "Settings (edit me)",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
240,
400
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r1",
"leftValue": "={{ $('Create API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "create"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r2",
"leftValue": "={{ $('View API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "view"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r3",
"leftValue": "={{ $('Accept API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "accept"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r4",
"leftValue": "={{ $('Status API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "status"
}
]
},
"options": {}
},
"id": "m24-route",
"name": "Route",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
460,
400
]
},
{
"parameters": {
"jsCode": "// Normalize the intake (form or API) and build the quote from the service catalog:\n// cheapest tier of each requested service first, then upgrade while the total still fits the budget.\nconst s = $('Settings (edit me)').first().json;\n// (The n8n Form lives in its own workflow, learner-form.json, and posts here: Form Trigger refuses workflows with Respond nodes.)\nconst viaApi = true;\nconst f = $('Create API').first().json.body ?? {};\nconst get = (...ks) => { for (const k of ks) if (f[k] !== undefined && f[k] !== '') return f[k]; return undefined; };\n\n// \"$5k-$10k\", \"10000\", \"under 8k\", \"8,500\" → the upper limit in dollars\nfunction budgetCap(v) {\n if (typeof v === 'number') return v;\n const nums = [...String(v ?? '').toLowerCase().replace(/,/g, '').matchAll(/(\\d+(?:\\.\\d+)?)\\s*(k)?/g)].map((m) => Number(m[1]) * (m[2] ? 1000 : 1));\n return nums.length ? Math.max(...nums) : null;\n}\nconst wanted = (Array.isArray(get('services')) ? get('services') : String(get('services') ?? '').split(','))\n .map((x) => String(x).trim().toLowerCase()).filter(Boolean)\n .map((x) => s.services.find((sv) => sv.key === x || sv.name.toLowerCase() === x)).filter(Boolean);\nconst lead = {\n client: String(get('client') ?? '').trim(),\n company: String(get('company') ?? '').trim(),\n email: String(get('email') ?? '').trim().toLowerCase(),\n goals: String(get('goals') ?? '').trim(),\n notes: String(get('notes') ?? '').trim(),\n budget_text: String(get('budget') ?? ''),\n timeline_weeks: Number(String(get('timeline_weeks') ?? '').replace(/\\D/g, '')) || null,\n};\nconst errors = [];\nif (!lead.client) errors.push('client name is required');\nif (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(lead.email)) errors.push('a valid email is required');\nif (!wanted.length) errors.push(`pick at least one service: ${s.services.map((x) => x.key).join(', ')}`);\n\nconst cap = budgetCap(lead.budget_text);\nconst discountPct = wanted.length >= s.bundle_min_services ? s.bundle_discount_percent : 0;\nconst priceOf = (sel) => { const sub = sel.reduce((t, x) => t + x.tier.price, 0); return Math.round(sub * (1 - discountPct / 100) * (1 + s.tax_rate / 100)); };\nconst sel = wanted.map((sv) => ({ service: sv, i: 0, tier: sv.tiers[0] }));\nif (cap) {\n for (let upgraded = true; upgraded;) {\n upgraded = false;\n for (const x of sel) {\n if (x.i + 1 >= x.service.tiers.length) continue;\n const trial = sel.map((y) => (y === x ? { ...y, tier: y.service.tiers[y.i + 1] } : y));\n if (priceOf(trial) <= cap) { x.i++; x.tier = x.service.tiers[x.i]; upgraded = true; }\n }\n }\n}\nconst items = sel.map((x) => ({ service: x.service.key, name: `${x.service.name}: ${x.tier.name}`, description: x.service.description,\n deliverables: x.tier.deliverables, weeks: x.tier.weeks, price: x.tier.price,\n upgrade: x.service.tiers[x.i + 1] ? { name: x.service.tiers[x.i + 1].name, price: x.service.tiers[x.i + 1].price } : null }));\nconst subtotal = items.reduce((t, x) => t + x.price, 0);\nconst discount = Math.round(subtotal * discountPct / 100);\nconst tax = Math.round((subtotal - discount) * s.tax_rate / 100);\nconst total = subtotal - discount + tax;\nconst weeks = items.reduce((t, x) => t + x.weeks, 0); // phases run one after another\nconst flags = [];\nif (cap && total > cap) flags.push(`over budget: the smallest option is ${total} vs a budget of ${cap}`);\nif (!cap) flags.push('no budget given: quoted the entry tier of each service');\nif (lead.timeline_weeks && weeks > lead.timeline_weeks) flags.push(`timeline: ${weeks} weeks of work vs ${lead.timeline_weeks} requested`);\n\nconst now = f.now || new Date().toISOString(); // tests inject the clock\nreturn [{\n json: {\n ok: errors.length === 0, errors, lead, budget_cap: cap,\n quote: { items, subtotal, discount_percent: discountPct, discount, tax_rate: s.tax_rate, tax, total, deposit: Math.round(total * s.deposit_percent / 100), weeks, currency: s.currency },\n flags, created_at: now, valid_until: new Date(new Date(now).getTime() + s.valid_days * 864e5).toISOString().slice(0, 10),\n dry_run: viaApi && f.dry_run === true, send_email: !(viaApi && f.send_email === false), via_api: viaApi,\n use_ai: s.use_ai && !(viaApi && (f.ai === false || f.sections)), supplied_sections: viaApi ? f.sections ?? null : null,\n },\n}];\n"
},
"id": "m24-build-quote",
"name": "Build Quote",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
60
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.ok }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m24-valid-intake",
"name": "Valid Intake?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
920,
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": "m24-invalid-via-api",
"name": "Invalid via API?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1140,
240
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ok: false, errors: $json.errors } }}",
"options": {
"responseCode": 400
}
},
"id": "m24-respond-invalid",
"name": "Respond Invalid",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1360,
240
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.use_ai }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m24-use-ai",
"name": "Use AI?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1140,
40
]
},
{
"parameters": {
"promptType": "define",
"text": "=Write the narrative sections of a sales proposal from {{ $('Settings (edit me)').first().json.business_name }}.\n\nClient: {{ $json.lead.client }}{{ $json.lead.company ? ' (' + $json.lead.company + ')' : '' }}\nTheir goals, in their words: {{ $json.lead.goals || '(not given)' }}\nOther notes: {{ $json.lead.notes || '(none)' }}\nWhat we're proposing (fixed, don't change): {{ $json.quote.items.map(i => i.name + ' - ' + i.deliverables.join(', ') + ' (' + i.weeks + ' weeks)').join('; ') }}\n\nTone: {{ $('Settings (edit me)').first().json.tone }}\n\nRules:\n- summary: 2 sentences on the outcome for the client.\n- understanding: restate their goals and constraints in 2-3 sentences. Don't invent facts about their business.\n- approach: one short paragraph per item above, in the same order, about how we'll deliver it.\n- next_steps: 1-2 sentences inviting them to accept and book a kickoff.\n- Do NOT mention any prices, discounts or dates; the pricing table is added separately.\n- Never promise results you can't control (rankings, revenue, \"guaranteed\").",
"hasOutputParser": true
},
"id": "m24-write-sections",
"name": "Write Sections",
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"typeVersion": 1.9,
"position": [
1360,
-100
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "gpt-5-mini",
"cachedResultName": "gpt-5-mini"
},
"options": {}
},
"id": "m24-openai-chat-model",
"name": "OpenAI Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.3,
"position": [
1300,
100
]
},
{
"parameters": {
"schemaType": "fromJson",
"jsonSchemaExample": "{\n \"summary\": \"A faster, clearer website that turns more visitors into booked calls.\",\n \"understanding\": \"You want more qualified enquiries and a site your team can update without a developer.\",\n \"approach\": [\n \"We start with a short discovery workshop, then design and build in weekly iterations.\"\n ],\n \"next_steps\": \"Accept below and we'll book a kickoff call this week.\"\n}"
},
"id": "m24-sections-format",
"name": "Sections Format",
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
"typeVersion": 1.3,
"position": [
1460,
100
]
},
{
"parameters": {},
"id": "m24-join-branches",
"name": "Join Branches",
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [
1600,
40
]
},
{
"parameters": {
"jsCode": "// Final text (AI, supplied or template) + guardrails, then the hosted proposal page. Saves it unless dry run.\nconst s = $('Settings (edit me)').first().json;\nconst item = $input.first();\nconst fromAi = item.json.ok === undefined; // the AI chain only outputs { output } or { error }\nconst q = fromAi ? $('Build Quote').first().json : item.json;\nconst ai = fromAi ? item.json.output : null;\nconst money = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: q.quote.currency.toUpperCase(), maximumFractionDigits: 0 }).format(n);\nconst esc = (t) => String(t ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"');\nconst first = q.lead.client.split(' ')[0];\nconst who = q.lead.company || q.lead.client;\n\nconst template = {\n summary: `${who} wants to ${q.lead.goals ? q.lead.goals.replace(/\\.$/, '').replace(/^./, (c) => c.toLowerCase()) : 'move a key project forward'}. This proposal covers ${q.quote.items.map((i) => i.name).join(' and ')}, delivered in about ${q.quote.weeks} weeks.`,\n understanding: q.lead.goals ? `From our conversation: ${q.lead.goals}${q.lead.notes ? ' ' + q.lead.notes : ''}` : `We'll confirm goals and success measures in the kickoff call.`,\n approach: q.quote.items.map((i) => `${i.name}: ${i.description} Includes ${i.deliverables.join(', ')}.`),\n next_steps: `Accept this proposal below, and we'll send the deposit invoice and book a kickoff call within two business days.`,\n};\nlet text, source, ai_error;\nif (q.supplied_sections) { text = { ...template, ...q.supplied_sections }; source = 'supplied'; }\nelse if (ai?.summary) { text = { ...template, ...ai }; source = 'ai'; }\nelse { text = template; source = 'template'; if (fromAi) ai_error = String(item.json.error?.message ?? item.json.error ?? 'no AI output').slice(0, 200); }\n\n// Guardrails: prices only come from the quote; no promises we can't keep.\nconst issues = [];\nconst allowed = new Set([q.quote.subtotal, q.quote.discount, q.quote.tax, q.quote.total, q.quote.deposit, ...q.quote.items.map((i) => i.price)].map(String));\nfor (const key of ['summary', 'understanding', 'next_steps']) {\n const bad = (String(text[key]).match(/\\$\\s?[\\d,]+(?:\\.\\d+)?k?/gi) ?? []).filter((m) => !allowed.has(m.replace(/[$,\\s]/g, '').replace(/k$/i, '000')));\n if (bad.length) { issues.push(`${key}: price not in the quote (${bad.join(', ')}), replaced with the template text`); text[key] = template[key]; }\n}\ntext.approach = (Array.isArray(text.approach) ? text.approach : [String(text.approach)]).map((p, i) => {\n const bad = (String(p).match(/\\$\\s?[\\d,]+/g) ?? []).filter((m) => !allowed.has(m.replace(/[$,\\s]/g, '')));\n if (bad.length) { issues.push(`approach ${i + 1}: price not in the quote (${bad.join(', ')}), replaced`); return template.approach[i] ?? ''; }\n return p;\n});\nconst blob = [text.summary, text.understanding, ...text.approach, text.next_steps].join(' ');\nfor (const p of s.banned_promises) if (new RegExp(`\\\\b${p}\\\\b`, 'i').test(blob)) issues.push(`promise to check: \"${p}\"`);\n\nconst id = `P-${q.created_at.slice(0, 10).replace(/-/g, '')}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`;\nconst token = Array.from({ length: 3 }, () => Math.random().toString(36).slice(2)).join('').slice(0, 24);\nconst link = `${s.public_base_url}/webhook/prop/view?id=${id}&t=${token}`;\nconst rows = q.quote.items.map((i) => `<tr><td><strong>${esc(i.name)}</strong><br><small>${i.deliverables.map(esc).join(' · ')}</small></td><td>${i.weeks} wk</td><td class=\"r\">${money(i.price)}</td></tr>`).join('');\nconst html = `<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Proposal for ${esc(who)}</title>\n<style>body{font-family:system-ui,-apple-system,sans-serif;max-width:760px;margin:2rem auto;padding:0 1rem;color:#1d2433;line-height:1.55}h1{margin-bottom:.2rem}.muted{color:#667}table{width:100%;border-collapse:collapse;margin:1rem 0}td,th{padding:.6rem .4rem;border-bottom:1px solid #e3e6ee;vertical-align:top;text-align:left}.r{text-align:right;white-space:nowrap}.total td{font-weight:700;border-top:2px solid #1d2433}.box{background:#f5f7fb;border-radius:10px;padding:1rem 1.2rem;margin:1.5rem 0}button{background:#1d4ed8;color:#fff;border:0;border-radius:8px;padding:.7rem 1.4rem;font-size:1rem;cursor:pointer}input[type=text]{padding:.55rem;border:1px solid #c9cedb;border-radius:6px;width:100%;max-width:320px}@media(prefers-color-scheme:dark){body{background:#12151c;color:#e6e9ef}.box{background:#1c212c}td,th{border-color:#2a3040}.total td{border-color:#e6e9ef}.muted{color:#9aa3b2}}</style></head><body>\n<!--BANNER-->\n<p class=\"muted\">${esc(s.business_name)} · Proposal ${id} · valid until ${q.valid_until}</p>\n<h1>Proposal for ${esc(who)}</h1><p class=\"muted\">Prepared for ${esc(q.lead.client)}</p>\n<h2>Summary</h2><p>${esc(text.summary)}</p>\n<h2>What we heard</h2><p>${esc(text.understanding)}</p>\n<h2>Our approach</h2>${text.approach.map((p) => `<p>${esc(p)}</p>`).join('')}\n<h2>Investment</h2><table><tr><th>Item</th><th>Time</th><th class=\"r\">Price</th></tr>${rows}\n${q.quote.discount ? `<tr><td>Bundle discount (${q.quote.discount_percent}%)</td><td></td><td class=\"r\">−${money(q.quote.discount)}</td></tr>` : ''}\n${q.quote.tax ? `<tr><td>Tax (${q.quote.tax_rate}%)</td><td></td><td class=\"r\">${money(q.quote.tax)}</td></tr>` : ''}\n<tr class=\"total\"><td>Total</td><td>${q.quote.weeks} wk</td><td class=\"r\">${money(q.quote.total)}</td></tr></table>\n<p>${s.deposit_percent}% deposit (${money(q.quote.deposit)}) to start, the rest on completion.</p>\n<h2>Next steps</h2><p>${esc(text.next_steps)}</p>\n<div class=\"box\"><!--ACCEPT--></div>\n</body></html>`;\n\nconst record = { id, token, status: 'draft', lead: q.lead, quote: q.quote, flags: q.flags, issues, source, text, html, link,\n created_at: q.created_at, valid_until: q.valid_until, views: 0, first_viewed_at: null, sent_at: null };\nif (q.ok && !q.dry_run) ($getWorkflowStaticData('global').proposals ??= {})[id] = record;\n\nreturn [{ json: { ok: q.ok, errors: q.errors, dry_run: q.dry_run, send_email: q.send_email, via_api: q.via_api, ai_error,\n ...(q.ok ? { id, link, status: 'draft', source, flags: q.flags, issues, needs_review: issues.length > 0 || q.flags.length > 0, quote: q.quote, text, valid_until: q.valid_until,\n email: { to: q.lead.email, subject: `Your proposal from ${s.business_name}`, body: `Hi ${first},\\n\\nThanks for the conversation. Here's your proposal for ${q.quote.items.map((i) => i.name).join(' and ')}:\\n${link}\\n\\nIt's valid until ${q.valid_until}. You can accept it right on the page, or reply with any questions.\\n\\n${s.sender_name}\\n${s.business_name}` },\n html: q.dry_run ? html : undefined } : {}) } }];\n"
},
"id": "m24-assemble-proposal",
"name": "Assemble Proposal",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1820,
40
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.dry_run }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m24-dry-run",
"name": "Dry Run?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2040,
40
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"id": "m24-respond-preview",
"name": "Respond Preview",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2260,
-100
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.send_email }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m24-email-it",
"name": "Email It?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2260,
60
]
},
{
"parameters": {
"sendTo": "={{ $json.email.to }}",
"subject": "={{ $json.email.subject }}",
"emailType": "text",
"message": "={{ $json.email.body }}",
"options": {
"appendAttribution": false,
"senderName": "={{ $('Settings (edit me)').first().json.sender_name }}"
}
},
"id": "m24-email-proposal",
"name": "Email Proposal",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.1,
"position": [
2480,
-20
],
"onError": "continueRegularOutput",
"webhookId": "course-24-gmail"
},
{
"parameters": {
"jsCode": "// Mark the proposal sent only if Gmail returned a message id.\nconst x = $('Assemble Proposal').first().json;\nconst res = $input.first().json;\nconst sent = Boolean(res.id) && !res.ok; // Gmail's response has id/threadId; without a credential the input item passes through\nconst p = $getWorkflowStaticData('global').proposals[x.id];\nif (sent) { p.status = 'sent'; p.sent_at = new Date().toISOString(); }\nelse p.send_error = String(res.error?.message ?? res.error ?? 'no message id returned (check the Gmail credential)').slice(0, 300);\nconst { html, ...rest } = x;\nreturn [{ json: { ...rest, status: p.status, sent, send_error: sent ? null : p.send_error } }];\n"
},
"id": "m24-record-sent",
"name": "Record Sent",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2700,
-20
]
},
{
"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": "m24-created-via-api",
"name": "Created via API?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2920,
60
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ (({ html, ...rest }) => rest)($json) }}",
"options": {}
},
"id": "m24-respond-created",
"name": "Respond Created",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
3140,
60
]
},
{
"parameters": {
"jsCode": "// Serve the proposal page (token required), count views, show the accept form or the current state.\nconst q = $('View API').first().json.query ?? {};\nconst p = ($getWorkflowStaticData('global').proposals ?? {})[String(q.id ?? '')];\nconst page = (title, msg) => `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title></head><body style=\"font-family:system-ui;max-width:560px;margin:4rem auto;padding:0 1rem\"><h1>${title}</h1><p>${msg}</p></body></html>`;\nif (!p) return [{ json: { code: 404, html: page('Proposal not found', 'Check the link in your email.') } }];\nif (q.t !== p.token) return [{ json: { code: 403, html: page('Link not valid', 'Please use the link from your email.') } }];\nconst today = String(q.now || new Date().toISOString()).slice(0, 10);\nconst expired = p.status !== 'accepted' && today > p.valid_until;\nif (q.preview !== '1') { p.views += 1; p.first_viewed_at ??= new Date().toISOString(); p.last_viewed_at = new Date().toISOString(); }\nconst s = $('Settings (edit me)').first().json;\nconst banner = expired ? `<p style=\"background:#fde68a;color:#422;padding:.8rem 1rem;border-radius:8px\">This proposal expired on ${p.valid_until}. Reply to the email and we'll refresh it.</p>`\n : p.status === 'accepted' ? `<p style=\"background:#bbf7d0;color:#123;padding:.8rem 1rem;border-radius:8px\">Accepted by ${p.accepted_by} on ${p.accepted_at.slice(0, 10)}. Thank you!</p>` : '';\nconst accept = expired || p.status === 'accepted' ? `<p>Questions? Reply to the email or write to ${s.owner_email}.</p>`\n : `<form method=\"post\" action=\"${s.public_base_url}/webhook/prop/accept\"><input type=\"hidden\" name=\"id\" value=\"${p.id}\"><input type=\"hidden\" name=\"t\" value=\"${p.token}\">\n<p><strong>Ready to go?</strong> Type your full name to accept this proposal and its terms.</p><p><input type=\"text\" name=\"name\" required placeholder=\"Full name\" autocomplete=\"name\"></p>\n<p><label><input type=\"checkbox\" name=\"agree\" value=\"yes\" required> I accept the scope and the ${s.deposit_percent}% deposit</label></p><button type=\"submit\">Accept proposal</button></form>`;\nreturn [{ json: { code: 200, html: p.html.replace('<!--BANNER-->', banner).replace('<!--ACCEPT-->', accept) } }];\n"
},
"id": "m24-render-proposal",
"name": "Render Proposal",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
420
]
},
{
"parameters": {
"respondWith": "text",
"responseBody": "={{ $json.html }}",
"options": {
"responseCode": "={{ $json.code }}",
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "text/html; charset=utf-8"
}
]
}
}
},
"id": "m24-respond-page",
"name": "Respond Page",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
920,
420
]
},
{
"parameters": {
"jsCode": "// Accept from the page's form (POST, so link scanners in email can't accept by visiting a URL).\nconst b = $('Accept API').first().json.body ?? {};\nconst q = $('Accept API').first().json.query ?? {};\nconst p = ($getWorkflowStaticData('global').proposals ?? {})[String(b.id ?? '')];\nconst today = String(q.now || new Date().toISOString()).slice(0, 10);\nconst fail = (code, title, msg) => [{ json: { accepted: false, code, title, msg } }];\nif (!p) return fail(404, 'Proposal not found', 'Check the link in your email.');\nif (b.t !== p.token) return fail(403, 'Link not valid', 'Please use the link from your email.');\nif (p.status === 'accepted') return fail(409, 'Already accepted', `Accepted by ${p.accepted_by} on ${p.accepted_at.slice(0, 10)}.`);\nif (today > p.valid_until) return fail(410, 'Proposal expired', `It expired on ${p.valid_until}. Reply to the email and we'll refresh it.`);\nconst name = String(b.name ?? '').trim();\nif (name.length < 2 || b.agree !== 'yes') return fail(400, 'Almost there', 'Type your full name and tick the box to accept.');\np.status = 'accepted'; p.accepted_by = name; p.accepted_at = new Date().toISOString();\np.accepted_from = $('Accept API').first().json.headers?.['x-forwarded-for'] ?? null;\nconst s = $('Settings (edit me)').first().json;\nconst money = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: p.quote.currency.toUpperCase(), maximumFractionDigits: 0 }).format(n);\nreturn [{ json: { accepted: true, code: 200, id: p.id, title: 'Thank you!', msg: `Proposal accepted. We'll send the deposit invoice (${money(p.quote.deposit)}) and a kickoff invite shortly.`,\n notify: { to: s.owner_email, subject: `✅ ${p.lead.company || p.lead.client} accepted ${p.id} (${money(p.quote.total)})`,\n body: `${name} accepted proposal ${p.id} for ${p.quote.items.map((i) => i.name).join(', ')}.\\nTotal ${money(p.quote.total)}, deposit ${money(p.quote.deposit)}.\\nClient: ${p.lead.client} <${p.lead.email}>\\nViewed ${p.views} time(s).` } } }];\n"
},
"id": "m24-accept-proposal",
"name": "Accept Proposal",
"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.accepted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m24-accepted",
"name": "Accepted?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
920,
600
]
},
{
"parameters": {
"sendTo": "={{ $json.notify.to }}",
"subject": "={{ $json.notify.subject }}",
"emailType": "text",
"message": "={{ $json.notify.body }}",
"options": {
"appendAttribution": false
}
},
"id": "m24-notify-owner",
"name": "Notify Owner",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.1,
"position": [
1140,
560
],
"onError": "continueRegularOutput",
"webhookId": "course-24-gmail-owner"
},
{
"parameters": {
"respondWith": "text",
"responseBody": "={{ '<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>' + $('Accept Proposal').first().json.title + '</title></head><body style=\"font-family:system-ui;max-width:560px;margin:4rem auto;padding:0 1rem\"><h1>' + $('Accept Proposal').first().json.title + '</h1><p>' + $('Accept Proposal').first().json.msg + '</p></body></html>' }}",
"options": {
"responseCode": "={{ $('Accept Proposal').first().json.code }}",
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "text/html; charset=utf-8"
}
]
}
}
},
"id": "m24-respond-thanks",
"name": "Respond Thanks",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1360,
560
]
},
{
"parameters": {
"respondWith": "text",
"responseBody": "={{ '<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>' + $('Accept Proposal').first().json.title + '</title></head><body style=\"font-family:system-ui;max-width:560px;margin:4rem auto;padding:0 1rem\"><h1>' + $('Accept Proposal').first().json.title + '</h1><p>' + $('Accept Proposal').first().json.msg + '</p></body></html>' }}",
"options": {
"responseCode": "={{ $('Accept Proposal').first().json.code }}",
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "text/html; charset=utf-8"
}
]
}
}
},
"id": "m24-respond-not-accepted",
"name": "Respond Not Accepted",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1140,
720
]
},
{
"parameters": {
"jsCode": "// Proposal status for the owner (no HTML, no token): by ?id=, or the latest one for ?email=.\nconst q = $('Status API').first().json.query ?? {};\nconst all = Object.values($getWorkflowStaticData('global').proposals ?? {});\nconst p = q.id ? all.find((x) => x.id === String(q.id))\n : all.filter((x) => x.lead.email === String(q.email ?? '').toLowerCase()).sort((a, b) => (a.created_at < b.created_at ? 1 : -1))[0];\nif (!p) return [{ json: { found: false, id: q.id ?? null, email: q.email ?? null } }];\nconst { html, token, text, ...rest } = p;\nreturn [{ json: { found: true, ...rest } }];\n"
},
"id": "m24-proposal-status",
"name": "Proposal Status",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
800
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {
"responseCode": "={{ $json.found ? 200 : 404 }}"
}
},
"id": "m24-respond-status",
"name": "Respond Status",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
920,
800
]
}
],
"connections": {
"Create API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"View API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Accept API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Status API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Settings (edit me)": {
"main": [
[
{
"node": "Route",
"type": "main",
"index": 0
}
]
]
},
"Route": {
"main": [
[
{
"node": "Build Quote",
"type": "main",
"index": 0
}
],
[
{
"node": "Render Proposal",
"type": "main",
"index": 0
}
],
[
{
"node": "Accept Proposal",
"type": "main",
"index": 0
}
],
[
{
"node": "Proposal Status",
"type": "main",
"index": 0
}
]
]
},
"Build Quote": {
"main": [
[
{
"node": "Valid Intake?",
"type": "main",
"index": 0
}
]
]
},
"Valid Intake?": {
"main": [
[
{
"node": "Use AI?",
"type": "main",
"index": 0
}
],
[
{
"node": "Invalid via API?",
"type": "main",
"index": 0
}
]
]
},
"Invalid via API?": {
"main": [
[
{
"node": "Respond Invalid",
"type": "main",
"index": 0
}
]
]
},
"Use AI?": {
"main": [
[
{
"node": "Write Sections",
"type": "main",
"index": 0
}
],
[
{
"node": "Join Branches",
"type": "main",
"index": 1
}
]
]
},
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "Write Sections",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Sections Format": {
"ai_outputParser": [
[
{
"node": "Write Sections",
"type": "ai_outputParser",
"index": 0
}
]
]
},
"Write Sections": {
"main": [
[
{
"node": "Join Branches",
"type": "main",
"index": 0
}
]
]
},
"Join Branches": {
"main": [
[
{
"node": "Assemble Proposal",
"type": "main",
"index": 0
}
]
]
},
"Assemble Proposal": {
"main": [
[
{
"node": "Dry Run?",
"type": "main",
"index": 0
}
]
]
},
"Dry Run?": {
"main": [
[
{
"node": "Respond Preview",
"type": "main",
"index": 0
}
],
[
{
"node": "Email It?",
"type": "main",
"index": 0
}
]
]
},
"Email It?": {
"main": [
[
{
"node": "Email Proposal",
"type": "main",
"index": 0
}
],
[
{
"node": "Created via API?",
"type": "main",
"index": 0
}
]
]
},
"Email Proposal": {
"main": [
[
{
"node": "Record Sent",
"type": "main",
"index": 0
}
]
]
},
"Record Sent": {
"main": [
[
{
"node": "Created via API?",
"type": "main",
"index": 0
}
]
]
},
"Created via API?": {
"main": [
[
{
"node": "Respond Created",
"type": "main",
"index": 0
}
]
]
},
"Render Proposal": {
"main": [
[
{
"node": "Respond Page",
"type": "main",
"index": 0
}
]
]
},
"Accept Proposal": {
"main": [
[
{
"node": "Accepted?",
"type": "main",
"index": 0
}
]
]
},
"Accepted?": {
"main": [
[
{
"node": "Notify Owner",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond Not Accepted",
"type": "main",
"index": 0
}
]
]
},
"Notify Owner": {
"main": [
[
{
"node": "Respond Thanks",
"type": "main",
"index": 0
}
]
]
},
"Proposal Status": {
"main": [
[
{
"node": "Respond Status",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"pinData": {}
}