{
  "name": "[실습 3-G] 그랑샌드 · 접수확인 문자 승인발송",
  "nodes": [
    {
      "parameters": {
        "path": "gransand-approve",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "wh-approve",
      "name": "승인 화면 열기",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -688,
        144
      ]
    },
    {
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "value": "={{ $json.query.id }}",
        "dataPropertyName": "expectedSig",
        "secret": "여기에_내_서명키"
      },
      "id": "crypto-verify-1",
      "name": "링크 서명 재계산",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        -464,
        144
      ]
    },
    {
      "parameters": {
        "jsCode": "// 링크 위조 차단: ?id=만 바꿔치기해서 아무 건이나 발송하지 못하게 한다.\nconst r = $input.first().json;\nif (r.expectedSig !== r.query.sig) {\n  throw new Error('서명이 맞지 않는 링크입니다.');\n}\nreturn [{ json: { orderId: r.query.id, sig: r.query.sig } }];"
      },
      "id": "code-verify-1",
      "name": "서명 대조",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -240,
        144
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "value": "접수",
          "mode": "name"
        },
        "filtersUI": {
          "values": [
            {
              "lookupColumn": "접수번호",
              "lookupValue": "={{ $json.orderId }}"
            }
          ]
        },
        "options": {}
      },
      "id": "sheets-read-1",
      "name": "접수 조회",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        -16,
        144
      ]
    },
    {
      "parameters": {
        "jsCode": "// 확인 화면 HTML을 만든다.\n// GET(이 화면)은 아무것도 발송하지 않는다 — 발송은 아래 폼의 POST에서만 일어난다.\n// 디스코드/카카오 링크 미리보기 크롤러는 GET만 하므로 여기서 막힌다.\n\nconst SEND_URL = 'https://여기에-내-n8n-주소/webhook/gransand-send';\nconst SHOP_PHONE = '0000-000-0000';\n\nconst d = $input.first().json;\nconst sig = $('서명 대조').first().json.sig;\nconst esc = (s) => String(s ?? '').replace(/[&<>\"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c]));\n\n// 시트가 2026-08-25 를 '날짜'로 인식하면 n8n 에는 직렬값(46265)으로 돌아온다.\n// 그대로 문자에 넣으면 고객이 숫자를 받게 되므로 되돌린다.\nconst fmtDate = (v) => {\n  const s = String(v ?? '').trim();\n  if (!/^\\d+(\\.\\d+)?$/.test(s)) return s || '-';\n  return new Date(Date.UTC(1899, 11, 30) + Number(s) * 86400000).toISOString().slice(0, 10);\n};\n\nconst draft =\n  `[그랑샌드] 단체주문 신청이 접수되었습니다.\\n` +\n  `${fmtDate(d['행사일'])} ${d['수령시간']} / ${d['인원']}명 건으로 확인했습니다.\\n` +\n  `담당자가 영업일 1일 내 연락드려 구성·금액을 확정합니다.\\n` +\n  `※ 아직 주문 확정 전입니다.\\n` +\n  `문의 ${SHOP_PHONE}`;\n\nconst style = `body{font-family:-apple-system,\"Apple SD Gothic Neo\",sans-serif;background:#FBF7F1;color:#24190F;margin:0;padding:28px;line-height:1.6}\n.w{max-width:520px;margin:0 auto}\n.c{background:#fff;border:1.5px solid #E8DDCE;border-radius:14px;padding:22px}\nh1{font-size:21px;margin:0 0 6px}\n.m{color:#7A6754;font-size:14px;margin:0 0 18px}\ndl{display:grid;grid-template-columns:80px 1fr;gap:0 12px;margin:0 0 18px;font-size:15px}\ndt{color:#7A6754;font-size:13.5px;padding:8px 0;border-top:1px solid #F0E7DA}\ndd{margin:0;padding:8px 0;border-top:1px solid #F0E7DA}\ntextarea{width:100%;font:inherit;font-size:15px;border:1.5px solid #E8DDCE;border-radius:10px;padding:12px;min-height:130px;box-sizing:border-box}\nbutton{font:inherit;font-size:17px;font-weight:700;background:#B4511E;color:#fff;border:0;border-radius:11px;padding:14px 26px;cursor:pointer;margin-top:14px;width:100%}\n.warn{background:#F7EEDA;border-radius:10px;padding:12px 14px;font-size:13.5px;margin:0 0 16px}`;\n\n// 이미 보낸 건이면 발송 폼을 아예 안 그린다 (새로고침·두 번 클릭 방어)\nif (String(d['문자발송시각'] ?? '').trim()) {\n  return [{ json: { html: `<!doctype html><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><style>${style}</style><div class=\"w\"><div class=\"c\"><h1>이미 발송된 건입니다</h1><p class=\"m\">${esc(d['신청자'])}님 · ${esc(d['연락처'])}<br>발송시각 ${esc(d['문자발송시각'])}</p></div></div>` } }];\n}\n\nconst html = `<!doctype html><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>접수확인 문자 발송</title><style>${style}</style>\n<div class=\"w\"><div class=\"c\">\n<h1>접수확인 문자 발송</h1>\n<p class=\"m\">아래 내용으로 보냅니다. 문구는 고쳐도 됩니다.</p>\n<dl>\n<dt>신청자</dt><dd>${esc(d['신청자'])} / ${esc(d['회사'])}</dd>\n<dt>받는 번호</dt><dd><b>${esc(d['연락처'])}</b></dd>\n<dt>행사</dt><dd>${esc(d['행사일'])} ${esc(d['수령시간'])} · ${esc(d['인원'])}명</dd>\n<dt>접수번호</dt><dd>${esc(d['접수번호'])}</dd>\n</dl>\n<div class=\"warn\">번호가 맞는지 한 번만 확인해 주세요. 발송하면 되돌릴 수 없습니다.</div>\n<form method=\"post\" action=\"${SEND_URL}\">\n<input type=\"hidden\" name=\"id\" value=\"${esc(d['접수번호'])}\">\n<input type=\"hidden\" name=\"sig\" value=\"${esc(sig)}\">\n<textarea name=\"text\">${esc(draft)}</textarea>\n<button type=\"submit\">이 내용으로 문자 발송</button>\n</form>\n</div></div>`;\n\nreturn [{ json: { html } }];"
      },
      "id": "code-screen",
      "name": "확인 화면 만들기",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        208,
        144
      ]
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "={{ $json.html }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "text/html; charset=utf-8"
              }
            ]
          }
        }
      },
      "id": "resp-screen",
      "name": "확인 화면 응답",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        432,
        144
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "gransand-send",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "wh-send",
      "name": "발송 실행",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -688,
        480
      ]
    },
    {
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "value": "={{ $json.body.id }}",
        "dataPropertyName": "expectedSig",
        "secret": "여기에_내_서명키"
      },
      "id": "crypto-verify-2",
      "name": "서명 재계산",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        -464,
        480
      ]
    },
    {
      "parameters": {
        "jsCode": "const r = $input.first().json;\nif (r.expectedSig !== r.body.sig) {\n  throw new Error('서명이 맞지 않는 요청입니다.');\n}\nreturn [{ json: { orderId: r.body.id, text: r.body.text } }];"
      },
      "id": "code-verify-2",
      "name": "서명 대조 (발송)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -240,
        480
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "value": "접수",
          "mode": "name"
        },
        "filtersUI": {
          "values": [
            {
              "lookupColumn": "접수번호",
              "lookupValue": "={{ $json.orderId }}"
            }
          ]
        },
        "options": {}
      },
      "id": "sheets-read-2",
      "name": "접수 재조회",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        -16,
        480
      ]
    },
    {
      "parameters": {
        "jsCode": "// 중복 발송 방어 + Solapi 인증 재료 생성\nconst d = $input.first().json;\n\nif (String(d['문자발송시각'] ?? '').trim()) {\n  throw new Error('이미 발송된 건입니다: ' + d['문자발송시각']);\n}\n\n// Solapi 문서 예시는 밀리초가 없다(2019-07-01T00:41:48Z). .000Z 를 잘라 형식을 맞춘다\nconst date = new Date().toISOString().replace(/\\.\\d{3}Z$/, 'Z');\nconst salt = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);\n\nreturn [{\n  json: {\n    orderId: d['접수번호'],\n    name:    d['신청자'],\n    to:      String(d['연락처']).replace(/\\D/g, ''),\n    text:    $('서명 대조 (발송)').first().json.text,\n    date,\n    salt,\n    signBase: date + salt,\n    sentAt:   date,\n  },\n}];"
      },
      "id": "code-prep",
      "name": "발송 준비",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        208,
        480
      ]
    },
    {
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "value": "={{ $json.signBase }}",
        "dataPropertyName": "solapiSig",
        "secret": "여기에_내_서명키"
      },
      "id": "crypto-solapi",
      "name": "Solapi 서명",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        432,
        480
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.solapi.com/messages/v4/send",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=HMAC-SHA256 apiKey=여기에_내_SOLAPI_키, date={{ $json.date }}, salt={{ $json.salt }}, signature={{ $json.solapiSig }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ message: { to: $json.to, from: '01000000000', text: $json.text } }) }}",
        "options": {}
      },
      "id": "http-solapi",
      "name": "Solapi 문자 발송",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        640,
        480
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "value": "접수",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "접수번호": "={{ $('발송 준비').item.json.orderId }}",
            "문자발송시각": "={{ $('발송 준비').item.json.sentAt }}"
          },
          "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-update",
      "name": "발송시각 기록",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        864,
        480
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://discord.com/api/webhooks/여기에_내_웹훅주소",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ username: 'GRANSAND 주문알림', content: `✅ **${$('발송 준비').item.json.name}**님께 접수확인 문자 발송 완료 (${$('발송 준비').item.json.orderId})` }) }}",
        "options": {}
      },
      "id": "http-discord-done",
      "name": "디스코드 발송완료 알림",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1088,
        480
      ]
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "={{ `<!doctype html><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><body style=\"font-family:-apple-system,sans-serif;background:#FBF7F1;color:#24190F;padding:40px;text-align:center\"><div style=\"font-size:46px\">📩</div><h1 style=\"font-size:22px\">문자 발송 완료</h1><p style=\"color:#7A6754\">${$('발송 준비').item.json.name}님 · ${$('발송 준비').item.json.to}<br>접수번호 ${$('발송 준비').item.json.orderId}</p><p style=\"color:#7A6754;font-size:14px\">이 창은 닫으셔도 됩니다.</p></body>` }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "text/html; charset=utf-8"
              }
            ]
          }
        }
      },
      "id": "resp-done",
      "name": "발송 완료 화면",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1312,
        480
      ]
    }
  ],
  "connections": {
    "승인 화면 열기": {
      "main": [
        [
          {
            "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
          }
        ]
      ]
    },
    "발송 실행": {
      "main": [
        [
          {
            "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": "Solapi 서명",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Solapi 서명": {
      "main": [
        [
          {
            "node": "Solapi 문자 발송",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Solapi 문자 발송": {
      "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,
    "timeSavedMode": "fixed",
    "callerPolicy": "workflowsFromSameOwner"
  },
  "nodeGroups": []
}
