{
  "name": "[실습 3-G] 그랑샌드 · 단체주문 접수",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "gransand-order",
        "responseMode": "responseNode",
        "options": {
          "allowedOrigins": "*"
        }
      },
      "id": "wh-order",
      "name": "폼 접수",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -464,
        304
      ]
    },
    {
      "parameters": {
        "jsCode": "// 폼에서 온 값을 정규화하고, 보낼 만한 접수인지 판정한다.\n// 여기서 걸러내지 않으면 장난·오타 접수가 그대로 문자로 나간다.\n\nconst TEST_NUMBERS = ['01012341234', '01011111111', '01000000000', '01012345678'];\n\nconst out = [];\nfor (const item of $input.all()) {\n  const b = item.json.body ?? item.json;\n\n  const digits = String(b.phone ?? '').replace(/\\D/g, '');\n  const reasons = [];\n\n  // 허니팟: 사람은 절대 못 채우는 숨은 칸. 값이 있으면 봇이다.\n  if (String(b.website ?? '').trim() !== '') reasons.push('허니팟에 값이 들어옴(봇)');\n  if (!/^01[0-9]\\d{7,8}$/.test(digits))     reasons.push('휴대폰 번호 형식 아님');\n  if (TEST_NUMBERS.includes(digits))         reasons.push('테스트 번호');\n  if (!String(b.name ?? '').trim())          reasons.push('이름 없음');\n  if (!b.agreed)                             reasons.push('개인정보 동의 없음');\n\n  const now = new Date();\n  const p = (n) => String(n).padStart(2, '0');\n  const orderId = `GS-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${Math.random().toString(36).slice(2, 7).toUpperCase()}`;\n\n  out.push({\n    json: {\n      valid: reasons.length === 0,\n      reasons,\n      orderId,\n      receivedAt: now.toISOString(),\n      name:       String(b.name ?? '').trim(),\n      company:    String(b.company ?? '').trim() || '-',\n      phone:      digits.replace(/^(\\d{3})(\\d{3,4})(\\d{4})$/, '$1-$2-$3'),\n      eventDate:  String(b.eventDate ?? '').trim() || '-',\n      pickupTime: String(b.pickupTime ?? '').trim() || '-',\n      headcount:  String(b.headcount ?? '').trim() || '-',\n      menu:       String(b.menu ?? '').trim() || '-',\n      receive:    String(b.receive ?? '').trim() || '-',\n      address:    String(b.address ?? '').trim() || '-',\n      payment:    String(b.payment ?? '').trim() || '-',\n      note:       String(b.note ?? '').trim() || '-',\n      shop:       String(b.shop ?? '그랑샌드 판교점'),\n      smsSentAt:  ''\n    }\n  });\n}\nreturn out;"
      },
      "id": "code-validate",
      "name": "검증·정규화",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -240,
        304
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "is-valid",
              "leftValue": "={{ $json.valid }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "if-valid",
      "name": "유효한 접수인가",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        -16,
        304
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "value": "접수",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "접수번호": "={{ $json.orderId }}",
            "접수시각": "={{ $json.receivedAt }}",
            "신청자": "={{ $json.name }}",
            "회사": "={{ $json.company }}",
            "연락처": "={{ $json.phone }}",
            "행사일": "={{ $json.eventDate }}",
            "수령시간": "={{ $json.pickupTime }}",
            "인원": "={{ $json.headcount }}",
            "구성": "={{ $json.menu }}",
            "수령방법": "={{ $json.receive }}",
            "주소": "={{ $json.address }}",
            "결제": "={{ $json.payment }}",
            "요청사항": "={{ $json.note }}",
            "문자발송시각": "={{ $json.smsSentAt }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "접수번호",
              "displayName": "접수번호",
              "required": false,
              "defaultMatch": true,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "접수시각",
              "displayName": "접수시각",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "신청자",
              "displayName": "신청자",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "회사",
              "displayName": "회사",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "연락처",
              "displayName": "연락처",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "행사일",
              "displayName": "행사일",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "수령시간",
              "displayName": "수령시간",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "인원",
              "displayName": "인원",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "구성",
              "displayName": "구성",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "수령방법",
              "displayName": "수령방법",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "주소",
              "displayName": "주소",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "결제",
              "displayName": "결제",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "요청사항",
              "displayName": "요청사항",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "문자발송시각",
              "displayName": "문자발송시각",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "sheets-append",
      "name": "시트에 저장",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        224,
        208
      ]
    },
    {
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "value": "={{ $('검증·정규화').item.json.orderId }}",
        "dataPropertyName": "sig",
        "secret": "여기에_내_서명키"
      },
      "id": "crypto-sign",
      "name": "승인링크 서명",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        448,
        208
      ]
    },
    {
      "parameters": {
        "jsCode": "// 디스코드 카드 + 승인 링크를 만든다.\n// 링크는 GET이지만, 눌러도 '확인 화면'만 뜨고 발송은 그 화면의 버튼(POST)에서 일어난다.\n// 디스코드 링크 미리보기 크롤러가 이 URL을 먼저 긁어도 문자가 나가지 않게 하려는 것.\n\nconst APPROVE_BASE = 'https://여기에-내-n8n-주소/webhook/gransand-approve';\n\n// 시트 노드를 거치며 $json 이 시트 출력으로 바뀌므로 원본은 검증 노드에서 가져온다.\nconst src = $('검증·정규화').all();\n\nreturn $input.all().map((item, i) => {\n  const d = { ...src[i].json, sig: item.json.sig };\n  const approveUrl = `${APPROVE_BASE}?id=${encodeURIComponent(d.orderId)}&sig=${encodeURIComponent(d.sig)}`;\n\n  return {\n    json: {\n      ...d,\n      approveUrl,\n      discordPayload: {\n        username: 'GRANSAND 주문알림',\n        embeds: [{\n          title: '🚨 그랑샌드 단체주문 신규 접수',\n          description: `**[📩 접수 확인 문자 보내기](${approveUrl})**\\n_링크를 누르면 확인 화면이 뜹니다. 거기서 한 번 더 눌러야 실제로 발송됩니다._`,\n          color: 0xB4511E,\n          fields: [\n            { name: '👤 신청자',      value: `${d.name} / ${d.company}`, inline: false },\n            { name: '📞 연락처',      value: d.phone,                     inline: true  },\n            { name: '👥 예상 인원',   value: `${d.headcount}명`,          inline: true  },\n            { name: '📅 행사',        value: `${d.eventDate} ${d.pickupTime} 수령`, inline: false },\n            { name: '🥪 희망 구성',   value: d.menu,                      inline: false },\n            { name: '🚚 수령',        value: d.receive === '배달' ? `배달 — ${d.address}` : '매장 픽업', inline: false },\n            { name: '💳 결제',        value: d.payment,                   inline: true  },\n            { name: '📝 요청사항',    value: d.note,                      inline: false },\n          ],\n          footer: { text: `접수번호 ${d.orderId}` },\n          timestamp: d.receivedAt,\n        }],\n      },\n    },\n  };\n});"
      },
      "id": "code-card",
      "name": "디스코드 카드 만들기",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        672,
        208
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://discord.com/api/webhooks/여기에_내_웹훅주소",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.discordPayload) }}",
        "options": {}
      },
      "id": "http-discord",
      "name": "디스코드 알림",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        880,
        208
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: true, orderId: $json.orderId }) }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
        }
      },
      "id": "resp-ok",
      "name": "접수 완료 응답",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1104,
        208
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: false, reasons: $json.reasons }) }}",
        "options": {
          "responseCode": 400,
          "responseHeaders": {
            "entries": [
              {
                "name": "Access-Control-Allow-Origin",
                "value": "*"
              }
            ]
          }
        }
      },
      "id": "resp-ng",
      "name": "거부 응답",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        224,
        432
      ]
    }
  ],
  "connections": {
    "폼 접수": {
      "main": [
        [
          {
            "node": "검증·정규화",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "검증·정규화": {
      "main": [
        [
          {
            "node": "유효한 접수인가",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "유효한 접수인가": {
      "main": [
        [
          {
            "node": "시트에 저장",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "거부 응답",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "시트에 저장": {
      "main": [
        [
          {
            "node": "승인링크 서명",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "승인링크 서명": {
      "main": [
        [
          {
            "node": "디스코드 카드 만들기",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "디스코드 카드 만들기": {
      "main": [
        [
          {
            "node": "디스코드 알림",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "디스코드 알림": {
      "main": [
        [
          {
            "node": "접수 완료 응답",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "availableInMCP": true
  },
  "nodeGroups": []
}
