{
  "name": "Menu Translator",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "translate-menu",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "b3f59d7b-2e8c-438a-8305-7e4d420af11d",
      "name": "Menu Translation Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -672,
        128
      ],
      "webhookId": "translate-menu"
    },
    {
      "parameters": {
        "jsCode": "const payload = $('Menu Translation Webhook').first()?.json?.body && typeof $('Menu Translation Webhook').first().json.body === 'object'\n  ? $('Menu Translation Webhook').first().json.body\n  : ($('Menu Translation Webhook').first()?.json || {});\nconst menuResponseRaw = $json || {};\nconst traceId = payload.trace_id || 'menu-translate-' + Date.now() + '-' + Math.random().toString(16).slice(2);\nconst notifyEmail = 'zavedenia@gmail.com';\nconst startedAtMs = Date.now();\nconst startedAt = new Date(startedAtMs).toISOString();\n\nconst languages = [\n  { code: 'ru', label: 'Russian' },\n  { code: 'de', label: 'German' },\n  { code: 'gr', label: 'Greek' },\n  { code: 'rs', label: 'Serbian Latin' },\n  { code: 'ro', label: 'Romanian' },\n  { code: 'ua', label: 'Ukrainian' },\n  { code: 'pl', label: 'Polish' },\n  { code: 'fr', label: 'French' },\n  { code: 'it', label: 'Italian' },\n  { code: 'es', label: 'Spanish' },\n  { code: 'il', label: 'Hebrew' },\n  { code: 'cz', label: 'Czech' },\n  { code: 'tr', label: 'Turkish' },\n  { code: 'ar', label: 'Arabic' },\n  { code: 'cn', label: 'Simplified Chinese' },\n  { code: 'ja', label: 'Japanese' },\n  { code: 'ga', label: 'Irish Gaelic' },\n  { code: 'mk', label: 'Macedonian' }\n];\nconst languageCodes = languages.map((language) => language.code);\n\nfunction parseBody(value) {\n  if (typeof value !== 'string') return value;\n  const trimmed = value.trim();\n  if (!trimmed) return {};\n  try { return JSON.parse(trimmed); } catch (_) { return { raw: trimmed }; }\n}\n\nfunction clean(value) {\n  return String(value ?? '')\n    .replace(/<!--.*?-->/gs, '')\n    .replace(/[\\r\\n\\t]+/g, ' ')\n    .replace(/\\s+/g, ' ')\n    .trim();\n}\n\nfunction parseAllergensValue(value) {\n  if (Array.isArray(value)) return value.map(Number).filter(Number.isFinite).sort((a, b) => a - b);\n  if (value === undefined || value === null || value === '') return [];\n  if (typeof value === 'string') {\n    try {\n      const parsed = JSON.parse(value);\n      if (Array.isArray(parsed)) return parsed.map(Number).filter(Number.isFinite).sort((a, b) => a - b);\n    } catch (_) {}\n  }\n  return extractAllergens(value);\n}\n\nfunction extractAllergens(text) {\n  const matches = String(text || '').match(/\\b\\d{1,2}\\b/g) || [];\n  return [...new Set(matches.map(Number))].sort((a, b) => a - b);\n}\n\nfunction error(statusCode, message, details = {}) {\n  return [{\n    json: {\n      kind: 'error',\n      ok: false,\n      statusCode,\n      version: '2.1.0',\n      trace_id: traceId,\n      menu_group_id: payload.menu_group_id ?? null,\n      zavedenia_id: payload.zavedenia_id ?? menuResponseRaw.menu_group?.zavedenia_id ?? null,\n      city: payload.city ?? null,\n      notify_email: notifyEmail,\n      started_at: startedAt,\n      started_at_ms: startedAtMs,\n      error: message,\n      details: { ...details, request_payload: payload, menu_response: menuResponseRaw },\n      failed_at: new Date().toISOString()\n    }\n  }];\n}\n\nconst missing = [];\nif (!payload.language_from) missing.push('language_from');\nif (!payload.city) missing.push('city');\nif (!payload.token) missing.push('token');\nif (!payload.menu_group_id) missing.push('menu_group_id');\nif (missing.length) return error(400, 'Missing required input', { missing });\n\nif (menuResponseRaw.error) {\n  return error(502, 'Menu items API request failed', { error: menuResponseRaw.error });\n}\n\nconst menuResponse = parseBody(menuResponseRaw.body ?? menuResponseRaw.data ?? menuResponseRaw.response ?? menuResponseRaw);\nif (menuResponse.success === false) {\n  return error(502, 'Menu items API returned success=false', { response: menuResponse });\n}\nif (!Array.isArray(menuResponse.menu) || menuResponse.menu.length === 0) {\n  return error(502, 'Menu items API did not return menu items', { response: menuResponse });\n}\n\nconst menuGroup = menuResponse.menu_group || {};\nconst zavedeniaId = payload.zavedenia_id ?? menuGroup.zavedenia_id ?? null;\nconst menuGroupId = String(payload.menu_group_id ?? menuResponse.menu_group_id ?? menuGroup.id ?? '');\n\nconst sourceItems = menuResponse.menu.map((item, index) => {\n  const description = clean(item.description);\n  return {\n    order: index,\n    id: item.id,\n    name: clean(item.name),\n    description,\n    allergens: parseAllergensValue(item.allergens).length ? parseAllergensValue(item.allergens) : extractAllergens(description),\n    image_url: item.image_url || null,\n    position: item.position ?? null,\n    gramaji: Array.isArray(item.gramaji) ? item.gramaji : []\n  };\n});\n\nif (payload.allergensOnly === true) {\n  return [{\n    json: {\n      items: sourceItems.map((item) => ({\n        id: item.id,\n        name_lg: item.name,\n        description_lg: item.description,\n        allergens: item.allergens,\n        name_uk: '',\n        description_uk: ''\n      })),\n      version: '2.1.0',\n      _meta: {\n        trace_id: traceId,\n        started_at: startedAt,\n        started_at_ms: startedAtMs,\n        zavedenia_id: zavedeniaId,\n        city: payload.city,\n        token: payload.token,\n        menu_group_id: menuGroupId,\n        menu_group_name: menuGroup.name || payload.menu_group_name || payload.group_name || null,\n        input_item_count: sourceItems.length,\n        translated_item_count: sourceItems.length,\n        allergens_only: true,\n        source_items: sourceItems\n      }\n    }\n  }];\n}\n\nconst languageList = languages.map((lang) => lang.code + ': ' + lang.label).join(', ');\n\nreturn sourceItems.map((item) => {\n  const prompt = [\n    'Translate exactly one restaurant menu item into all target languages and return only JSON matching the schema.',\n    '',\n    'Trace ID: ' + traceId,\n    'Source language: ' + payload.language_from,\n    'City: ' + payload.city,\n    'Zavedenia ID: ' + zavedeniaId,\n    'Menu group ID: ' + menuGroupId,\n    'Item ID: ' + item.id,\n    'Target languages: ' + languageList,\n    'English included in translation blocks: ' + (payload.englishIncluded === true),\n    '',\n    'Rules:',\n    '- Return exactly this one source item.',\n    '- Preserve id exactly.',\n    '- Translate name and description naturally for restaurant menus.',\n    '- Preserve brand names and proper nouns when appropriate.',\n    '- Preserve allergen numeric lists exactly in descriptions, for example (2, 4, 7).',\n    '- Always return English translation in name_en and description_en.',\n    '- Echo englishIncluded as true only when the request englishIncluded value is true.',\n    '- For Serbian use Latin script.',\n    '- For Chinese use Simplified Chinese.',\n    '- Return JSON only, no markdown.',\n    '',\n    'Item: ' + JSON.stringify(item)\n  ].join('\\n');\n\n  return {\n    json: {\n      kind: 'job',\n      trace_id: traceId,\n      started_at: startedAt,\n      started_at_ms: startedAtMs,\n      item_id: item.id,\n      order: item.order,\n      item_count: sourceItems.length,\n      zavedenia_id: zavedeniaId,\n      menu_group_id: menuGroupId,\n      menu_group_name: menuGroup.name || payload.menu_group_name || payload.group_name || null,\n      city: payload.city,\n      token: payload.token,\n      language_from: payload.language_from,\n      englishIncluded: payload.englishIncluded === true,\n      source_item: item,\n      prompt,\n      openai_request: {\n        model: payload.model || 'gpt-4o',\n        input: [\n          {\n            role: 'system',\n            content: 'You are a precise professional restaurant menu translator. Return valid JSON only.'\n          },\n          {\n            role: 'user',\n            content: prompt\n          }\n        ],\n        text: {\n          format: {\n            type: 'json_schema',\n            name: 'menu_translation_item',\n            strict: true,\n            schema: {\n              type: 'object',\n              additionalProperties: false,\n              properties: {\n                trace_id: { type: 'string' },\n                item_id: { anyOf: [{ type: 'number' }, { type: 'string' }] },\n                englishIncluded: { type: 'boolean' },\n                item: {\n                  type: 'object',\n                  additionalProperties: false,\n                  properties: {\n                    id: { anyOf: [{ type: 'number' }, { type: 'string' }] },\n                    allergens: { type: 'array', items: { type: 'number' } },\n                    name_en: { type: 'string' },\n                    description_en: { type: 'string' },\n                    names: {\n                      type: 'object',\n                      additionalProperties: false,\n                      properties: Object.fromEntries(languageCodes.map((code) => [code, { type: 'string' }])),\n                      required: languageCodes\n                    },\n                    descriptions: {\n                      type: 'object',\n                      additionalProperties: false,\n                      properties: Object.fromEntries(languageCodes.map((code) => [code, { type: 'string' }])),\n                      required: languageCodes\n                    }\n                  },\n                  required: ['id', 'allergens', 'name_en', 'description_en', 'names', 'descriptions']\n                }\n              },\n              required: ['trace_id', 'item_id', 'englishIncluded', 'item']\n            }\n          }\n        }\n      }\n    }\n  };\n});"
      },
      "id": "c73ffffc-d8cc-4f2b-b4c8-1bc7e450f0ba",
      "name": "Prepare Translation Jobs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        448,
        432
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "a4d7a0ac-ecb0-49bf-9df8-26753fbc844d",
              "leftValue": "={{ $json.kind }}",
              "rightValue": "error",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "f8f5c719-bbd6-4062-bccb-0664c2229f2a",
      "name": "Is Input Error?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        672,
        432
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "0e7c719b-5cad-457e-9d9d-bfa2218bf19a",
              "leftValue": "={{ Array.isArray($json.items) }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "cf10546e-fc67-48d0-85f3-22a92d658f4a",
      "name": "Already Final?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        896,
        512
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.openai.com/v1/responses",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer 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"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.openai_request) }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          },
          "timeout": 1200000
        }
      },
      "id": "3db2f119-de43-4871-9832-b1bcad2602d8",
      "name": "OpenAI HTTP Translate",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1120,
        432
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "continueOnFail": true
    },
    {
      "parameters": {
        "jsCode": "const httpItems = $input.all();\nconst sourceJobs = $('Prepare Translation Jobs').all().map((item) => item.json).filter((item) => item.kind === 'job');\nconst langs = ['ru', 'de', 'gr', 'rs', 'ro', 'ua', 'pl', 'fr', 'it', 'es', 'il', 'cz', 'tr', 'ar', 'cn', 'ja', 'ga', 'mk'];\n\nfunction clean(value) {\n  return String(value ?? '')\n    .replace(/<!--.*?-->/gs, '')\n    .replace(/[\\r\\n\\t]+/g, ' ')\n    .replace(/\\s+/g, ' ')\n    .trim()\n    .replace(/--/g, '-');\n}\n\nfunction block(code, value) {\n  return `<!-- start ${code} -->${clean(value)}<!-- end ${code} -->`;\n}\n\nfunction textFromOpenAi(json) {\n  if (json.output_text) return json.output_text;\n  const message = (json.output || []).find((entry) => entry.type === 'message');\n  if (Array.isArray(message?.content)) {\n    return message.content.map((part) => part.text || part.content || '').join('');\n  }\n  return '';\n}\n\nfunction fail(statusCode, message, details = {}) {\n  const first = sourceJobs[0] || httpItems[0]?.json || {};\n  const durationMs = Date.now() - (first.started_at_ms || Date.now());\n  return [{\n    json: {\n      kind: 'error',\n      ok: false,\n      statusCode,\n      version: '2.1.0',\n      trace_id: first.trace_id || null,\n      zavedenia_id: first.zavedenia_id || null,\n      menu_group_id: first.menu_group_id || null,\n      menu_group_name: first.menu_group_name || null,\n      city: first.city || null,\n      started_at: first.started_at || null,\n      duration_ms: durationMs,\n      duration_seconds: Number((durationMs / 1000).toFixed(2)),\n      notify_email: 'zavedenia@gmail.com',\n      error: message,\n      details,\n      failed_at: new Date().toISOString()\n    }\n  }];\n}\n\nconst memory = new Map();\nconst errors = [];\nlet includeEnglishBlocks = sourceJobs.some((job) => job.englishIncluded === true);\n\nfor (let index = 0; index < httpItems.length; index++) {\n  const response = httpItems[index]?.json || {};\n  const source = sourceJobs[index] || {};\n\n  if (response.error) {\n    errors.push({\n      message: 'OpenAI HTTP request failed',\n      item_id: source.item_id,\n      order: source.order,\n      trace_id: source.trace_id,\n      zavedenia_id: source.zavedenia_id,\n      city: source.city,\n      error: response.error\n    });\n    continue;\n  }\n\n  try {\n    const text = textFromOpenAi(response);\n    if (!text) throw new Error('OpenAI response did not contain output_text');\n    const parsed = JSON.parse(text);\n    if (!parsed.item || parsed.item.id === undefined || parsed.item.id === null) {\n      throw new Error('Parsed OpenAI JSON does not contain item.id');\n    }\n    includeEnglishBlocks = includeEnglishBlocks || parsed.englishIncluded === true;\n    memory.set(String(source.item_id ?? parsed.item.id), {\n      order: source.order,\n      source,\n      translated: parsed.item\n    });\n  } catch (error) {\n    errors.push({\n      message: error.message,\n      item_id: source.item_id,\n      order: source.order,\n      trace_id: source.trace_id,\n      zavedenia_id: source.zavedenia_id,\n      city: source.city,\n      response\n    });\n  }\n}\n\nfor (const job of sourceJobs) {\n  if (!memory.has(String(job.item_id))) {\n    errors.push({\n      message: 'Missing translation result for item',\n      item_id: job.item_id,\n      order: job.order,\n      trace_id: job.trace_id,\n      zavedenia_id: job.zavedenia_id,\n      city: job.city\n    });\n  }\n}\n\nif (errors.length) {\n  return fail(502, 'OpenAI translation failed for one or more menu items', { errors });\n}\n\nconst missing = [];\nconst responseItems = [...memory.values()]\n  .sort((a, b) => a.order - b.order)\n  .map(({ translated, source }) => {\n    const names = translated.names || {};\n    const descriptions = translated.descriptions || {};\n    const sourceDescription = clean(source?.source_item?.description || '');\n\n    for (const lang of langs) {\n      if (!names[lang]) missing.push({ id: translated.id, language: lang, field: 'name' });\n      if (sourceDescription && !descriptions[lang]) missing.push({ id: translated.id, language: lang, field: 'description' });\n    }\n\n    const responseItem = {\n      id: translated.id,\n      menuItemTranslations: [\n        ...(includeEnglishBlocks ? [block('en', translated.name_en)] : []),\n        ...langs.map((lang) => block(lang, names[lang] || ''))\n      ].join(' '),\n      descriptionTranslations: [\n        ...(includeEnglishBlocks ? [block('en', translated.description_en)] : []),\n        ...langs.map((lang) => block(lang, descriptions[lang] || ''))\n      ].join(' '),\n      allergens: Array.isArray(translated.allergens) ? translated.allergens : [],\n      name_uk: clean(translated.name_en),\n      description_uk: clean(translated.description_en)\n    };\n\n    if (includeEnglishBlocks) {\n      responseItem.name_en = clean(translated.name_en);\n      responseItem.description_en = clean(translated.description_en);\n    }\n\n    return responseItem;\n  });\n\nif (missing.length) {\n  return fail(502, 'OpenAI completed but returned incomplete translations', { missing });\n}\n\nconst first = sourceJobs[0] || {};\nconst durationMs = Date.now() - (first.started_at_ms || Date.now());\nreturn [{\n  json: {\n    items: responseItems,\n    version: '2.1.0',\n    _meta: {\n      trace_id: first.trace_id || null,\n      started_at: first.started_at || null,\n      zavedenia_id: first.zavedenia_id || null,\n      city: first.city || null,\n      token: first.token || null,\n      menu_group_id: first.menu_group_id || null,\n      menu_group_name: first.menu_group_name || null,\n      source_items: sourceJobs.map((job) => job.source_item).filter(Boolean),\n      input_item_count: sourceJobs.length,\n      translated_item_count: responseItems.length,\n      duration_ms: durationMs,\n      duration_seconds: Number((durationMs / 1000).toFixed(2))\n    }\n  }\n}];\n"
      },
      "id": "d3110b4b-ec47-421c-8ab4-27f75bb9bc30",
      "name": "Build Final Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1344,
        432
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "027f5933-1ef2-4f13-aadb-f043da0cf1d3",
              "leftValue": "={{ $json.kind }}",
              "rightValue": "error",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "2e2226ee-a693-46a8-b8e6-f3ca22b799ba",
      "name": "Translation Failed?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1568,
        432
      ]
    },
    {
      "parameters": {
        "jsCode": "const finalResponse = $json;\nconst webhookPayload = $('Menu Translation Webhook').first()?.json?.body || {};\nconst city = finalResponse._meta?.city || webhookPayload.city;\nconst token = finalResponse._meta?.token || webhookPayload.token;\n\nfunction text(value) {\n  return String(value ?? '')\n    .replace(/[\\r\\n\\t]+/g, ' ')\n    .replace(/\\s+/g, ' ')\n    .trim();\n}\n\nfunction allergens(value) {\n  let values = [];\n  if (Array.isArray(value)) {\n    values = value;\n  } else if (typeof value === 'string') {\n    try {\n      const parsed = JSON.parse(value);\n      values = Array.isArray(parsed) ? parsed : value.match(/\\b\\d{1,2}\\b/g) || [];\n    } catch (_) {\n      values = value.match(/\\b\\d{1,2}\\b/g) || [];\n    }\n  } else if (value !== undefined && value !== null) {\n    values = [value];\n  }\n\n  return [...new Set(values.map(Number).filter(Number.isFinite))].sort((a, b) => a - b);\n}\n\nif (!city || !token) {\n  return [{\n    json: {\n      kind: 'error',\n      ok: false,\n      statusCode: 400,\n      version: finalResponse.version || '2.1.0',\n      trace_id: finalResponse._meta?.trace_id || null,\n      zavedenia_id: finalResponse._meta?.zavedenia_id || webhookPayload.zavedenia_id || null,\n      menu_group_id: finalResponse._meta?.menu_group_id || webhookPayload.menu_group_id || null,\n      city: city || null,\n      error: 'Missing city or token for menu item bulk update',\n      details: { missing: [!city ? 'city' : null, !token ? 'token' : null].filter(Boolean) },\n      failed_at: new Date().toISOString()\n    }\n  }];\n}\n\nconst sourceById = new Map((finalResponse._meta?.source_items || []).map((item) => [String(item.id), item]));\nconst insertItems = (finalResponse.items || []).map((item) => {\n  const source = sourceById.get(String(item.id)) || {};\n  const numericId = Number(item.id);\n  return {\n    id: Number.isFinite(numericId) ? numericId : item.id,\n    name_lg: text(item.menuItemTranslations ?? item.name_lg ?? source.name),\n    description_lg: text(item.descriptionTranslations ?? item.description_lg ?? source.description),\n    allergens: allergens(item.allergens),\n    name_uk: text(item.name_uk ?? item.name_en ?? source.name),\n    description_uk: text(item.description_uk ?? item.description_en ?? source.description)\n  };\n});\n\nreturn [{\n  json: {\n    response: finalResponse,\n    insert_payload: {\n      city,\n      token,\n      items: insertItems\n    }\n  }\n}];"
      },
      "id": "79844e52-4380-41e3-b590-56ebd133b2c2",
      "name": "Build Insert Menu Items Payload",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1792,
        576
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://zavedenia.com/apps/orderapprestaurant/api/menu_items_bulk_update_api.php",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Authorization",
              "value": "Bearer 1f82e364-9c2d-4c59-86b0-8f83c7f0285f"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.insert_payload) }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          },
          "timeout": 120000
        }
      },
      "id": "8afcbf3a-2dce-4639-97c9-21fc461db9b1",
      "name": "Insert Menu Items Bulk Update",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2016,
        576
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "continueOnFail": true
    },
    {
      "parameters": {
        "jsCode": "const rawInsertResponse = $json;\nconst context = $('Build Insert Menu Items Payload').first()?.json || {};\nconst response = context.response || {};\nconst payload = context.insert_payload || {};\n\nfunction parseBody(value) {\n  if (typeof value !== 'string') return value;\n  const trimmed = value.trim();\n  if (!trimmed) return '';\n  try {\n    return JSON.parse(trimmed);\n  } catch (_) {\n    return trimmed;\n  }\n}\n\nconst insertResponse = {\n  ...rawInsertResponse,\n  body: parseBody(rawInsertResponse.body ?? rawInsertResponse.data ?? rawInsertResponse.response ?? rawInsertResponse)\n};\n\nfunction failed() {\n  if (rawInsertResponse.error) return true;\n  const body = insertResponse.body;\n  if (body && typeof body === 'object') {\n    if (body.success === false) return true;\n    const statusCode = Number(body.status || body.statusCode || body.code || 0);\n    if (statusCode >= 400) return true;\n    const status = String(body.status || body.result || '').toLowerCase();\n    if (status && !['success', 'ok', '200'].includes(status)) return true;\n    return false;\n  }\n  const text = String(body ?? '').toLowerCase();\n  return text.includes('error') || text.includes('failed') || text.includes('not found');\n}\n\nif (failed()) {\n  return [{\n    json: {\n      kind: 'error',\n      ok: false,\n      statusCode: 502,\n      version: response.version || '2.1.0',\n      trace_id: response._meta?.trace_id || null,\n      zavedenia_id: response._meta?.zavedenia_id || null,\n      menu_group_id: response._meta?.menu_group_id || null,\n      city: response._meta?.city || payload.city || null,\n      started_at: response._meta?.started_at || null,\n      duration_ms: response._meta?.duration_ms || null,\n      duration_seconds: response._meta?.duration_seconds || null,\n      notify_email: 'zavedenia@gmail.com',\n      error: 'Menu item bulk update failed',\n      details: { insert_response: insertResponse, insert_payload: payload },\n      failed_at: new Date().toISOString()\n    }\n  }];\n}\n\nreturn [{\n  json: {\n    ...response,\n    ok: true,\n    _meta: {\n      ...(response._meta || {}),\n      inserted_item_count: Array.isArray(payload.items) ? payload.items.length : 0,\n      insert_status: (insertResponse.body && typeof insertResponse.body === 'object')\n        ? (insertResponse.body.status || insertResponse.body.result || 'success')\n        : 'success',\n      insert_response: insertResponse.body\n    }\n  }\n}];\n"
      },
      "id": "7c73cff3-4ca4-4a9f-9673-0bdc1e61689f",
      "name": "Build Insert Final Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2240,
        576
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "743949dc-d8f1-4441-bcfa-1f990e1a26ea",
              "leftValue": "={{ $json.kind }}",
              "rightValue": "error",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "509a91c3-b1b4-4f27-83f4-73d745fe5a0f",
      "name": "Insert Failed?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2464,
        576
      ]
    },
    {
      "parameters": {
        "url": "={{ 'https://zavedenia.com/apps/orderapprestaurant/api/menu_items_api.php?token=' + encodeURIComponent(($('Menu Translation Webhook').first()?.json?.body || {}).token || '') + '&city=' + encodeURIComponent(($('Menu Translation Webhook').first()?.json?.body || {}).city || '') + '&menu_group_id=' + encodeURIComponent(($('Menu Translation Webhook').first()?.json?.body || {}).menu_group_id || '') }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          },
          "timeout": 120000
        }
      },
      "id": "2b29c646-a07e-4620-9077-f5f6cfa035fa",
      "name": "Fetch Menu Items",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        224,
        432
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "continueOnFail": true
    },
    {
      "parameters": {
        "fromEmail": "zavedenia@gmail.com",
        "toEmail": "zavedenia@gmail.com",
        "subject": "={{ 'Menu translation started for group ' + (($json.body || $json).menu_group_id || 'unknown') }}",
        "html": "=<p>Menu translation started.</p><p><strong>Menu group ID:</strong> {{ ($json.body || $json).menu_group_id || 'unknown' }}<br><strong>City:</strong> {{ ($json.body || $json).city || 'unknown' }}<br><strong>Language from:</strong> {{ ($json.body || $json).language_from || 'unknown' }}<br><strong>Allergens only:</strong> {{ ($json.body || $json).allergensOnly === true }}<br><strong>English included:</strong> {{ ($json.body || $json).englishIncluded === true }}</p><p><strong>Request payload:</strong></p><pre>{{ JSON.stringify($json.body || $json, null, 2) }}</pre>",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "16a4db14-9d7f-4e2f-81bd-34ddc5910d6c",
      "name": "Email Translation Started",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        224,
        624
      ],
      "webhookId": "1a424893-562b-4185-ace7-c5a9f26f78bb",
      "credentials": {
        "smtp": {
          "id": "FHnrd8vBCAukd5VU",
          "name": "SMTP account"
        }
      },
      "continueOnFail": true
    },
    {
      "parameters": {
        "fromEmail": "zavedenia@gmail.com",
        "toEmail": "zavedenia@gmail.com",
        "subject": "={{ 'Menu translation failed for group ' + ($json.menu_group_id || $json.details?.request_payload?.menu_group_id || 'unknown') }}",
        "html": "=<p>Menu translation failed.</p><p><strong>Error:</strong> {{ $json.error || 'unknown' }}<br><strong>Menu group ID:</strong> {{ $json.menu_group_id || $json.details?.request_payload?.menu_group_id || 'unknown' }}<br><strong>Zavedenia ID:</strong> {{ $json.zavedenia_id || 'unknown' }}<br><strong>City:</strong> {{ $json.city || 'unknown' }}<br><strong>Trace ID:</strong> {{ $json.trace_id || 'unknown' }}<br><strong>Started at:</strong> {{ $json.started_at || 'unknown' }}<br><strong>Failed at:</strong> {{ $json.failed_at || 'unknown' }}<br><strong>Duration:</strong> {{ $json.duration_seconds || 0 }} seconds</p><p><strong>Details:</strong></p><pre>{{ JSON.stringify($json.details || {}, null, 2) }}</pre>",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "0bef21dd-0f7d-4fad-8fce-ca14a95c7523",
      "name": "Email Translation Error",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        2688,
        320
      ],
      "webhookId": "d25f9f16-67ab-47ea-85e0-9b74f93f7c6c",
      "credentials": {
        "smtp": {
          "id": "FHnrd8vBCAukd5VU",
          "name": "SMTP account"
        }
      },
      "continueOnFail": true
    },
    {
      "parameters": {
        "fromEmail": "zavedenia@gmail.com",
        "toEmail": "zavedenia@gmail.com",
        "subject": "={{ 'Menu translation completed for group ' + ($json._meta?.menu_group_id || 'unknown') }}",
        "html": "=<p>Menu translation completed successfully.</p><p><strong>Menu group ID:</strong> {{ $json._meta?.menu_group_id || 'unknown' }}<br><strong>Menu group:</strong> {{ $json._meta?.menu_group_name || 'unknown' }}<br><strong>Zavedenia ID:</strong> {{ $json._meta?.zavedenia_id || 'unknown' }}<br><strong>City:</strong> {{ $json._meta?.city || 'unknown' }}<br><strong>Trace ID:</strong> {{ $json._meta?.trace_id || 'unknown' }}<br><strong>Input items:</strong> {{ $json._meta?.input_item_count || 0 }}<br><strong>Translated items:</strong> {{ $json._meta?.translated_item_count || 0 }}<br><strong>Inserted items:</strong> {{ $json._meta?.inserted_item_count || 0 }}<br><strong>Duration:</strong> {{ $json._meta?.duration_seconds || 0 }} seconds</p><p><strong>Insert response:</strong></p><pre>{{ JSON.stringify($json._meta?.insert_response || {}, null, 2) }}</pre>",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "ac14f1de-0669-4e3b-a218-5f0a58b7b930",
      "name": "Email Translation Success",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        2688,
        624
      ],
      "webhookId": "0bc486a7-af1e-419f-9a97-a4612238d3f0",
      "credentials": {
        "smtp": {
          "id": "FHnrd8vBCAukd5VU",
          "name": "SMTP account"
        }
      },
      "continueOnFail": true
    },
    {
      "parameters": {
        "jsCode": "const payload = $json.body && typeof $json.body === 'object' ? $json.body : $json;\nconst headers = $json.headers || {};\nconst expectedAuthorization = 'Bearer 1f82e364-9c2d-4c59-86b0-8f83c7f0285f';\nconst authorization = headers.authorization || headers.Authorization || payload.authorization || '';\nconst traceId = payload.trace_id || 'menu-translate-' + Date.now() + '-' + Math.random().toString(16).slice(2);\nconst startedAt = new Date().toISOString();\nconst notifyEmail = 'zavedenia@gmail.com';\n\nconst missing = [];\nif (!authorization) missing.push('Authorization header');\nif (authorization && authorization !== expectedAuthorization) missing.push('valid Authorization bearer token');\nif (!payload.language_from) missing.push('language_from');\nif (!payload.city) missing.push('city');\nif (!payload.token) missing.push('token');\nif (!payload.menu_group_id) missing.push('menu_group_id');\n\nif (missing.length) {\n  return [{\n    json: {\n      kind: 'error',\n      ok: false,\n      statusCode: 400,\n      version: '2.1.0',\n      trace_id: traceId,\n      menu_group_id: payload.menu_group_id ?? null,\n      city: payload.city ?? null,\n      notify_email: notifyEmail,\n      started_at: startedAt,\n      error: 'Missing required input',\n      details: { missing, request_payload: payload },\n      response: {\n        status: 'error',\n        trace_id: traceId,\n        message: 'Something went wrong. Missing required input.',\n        details: { missing }\n      }\n    }\n  }];\n}\n\nreturn [{\n  json: {\n    kind: 'accepted',\n    ok: true,\n    statusCode: 202,\n    version: '2.1.0',\n    trace_id: traceId,\n    menu_group_id: String(payload.menu_group_id),\n    city: payload.city,\n    notify_email: notifyEmail,\n    started_at: startedAt,\n    request_payload: payload,\n    response: {\n      status: 'processing',\n      trace_id: traceId,\n      menu_group_id: String(payload.menu_group_id),\n      message: 'Menu translation is processing in the background.'\n    }\n  }\n}];"
      },
      "id": "b509d905-06b6-4959-b0a1-95f573d021ef",
      "name": "Validate Initial Translation Request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -448,
        128
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "f1228992-688a-432c-aa6c-5ef54e4282cf",
              "leftValue": "={{ $json.kind }}",
              "rightValue": "error",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "fa91152c-3417-4c7b-b9cf-ec3d2eb3eee2",
      "name": "Is Initial Request Invalid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -224,
        128
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json.response }}",
        "options": {
          "responseCode": "={{ $json.statusCode || 400 }}"
        }
      },
      "id": "cb39ed78-a75e-4a44-9040-6c52805aecaa",
      "name": "Respond Immediate Translation Error",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json.response }}",
        "options": {
          "responseCode": 202
        }
      },
      "id": "0d1d9b76-309d-41b7-bb91-0e4cc263a21e",
      "name": "Respond Translation Processing",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        0,
        528
      ]
    }
  ],
  "pinData": {
    "Menu Translation Webhook": [
      {
        "json": {
          "headers": {
            "content-type": "application/json",
            "user-agent": "PostmanRuntime/7.54.0",
            "accept": "*/*",
            "postman-token": "2b23f03b-6d89-4af6-b459-090f4c6dd703",
            "host": "13.53.187.163:5678",
            "accept-encoding": "gzip, deflate, br",
            "connection": "keep-alive",
            "content-length": "151",
            "authorization": "Bearer 1f82e364-9c2d-4c59-86b0-8f83c7f0285f"
          },
          "params": {},
          "query": {},
          "body": {
            "englishIncluded": false,
            "allergensOnly": false,
            "language_from": "български",
            "city": "sofia",
            "menu_group_id": 49627,
            "token": "d7fa0ae1429716c8776ee79e10db2a97"
          },
          "webhookUrl": "http://0.0.0.0:5678/webhook/translate-menu",
          "executionMode": "production"
        }
      }
    ]
  },
  "connections": {
    "Menu Translation Webhook": {
      "main": [
        [
          {
            "node": "Validate Initial Translation Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Translation Jobs": {
      "main": [
        [
          {
            "node": "Is Input Error?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is Input Error?": {
      "main": [
        [
          {
            "node": "Email Translation Error",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Already Final?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Already Final?": {
      "main": [
        [
          {
            "node": "Build Insert Menu Items Payload",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "OpenAI HTTP Translate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI HTTP Translate": {
      "main": [
        [
          {
            "node": "Build Final Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Final Response": {
      "main": [
        [
          {
            "node": "Translation Failed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Translation Failed?": {
      "main": [
        [
          {
            "node": "Email Translation Error",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Insert Menu Items Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Insert Menu Items Payload": {
      "main": [
        [
          {
            "node": "Insert Menu Items Bulk Update",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Insert Menu Items Bulk Update": {
      "main": [
        [
          {
            "node": "Build Insert Final Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Insert Final Response": {
      "main": [
        [
          {
            "node": "Insert Failed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Insert Failed?": {
      "main": [
        [
          {
            "node": "Email Translation Error",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Email Translation Success",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Menu Items": {
      "main": [
        [
          {
            "node": "Prepare Translation Jobs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Initial Translation Request": {
      "main": [
        [
          {
            "node": "Is Initial Request Invalid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is Initial Request Invalid?": {
      "main": [
        [
          {
            "node": "Respond Immediate Translation Error",
            "type": "main",
            "index": 0
          },
          {
            "node": "Email Translation Error",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond Translation Processing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Respond Translation Processing": {
      "main": [
        [
          {
            "node": "Fetch Menu Items",
            "type": "main",
            "index": 0
          },
          {
            "node": "Email Translation Started",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": true,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "availableInMCP": false
  },
  "versionId": "e57516ef-8854-4dac-901d-d7b195e6d109",
  "meta": {
    "templateCredsSetupCompleted": true,
    "instanceId": "a69a2ae11d22a0ab13dc5c02e8089d4035c7a914714358414fedccbd61128bac"
  },
  "nodeGroups": [],
  "id": "GxqeDlVLmvQylh4V",
  "tags": []
}