{
  "id": "3en3fyiTwfBWpwmx",
  "name": "PPWR Data Screener: collects packaging declarations from vendors, validates and follows up.",
  "tags": [],
  "nodes": [
    {
      "id": "sticky-readme",
      "name": "README",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        0
      ],
      "parameters": {
        "width": 560,
        "height": 1200,
        "content": "## PPWR Data Screener: collects packaging declarations from vendors, validates and follows up.\n\n### Who's it for\n\nAnyone who has to get a declaration of conformity out of every packaging supplier and check each one.\n\n### How it works\n\n**Send.** Upload a CSV of suppliers and items. Every row gets an email with a link to the form.\n\n**Screen.** The supplier fills the form and attaches their declaration. An agent reads the document and a checklist in one Code node decides. **No model produces a pass or a fail.**\n\n**Answer.** Complete, and they get a confirmation. Not complete, and they get a named list of what is missing plus the form link again.\n\n### How to set up\n\nDrag your chat model onto the two agent nodes and add an SMTP credential to the two email nodes. Open **Configuration** and paste the form URL from **Supplier declaration form**.\n\n### Requirements\n\nA chat model of your choice, an SMTP credential, and n8n 1.7 or newer for the agent node.\n\n---\n\n**This checks declarations for completeness and consistency. It is not a conformity assessment and it is not legal advice.** The checklist here is an example. Read it and change it before you point it at real suppliers.\n\n**It keeps no record and the form link is not private.**"
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-send",
      "name": "Lane: Send",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1408,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 1616,
        "height": 464,
        "content": "### Send\n\nUpload a CSV, everybody on it gets a link.\n\nColumns: `supplier_name`, `contact_email`, `internal_sku`, and `description` if you have it. A row that cannot be used is reported by line number and the rest still go out."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-start",
      "name": "Lane: Start",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        624,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 720,
        "height": 688,
        "content": "### Start\n\nTwo ways in. You upload a CSV of suppliers, or a supplier submits their declaration. Both land on **Configuration**, which every later node reads.\n\nOne supplier, one item, one attachment. The form is the only thing they ever see."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-read",
      "name": "Lane: Read",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1408,
        752
      ],
      "parameters": {
        "color": 7,
        "width": 944,
        "height": 768,
        "content": "### Read\n\nThe agent reports what the document says. It does not decide whether anything passes.\n\n**Drag your own chat model onto it.** Any of them works, and swapping it cannot change a verdict."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-decide",
      "name": "Lane: Decide",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2416,
        752
      ],
      "parameters": {
        "color": 7,
        "width": 496,
        "height": 768,
        "content": "### Decide\n\nOne Code node holds the checklist. Same submission, same answer, every time."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-answer",
      "name": "Lane: Answer",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2976,
        752
      ],
      "parameters": {
        "color": 7,
        "width": 1168,
        "height": 768,
        "content": "### Answer\n\nComplete, and they get a confirmation.\n\nNot complete, and they get a named list plus the form link again, so a resubmission is one click and not an email thread."
      },
      "typeVersion": 1
    },
    {
      "id": "node-list-form",
      "name": "Load your supplier list",
      "type": "n8n-nodes-base.formTrigger",
      "notes": "This is the one you open. The supplier never sees it.\n\nOne email per row, so a supplier with forty items gets forty emails. Group them in Build the invitation if that matters to you.",
      "position": [
        704,
        240
      ],
      "parameters": {
        "options": {
          "buttonLabel": "Send the requests",
          "appendAttribution": false
        },
        "formTitle": "Send declaration requests",
        "formFields": {
          "values": [
            {
              "fieldType": "file",
              "fieldLabel": "Supplier list",
              "requiredField": true,
              "acceptFileTypes": ".csv"
            }
          ]
        },
        "formDescription": "Upload a CSV of the suppliers and items you need a declaration for. Everybody on it gets an email with a link to the form.\n\nColumns: supplier_name, contact_email, internal_sku, description."
      },
      "typeVersion": 2.2
    },
    {
      "id": "node-find-list",
      "name": "Find the list",
      "type": "n8n-nodes-base.code",
      "notes": "Form uploads land under a key that has moved between n8n versions. This takes whatever arrived.",
      "position": [
        1488,
        240
      ],
      "parameters": {
        "jsCode": "const uploaded = $('Load your supplier list').first().binary || {};\nconst keys = Object.keys(uploaded);\n\nif (keys.length === 0) throw new Error('No file was uploaded, so there is nothing to send.');\n\nreturn [{ json: { filename: uploaded[keys[0]].fileName || null }, binary: { data: uploaded[keys[0]] } }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "node-csv",
      "name": "CSV to rows",
      "type": "n8n-nodes-base.extractFromFile",
      "notes": "One item per row, with the header names as keys. Capitalisation and spacing in the header do not matter; the next node normalises them.",
      "position": [
        1712,
        240
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 1
    },
    {
      "id": "node-check-list",
      "name": "Check the list",
      "type": "n8n-nodes-base.code",
      "notes": "The whole file gets checked before anything is sent.\n\nA row with no email, or the same reference twice for one supplier, is reported by line number and dropped. The rest still go out. A file where nothing is usable stops here with the reasons.",
      "position": [
        1936,
        240
      ],
      "parameters": {
        "jsCode": "const REQUIRED = ['supplier_name', 'contact_email', 'internal_sku'];\nconst EMAIL = /^[^@\\s]+@[^@\\s.]+\\.[^@\\s]+$/;\n\nfunction key(name) {\n  return String(name || '').trim().toLowerCase().replace(/[\\s.-]+/g, '_');\n}\n\nconst rows = $input.all().map((entry) => {\n  const row = {};\n  for (const [name, value] of Object.entries(entry.json)) row[key(name)] = String(value === null || value === undefined ? '' : value).trim();\n  return row;\n});\n\nconst invite = [];\nconst rejected = [];\nconst seen = new Set();\n\nrows.forEach((row, index) => {\n  const line = index + 2;\n  const problems = [];\n\n  for (const name of REQUIRED) {\n    if (row[name] === undefined || row[name] === '') problems.push(`no ${name.replace(/_/g, ' ')}`);\n  }\n  if (row.contact_email && !EMAIL.test(row.contact_email)) problems.push(`'${row.contact_email}' is not an email address`);\n\n  const fingerprint = `${String(row.contact_email || '').toLowerCase()}|${String(row.internal_sku || '').toLowerCase()}`;\n  if (problems.length === 0 && seen.has(fingerprint)) problems.push(`${row.internal_sku} appears more than once for ${row.contact_email}`);\n\n  if (problems.length > 0) {\n    rejected.push({ line, internal_sku: row.internal_sku || null, reason: problems.join('; ') });\n    return;\n  }\n\n  seen.add(fingerprint);\n  invite.push({\n    line,\n    supplier_name: row.supplier_name,\n    contact_email: String(row.contact_email).toLowerCase(),\n    internal_sku: row.internal_sku,\n    description: row.description || '',\n  });\n});\n\nif (invite.length === 0) {\n  throw new Error(`Nothing in that file can be sent. ${rejected.map((r) => `line ${r.line}: ${r.reason}`).join(' | ')}`);\n}\n\nreturn invite.map((row) => ({ json: Object.assign({ rejected, invite_count: invite.length }, row) }));\n"
      },
      "typeVersion": 2
    },
    {
      "id": "node-invitation",
      "name": "Build the invitation",
      "type": "n8n-nodes-base.code",
      "notes": "One email per row, carrying the form link and their reference.\n\nRuns once over every row, not once per row. A Code node set to run per item cannot call .first() on another node, and this one reads Configuration.\n\nThere is no token on the link. Anyone holding it can submit against any reference, which is fine for a handful of suppliers you already know.",
      "position": [
        2160,
        240
      ],
      "parameters": {
        "jsCode": "const config = $('Configuration').first().json;\n\nreturn $input.all().map((entry) => {\n  const row = entry.json;\n\n  const body = [\n    `Hello ${row.supplier_name},`,\n    '',\n    `${config.company_name} needs a packaging declaration for this item:`,\n    '',\n    `Reference: ${row.internal_sku}`,\n    row.description ? `Description: ${row.description}` : null,\n    '',\n    'Please fill in the short form below and attach your declaration of conformity. It takes a couple of minutes.',\n    '',\n    config.form_url,\n    '',\n    `Use ${row.internal_sku} as the reference on the form so we can match it up.`,\n    '',\n    config.disclaimer,\n  ]\n    .filter((line) => line !== null)\n    .join('\\n');\n\n  return {\n    json: {\n      to: row.contact_email,\n      subject: `Packaging declaration needed for ${row.internal_sku}`,\n      body,\n    },\n  };\n});\n"
      },
      "typeVersion": 2
    },
    {
      "id": "node-send-request",
      "name": "Send the request",
      "type": "n8n-nodes-base.emailSend",
      "notes": "Swap for Gmail, Outlook or anything else. The body arrives as plain text, already assembled.",
      "position": [
        2384,
        240
      ],
      "parameters": {
        "text": "={{ $json.body }}",
        "options": {},
        "subject": "={{ $json.subject }}",
        "toEmail": "={{ $json.to }}",
        "fromEmail": "={{ $('Configuration').first().json.from_email }}",
        "emailFormat": "text"
      },
      "typeVersion": 2.1
    },
    {
      "id": "node-invite-summary",
      "name": "What went out",
      "type": "n8n-nodes-base.code",
      "notes": "What you get back: how many went, and every row that did not, with its line number.",
      "position": [
        2608,
        240
      ],
      "parameters": {
        "jsCode": "const checked = $('Check the list').first().json;\nconst sent = $input.all().length;\n\nconst lines = [\n  `Sent: ${sent}`,\n  checked.rejected.length > 0 ? `Rejected: ${checked.rejected.length}` : null,\n]\n  .filter((line) => line !== null)\n  .concat(checked.rejected.map((r) => `  line ${r.line}${r.internal_sku ? ` (${r.internal_sku})` : ''}: ${r.reason}`));\n\nreturn [{ json: { sent, rejected: checked.rejected, report: lines.join('\\n') } }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "node-list-done",
      "name": "Show what was sent",
      "type": "n8n-nodes-base.form",
      "position": [
        2832,
        240
      ],
      "parameters": {
        "options": {},
        "operation": "completion",
        "completionTitle": "Requests sent",
        "completionMessage": "={{ $json.report }}"
      },
      "typeVersion": 2.5
    },
    {
      "id": "node-form",
      "name": "Supplier declaration form",
      "type": "n8n-nodes-base.formTrigger",
      "notes": "Copy this node's form URL into Configuration. It goes in the invitation and again in the gap notice, so a resubmission is the same form.\n\nLeave a field blank and the checklist reports it as missing. Nothing gets rejected on the way in.",
      "position": [
        704,
        464
      ],
      "parameters": {
        "options": {
          "buttonLabel": "Send it",
          "appendAttribution": false
        },
        "formTitle": "Packaging declaration",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Your company",
              "requiredField": true
            },
            {
              "fieldType": "email",
              "fieldLabel": "Your email address",
              "requiredField": true
            },
            {
              "fieldLabel": "Your reference for this item",
              "requiredField": true
            },
            {
              "fieldLabel": "What is it",
              "placeholder": "500ml PP tub with lid"
            },
            {
              "fieldType": "dropdown",
              "fieldLabel": "Main material",
              "fieldOptions": {
                "values": [
                  {
                    "option": "plastic"
                  },
                  {
                    "option": "paper"
                  },
                  {
                    "option": "glass"
                  },
                  {
                    "option": "metal"
                  },
                  {
                    "option": "composite"
                  },
                  {
                    "option": "wood"
                  }
                ]
              }
            },
            {
              "fieldType": "dropdown",
              "fieldLabel": "Does it touch food",
              "fieldOptions": {
                "values": [
                  {
                    "option": "Yes"
                  },
                  {
                    "option": "No"
                  }
                ]
              },
              "requiredField": true
            },
            {
              "fieldType": "date",
              "fieldLabel": "Date on the declaration"
            },
            {
              "fieldLabel": "Who signed it"
            },
            {
              "fieldType": "file",
              "fieldLabel": "The declaration itself",
              "requiredField": true,
              "acceptFileTypes": ".pdf"
            }
          ]
        },
        "formDescription": "Tell us about this packaging item and attach your declaration of conformity.\n\nWe check it for completeness and consistency. That is not a conformity assessment and it is not legal advice."
      },
      "typeVersion": 2.2
    },
    {
      "id": "node-config",
      "name": "Configuration",
      "type": "n8n-nodes-base.set",
      "notes": "Everything you need to change is in here.\n\nform_url: paste the production URL from Supplier declaration form. Both emails use it.\n\ndeclaration_max_age_days: how old a declaration you will still accept.\n\nconfidence_floor: how sure the agent has to be before a value it read gets used, instead of being reported as unconfirmed.",
      "position": [
        928,
        352
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "cfg-company",
              "name": "company_name",
              "type": "string",
              "value": "Your company"
            },
            {
              "id": "cfg-form",
              "name": "form_url",
              "type": "string",
              "value": "https://your-n8n.example.com/form/packaging-declaration"
            },
            {
              "id": "cfg-from",
              "name": "from_email",
              "type": "string",
              "value": "user@example.com"
            },
            {
              "id": "cfg-today",
              "name": "today",
              "type": "string",
              "value": "={{ $now.toFormat('yyyy-MM-dd') }}"
            },
            {
              "id": "cfg-age",
              "name": "declaration_max_age_days",
              "type": "number",
              "value": 730
            },
            {
              "id": "cfg-floor",
              "name": "confidence_floor",
              "type": "number",
              "value": 0.75
            },
            {
              "id": "cfg-version",
              "name": "checklist_version",
              "type": "string",
              "value": "example-2026.01"
            },
            {
              "id": "cfg-disclaimer",
              "name": "disclaimer",
              "type": "string",
              "value": "This message reports a completeness and consistency check only. It is not a conformity assessment and it is not legal advice."
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "node-router",
      "name": "Which form was it?",
      "type": "n8n-nodes-base.if",
      "notes": "Both forms come through Configuration, so there is one place to set things, and this sends each one down its own lane.\n\nIt tells them apart by a field only the supplier form has. Rename 'Your company' on that form and you have to change it here too.",
      "position": [
        1152,
        352
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-lane",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $json['Your company'] }}",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "node-attachment",
      "name": "Find the attachment",
      "type": "n8n-nodes-base.code",
      "notes": "Same trick as the CSV lane: take whatever key the upload arrived under.",
      "position": [
        1488,
        992
      ],
      "parameters": {
        "jsCode": "const uploaded = $('Supplier declaration form').first().binary || {};\nconst keys = Object.keys(uploaded);\n\nif (keys.length === 0) {\n  return [{ json: { attached: false }, binary: {} }];\n}\n\nreturn [\n  {\n    json: { attached: true, filename: uploaded[keys[0]].fileName || null },\n    binary: { data: uploaded[keys[0]] },\n  },\n];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "node-pdf",
      "name": "Declaration to text",
      "type": "n8n-nodes-base.extractFromFile",
      "notes": "A scanned declaration has no text layer and comes back empty. The checklist reports it as a document it could not read, which is the truth.",
      "position": [
        1712,
        992
      ],
      "parameters": {
        "options": {},
        "operation": "pdf"
      },
      "typeVersion": 1
    },
    {
      "id": "node-read",
      "name": "Read the declaration",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "notes": "Drag your own chat model onto this node.\n\nIt reads and reports. Nothing it returns can produce a pass or a fail, so whichever model you use, the answer the supplier gets is the same.",
      "position": [
        1872,
        1088
      ],
      "parameters": {
        "text": "=Read this packaging declaration and report what it says.\n\n--- document ---\n{{ ($json.text || '').slice(0, 12000) }}\n--- end ---",
        "options": {
          "systemMessage": "You read one supplier packaging declaration and report what it says. You do not judge it.\n\nReturn only JSON, no prose and no markdown fences, in exactly this shape:\n\n{\n  \"signatory\": { \"value\": \"\", \"confidence\": 0 },\n  \"declaration_date\": { \"value\": \"\", \"confidence\": 0 },\n  \"primary_material\": { \"value\": \"\", \"confidence\": 0 },\n  \"pfas_statement\": { \"value\": \"\", \"confidence\": 0 },\n  \"bpa_statement\": { \"value\": \"\", \"confidence\": 0 }\n}\n\nRules:\n\n- value is the text as it appears in the document. Do not paraphrase and do not translate.\n- confidence is 0 to 1. Use it honestly. 0.9 means you are reading it off the page, 0.4 means you are inferring it from context.\n- A field the document does not mention gets an empty value and a confidence of 0. Never invent one, and never carry a value over from an example.\n- declaration_date must be YYYY-MM-DD. If the document writes it another way, convert it. If the day and month are ambiguous, return an empty value.\n- pfas_statement and bpa_statement are the sentence in which the document addresses that substance, whether it says the substance is present or absent. Silence is an empty value.\n- primary_material is the packaging material, not the product inside it.\n\nYou are reading a document somebody signed. If it does not say something, say that it does not say it."
        },
        "promptType": "define"
      },
      "typeVersion": 1.7
    },
    {
      "id": "node-parse",
      "name": "Parse what was read",
      "type": "n8n-nodes-base.code",
      "notes": "Models wrap JSON in fences and put a sentence in front of it. This strips both.\n\nAn answer it cannot parse counts as nothing read. That is a finding, not a failed run.",
      "position": [
        2160,
        992
      ],
      "parameters": {
        "jsCode": "const FIELDS = ['signatory', 'declaration_date', 'primary_material', 'pfas_statement', 'bpa_statement'];\n\nfunction extractJson(text) {\n  const body = String(text === undefined || text === null ? '' : text).trim();\n  const fenced = /```(?:json)?\\s*([\\s\\S]*?)```/.exec(body);\n  const candidate = fenced ? fenced[1] : body;\n  const start = candidate.indexOf('{');\n  const end = candidate.lastIndexOf('}');\n  if (start === -1 || end <= start) return null;\n  try {\n    return JSON.parse(candidate.slice(start, end + 1));\n  } catch (e) {\n    return null;\n  }\n}\n\nconst answer = $input.first().json;\nconst parsed = extractJson(answer.output || answer.text || '');\n\nif (!parsed) {\n  return [{ json: { read: false, reason: 'the declaration could not be read', fields: {} } }];\n}\n\nconst fields = {};\nfor (const name of FIELDS) {\n  const entry = parsed[name];\n  if (!entry || entry.value === null || entry.value === undefined || String(entry.value).trim() === '') continue;\n  const raw = Number(entry.confidence);\n  fields[name] = {\n    value: String(entry.value).trim(),\n    confidence: Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 0,\n  };\n}\n\nreturn [{ json: { read: true, reason: null, fields } }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "node-merge",
      "name": "Merge answers",
      "type": "n8n-nodes-base.code",
      "notes": "What the supplier typed wins. The document only fills blanks, and only above the confidence floor.\n\nA confident reading that contradicts what they typed gets flagged, never written over. The supplier is the one signing.",
      "position": [
        2496,
        992
      ],
      "parameters": {
        "jsCode": "const form = $('Supplier declaration form').first().json;\nconst reading = $('Parse what was read').first().json;\nconst config = $('Configuration').first().json;\n\nconst floor = Number(config.confidence_floor);\n\nconst typed = {\n  internal_sku: form['Your reference for this item'] || form.internal_sku || '',\n  description: form['What is it'] || form.description || '',\n  primary_material: form['Main material'] || form.primary_material || '',\n  food_contact: String(form['Does it touch food'] || form.food_contact || '').toLowerCase() === 'yes',\n  declaration_date: form['Date on the declaration'] || form.declaration_date || '',\n  signatory: form['Who signed it'] || form.signatory || '',\n};\n\nconst merged = {};\nconst marks = {};\nconst conflicts = [];\n\nfor (const name of ['signatory', 'declaration_date', 'primary_material']) {\n  const own = String(typed[name] || '').trim();\n  const found = reading.fields[name];\n\n  if (own !== '') {\n    merged[name] = own;\n    marks[name] = 'supplier';\n    if (found && found.confidence >= floor && found.value.toLowerCase() !== own.toLowerCase()) {\n      marks[name] = 'conflict';\n      conflicts.push({ field: name, typed: own, document: found.value });\n    }\n    continue;\n  }\n\n  if (found && found.confidence >= floor) {\n    merged[name] = found.value;\n    marks[name] = 'document';\n    continue;\n  }\n\n  merged[name] = '';\n  marks[name] = found ? 'unconfirmed' : 'missing';\n}\n\nfor (const name of ['pfas_statement', 'bpa_statement']) {\n  const found = reading.fields[name];\n  merged[name] = found && found.confidence >= floor ? found.value : '';\n  marks[name] = found ? (found.confidence >= floor ? 'document' : 'unconfirmed') : 'missing';\n}\n\nreturn [\n  {\n    json: {\n      internal_sku: typed.internal_sku,\n      description: typed.description,\n      food_contact: typed.food_contact,\n      contact_email: form['Your email address'] || form.contact_email || '',\n      supplier_name: form['Your company'] || form.supplier_name || '',\n      attached: $('Find the attachment').first().json.attached === true,\n      document_read: reading.read === true,\n      fields: merged,\n      marks,\n      conflicts,\n    },\n  },\n];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "node-checklist",
      "name": "Run the checklist",
      "type": "n8n-nodes-base.code",
      "notes": "This is where the pass or fail happens, and the only node that decides anything.\n\nThe rules are the list at the top: an id, a severity, the sentence the supplier reads, and a function returning pass, fail or skip. Add a rule by adding an entry to the list. You should never need another node.\n\nSkip means the rule did not apply, and a skipped rule is not reported to anyone.",
      "position": [
        2720,
        992
      ],
      "parameters": {
        "jsCode": "const CHECKS = [\n  {\n    id: 'DOC-001',\n    severity: 'blocker',\n    message: 'No declaration was attached.',\n    run: (s) => (s.attached ? 'pass' : 'fail'),\n  },\n  {\n    id: 'DOC-002',\n    severity: 'blocker',\n    message:\n      'The attached file could not be read. Send it as a text PDF rather than a photograph, or type the details into the form.',\n    run: (s) => (!s.attached ? 'skip' : s.document_read ? 'pass' : 'fail'),\n  },\n  {\n    id: 'SIG-010',\n    severity: 'blocker',\n    message: 'The declaration needs a named signatory.',\n    run: (s) => (s.fields.signatory ? 'pass' : 'fail'),\n  },\n  {\n    id: 'SIG-011',\n    severity: 'blocker',\n    message: 'The name you typed and the name on the document are different. Confirm which one is right.',\n    run: (s) => (s.conflicts.some((c) => c.field === 'signatory') ? 'fail' : 'pass'),\n  },\n  {\n    id: 'DATE-020',\n    severity: 'blocker',\n    message: 'The declaration needs a date.',\n    run: (s) => (s.fields.declaration_date ? 'pass' : 'fail'),\n  },\n  {\n    id: 'DATE-021',\n    severity: 'blocker',\n    message: 'The declaration is older than we can accept. Send a current one.',\n    run: (s, config) => {\n      const iso = /^\\d{4}-\\d{2}-\\d{2}$/.exec(String(s.fields.declaration_date || '').trim());\n      if (!iso) return 'skip';\n      const age = (Date.parse(config.today) - Date.parse(iso[0])) / 86400000;\n      return age <= Number(config.declaration_max_age_days) ? 'pass' : 'fail';\n    },\n  },\n  {\n    id: 'MAT-030',\n    severity: 'blocker',\n    message: 'The main material is missing.',\n    run: (s) => (s.fields.primary_material ? 'pass' : 'fail'),\n  },\n  {\n    id: 'PFAS-040',\n    severity: 'blocker',\n    message: 'This item touches food, so the declaration has to say whether PFAS were intentionally added.',\n    run: (s) => (!s.food_contact ? 'skip' : s.fields.pfas_statement ? 'pass' : 'fail'),\n  },\n  {\n    id: 'BPA-050',\n    severity: 'blocker',\n    message: 'This is plastic and touches food, so the declaration has to cover bisphenol A.',\n    run: (s) => {\n      const plastic = /plast|pet\\b|pp\\b|pe\\b|ps\\b|pvc/i.test(s.fields.primary_material || '');\n      if (!s.food_contact || !plastic) return 'skip';\n      return s.fields.bpa_statement ? 'pass' : 'fail';\n    },\n  },\n  {\n    id: 'DESC-060',\n    severity: 'advisory',\n    message: 'A short description helps us file this against the right item.',\n    run: (s) => (String(s.description || '').trim().length >= 3 ? 'pass' : 'fail'),\n  },\n];\n\nconst submission = $input.first().json;\nconst config = $('Configuration').first().json;\n\nconst findings = [];\nfor (const check of CHECKS) {\n  const status = check.run(submission, config);\n  if (status === 'skip') continue;\n  findings.push({ check_id: check.id, severity: check.severity, status, message: check.message });\n}\n\nconst outstanding = findings.filter((f) => f.status === 'fail');\nconst blocking = outstanding.filter((f) => f.severity === 'blocker');\n\nreturn [\n  {\n    json: {\n      submission,\n      findings,\n      outstanding,\n      complete: blocking.length === 0,\n      checked_at: config.today,\n      checklist_version: config.checklist_version,\n      outstanding_text: outstanding\n        .map((f) => `- ${f.severity === 'blocker' ? 'Required' : 'Note'}: ${f.message} [${f.check_id}]`)\n        .join('\\n'),\n    },\n  },\n];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "node-complete",
      "name": "Everything present?",
      "type": "n8n-nodes-base.if",
      "notes": "An advisory finding does not stop an item. Only a blocker does.",
      "position": [
        3056,
        992
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-complete",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.complete }}",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.2
    },
    {
      "id": "node-confirmation",
      "name": "Build the confirmation",
      "type": "n8n-nodes-base.code",
      "notes": "Fixed sentences and the checklist output. Nothing here is drafted.",
      "position": [
        3280,
        992
      ],
      "parameters": {
        "jsCode": "const verdict = $('Run the checklist').first().json;\nconst config = $('Configuration').first().json;\n\nconst body = [\n  `Item: ${verdict.submission.internal_sku}`,\n  verdict.submission.description ? `Description: ${verdict.submission.description}` : null,\n  '',\n  'Thank you. Your declaration is complete against the checks we run and we do not need anything further for this item.',\n  '',\n  config.disclaimer,\n]\n  .filter((line) => line !== null)\n  .join('\\n');\n\nreturn [\n  {\n    json: {\n      to: verdict.submission.contact_email,\n      subject: 'Packaging declaration received and complete',\n      body,\n      complete: true,\n      outstanding: 0,\n    },\n  },\n];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "node-draft",
      "name": "Draft the covering paragraph",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "notes": "Drag your own chat model onto this node too.\n\nIt writes the two sentences above the list and nothing else. The list underneath is rendered by the checklist and is not up for editing here.",
      "position": [
        3200,
        1280
      ],
      "parameters": {
        "text": "=Write the covering paragraph for this supplier.\n\nSupplier: {{ $json.submission.supplier_name }}\nItem: {{ $json.submission.internal_sku }}{{ $json.submission.description ? ' (' + $json.submission.description + ')' : '' }}\n\nOutstanding:\n{{ $json.outstanding_text }}",
        "options": {
          "systemMessage": "You write one short paragraph to a supplier whose packaging declaration is missing something.\n\nYou will be shown the list of what is outstanding. That list is already written and will be printed under your paragraph exactly as it is. Your job is the sentence or two above it.\n\nRules:\n\n- Two or three sentences. Plain English. No greeting and no sign-off; both are added around you.\n- Never mention a check id. They appear in the list below you.\n- Never add a point that is not in the list, and never say a point is resolved.\n- Do not apologise, do not thank them twice, and do not say 'kindly'.\n- Write to somebody who is busy and did not do anything wrong. Most gaps are a missing sentence in a template, not negligence.\n\nReturn the paragraph and nothing else."
        },
        "promptType": "define"
      },
      "typeVersion": 1.7
    },
    {
      "id": "node-gap",
      "name": "Build the gap notice",
      "type": "n8n-nodes-base.code",
      "notes": "Assembles the email, and puts the form link back in it so the supplier can resubmit without asking anyone for it.\n\nIf the drafted paragraph mentions a check that did not run, this stops the send. A supplier chasing a rule that never ran is worse than no covering note at all.",
      "position": [
        3504,
        1216
      ],
      "parameters": {
        "jsCode": "const verdict = $('Run the checklist').first().json;\nconst config = $('Configuration').first().json;\nconst drafted = String($input.first().json.output || $input.first().json.text || '').trim();\n\nconst allowed = new Set(verdict.outstanding.map((f) => f.check_id));\nconst prefixes = new Set(verdict.findings.map((f) => String(f.check_id).split('-')[0]));\nconst reference = String(verdict.submission.internal_sku || '').toUpperCase();\n\nconst mentioned = (drafted.match(/\\b[A-Z]{2,8}-\\d{2,4}\\b/g) || []).filter(\n  (id) => id.toUpperCase() !== reference && prefixes.has(id.split('-')[0])\n);\nconst invented = [...new Set(mentioned.filter((id) => !allowed.has(id)))];\nif (invented.length > 0) {\n  throw new Error(`The drafted paragraph refers to ${invented.join(', ')}, which is not in this notice.`);\n}\n\nconst cover =\n  drafted !== ''\n    ? drafted\n    : 'We have looked at the declaration you sent and some of it is not complete yet. The points below are what we still need.';\n\nconst body = [\n  `Item: ${verdict.submission.internal_sku}`,\n  verdict.submission.description ? `Description: ${verdict.submission.description}` : null,\n  '',\n  cover,\n  '',\n  verdict.outstanding_text,\n  '',\n  'Send it again once you have those, using the same form:',\n  '',\n  config.form_url,\n  '',\n  `Use ${verdict.submission.internal_sku} as the reference again so we can match it up.`,\n  '',\n  config.disclaimer,\n]\n  .filter((line) => line !== null)\n  .join('\\n');\n\nreturn [\n  {\n    json: {\n      to: verdict.submission.contact_email,\n      subject: `Packaging declaration: ${verdict.outstanding.length} point${\n        verdict.outstanding.length === 1 ? '' : 's'\n      } outstanding`,\n      body,\n      complete: false,\n      outstanding: verdict.outstanding.length,\n    },\n  },\n];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "node-email",
      "name": "Send the answer",
      "type": "n8n-nodes-base.emailSend",
      "notes": "Swap this for Gmail, Outlook, Slack or a webhook without touching anything before it. The body arrives as plain text, already assembled.",
      "position": [
        3728,
        1104
      ],
      "parameters": {
        "text": "={{ $json.body }}",
        "options": {},
        "subject": "={{ $json.subject }}",
        "toEmail": "={{ $json.to }}",
        "fromEmail": "={{ $('Configuration').first().json.from_email }}",
        "emailFormat": "text"
      },
      "typeVersion": 2.1
    },
    {
      "id": "node-done",
      "name": "Show the supplier the result",
      "type": "n8n-nodes-base.form",
      "notes": "The supplier sees this straight away. The detail goes to them by email.",
      "position": [
        3952,
        1104
      ],
      "parameters": {
        "options": {},
        "operation": "completion",
        "completionTitle": "={{ $('Run the checklist').first().json.complete ? 'Declaration received and complete' : 'Declaration received' }}",
        "completionMessage": "={{ $('Run the checklist').first().json.complete ? 'Thank you. We have what we need for this item and have emailed you a confirmation.' : 'Thank you. Some points are still outstanding. We have emailed you the list and a link to send it again.' }}"
      },
      "typeVersion": 2.5
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "8af231b8-81a2-489c-aeb8-72022a4ed8a4",
  "nodeGroups": [],
  "connections": {
    "CSV to rows": {
      "main": [
        [
          {
            "node": "Check the list",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configuration": {
      "main": [
        [
          {
            "node": "Which form was it?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find the list": {
      "main": [
        [
          {
            "node": "CSV to rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge answers": {
      "main": [
        [
          {
            "node": "Run the checklist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "What went out": {
      "main": [
        [
          {
            "node": "Show what was sent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check the list": {
      "main": [
        [
          {
            "node": "Build the invitation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send the answer": {
      "main": [
        [
          {
            "node": "Show the supplier the result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send the request": {
      "main": [
        [
          {
            "node": "What went out",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run the checklist": {
      "main": [
        [
          {
            "node": "Everything present?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Which form was it?": {
      "main": [
        [
          {
            "node": "Find the attachment",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Find the list",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Declaration to text": {
      "main": [
        [
          {
            "node": "Read the declaration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Everything present?": {
      "main": [
        [
          {
            "node": "Build the confirmation",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Draft the covering paragraph",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find the attachment": {
      "main": [
        [
          {
            "node": "Declaration to text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse what was read": {
      "main": [
        [
          {
            "node": "Merge answers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build the gap notice": {
      "main": [
        [
          {
            "node": "Send the answer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build the invitation": {
      "main": [
        [
          {
            "node": "Send the request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read the declaration": {
      "main": [
        [
          {
            "node": "Parse what was read",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build the confirmation": {
      "main": [
        [
          {
            "node": "Send the answer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load your supplier list": {
      "main": [
        [
          {
            "node": "Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Supplier declaration form": {
      "main": [
        [
          {
            "node": "Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Draft the covering paragraph": {
      "main": [
        [
          {
            "node": "Build the gap notice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}