{
  "name": "Appointment Scheduling Automation - Production",
  "nodes": [
    {
      "id": "d2f32cf3-364b-488b-bbd8-96d85be60639",
      "name": "Tally Booking Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        -980,
        -520
      ],
      "parameters": {
        "path": "appointments/tally-booking",
        "httpMethod": "POST",
        "responseMode": "onReceived",
        "options": {}
      },
      "typeVersion": 2.1
    },
    {
      "id": "4c418823-e517-4d04-9cad-3c358c67adc3",
      "name": "Normalize Tally Booking Payload",
      "type": "n8n-nodes-base.code",
      "position": [
        -720,
        -520
      ],
      "parameters": {
        "jsCode": "const env = (typeof $env !== 'undefined') ? $env : ((typeof process !== 'undefined' && process.env) ? process.env : {});\n\nconst clientConfig = {\n  businessName: env.CLIENT_BUSINESS_NAME || 'Your Business',\n  timezone: env.CLIENT_TIMEZONE || 'Europe/Tirane',\n  defaultServiceType: env.CLIENT_DEFAULT_SERVICE_TYPE || 'appointment',\n  tallyFieldMap: {\n    name: env.TALLY_FIELD_NAME || 'name',\n    email: env.TALLY_FIELD_EMAIL || 'email',\n    phone: env.TALLY_FIELD_PHONE || 'phone',\n    datetime: env.TALLY_FIELD_DATETIME || 'datetime',\n    serviceType: env.TALLY_FIELD_SERVICE_TYPE || 'service_type',\n  },\n};\n\n// This Code node normalizes Tally's webhook payload into the appointment contract and prepares parameterized Postgres values.\nfunction normalizeKey(value) {\n  return String(value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');\n}\n\nfunction unwrapValue(value) {\n  if (Array.isArray(value)) return value.map(unwrapValue).filter(Boolean).join(', ');\n  if (value && typeof value === 'object') {\n    return value.value ?? value.label ?? value.text ?? value.email ?? value.phone_number ?? value.name ?? JSON.stringify(value);\n  }\n  return value;\n}\n\nfunction collectFieldArrays(payload) {\n  const arrays = [\n    payload?.fields,\n    payload?.answers,\n    payload?.data?.fields,\n    payload?.data?.answers,\n    payload?.body?.fields,\n    payload?.body?.answers,\n    payload?.body?.data?.fields,\n    payload?.body?.data?.answers,\n    payload?.form_response?.answers,\n    payload?.body?.form_response?.answers,\n  ];\n  return arrays.filter(Array.isArray).flat();\n}\n\nfunction findMappedValue(payload, mappedName) {\n  const targets = new Set([mappedName, normalizeKey(mappedName)]);\n  const roots = [\n    payload,\n    payload?.body,\n    payload?.data,\n    payload?.body?.data,\n    payload?.form_response,\n    payload?.body?.form_response,\n  ].filter(Boolean);\n\n  for (const root of roots) {\n    for (const [key, value] of Object.entries(root)) {\n      if (targets.has(key) || targets.has(normalizeKey(key))) return unwrapValue(value);\n    }\n  }\n\n  for (const field of collectFieldArrays(payload)) {\n    const identifiers = [\n      field.key,\n      field.id,\n      field.ref,\n      field.name,\n      field.label,\n      field.title,\n      field.field?.key,\n      field.field?.id,\n      field.field?.ref,\n      field.field?.label,\n      field.field?.title,\n    ].map(normalizeKey);\n\n    if (identifiers.some((identifier) => targets.has(identifier))) {\n      return unwrapValue(field.value ?? field.answer ?? field.text ?? field.email ?? field.phone_number ?? field.choice ?? field.choices);\n    }\n  }\n\n  return undefined;\n}\n\nfunction requireValue(payload, mappedName, label) {\n  const value = findMappedValue(payload, mappedName);\n  if (value === undefined || value === null || String(value).trim() === '') {\n    throw new Error('Missing required booking field: ' + label);\n  }\n  return String(value).trim();\n}\n\nreturn $input.all().map((item) => {\n  const payload = item.json;\n  const name = requireValue(payload, clientConfig.tallyFieldMap.name, 'name');\n  const email = requireValue(payload, clientConfig.tallyFieldMap.email, 'email').toLowerCase();\n  const rawDatetime = requireValue(payload, clientConfig.tallyFieldMap.datetime, 'datetime');\n  const parsedDatetime = new Date(rawDatetime);\n\n  if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)) throw new Error('Invalid email address: ' + email);\n  if (Number.isNaN(parsedDatetime.getTime())) throw new Error('Invalid appointment datetime: ' + rawDatetime);\n\n  const phone = String(findMappedValue(payload, clientConfig.tallyFieldMap.phone) ?? '').trim();\n  const serviceType = String(findMappedValue(payload, clientConfig.tallyFieldMap.serviceType) ?? clientConfig.defaultServiceType).trim();\n  const datetime = parsedDatetime.toISOString();\n\n  return {\n    json: {\n      name,\n      email,\n      phone,\n      service_type: serviceType,\n      datetime,\n      status: 'pending',\n      reminder_24h_sent: false,\n      reminder_2h_sent: false,\n      review_sent: false,\n      followup_count: 0,\n      pgParams: [name, email, phone, serviceType, datetime],\n      clientConfig,\n    },\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "f0b19b30-b8b4-4a2a-b70a-825ac9af1e9d",
      "name": "Insert Appointment in Postgres",
      "type": "n8n-nodes-base.postgres",
      "position": [
        -460,
        -520
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO appointments (\n  name,\n  email,\n  phone,\n  service_type,\n  datetime,\n  status,\n  reminder_24h_sent,\n  reminder_2h_sent,\n  review_sent,\n  followup_count,\n  created_at\n) VALUES (\n  $1,\n  $2,\n  $3,\n  $4,\n  $5::timestamptz,\n  'pending',\n  false,\n  false,\n  false,\n  0,\n  NOW()\n)\nRETURNING\n  id,\n  name,\n  email,\n  phone,\n  service_type,\n  datetime,\n  status,\n  reminder_24h_sent,\n  reminder_2h_sent,\n  review_sent,\n  followup_count,\n  created_at;",
        "options": {
          "queryReplacement": "={{ $json.pgParams }}"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "099c2c80-941d-4355-9ca7-b0943fdac787",
      "name": "Build Confirmation Groq Request",
      "type": "n8n-nodes-base.code",
      "position": [
        -200,
        -520
      ],
      "parameters": {
        "jsCode": "const env = (typeof $env !== 'undefined') ? $env : ((typeof process !== 'undefined' && process.env) ? process.env : {});\n\nconst clientConfig = {\n  businessName: env.CLIENT_BUSINESS_NAME || 'Your Business',\n  timezone: env.CLIENT_TIMEZONE || 'Europe/Tirane',\n  location: env.CLIENT_LOCATION || 'Your appointment location',\n  supportEmail: env.CLIENT_SUPPORT_EMAIL || 'support@example.com',\n  tone: env.CLIENT_EMAIL_TONE || 'warm, concise, professional',\n  cancellationWebhookUrl: env.CANCEL_WEBHOOK_PUBLIC_URL || 'https://n8n.example.com/webhook/appointments/cancel',\n  confirmationWebhookUrl: env.CONFIRM_WEBHOOK_PUBLIC_URL || 'https://n8n.example.com/webhook/appointments/confirm',\n  rescheduleUrl: env.RESCHEDULE_URL || 'https://example.com/reschedule',\n  googleMapsReviewUrl: env.GOOGLE_MAPS_REVIEW_URL || 'https://maps.google.com/?q=your-business',\n  waitlistClaimUrl: env.WAITLIST_CLAIM_URL || 'https://example.com/claim-slot',\n  serviceInstructionsJson: env.SERVICE_INSTRUCTIONS_JSON || '{}',\n  extra: {},\n};\n\n// This Code node builds the Groq request for a personalized booking confirmation email.\nfunction safeJsonParse(value, fallback) {\n  try {\n    return JSON.parse(value);\n  } catch (_) {\n    return fallback;\n  }\n}\n\nfunction makeGroqRequest(messageType, appointment, waitlistCandidate) {\n  const appointmentId = appointment.id;\n  const links = {\n    confirm: clientConfig.confirmationWebhookUrl + '?appointment_id=' + encodeURIComponent(appointmentId),\n    cancel: clientConfig.cancellationWebhookUrl + '?appointment_id=' + encodeURIComponent(appointmentId),\n    reschedule: clientConfig.rescheduleUrl,\n    review: clientConfig.googleMapsReviewUrl,\n    waitlistClaim: waitlistCandidate?.id\n      ? clientConfig.waitlistClaimUrl + '?waitlist_id=' + encodeURIComponent(waitlistCandidate.id) + '&appointment_id=' + encodeURIComponent(appointmentId)\n      : clientConfig.waitlistClaimUrl,\n  };\n\n  return {\n    model: 'llama-3.3-70b-versatile',\n    temperature: 0.3,\n    max_tokens: 500,\n    response_format: { type: 'json_object' },\n    messages: [\n      {\n        role: 'system',\n        content: [\n          'You generate transactional appointment emails.',\n          'Return JSON only with exactly these keys: subject, html_body, text_body.',\n          'Do not wrap the JSON in markdown fences.',\n          'Do not invent policies, prices, addresses, or medical/legal advice.',\n          'Use the supplied business configuration and appointment data only.',\n        ].join(' '),\n      },\n      {\n        role: 'user',\n        content: JSON.stringify({\n          message_type: messageType,\n          business: {\n            name: clientConfig.businessName,\n            timezone: clientConfig.timezone,\n            location: clientConfig.location,\n            support_email: clientConfig.supportEmail,\n            tone: clientConfig.tone,\n            service_instructions: safeJsonParse(clientConfig.serviceInstructionsJson, {}),\n          },\n          appointment,\n          waitlist_candidate: waitlistCandidate || null,\n          links,\n          output_contract: {\n            subject: 'short email subject',\n            html_body: 'complete HTML email body',\n            text_body: 'plain text fallback',\n          },\n        }),\n      },\n    ],\n  };\n}\n\nreturn $input.all().map((item, index) => {\n  const appointment = item.json;\n  const waitlistCandidate = item.json.waitlistCandidate || null;\n  return {\n    json: {\n      ...item.json,\n      groqMessageType: 'booking_confirmation',\n      groqRequest: makeGroqRequest('booking_confirmation', appointment, waitlistCandidate),\n    },\n    pairedItem: { item: index },\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "d4b5929c-d95e-40e9-a72d-63f8b0140d22",
      "name": "Groq Generate Confirmation Email",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        60,
        -520
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.groq.com/openai/v1/chat/completions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.groqRequest) }}",
        "options": {}
      },
      "typeVersion": 4.2
    },
    {
      "id": "7fb46ef9-024e-4843-9402-0531769bc43c",
      "name": "Parse Confirmation Email JSON",
      "type": "n8n-nodes-base.code",
      "position": [
        320,
        -520
      ],
      "parameters": {
        "jsCode": "const env = (typeof $env !== 'undefined') ? $env : ((typeof process !== 'undefined' && process.env) ? process.env : {});\n\nconst clientConfig = {\n  parserName: env.CLIENT_EMAIL_JSON_PARSER_NAME || 'groq-email-json-parser',\n};\n\n// This Code node parses Groq confirmation email JSON, strips markdown fences, and preserves the appointment data.\nfunction stripMarkdownFences(value) {\n  const fence = String.fromCharCode(96).repeat(3);\n  return String(value ?? '')\n    .trim()\n    .replace(new RegExp('^' + fence + '(?:json)?\\\\s*', 'i'), '')\n    .replace(new RegExp('\\\\s*' + fence + '$', 'i'), '')\n    .trim();\n}\n\nconst sourceItems = $('Build Confirmation Groq Request').all();\n\nreturn $input.all().map((item, index) => {\n  const base = sourceItems[index]?.json || {};\n  const rawContent =\n    item.json?.choices?.[0]?.message?.content ??\n    item.json?.body?.choices?.[0]?.message?.content ??\n    item.json?.data?.choices?.[0]?.message?.content;\n\n  if (!rawContent) {\n    throw new Error('Groq response missing choices[0].message.content');\n  }\n\n  let parsed;\n  const cleaned = stripMarkdownFences(rawContent);\n  try {\n    parsed = JSON.parse(cleaned);\n  } catch (error) {\n    throw new Error('Groq JSON parse failed: ' + error.message + '. Cleaned response preview: ' + cleaned.slice(0, 500));\n  }\n\n  for (const field of ['subject', 'html_body', 'text_body']) {\n    if (!parsed[field] || typeof parsed[field] !== 'string') {\n      throw new Error('Groq JSON missing required string field: ' + field);\n    }\n  }\n\n  return {\n    json: {\n      ...base,\n      email_subject: parsed.subject,\n      email_html: parsed.html_body,\n      email_text: parsed.text_body,\n      groqParsedBy: clientConfig.parserName,\n    },\n    pairedItem: { item: index },\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "790ed75c-753d-412e-836d-9529cc735cd0",
      "name": "Send Confirmation Email via Gmail",
      "type": "n8n-nodes-base.gmail",
      "position": [
        580,
        -520
      ],
      "parameters": {
        "sendTo": "={{ $json.email }}",
        "subject": "={{ $json.email_subject }}",
        "message": "={{ $json.email_html }}",
        "emailType": "html",
        "options": {
          "appendAttribution": false
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "6eb75115-a412-4a66-bc89-882880a49331",
      "name": "Hourly 24h Reminder Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -980,
        -180
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 1,
              "triggerAtMinute": 0
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "ded0c902-d3a5-488a-878d-43069831fe49",
      "name": "Select Appointments Needing 24h Reminder",
      "type": "n8n-nodes-base.postgres",
      "position": [
        -720,
        -180
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT\n  id,\n  name,\n  email,\n  phone,\n  service_type,\n  datetime,\n  status,\n  reminder_24h_sent,\n  reminder_2h_sent,\n  review_sent,\n  followup_count,\n  created_at\nFROM appointments\nWHERE status IN ('pending', 'confirmed')\n  AND reminder_24h_sent = false\n  AND datetime >= NOW()\n  AND datetime <= NOW() + INTERVAL '24 hours'\nORDER BY datetime ASC;",
        "options": {}
      },
      "typeVersion": 2.6
    },
    {
      "id": "da93eb7f-9078-4cdc-8be9-39140106240b",
      "name": "Build 24h Reminder Groq Request",
      "type": "n8n-nodes-base.code",
      "position": [
        -460,
        -180
      ],
      "parameters": {
        "jsCode": "const env = (typeof $env !== 'undefined') ? $env : ((typeof process !== 'undefined' && process.env) ? process.env : {});\n\nconst clientConfig = {\n  businessName: env.CLIENT_BUSINESS_NAME || 'Your Business',\n  timezone: env.CLIENT_TIMEZONE || 'Europe/Tirane',\n  location: env.CLIENT_LOCATION || 'Your appointment location',\n  supportEmail: env.CLIENT_SUPPORT_EMAIL || 'support@example.com',\n  tone: env.CLIENT_EMAIL_TONE || 'warm, concise, professional',\n  cancellationWebhookUrl: env.CANCEL_WEBHOOK_PUBLIC_URL || 'https://n8n.example.com/webhook/appointments/cancel',\n  confirmationWebhookUrl: env.CONFIRM_WEBHOOK_PUBLIC_URL || 'https://n8n.example.com/webhook/appointments/confirm',\n  rescheduleUrl: env.RESCHEDULE_URL || 'https://example.com/reschedule',\n  googleMapsReviewUrl: env.GOOGLE_MAPS_REVIEW_URL || 'https://maps.google.com/?q=your-business',\n  waitlistClaimUrl: env.WAITLIST_CLAIM_URL || 'https://example.com/claim-slot',\n  serviceInstructionsJson: env.SERVICE_INSTRUCTIONS_JSON || '{}',\n  extra: {},\n};\n\n// This Code node builds the Groq request for the 24-hour reminder email using client timing/link config.\nfunction safeJsonParse(value, fallback) {\n  try {\n    return JSON.parse(value);\n  } catch (_) {\n    return fallback;\n  }\n}\n\nfunction makeGroqRequest(messageType, appointment, waitlistCandidate) {\n  const appointmentId = appointment.id;\n  const links = {\n    confirm: clientConfig.confirmationWebhookUrl + '?appointment_id=' + encodeURIComponent(appointmentId),\n    cancel: clientConfig.cancellationWebhookUrl + '?appointment_id=' + encodeURIComponent(appointmentId),\n    reschedule: clientConfig.rescheduleUrl,\n    review: clientConfig.googleMapsReviewUrl,\n    waitlistClaim: waitlistCandidate?.id\n      ? clientConfig.waitlistClaimUrl + '?waitlist_id=' + encodeURIComponent(waitlistCandidate.id) + '&appointment_id=' + encodeURIComponent(appointmentId)\n      : clientConfig.waitlistClaimUrl,\n  };\n\n  return {\n    model: 'llama-3.3-70b-versatile',\n    temperature: 0.3,\n    max_tokens: 500,\n    response_format: { type: 'json_object' },\n    messages: [\n      {\n        role: 'system',\n        content: [\n          'You generate transactional appointment emails.',\n          'Return JSON only with exactly these keys: subject, html_body, text_body.',\n          'Do not wrap the JSON in markdown fences.',\n          'Do not invent policies, prices, addresses, or medical/legal advice.',\n          'Use the supplied business configuration and appointment data only.',\n        ].join(' '),\n      },\n      {\n        role: 'user',\n        content: JSON.stringify({\n          message_type: messageType,\n          business: {\n            name: clientConfig.businessName,\n            timezone: clientConfig.timezone,\n            location: clientConfig.location,\n            support_email: clientConfig.supportEmail,\n            tone: clientConfig.tone,\n            service_instructions: safeJsonParse(clientConfig.serviceInstructionsJson, {}),\n          },\n          appointment,\n          waitlist_candidate: waitlistCandidate || null,\n          links,\n          output_contract: {\n            subject: 'short email subject',\n            html_body: 'complete HTML email body',\n            text_body: 'plain text fallback',\n          },\n        }),\n      },\n    ],\n  };\n}\n\nreturn $input.all().map((item, index) => {\n  const appointment = item.json;\n  const waitlistCandidate = item.json.waitlistCandidate || null;\n  return {\n    json: {\n      ...item.json,\n      groqMessageType: '24h_reminder',\n      groqRequest: makeGroqRequest('24h_reminder', appointment, waitlistCandidate),\n    },\n    pairedItem: { item: index },\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "a3c348f1-c1bb-4158-bcb5-b01448a0d3fb",
      "name": "Groq Generate 24h Reminder Email",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -200,
        -180
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.groq.com/openai/v1/chat/completions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.groqRequest) }}",
        "options": {}
      },
      "typeVersion": 4.2
    },
    {
      "id": "52c865de-911a-4154-bd31-9a224a8458cb",
      "name": "Parse 24h Reminder Email JSON",
      "type": "n8n-nodes-base.code",
      "position": [
        60,
        -180
      ],
      "parameters": {
        "jsCode": "const env = (typeof $env !== 'undefined') ? $env : ((typeof process !== 'undefined' && process.env) ? process.env : {});\n\nconst clientConfig = {\n  parserName: env.CLIENT_EMAIL_JSON_PARSER_NAME || 'groq-email-json-parser',\n};\n\n// This Code node parses Groq 24-hour reminder JSON, strips markdown fences, and preserves the appointment data.\nfunction stripMarkdownFences(value) {\n  const fence = String.fromCharCode(96).repeat(3);\n  return String(value ?? '')\n    .trim()\n    .replace(new RegExp('^' + fence + '(?:json)?\\\\s*', 'i'), '')\n    .replace(new RegExp('\\\\s*' + fence + '$', 'i'), '')\n    .trim();\n}\n\nconst sourceItems = $('Build 24h Reminder Groq Request').all();\n\nreturn $input.all().map((item, index) => {\n  const base = sourceItems[index]?.json || {};\n  const rawContent =\n    item.json?.choices?.[0]?.message?.content ??\n    item.json?.body?.choices?.[0]?.message?.content ??\n    item.json?.data?.choices?.[0]?.message?.content;\n\n  if (!rawContent) {\n    throw new Error('Groq response missing choices[0].message.content');\n  }\n\n  let parsed;\n  const cleaned = stripMarkdownFences(rawContent);\n  try {\n    parsed = JSON.parse(cleaned);\n  } catch (error) {\n    throw new Error('Groq JSON parse failed: ' + error.message + '. Cleaned response preview: ' + cleaned.slice(0, 500));\n  }\n\n  for (const field of ['subject', 'html_body', 'text_body']) {\n    if (!parsed[field] || typeof parsed[field] !== 'string') {\n      throw new Error('Groq JSON missing required string field: ' + field);\n    }\n  }\n\n  return {\n    json: {\n      ...base,\n      email_subject: parsed.subject,\n      email_html: parsed.html_body,\n      email_text: parsed.text_body,\n      groqParsedBy: clientConfig.parserName,\n    },\n    pairedItem: { item: index },\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "f1c4d88c-7f4c-45c1-a712-67dd7b8db5c3",
      "name": "Send 24h Reminder via Gmail",
      "type": "n8n-nodes-base.gmail",
      "position": [
        320,
        -180
      ],
      "parameters": {
        "sendTo": "={{ $json.email }}",
        "subject": "={{ $json.email_subject }}",
        "message": "={{ $json.email_html }}",
        "emailType": "html",
        "options": {
          "appendAttribution": false
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "9ceb60b0-36c5-4662-af2b-5c6d28d1ba94",
      "name": "Mark 24h Reminder Sent in Postgres",
      "type": "n8n-nodes-base.postgres",
      "position": [
        580,
        -180
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE appointments\nSET reminder_24h_sent = true\nWHERE id = $1::uuid\nRETURNING id, reminder_24h_sent;",
        "options": {
          "queryReplacement": "={{ [$('Parse 24h Reminder Email JSON').item.json.id] }}"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "cf1d2c0e-fb3c-486d-95e2-de80b3f17997",
      "name": "Every 30m 2h Reminder Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -980,
        160
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 30
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "ddc349b1-8f83-4da1-880e-1d00ae1b2395",
      "name": "Select Appointments Needing 2h Reminder",
      "type": "n8n-nodes-base.postgres",
      "position": [
        -720,
        160
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT\n  id,\n  name,\n  email,\n  phone,\n  service_type,\n  datetime,\n  status,\n  reminder_24h_sent,\n  reminder_2h_sent,\n  review_sent,\n  followup_count,\n  created_at\nFROM appointments\nWHERE status IN ('pending', 'confirmed')\n  AND reminder_2h_sent = false\n  AND datetime >= NOW()\n  AND datetime <= NOW() + INTERVAL '2 hours'\nORDER BY datetime ASC;",
        "options": {}
      },
      "typeVersion": 2.6
    },
    {
      "id": "0e54782a-eec0-4a21-bf84-e87575568653",
      "name": "Build 2h Reminder Groq Request",
      "type": "n8n-nodes-base.code",
      "position": [
        -460,
        160
      ],
      "parameters": {
        "jsCode": "const env = (typeof $env !== 'undefined') ? $env : ((typeof process !== 'undefined' && process.env) ? process.env : {});\n\nconst clientConfig = {\n  businessName: env.CLIENT_BUSINESS_NAME || 'Your Business',\n  timezone: env.CLIENT_TIMEZONE || 'Europe/Tirane',\n  location: env.CLIENT_LOCATION || 'Your appointment location',\n  supportEmail: env.CLIENT_SUPPORT_EMAIL || 'support@example.com',\n  tone: env.CLIENT_EMAIL_TONE || 'warm, concise, professional',\n  cancellationWebhookUrl: env.CANCEL_WEBHOOK_PUBLIC_URL || 'https://n8n.example.com/webhook/appointments/cancel',\n  confirmationWebhookUrl: env.CONFIRM_WEBHOOK_PUBLIC_URL || 'https://n8n.example.com/webhook/appointments/confirm',\n  rescheduleUrl: env.RESCHEDULE_URL || 'https://example.com/reschedule',\n  googleMapsReviewUrl: env.GOOGLE_MAPS_REVIEW_URL || 'https://maps.google.com/?q=your-business',\n  waitlistClaimUrl: env.WAITLIST_CLAIM_URL || 'https://example.com/claim-slot',\n  serviceInstructionsJson: env.SERVICE_INSTRUCTIONS_JSON || '{}',\n  extra: {},\n};\n\n// This Code node builds the Groq request for the 2-hour final reminder email.\nfunction safeJsonParse(value, fallback) {\n  try {\n    return JSON.parse(value);\n  } catch (_) {\n    return fallback;\n  }\n}\n\nfunction makeGroqRequest(messageType, appointment, waitlistCandidate) {\n  const appointmentId = appointment.id;\n  const links = {\n    confirm: clientConfig.confirmationWebhookUrl + '?appointment_id=' + encodeURIComponent(appointmentId),\n    cancel: clientConfig.cancellationWebhookUrl + '?appointment_id=' + encodeURIComponent(appointmentId),\n    reschedule: clientConfig.rescheduleUrl,\n    review: clientConfig.googleMapsReviewUrl,\n    waitlistClaim: waitlistCandidate?.id\n      ? clientConfig.waitlistClaimUrl + '?waitlist_id=' + encodeURIComponent(waitlistCandidate.id) + '&appointment_id=' + encodeURIComponent(appointmentId)\n      : clientConfig.waitlistClaimUrl,\n  };\n\n  return {\n    model: 'llama-3.3-70b-versatile',\n    temperature: 0.3,\n    max_tokens: 500,\n    response_format: { type: 'json_object' },\n    messages: [\n      {\n        role: 'system',\n        content: [\n          'You generate transactional appointment emails.',\n          'Return JSON only with exactly these keys: subject, html_body, text_body.',\n          'Do not wrap the JSON in markdown fences.',\n          'Do not invent policies, prices, addresses, or medical/legal advice.',\n          'Use the supplied business configuration and appointment data only.',\n        ].join(' '),\n      },\n      {\n        role: 'user',\n        content: JSON.stringify({\n          message_type: messageType,\n          business: {\n            name: clientConfig.businessName,\n            timezone: clientConfig.timezone,\n            location: clientConfig.location,\n            support_email: clientConfig.supportEmail,\n            tone: clientConfig.tone,\n            service_instructions: safeJsonParse(clientConfig.serviceInstructionsJson, {}),\n          },\n          appointment,\n          waitlist_candidate: waitlistCandidate || null,\n          links,\n          output_contract: {\n            subject: 'short email subject',\n            html_body: 'complete HTML email body',\n            text_body: 'plain text fallback',\n          },\n        }),\n      },\n    ],\n  };\n}\n\nreturn $input.all().map((item, index) => {\n  const appointment = item.json;\n  const waitlistCandidate = item.json.waitlistCandidate || null;\n  return {\n    json: {\n      ...item.json,\n      groqMessageType: '2h_reminder',\n      groqRequest: makeGroqRequest('2h_reminder', appointment, waitlistCandidate),\n    },\n    pairedItem: { item: index },\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "11889486-406e-4f0a-8d29-458785701cb0",
      "name": "Groq Generate 2h Reminder Email",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -200,
        160
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.groq.com/openai/v1/chat/completions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.groqRequest) }}",
        "options": {}
      },
      "typeVersion": 4.2
    },
    {
      "id": "f06ead89-2b95-49b9-b710-c5cd25325c29",
      "name": "Parse 2h Reminder Email JSON",
      "type": "n8n-nodes-base.code",
      "position": [
        60,
        160
      ],
      "parameters": {
        "jsCode": "const env = (typeof $env !== 'undefined') ? $env : ((typeof process !== 'undefined' && process.env) ? process.env : {});\n\nconst clientConfig = {\n  parserName: env.CLIENT_EMAIL_JSON_PARSER_NAME || 'groq-email-json-parser',\n};\n\n// This Code node parses Groq 2-hour reminder JSON, strips markdown fences, and preserves the appointment data.\nfunction stripMarkdownFences(value) {\n  const fence = String.fromCharCode(96).repeat(3);\n  return String(value ?? '')\n    .trim()\n    .replace(new RegExp('^' + fence + '(?:json)?\\\\s*', 'i'), '')\n    .replace(new RegExp('\\\\s*' + fence + '$', 'i'), '')\n    .trim();\n}\n\nconst sourceItems = $('Build 2h Reminder Groq Request').all();\n\nreturn $input.all().map((item, index) => {\n  const base = sourceItems[index]?.json || {};\n  const rawContent =\n    item.json?.choices?.[0]?.message?.content ??\n    item.json?.body?.choices?.[0]?.message?.content ??\n    item.json?.data?.choices?.[0]?.message?.content;\n\n  if (!rawContent) {\n    throw new Error('Groq response missing choices[0].message.content');\n  }\n\n  let parsed;\n  const cleaned = stripMarkdownFences(rawContent);\n  try {\n    parsed = JSON.parse(cleaned);\n  } catch (error) {\n    throw new Error('Groq JSON parse failed: ' + error.message + '. Cleaned response preview: ' + cleaned.slice(0, 500));\n  }\n\n  for (const field of ['subject', 'html_body', 'text_body']) {\n    if (!parsed[field] || typeof parsed[field] !== 'string') {\n      throw new Error('Groq JSON missing required string field: ' + field);\n    }\n  }\n\n  return {\n    json: {\n      ...base,\n      email_subject: parsed.subject,\n      email_html: parsed.html_body,\n      email_text: parsed.text_body,\n      groqParsedBy: clientConfig.parserName,\n    },\n    pairedItem: { item: index },\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "a36531d9-2595-485a-81a1-a7a53fe0a953",
      "name": "Send 2h Reminder via Gmail",
      "type": "n8n-nodes-base.gmail",
      "position": [
        320,
        160
      ],
      "parameters": {
        "sendTo": "={{ $json.email }}",
        "subject": "={{ $json.email_subject }}",
        "message": "={{ $json.email_html }}",
        "emailType": "html",
        "options": {
          "appendAttribution": false
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "8f5a2f82-dbf7-4852-83d0-7e54b04f8514",
      "name": "Mark 2h Reminder Sent in Postgres",
      "type": "n8n-nodes-base.postgres",
      "position": [
        580,
        160
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE appointments\nSET reminder_2h_sent = true\nWHERE id = $1::uuid\nRETURNING id, reminder_2h_sent;",
        "options": {
          "queryReplacement": "={{ [$('Parse 2h Reminder Email JSON').item.json.id] }}"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "dba825a9-4640-4493-9451-b9639c72956e",
      "name": "Cancellation Webhook Listener",
      "type": "n8n-nodes-base.webhook",
      "position": [
        -980,
        500
      ],
      "parameters": {
        "path": "appointments/cancel",
        "httpMethod": "GET",
        "responseMode": "onReceived",
        "options": {}
      },
      "typeVersion": 2.1
    },
    {
      "id": "1b6f6914-1e0d-4694-80b2-4d4b4d013b27",
      "name": "Normalize Cancellation Request",
      "type": "n8n-nodes-base.code",
      "position": [
        -720,
        500
      ],
      "parameters": {
        "jsCode": "const env = (typeof $env !== 'undefined') ? $env : ((typeof process !== 'undefined' && process.env) ? process.env : {});\n\nconst clientConfig = {\n  acceptedIdFields: (env.CANCEL_APPOINTMENT_ID_FIELDS || 'appointment_id,id').split(',').map((value) => value.trim()),\n};\n\n// This Code node extracts the appointment id from cancellation webhook query/body data and prepares a parameterized update.\nfunction pickAppointmentId(payload) {\n  const roots = [payload?.query, payload?.body, payload?.params, payload].filter(Boolean);\n  for (const root of roots) {\n    for (const key of clientConfig.acceptedIdFields) {\n      if (root[key]) return String(root[key]).trim();\n    }\n  }\n  return '';\n}\n\nreturn $input.all().map((item, index) => {\n  const appointmentId = pickAppointmentId(item.json);\n  if (!appointmentId) throw new Error('Cancellation request missing appointment_id');\n  return {\n    json: {\n      appointment_id: appointmentId,\n      pgParams: [appointmentId],\n      clientConfig,\n    },\n    pairedItem: { item: index },\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "6f9f6ce5-a220-4c90-aeca-903c8a4e8341",
      "name": "Cancel Appointment in Postgres",
      "type": "n8n-nodes-base.postgres",
      "position": [
        -460,
        500
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE appointments\nSET status = 'cancelled'\nWHERE id = $1::uuid\n  AND status <> 'cancelled'\nRETURNING\n  id,\n  name,\n  email,\n  phone,\n  service_type,\n  datetime,\n  status,\n  reminder_24h_sent,\n  reminder_2h_sent,\n  review_sent,\n  followup_count,\n  created_at;",
        "options": {
          "queryReplacement": "={{ $json.pgParams }}"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "e7e4231c-3752-4be2-be95-856d683ae0fc",
      "name": "Select Next Waitlist Candidate",
      "type": "n8n-nodes-base.postgres",
      "position": [
        -200,
        500
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT\n  id,\n  name,\n  email,\n  phone,\n  service_type,\n  preferred_datetime,\n  status,\n  created_at\nFROM waitlist\nWHERE status = 'waiting'\n  AND service_type = $1\n  AND preferred_datetime = $2::timestamptz\nORDER BY created_at ASC\nLIMIT 1;",
        "options": {
          "queryReplacement": "={{ [$json.service_type, $json.datetime] }}"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "29a0d89b-e88f-4c23-a5c4-a484c37de8ff",
      "name": "Waitlist Candidate Exists",
      "type": "n8n-nodes-base.if",
      "position": [
        60,
        500
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "f98e2bcd-bbf4-43d2-9aab-571551223438",
              "leftValue": "={{ $json.email }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "exists",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "typeVersion": 2.2
    },
    {
      "id": "a264bf0b-af9b-4706-afa7-3e32794579a5",
      "name": "Prepare Waitlist Offer Context",
      "type": "n8n-nodes-base.code",
      "position": [
        320,
        500
      ],
      "parameters": {
        "jsCode": "const env = (typeof $env !== 'undefined') ? $env : ((typeof process !== 'undefined' && process.env) ? process.env : {});\n\nconst clientConfig = {\n  waitlistClaimUrl: env.WAITLIST_CLAIM_URL || 'https://example.com/claim-slot',\n};\n\n// This Code node combines the cancelled appointment with the first waitlist candidate before asking Groq to write the offer email.\nconst cancelledAppointment = $('Cancel Appointment in Postgres').first().json;\n\nreturn $input.all().map((item, index) => {\n  return {\n    json: {\n      ...cancelledAppointment,\n      waitlistCandidate: item.json,\n      clientConfig,\n    },\n    pairedItem: { item: index },\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "52cb9139-48a6-43ce-8344-4fd3dc927d88",
      "name": "Build Waitlist Offer Groq Request",
      "type": "n8n-nodes-base.code",
      "position": [
        580,
        500
      ],
      "parameters": {
        "jsCode": "const env = (typeof $env !== 'undefined') ? $env : ((typeof process !== 'undefined' && process.env) ? process.env : {});\n\nconst clientConfig = {\n  businessName: env.CLIENT_BUSINESS_NAME || 'Your Business',\n  timezone: env.CLIENT_TIMEZONE || 'Europe/Tirane',\n  location: env.CLIENT_LOCATION || 'Your appointment location',\n  supportEmail: env.CLIENT_SUPPORT_EMAIL || 'support@example.com',\n  tone: env.CLIENT_EMAIL_TONE || 'warm, concise, professional',\n  cancellationWebhookUrl: env.CANCEL_WEBHOOK_PUBLIC_URL || 'https://n8n.example.com/webhook/appointments/cancel',\n  confirmationWebhookUrl: env.CONFIRM_WEBHOOK_PUBLIC_URL || 'https://n8n.example.com/webhook/appointments/confirm',\n  rescheduleUrl: env.RESCHEDULE_URL || 'https://example.com/reschedule',\n  googleMapsReviewUrl: env.GOOGLE_MAPS_REVIEW_URL || 'https://maps.google.com/?q=your-business',\n  waitlistClaimUrl: env.WAITLIST_CLAIM_URL || 'https://example.com/claim-slot',\n  serviceInstructionsJson: env.SERVICE_INSTRUCTIONS_JSON || '{}',\n  extra: {},\n};\n\n// This Code node builds the Groq request for the waitlist slot-offer email.\nfunction safeJsonParse(value, fallback) {\n  try {\n    return JSON.parse(value);\n  } catch (_) {\n    return fallback;\n  }\n}\n\nfunction makeGroqRequest(messageType, appointment, waitlistCandidate) {\n  const appointmentId = appointment.id;\n  const links = {\n    confirm: clientConfig.confirmationWebhookUrl + '?appointment_id=' + encodeURIComponent(appointmentId),\n    cancel: clientConfig.cancellationWebhookUrl + '?appointment_id=' + encodeURIComponent(appointmentId),\n    reschedule: clientConfig.rescheduleUrl,\n    review: clientConfig.googleMapsReviewUrl,\n    waitlistClaim: waitlistCandidate?.id\n      ? clientConfig.waitlistClaimUrl + '?waitlist_id=' + encodeURIComponent(waitlistCandidate.id) + '&appointment_id=' + encodeURIComponent(appointmentId)\n      : clientConfig.waitlistClaimUrl,\n  };\n\n  return {\n    model: 'llama-3.3-70b-versatile',\n    temperature: 0.3,\n    max_tokens: 500,\n    response_format: { type: 'json_object' },\n    messages: [\n      {\n        role: 'system',\n        content: [\n          'You generate transactional appointment emails.',\n          'Return JSON only with exactly these keys: subject, html_body, text_body.',\n          'Do not wrap the JSON in markdown fences.',\n          'Do not invent policies, prices, addresses, or medical/legal advice.',\n          'Use the supplied business configuration and appointment data only.',\n        ].join(' '),\n      },\n      {\n        role: 'user',\n        content: JSON.stringify({\n          message_type: messageType,\n          business: {\n            name: clientConfig.businessName,\n            timezone: clientConfig.timezone,\n            location: clientConfig.location,\n            support_email: clientConfig.supportEmail,\n            tone: clientConfig.tone,\n            service_instructions: safeJsonParse(clientConfig.serviceInstructionsJson, {}),\n          },\n          appointment,\n          waitlist_candidate: waitlistCandidate || null,\n          links,\n          output_contract: {\n            subject: 'short email subject',\n            html_body: 'complete HTML email body',\n            text_body: 'plain text fallback',\n          },\n        }),\n      },\n    ],\n  };\n}\n\nreturn $input.all().map((item, index) => {\n  const appointment = item.json;\n  const waitlistCandidate = item.json.waitlistCandidate || null;\n  return {\n    json: {\n      ...item.json,\n      groqMessageType: 'waitlist_slot_offer',\n      groqRequest: makeGroqRequest('waitlist_slot_offer', appointment, waitlistCandidate),\n    },\n    pairedItem: { item: index },\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "35b0e5e3-8f8f-4b39-aed5-7a5823288f30",
      "name": "Groq Generate Waitlist Offer Email",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        840,
        500
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.groq.com/openai/v1/chat/completions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.groqRequest) }}",
        "options": {}
      },
      "typeVersion": 4.2
    },
    {
      "id": "e02bdaf3-2c63-416a-87d2-9c82ac645400",
      "name": "Parse Waitlist Offer Email JSON",
      "type": "n8n-nodes-base.code",
      "position": [
        1100,
        500
      ],
      "parameters": {
        "jsCode": "const env = (typeof $env !== 'undefined') ? $env : ((typeof process !== 'undefined' && process.env) ? process.env : {});\n\nconst clientConfig = {\n  parserName: env.CLIENT_EMAIL_JSON_PARSER_NAME || 'groq-email-json-parser',\n};\n\n// This Code node parses Groq waitlist offer JSON, strips markdown fences, and preserves the candidate data.\nfunction stripMarkdownFences(value) {\n  const fence = String.fromCharCode(96).repeat(3);\n  return String(value ?? '')\n    .trim()\n    .replace(new RegExp('^' + fence + '(?:json)?\\\\s*', 'i'), '')\n    .replace(new RegExp('\\\\s*' + fence + '$', 'i'), '')\n    .trim();\n}\n\nconst sourceItems = $('Build Waitlist Offer Groq Request').all();\n\nreturn $input.all().map((item, index) => {\n  const base = sourceItems[index]?.json || {};\n  const rawContent =\n    item.json?.choices?.[0]?.message?.content ??\n    item.json?.body?.choices?.[0]?.message?.content ??\n    item.json?.data?.choices?.[0]?.message?.content;\n\n  if (!rawContent) {\n    throw new Error('Groq response missing choices[0].message.content');\n  }\n\n  let parsed;\n  const cleaned = stripMarkdownFences(rawContent);\n  try {\n    parsed = JSON.parse(cleaned);\n  } catch (error) {\n    throw new Error('Groq JSON parse failed: ' + error.message + '. Cleaned response preview: ' + cleaned.slice(0, 500));\n  }\n\n  for (const field of ['subject', 'html_body', 'text_body']) {\n    if (!parsed[field] || typeof parsed[field] !== 'string') {\n      throw new Error('Groq JSON missing required string field: ' + field);\n    }\n  }\n\n  return {\n    json: {\n      ...base,\n      email_subject: parsed.subject,\n      email_html: parsed.html_body,\n      email_text: parsed.text_body,\n      groqParsedBy: clientConfig.parserName,\n    },\n    pairedItem: { item: index },\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "e6a65f0a-e212-46d5-8d20-e05880a4eee8",
      "name": "Offer Slot to Waitlist via Gmail",
      "type": "n8n-nodes-base.gmail",
      "position": [
        1360,
        500
      ],
      "parameters": {
        "sendTo": "={{ $json.waitlistCandidate.email }}",
        "subject": "={{ $json.email_subject }}",
        "message": "={{ $json.email_html }}",
        "emailType": "html",
        "options": {
          "appendAttribution": false
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "51c57605-9d75-44f1-88e6-71e131be90bd",
      "name": "Review Request Schedule Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -980,
        840
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 30
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "97f9c21c-a644-4d05-9bbe-1a8f9f91477f",
      "name": "Prepare Review Scan Window",
      "type": "n8n-nodes-base.code",
      "position": [
        -720,
        840
      ],
      "parameters": {
        "jsCode": "const env = (typeof $env !== 'undefined') ? $env : ((typeof process !== 'undefined' && process.env) ? process.env : {});\n\nconst clientConfig = {\n  appointmentDurationMinutes: Number(env.APPOINTMENT_DURATION_MINUTES || 60),\n  reviewDelayMinutes: Number(env.REVIEW_DELAY_MINUTES || 120),\n};\n\n// This Code node converts client timing config into a review cutoff timestamp used as a Postgres query parameter.\nconst cutoffMs = Date.now() - ((clientConfig.appointmentDurationMinutes + clientConfig.reviewDelayMinutes) * 60 * 1000);\nconst reviewCutoffIso = new Date(cutoffMs).toISOString();\n\nreturn [\n  {\n    json: {\n      reviewCutoffIso,\n      pgParams: [reviewCutoffIso],\n      clientConfig,\n    },\n  },\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "1e0d84ab-c51f-430a-bc49-2c5d214c9e75",
      "name": "Select Appointments Needing Review Request",
      "type": "n8n-nodes-base.postgres",
      "position": [
        -460,
        840
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT\n  id,\n  name,\n  email,\n  phone,\n  service_type,\n  datetime,\n  status,\n  reminder_24h_sent,\n  reminder_2h_sent,\n  review_sent,\n  followup_count,\n  created_at\nFROM appointments\nWHERE status = 'completed'\n  AND review_sent = false\n  AND datetime <= $1::timestamptz\nORDER BY datetime ASC;",
        "options": {
          "queryReplacement": "={{ $json.pgParams }}"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "86413806-5c09-4154-9971-c048d2b7445f",
      "name": "Build Review Request Groq Email",
      "type": "n8n-nodes-base.code",
      "position": [
        -200,
        840
      ],
      "parameters": {
        "jsCode": "const env = (typeof $env !== 'undefined') ? $env : ((typeof process !== 'undefined' && process.env) ? process.env : {});\n\nconst clientConfig = {\n  businessName: env.CLIENT_BUSINESS_NAME || 'Your Business',\n  timezone: env.CLIENT_TIMEZONE || 'Europe/Tirane',\n  location: env.CLIENT_LOCATION || 'Your appointment location',\n  supportEmail: env.CLIENT_SUPPORT_EMAIL || 'support@example.com',\n  tone: env.CLIENT_EMAIL_TONE || 'warm, concise, professional',\n  cancellationWebhookUrl: env.CANCEL_WEBHOOK_PUBLIC_URL || 'https://n8n.example.com/webhook/appointments/cancel',\n  confirmationWebhookUrl: env.CONFIRM_WEBHOOK_PUBLIC_URL || 'https://n8n.example.com/webhook/appointments/confirm',\n  rescheduleUrl: env.RESCHEDULE_URL || 'https://example.com/reschedule',\n  googleMapsReviewUrl: env.GOOGLE_MAPS_REVIEW_URL || 'https://maps.google.com/?q=your-business',\n  waitlistClaimUrl: env.WAITLIST_CLAIM_URL || 'https://example.com/claim-slot',\n  serviceInstructionsJson: env.SERVICE_INSTRUCTIONS_JSON || '{}',\n  extra: {},\n};\n\n// This Code node builds the Groq request for the post-visit review email.\nfunction safeJsonParse(value, fallback) {\n  try {\n    return JSON.parse(value);\n  } catch (_) {\n    return fallback;\n  }\n}\n\nfunction makeGroqRequest(messageType, appointment, waitlistCandidate) {\n  const appointmentId = appointment.id;\n  const links = {\n    confirm: clientConfig.confirmationWebhookUrl + '?appointment_id=' + encodeURIComponent(appointmentId),\n    cancel: clientConfig.cancellationWebhookUrl + '?appointment_id=' + encodeURIComponent(appointmentId),\n    reschedule: clientConfig.rescheduleUrl,\n    review: clientConfig.googleMapsReviewUrl,\n    waitlistClaim: waitlistCandidate?.id\n      ? clientConfig.waitlistClaimUrl + '?waitlist_id=' + encodeURIComponent(waitlistCandidate.id) + '&appointment_id=' + encodeURIComponent(appointmentId)\n      : clientConfig.waitlistClaimUrl,\n  };\n\n  return {\n    model: 'llama-3.3-70b-versatile',\n    temperature: 0.3,\n    max_tokens: 500,\n    response_format: { type: 'json_object' },\n    messages: [\n      {\n        role: 'system',\n        content: [\n          'You generate transactional appointment emails.',\n          'Return JSON only with exactly these keys: subject, html_body, text_body.',\n          'Do not wrap the JSON in markdown fences.',\n          'Do not invent policies, prices, addresses, or medical/legal advice.',\n          'Use the supplied business configuration and appointment data only.',\n        ].join(' '),\n      },\n      {\n        role: 'user',\n        content: JSON.stringify({\n          message_type: messageType,\n          business: {\n            name: clientConfig.businessName,\n            timezone: clientConfig.timezone,\n            location: clientConfig.location,\n            support_email: clientConfig.supportEmail,\n            tone: clientConfig.tone,\n            service_instructions: safeJsonParse(clientConfig.serviceInstructionsJson, {}),\n          },\n          appointment,\n          waitlist_candidate: waitlistCandidate || null,\n          links,\n          output_contract: {\n            subject: 'short email subject',\n            html_body: 'complete HTML email body',\n            text_body: 'plain text fallback',\n          },\n        }),\n      },\n    ],\n  };\n}\n\nreturn $input.all().map((item, index) => {\n  const appointment = item.json;\n  const waitlistCandidate = item.json.waitlistCandidate || null;\n  return {\n    json: {\n      ...item.json,\n      groqMessageType: 'review_request',\n      groqRequest: makeGroqRequest('review_request', appointment, waitlistCandidate),\n    },\n    pairedItem: { item: index },\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "8b2a2344-313e-42e2-abe6-e15bfbb5e431",
      "name": "Groq Generate Review Request Email",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        60,
        840
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.groq.com/openai/v1/chat/completions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.groqRequest) }}",
        "options": {}
      },
      "typeVersion": 4.2
    },
    {
      "id": "3575458e-c38d-480e-add1-b09bf4412e28",
      "name": "Parse Review Request Email JSON",
      "type": "n8n-nodes-base.code",
      "position": [
        320,
        840
      ],
      "parameters": {
        "jsCode": "const env = (typeof $env !== 'undefined') ? $env : ((typeof process !== 'undefined' && process.env) ? process.env : {});\n\nconst clientConfig = {\n  parserName: env.CLIENT_EMAIL_JSON_PARSER_NAME || 'groq-email-json-parser',\n};\n\n// This Code node parses Groq review request JSON, strips markdown fences, and preserves the appointment data.\nfunction stripMarkdownFences(value) {\n  const fence = String.fromCharCode(96).repeat(3);\n  return String(value ?? '')\n    .trim()\n    .replace(new RegExp('^' + fence + '(?:json)?\\\\s*', 'i'), '')\n    .replace(new RegExp('\\\\s*' + fence + '$', 'i'), '')\n    .trim();\n}\n\nconst sourceItems = $('Build Review Request Groq Email').all();\n\nreturn $input.all().map((item, index) => {\n  const base = sourceItems[index]?.json || {};\n  const rawContent =\n    item.json?.choices?.[0]?.message?.content ??\n    item.json?.body?.choices?.[0]?.message?.content ??\n    item.json?.data?.choices?.[0]?.message?.content;\n\n  if (!rawContent) {\n    throw new Error('Groq response missing choices[0].message.content');\n  }\n\n  let parsed;\n  const cleaned = stripMarkdownFences(rawContent);\n  try {\n    parsed = JSON.parse(cleaned);\n  } catch (error) {\n    throw new Error('Groq JSON parse failed: ' + error.message + '. Cleaned response preview: ' + cleaned.slice(0, 500));\n  }\n\n  for (const field of ['subject', 'html_body', 'text_body']) {\n    if (!parsed[field] || typeof parsed[field] !== 'string') {\n      throw new Error('Groq JSON missing required string field: ' + field);\n    }\n  }\n\n  return {\n    json: {\n      ...base,\n      email_subject: parsed.subject,\n      email_html: parsed.html_body,\n      email_text: parsed.text_body,\n      groqParsedBy: clientConfig.parserName,\n    },\n    pairedItem: { item: index },\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "76fce730-5c8f-4f20-83c1-aa19f151ec59",
      "name": "Send Review Request via Gmail",
      "type": "n8n-nodes-base.gmail",
      "position": [
        580,
        840
      ],
      "parameters": {
        "sendTo": "={{ $json.email }}",
        "subject": "={{ $json.email_subject }}",
        "message": "={{ $json.email_html }}",
        "emailType": "html",
        "options": {
          "appendAttribution": false
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "38f7a419-7e71-48cf-b120-38e385d66ce4",
      "name": "Mark Review Request Sent in Postgres",
      "type": "n8n-nodes-base.postgres",
      "position": [
        840,
        840
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE appointments\nSET review_sent = true\nWHERE id = $1::uuid\nRETURNING id, review_sent;",
        "options": {
          "queryReplacement": "={{ [$('Parse Review Request Email JSON').item.json.id] }}"
        }
      },
      "typeVersion": 2.6
    }
  ],
  "connections": {
    "Tally Booking Webhook": {
      "main": [
        [
          {
            "node": "Normalize Tally Booking Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Tally Booking Payload": {
      "main": [
        [
          {
            "node": "Insert Appointment in Postgres",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Insert Appointment in Postgres": {
      "main": [
        [
          {
            "node": "Build Confirmation Groq Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Confirmation Groq Request": {
      "main": [
        [
          {
            "node": "Groq Generate Confirmation Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Groq Generate Confirmation Email": {
      "main": [
        [
          {
            "node": "Parse Confirmation Email JSON",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Confirmation Email JSON": {
      "main": [
        [
          {
            "node": "Send Confirmation Email via Gmail",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Hourly 24h Reminder Trigger": {
      "main": [
        [
          {
            "node": "Select Appointments Needing 24h Reminder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Select Appointments Needing 24h Reminder": {
      "main": [
        [
          {
            "node": "Build 24h Reminder Groq Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build 24h Reminder Groq Request": {
      "main": [
        [
          {
            "node": "Groq Generate 24h Reminder Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Groq Generate 24h Reminder Email": {
      "main": [
        [
          {
            "node": "Parse 24h Reminder Email JSON",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse 24h Reminder Email JSON": {
      "main": [
        [
          {
            "node": "Send 24h Reminder via Gmail",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send 24h Reminder via Gmail": {
      "main": [
        [
          {
            "node": "Mark 24h Reminder Sent in Postgres",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every 30m 2h Reminder Trigger": {
      "main": [
        [
          {
            "node": "Select Appointments Needing 2h Reminder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Select Appointments Needing 2h Reminder": {
      "main": [
        [
          {
            "node": "Build 2h Reminder Groq Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build 2h Reminder Groq Request": {
      "main": [
        [
          {
            "node": "Groq Generate 2h Reminder Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Groq Generate 2h Reminder Email": {
      "main": [
        [
          {
            "node": "Parse 2h Reminder Email JSON",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse 2h Reminder Email JSON": {
      "main": [
        [
          {
            "node": "Send 2h Reminder via Gmail",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send 2h Reminder via Gmail": {
      "main": [
        [
          {
            "node": "Mark 2h Reminder Sent in Postgres",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cancellation Webhook Listener": {
      "main": [
        [
          {
            "node": "Normalize Cancellation Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Cancellation Request": {
      "main": [
        [
          {
            "node": "Cancel Appointment in Postgres",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cancel Appointment in Postgres": {
      "main": [
        [
          {
            "node": "Select Next Waitlist Candidate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Select Next Waitlist Candidate": {
      "main": [
        [
          {
            "node": "Waitlist Candidate Exists",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Waitlist Candidate Exists": {
      "main": [
        [
          {
            "node": "Prepare Waitlist Offer Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Waitlist Offer Context": {
      "main": [
        [
          {
            "node": "Build Waitlist Offer Groq Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Waitlist Offer Groq Request": {
      "main": [
        [
          {
            "node": "Groq Generate Waitlist Offer Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Groq Generate Waitlist Offer Email": {
      "main": [
        [
          {
            "node": "Parse Waitlist Offer Email JSON",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Waitlist Offer Email JSON": {
      "main": [
        [
          {
            "node": "Offer Slot to Waitlist via Gmail",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Review Request Schedule Trigger": {
      "main": [
        [
          {
            "node": "Prepare Review Scan Window",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Review Scan Window": {
      "main": [
        [
          {
            "node": "Select Appointments Needing Review Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Select Appointments Needing Review Request": {
      "main": [
        [
          {
            "node": "Build Review Request Groq Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Review Request Groq Email": {
      "main": [
        [
          {
            "node": "Groq Generate Review Request Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Groq Generate Review Request Email": {
      "main": [
        [
          {
            "node": "Parse Review Request Email JSON",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Review Request Email JSON": {
      "main": [
        [
          {
            "node": "Send Review Request via Gmail",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Review Request via Gmail": {
      "main": [
        [
          {
            "node": "Mark Review Request Sent in Postgres",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Europe/Tirane"
  }
}
