{ "name": "HC-SUB: プロセス実行と案内送信", "nodes": [ { "id": "trigger-1", "name": "Execute Workflow Trigger", "type": "n8n-nodes-base.executeWorkflowTrigger", "typeVersion": 1.1, "position": [ 220, 300 ], "parameters": { "workflowInputs": { "values": [ { "name": "resultId" }, { "name": "processId" } ] } } }, { "id": "dt-get-config", "name": "設定値一括取得", "type": "n8n-nodes-base.dataTable", "typeVersion": 1, "position": [ 440, 300 ], "parameters": { "operation": "get", "dataTableId": { "__rl": true, "mode": "id", "value": "bNkadTyDgDepYx2p" }, "returnAll": true } }, { "id": "code-pleasanter-query", "name": "Pleasanter照会・選択肢組み立て", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 660, 300 ], "parameters": { "jsCode": "function isUnsetSentinel(value) {\n return typeof value === \"string\" && value.startsWith(\"1899\");\n}\nfunction fillTemplate(template, columns, valueHash) {\n const labelToColumnName = new Map();\n for (const column of columns) {\n if (column.LabelText) labelToColumnName.set(column.LabelText, column.ColumnName);\n }\n return template.replace(/\\{([^{}]+)\\}/g, (matched, label) => {\n const columnName = labelToColumnName.get(label);\n if (!columnName) return matched;\n const value = valueHash[columnName];\n if (value === undefined || value === null || value === \"\" || isUnsetSentinel(value)) return \"未設定\";\n return String(value);\n });\n}\nfunction extractProcessesForStatus(processes, status) {\n return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1);\n}\n\nconst configRows = $('設定値一括取得').all().map((item) => item.json);\nconst config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue]));\n\nconst trigger = $('Execute Workflow Trigger').item.json;\nconst baseUrl = config.PLEASANTER_BASE_URL_PROD;\nconst apiKey = config.PLEASANTER_API_KEY_PROD;\n\nasync function pleasanterPost(path, body) {\n const res = await this.helpers.httpRequest({\n method: \"POST\",\n url: `${baseUrl}${path}`,\n body: { ApiVersion: 1.1, ApiKey: apiKey, ...body },\n json: true,\n });\n return res;\n}\n\nif (trigger.processId) {\n await pleasanterPost.call(this, `api/items/${trigger.resultId}/update`, { ProcessId: trigger.processId });\n}\n\nconst recordRes = await pleasanterPost.call(this, `api/items/${trigger.resultId}/get`, {});\nconst record = recordRes.Response.Data;\n\nconst siteRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/getsite`, {});\nconst siteSettings = siteRes.Response.Data.SiteSettings;\nconst columns = siteSettings.Columns || [];\nconst processes = siteSettings.Processes || [];\n\nconst botMasterRes = await pleasanterPost.call(this, `api/items/${config.LINEWORKS_BOT_MASTER_SITE_ID}/getsite`, {});\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造を見てBotIdの解決方法を確定させる。\n// 暫定: SiteSettings.BotIds配列 または Processes[].BotId のいずれかを想定し、\n// 生データのまま後段(JWTクレーム組み立て)へ渡して解決する。\nconst botMasterSiteSettings = botMasterRes.Response.Data.SiteSettings;\n\nconst valueHash = {\n ...record.ClassHash, ...record.NumHash, ...record.DateHash, ...record.DescriptionHash,\n};\n\nconst candidates = extractProcessesForStatus(processes, record.Status);\nconst options = candidates.map((p) => ({\n processId: p.Id,\n label: p.DisplayName || p.Name,\n tooltip: fillTemplate(p.ToolTip || \"\", columns, valueHash),\n}));\n\nconst userRes = await pleasanterPost.call(this, `api/users/get`, {\n View: { ApiGetMailAddresses: true },\n Where: { UserId: record.ClassHash.ClassC },\n});\n// Pleasanter /api/users/get のレスポンス形状は実機未検証のため、配列/単一\n// オブジェクトどちらでも動くように防御的に処理する。\nconst usersResponseData = userRes.Response.Data;\nconst userRecord = Array.isArray(usersResponseData) ? usersResponseData[0] : usersResponseData;\nconst targetEmail = userRecord && (userRecord.MailAddress\n || (Array.isArray(userRecord.MailAddresses) && userRecord.MailAddresses[0]));\nif (!targetEmail) {\n throw new Error('対象者のメールアドレスを解決できませんでした。');\n}\n\nreturn [{\n json: {\n resultId: record.ResultId,\n currentStatus: record.Status,\n options,\n targetEmail,\n config,\n botMasterSiteSettings,\n },\n}];" } }, { "id": "code-jwt-claims", "name": "JWTクレーム組み立て", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 880, 300 ], "parameters": { "jsCode": "const now = Math.floor(Date.now() / 1000);\nconst { config, botMasterSiteSettings, options, resultId, currentStatus, targetEmail } = $json;\n\nconst jwtClaims = JSON.stringify({\n iss: config.LW_BOT_CLIENT_ID,\n sub: config.LW_BOT_SERVICE_ACCOUNT,\n iat: now,\n exp: now + 3600,\n aud: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n});\n\n// --- Bot ID解決 ---\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造は未調査\n// (このワークフローは実データ確認前に構築している)。\n// Task 12で508971のProcess⇔Bot対応表を設計する際、実データに合わせて\n// この解決方法を書き換えるか、Execute Workflow Triggerの入力にbotIdを追加して\n// 呼び出し元(WP/WA)から明示的に渡す方式へ変更すること。\n// 暫定実装: getsiteのSiteSettings直下にBotIds配列がある、または\n// Processes[].BotIdが定義されているケースを想定し、最初の1件を採用する。\n// どちらの構造も無ければ、誤ったBotへの送信を避けるため明示的にエラーとする。\nconst candidateBotIds = botMasterSiteSettings.BotIds\n || (botMasterSiteSettings.Processes || []).map((p) => p.BotId).filter(Boolean);\nconst botId = candidateBotIds && candidateBotIds[0];\nif (!botId) {\n throw new Error('BotIdを484184(LINEWORKS_BOT_MASTER_SITE_ID)から解決できませんでした。Task 12でBot選択ロジックを実装してください。');\n}\n\nconst apiUrl = `https://www.worksapis.com/v1.0/bots/${botId}/users/${targetEmail}/messages`;\n\n// --- button_template組み立て ---\nconst actions = options.map((opt) => ({\n type: 'message',\n label: opt.label,\n postback: JSON.stringify({ resultId, processId: opt.processId }),\n displayText: opt.label,\n}));\n\nconst messageContent = {\n type: 'button_template',\n contentText: options.map((opt) => `${opt.label}: ${opt.tooltip}`).join('\\n') || '対応可能な操作がありません',\n actions,\n};\n\nreturn [{\n json: {\n jwtClaims,\n apiUrl,\n messageContent,\n targetEmail,\n resultId,\n currentStatus,\n options,\n config,\n },\n}];" } }, { "id": "jwt-sign", "name": "Sign JWT", "type": "n8n-nodes-base.jwt", "typeVersion": 1, "position": [ 1100, 300 ], "parameters": { "operation": "sign", "useJson": true, "claimsJson": "={{ $json.jwtClaims }}", "options": { "algorithm": "RS256" } }, "credentials": { "jwtAuth": { "id": "Hw0qlEaGfLPnQWp1", "name": "LINEWORKS Bot Private Key (v4)" } } }, { "id": "code-lineworks-send", "name": "アクセストークン取得・LINEWORKS送信", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1320, 300 ], "parameters": { "jsCode": "const claims = $('JWTクレーム組み立て').item.json;\nconst config = claims.config;\nconst assertion = $json.token;\n\n// LINEWORKS token/message呼び出しはx-www-form-urlencoded / JSONのどちらも\n// this.helpers.httpRequestのbodyに素の値を渡す形は実機未検証のため、\n// URLSearchParamsで明示的にエンコードしてContent-Typeも明示する。\nconst tokenBody = new URLSearchParams({\n assertion,\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n client_id: config.LW_BOT_CLIENT_ID,\n client_secret: config.LW_BOT_CLIENT_SECRET,\n scope: 'bot',\n}).toString();\n\nconst tokenRes = await this.helpers.httpRequest({\n method: 'POST',\n url: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: tokenBody,\n json: true,\n});\nconst accessToken = tokenRes.access_token;\nif (!accessToken) {\n throw new Error('LINEWORKSアクセストークンの取得に失敗しました。');\n}\n\nconst sendRes = await this.helpers.httpRequest({\n method: 'POST',\n url: claims.apiUrl,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json;charset=UTF-8',\n },\n body: { content: claims.messageContent },\n json: true,\n});\n\nreturn [{\n json: {\n resultId: claims.resultId,\n currentStatus: claims.currentStatus,\n options: claims.options,\n targetEmail: claims.targetEmail,\n lineworksResponse: sendRes,\n },\n}];" } }, { "id": "dt-state-insert", "name": "状態更新", "type": "n8n-nodes-base.dataTable", "typeVersion": 1, "position": [ 1540, 300 ], "parameters": { "operation": "insert", "dataTableId": { "__rl": true, "mode": "id", "value": "jqMDa2YZTI4f0iQ7" }, "columns": { "mappingMode": "defineBelow", "value": { "resultId": "={{ $json.resultId }}", "targetEmail": "={{ $json.targetEmail }}", "currentStatus": "={{ String($json.currentStatus) }}", "pendingProcesses": "={{ JSON.stringify($json.options) }}", "awaitInput": "none", "awaitProcessId": "", "awaitColumn": "" }, "schema": [ { "id": "resultId", "displayName": "resultId", "required": false, "type": "string", "canBeUsedToMatch": true }, { "id": "targetEmail", "displayName": "targetEmail", "required": false, "type": "string", "canBeUsedToMatch": true }, { "id": "currentStatus", "displayName": "currentStatus", "required": false, "type": "string", "canBeUsedToMatch": true }, { "id": "pendingProcesses", "displayName": "pendingProcesses", "required": false, "type": "string", "canBeUsedToMatch": true }, { "id": "awaitInput", "displayName": "awaitInput", "required": false, "type": "string", "canBeUsedToMatch": true }, { "id": "awaitProcessId", "displayName": "awaitProcessId", "required": false, "type": "string", "canBeUsedToMatch": true }, { "id": "awaitColumn", "displayName": "awaitColumn", "required": false, "type": "string", "canBeUsedToMatch": true } ] } } } ], "connections": { "Execute Workflow Trigger": { "main": [ [ { "node": "設定値一括取得", "type": "main", "index": 0 } ] ] }, "設定値一括取得": { "main": [ [ { "node": "Pleasanter照会・選択肢組み立て", "type": "main", "index": 0 } ] ] }, "Pleasanter照会・選択肢組み立て": { "main": [ [ { "node": "JWTクレーム組み立て", "type": "main", "index": 0 } ] ] }, "JWTクレーム組み立て": { "main": [ [ { "node": "Sign JWT", "type": "main", "index": 0 } ] ] }, "Sign JWT": { "main": [ [ { "node": "アクセストークン取得・LINEWORKS送信", "type": "main", "index": 0 } ] ] }, "アクセストークン取得・LINEWORKS送信": { "main": [ [ { "node": "状態更新", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1" } }