From 3b904e6214074a4341c263909d37eed0f72fa7cb Mon Sep 17 00:00:00 2001 From: Kenichiro NOGI Date: Sat, 5 Sep 2026 11:25:45 +0900 Subject: [PATCH] =?UTF-8?q?=E5=81=A5=E5=BA=B7=E8=A8=BA=E6=96=AD=E7=AE=A1?= =?UTF-8?q?=E7=90=86LINEWORKS=E9=80=A3=E6=90=BA=E3=81=AE=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=E8=A8=88=E7=94=BB=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 設計書(2026-09-05-healthcheck-lineworks-survey-n8n-design.md)を基に、 12タスクの実装計画を作成。純粋ロジック(日付パース/プレースホルダー 置換/Process抽出/署名検証)はNode.jsでTDD、n8nワークフロー構築は 既存のLINEWORKS通知送信ワークフロー(JWT署名→トークン取得→Bot API 送信)とdataTableノードの実機パターンを踏襲する形で具体化した。 Co-Authored-By: Claude Sonnet 5 --- ...-09-05-healthcheck-lineworks-survey-n8n.md | 1279 +++++++++++++++++ 1 file changed, 1279 insertions(+) create mode 100644 NodeSrv/docs/superpowers/plans/2026-09-05-healthcheck-lineworks-survey-n8n.md diff --git a/NodeSrv/docs/superpowers/plans/2026-09-05-healthcheck-lineworks-survey-n8n.md b/NodeSrv/docs/superpowers/plans/2026-09-05-healthcheck-lineworks-survey-n8n.md new file mode 100644 index 00000000..c548a866 --- /dev/null +++ b/NodeSrv/docs/superpowers/plans/2026-09-05-healthcheck-lineworks-survey-n8n.md @@ -0,0 +1,1279 @@ +# 健康診断管理×LINEWORKS Bot連携 n8n実装 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** プリザンター「健康診断管理」(SiteId 508971)のProcess機能を定義源として、LINEWORKS Botとの対話でStatusを段階的に進める仕組みをn8n上に構築する。 + +**Architecture:** n8nに3本のワークフロー(HC-SUB: プロセス実行+案内送信の共通ロジック/HC-WP: 担当者操作起点のプッシュ通知/HC-WA: LINEWORKS応答受信の唯一の受信口)を構築する。フロー定義は508971の`SiteSettings.Processes`(現在の状況/変更後の状況/表示名/ツールチップ/入力検証タブ)をそのまま使い、新規マスタは作らない。会話の待機状態はn8n Data Table(新規)で保持する。純粋ロジック(日付パース・プレースホルダー置換・Process抽出・署名検証)はNode.jsモジュールとしてTDDで開発し、動作確認済みのコードをn8n Codeノードへ書き写す。 + +**Tech Stack:** Node.js(`node --test`によるユニットテスト、追加npmパッケージなし)、n8n(Public API経由でのワークフロー・Data Table構築)、LINE WORKS Bot API、Pleasanter API + +## Global Constraints + +- n8nはコンテナメモリ768MB制限。複数行データの一括展開・集約は行わない(`NodeSrv/apps/n8n/docs/n8n-guide.md` 7-2参照) +- n8n Data Table操作は「Clear→Insert」を直列に繋がない。後続ノードは前段ノードを`$('ノード名')`で明示的に再参照する(同ガイド7-1参照) +- サーバー環境の指定は本番のみ対象(`https://nextoffice.next-hd.co.jp/pleasanter/`)。テスト環境は今回のスコープ外 +- n8nワークフローの構築・編集(PUT/POST)は確認不要。**Webhookを実際に叩く・508971へ書き込みを伴うテスト実行は毎回ユーザーへ事前確認**(同ガイド9章) +- 508971は本番の健診データそのもの。検証は既存レコードを壊さない捨てレコードで行う +- Pleasanter日本語ボディを含むリクエストはシェル引数に直書きせず、Writeツールでファイル化してから`curl --data-binary "@file"`で送る(同ガイド7-6参照) +- 具体的なProcess内容(①日程通知〜③検査結果受取り等の本番仕様)は本計画のスコープ外。本計画は「Process流用型フロー定義」の枠組みを動かすことがゴールで、テスト用Process1件で疎通確認する + +--- + +## File Structure + +``` +NodeSrv/apps/healthcheck-survey-bot/ + package.json + README.md -- 実機調査結果、n8nリソースID一覧を記録 + src/lib/ + dateParser.js -- 日付文字列パース(和暦・月日省略対応) + templateFill.js -- ツールチップ文言の{ラベル名}プレースホルダー置換 + processFlow.js -- Processes配列からの選択肢抽出・回答照合・追加入力種別判定 + signatureVerify.js -- LINEWORKS Webhook署名検証(HMAC-SHA256) + test/ + dateParser.test.js + templateFill.test.js + processFlow.test.js + signatureVerify.test.js + scripts/ + n8n-api.js -- n8n Public API共通fetchヘルパー + deploy-workflow.js -- workflows/*.json を n8n へPUT/POSTするCLI + workflows/ + hc-sub-run-process-and-notify.json + hc-wp-status-push.json + hc-wa-lineworks-response.json +``` + +n8n環境の接続情報(URL・APIキー・既存Credential)は`NodeSrv/apps/n8n/docs/n8n-guide.md`参照。Pleasanter本番APIキーは`Pleasanter/config_production.json`参照。 + +--- + +### Task 1: プロジェクト雛形作成 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/package.json` +- Create: `NodeSrv/apps/healthcheck-survey-bot/README.md` +- Create: `NodeSrv/apps/healthcheck-survey-bot/.gitignore` + +**Interfaces:** +- Produces: `node --test test/*.test.js`で実行できるテスト環境 + +- [ ] **Step 1: package.json作成** + +```json +{ + "name": "healthcheck-survey-bot", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "scripts": { + "test": "node --test test/*.test.js", + "deploy-workflow": "node scripts/deploy-workflow.js" + } +} +``` + +- [ ] **Step 2: README.md作成** + +```markdown +# healthcheck-survey-bot + +健康診断管理(SiteId 508971)×LINEWORKS Bot連携。508971の`SiteSettings.Processes` +をフロー定義として使い、n8n上でBot対話型のStatus管理を行う。 + +設計書: `NodeSrv/docs/superpowers/specs/2026-09-05-healthcheck-lineworks-survey-n8n-design.md` +実装計画: `NodeSrv/docs/superpowers/plans/2026-09-05-healthcheck-lineworks-survey-n8n.md` + +## 実機調査メモ + +(Task 2完了後、ここにPleasanterのProcess入力検証タブのJSON構造を記録する) + +## n8nリソースID一覧 + +(Task 8〜11完了後、ここに作成したData Table ID・ワークフローIDを記録する) +``` + +- [ ] **Step 3: .gitignore作成** + +``` +node_modules/ +``` + +- [ ] **Step 4: コミット** + +```bash +git add NodeSrv/apps/healthcheck-survey-bot/package.json NodeSrv/apps/healthcheck-survey-bot/README.md NodeSrv/apps/healthcheck-survey-bot/.gitignore +git commit -m "feat: healthcheck-survey-botプロジェクト雛形を追加" +``` + +--- + +### Task 2: 実機調査 — Processの入力検証タブのJSON構造を確認 + +Pleasanter公式マニュアルにも構造の記載がなく、508971の既存Processesには入力検証を使った実例がないため、実機で確認する。**508971(本番)へのSiteSettings変更を伴うため、着手前に必ずユーザーへ確認する。** + +**Files:** +- Modify: `NodeSrv/apps/healthcheck-survey-bot/README.md`(調査結果を追記) + +**Interfaces:** +- Produces: Task 5(processFlow.js)が前提とする、Process内で入力検証対象列を表すJSONキー名とその構造 + +- [ ] **Step 1: ユーザーへテストProcess作成を依頼** + +以下をプリザンター508971の「プロセス」設定画面で新規作成してもらうよう、ユーザーに依頼する(本番Statusに影響しないよう、現在の状況・変更後の状況は同じ値のままにしてもらう)。 + +- 名称: `テスト_入力検証構造確認` +- 表示名: `テスト` +- 現在の状況/変更後の状況: 触らず空欄のまま(全状況共通ボタンにしてもらう) +- 入力検証タブ: 「新規作成」→項目に`日付001`(Date001)を選択して保存 +- Process自体を保存 + +- [ ] **Step 2: 最新のSiteSettingsを取得** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\Pleasanter" +node .claude/js/get-site-config.js --project="健康診断管理" --env=production +``` + +Expected: `健康診断管理/configs/production/site-508971_健康診断管理/sitesettings/site-508971_latest.json`が更新される + +- [ ] **Step 3: Processes配列の該当エントリを確認** + +`site-508971_latest.json`内の`Response.Data.SiteSettings.Processes`配列から、Step1で追加したProcess(`Name`が`テスト_入力検証構造確認`のもの)を探し、入力検証設定に対応するキー(配列名・各要素のプロパティ名)を特定する。 + +- [ ] **Step 4: 発見した構造をREADME.mdに記録** + +例(実際のキー名は実機確認結果に置き換える): + +```markdown +## 実機調査メモ(2026-09-05確認) + +Processの入力検証タブは`SiteSettings.Processes[].Validations`配列として保存される。 +各要素は`{"Id": 1, "ColumnName": "Date001"}`形式(フィールド名は実機確認結果で確定)。 +``` + +- [ ] **Step 5: テストProcessを削除** + +ユーザーに508971の管理画面からテストProcessを削除してもらう(本番のProcess一覧を汚さないため)。 + +- [ ] **Step 6: コミット** + +```bash +git add NodeSrv/apps/healthcheck-survey-bot/README.md +git commit -m "docs: Process入力検証タブのJSON構造を調査結果として記録" +``` + +--- + +### Task 3: dateParser.js — 日付パースロジック + +Express版`OldCode/express/modules/lineworksSurvey.js`の`parseDateInput`系ロジックを移植する。 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/src/lib/dateParser.js` +- Test: `NodeSrv/apps/healthcheck-survey-bot/test/dateParser.test.js` + +**Interfaces:** +- Produces: `parseDateInput(value: string): string`(`"YYYY-MM-DD"`形式を返す。パース不能なら`Error`をthrow) + +- [ ] **Step 1: 失敗するテストを書く** + +```javascript +// test/dateParser.test.js +const { test } = require("node:test"); +const assert = require("node:assert"); +const { parseDateInput } = require("../src/lib/dateParser"); + +test("ISO形式の日付をそのまま解釈する", () => { + assert.strictEqual(parseDateInput("2026-03-01"), "2026-03-01"); +}); + +test("スラッシュ区切りの日付を解釈する", () => { + assert.strictEqual(parseDateInput("2026/3/1"), "2026-03-01"); +}); + +test("和暦(令和)を西暦に変換する", () => { + assert.strictEqual(parseDateInput("令和6年3月1日"), "2024-03-01"); +}); + +test("和暦の略記(R)を西暦に変換する", () => { + assert.strictEqual(parseDateInput("R6.3.1"), "2024-03-01"); +}); + +test("月日のみの入力は今年として解釈する", () => { + const currentYear = new Date().getFullYear(); + assert.strictEqual(parseDateInput("3/1"), `${currentYear}-03-01`); +}); + +test("空文字はエラーになる", () => { + assert.throws(() => parseDateInput(""), /日付が空です/); +}); + +test("存在しない日付はエラーになる", () => { + assert.throws(() => parseDateInput("2026-02-30"), /存在しない日付です/); +}); + +test("解釈不能な文字列はエラーになる", () => { + assert.throws(() => parseDateInput("あいうえお")); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\NodeSrv\apps\healthcheck-survey-bot" +node --test test/dateParser.test.js +``` + +Expected: `Cannot find module '../src/lib/dateParser'`で失敗 + +- [ ] **Step 3: 実装を書く** + +```javascript +// src/lib/dateParser.js +const ERA_INFO = { + "令和": 2018, + "平成": 1988, + "昭和": 1925, + "大正": 1911, +}; + +const ERA_ALIASES = { R: "令和", H: "平成", S: "昭和", T: "大正" }; + +function pad2(value) { + return String(value).padStart(2, "0"); +} + +function finalizeDateParts(year, month, day) { + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { + throw new Error("日付形式で回答してください(例: 2026-03-01)"); + } + const date = new Date(year, month - 1, day); + if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { + throw new Error("存在しない日付です"); + } + return `${year}-${pad2(month)}-${pad2(day)}`; +} + +function tryParseEraDate(compact) { + const match = compact.match(/^(令和|平成|昭和|大正|R|H|S|T)(.+)$/i); + if (!match) return null; + + let era = match[1]; + if (/^[RHST]$/i.test(era)) { + era = ERA_ALIASES[era.toUpperCase()] || era; + } + if (!ERA_INFO[era]) return null; + + const normalized = match[2] + .replace(/年/g, "-") + .replace(/月/g, "-") + .replace(/日/g, "") + .replace(/[.\/]/g, "-"); + const parts = normalized.split("-").filter((part) => part.length > 0); + if (parts.length < 3) { + throw new Error("月と日まで入力してください(例: 令和6年3月1日)"); + } + + const eraYear = Number(parts[0]); + const month = Number(parts[1]); + const day = Number(parts[2]); + if (!Number.isFinite(eraYear) || !Number.isFinite(month) || !Number.isFinite(day)) { + throw new Error("日付形式で回答してください(例: 令和6年3月1日)"); + } + + return finalizeDateParts(ERA_INFO[era] + eraYear - 1, month, day); +} + +function tryParseMonthDay(compact) { + const match = compact.match(/^(\d{1,2})(?:月|\/|-|\.)(\d{1,2})(?:日)?$/); + if (!match) return null; + const currentYear = new Date().getFullYear(); + return finalizeDateParts(currentYear, Number(match[1]), Number(match[2])); +} + +function parseDateInput(value) { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + throw new Error("日付が空です"); + } + const compact = trimmed.replace(/\s+/g, ""); + + const eraResult = tryParseEraDate(compact); + if (eraResult) return eraResult; + + const monthDayResult = tryParseMonthDay(compact); + if (monthDayResult) return monthDayResult; + + const normalized = compact + .replace(/年/g, "-") + .replace(/月/g, "-") + .replace(/日/g, "") + .replace(/[.\/]/g, "-"); + const isoParts = normalized.split("-").filter((part) => part.length > 0); + if (isoParts.length === 3 && isoParts[0].length >= 4) { + return finalizeDateParts(Number(isoParts[0]), Number(isoParts[1]), Number(isoParts[2])); + } + + const parsed = new Date(trimmed); + if (Number.isNaN(parsed.getTime())) { + throw new Error("日付形式で回答してください(例: 2026-03-01 や 令和6年3月1日)"); + } + return finalizeDateParts(parsed.getFullYear(), parsed.getMonth() + 1, parsed.getDate()); +} + +module.exports = { parseDateInput }; +``` + +- [ ] **Step 4: テストが通ることを確認** + +```bash +node --test test/dateParser.test.js +``` + +Expected: 8 tests、全てPASS + +- [ ] **Step 5: コミット** + +```bash +git add src/lib/dateParser.js test/dateParser.test.js +git commit -m "feat: 日付パースロジックを追加" +``` + +--- + +### Task 4: templateFill.js — ツールチップ文言のプレースホルダー置換 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/src/lib/templateFill.js` +- Test: `NodeSrv/apps/healthcheck-survey-bot/test/templateFill.test.js` + +**Interfaces:** +- Consumes: なし(Task 3とは独立) +- Produces: `fillTemplate(template: string, columns: Array<{ColumnName: string, LabelText?: string}>, valueHash: Record): string` + +`columns`はPleasanter `getsite`の`SiteSettings.Columns`配列そのもの。`valueHash`はレコードの`ClassHash`/`NumHash`/`DateHash`/`DescriptionHash`を`{...ClassHash, ...NumHash, ...DateHash, ...DescriptionHash}`のようにマージしたフラットオブジェクト(呼び出し側で用意する)。日付の未設定センチネル値(`"1899-12-30..."`で始まる文字列)は「未設定」として扱う。 + +- [ ] **Step 1: 失敗するテストを書く** + +```javascript +// test/templateFill.test.js +const { test } = require("node:test"); +const assert = require("node:assert"); +const { fillTemplate } = require("../src/lib/templateFill"); + +const columns = [ + { ColumnName: "Class003", LabelText: "検査機関" }, + { ColumnName: "Date001", LabelText: "検査日" }, +]; + +test("プレースホルダーをレコード値で置換する", () => { + const result = fillTemplate( + "検査機関: {検査機関}\n日程: {検査日}", + columns, + { Class003: "next健診クリニック", Date001: "2026-04-01T00:00:00" } + ); + assert.strictEqual(result, "検査機関: next健診クリニック\n日程: 2026-04-01T00:00:00"); +}); + +test("未設定の日付センチネル値は「未設定」に変換する", () => { + const result = fillTemplate("日程: {検査日}", columns, { + Date001: "1899-12-30T00:00:00", + }); + assert.strictEqual(result, "日程: 未設定"); +}); + +test("値が無い列は「未設定」に変換する", () => { + const result = fillTemplate("検査機関: {検査機関}", columns, {}); + assert.strictEqual(result, "検査機関: 未設定"); +}); + +test("対応するラベルが見つからないプレースホルダーはそのまま残す", () => { + const result = fillTemplate("不明: {存在しないラベル}", columns, {}); + assert.strictEqual(result, "不明: {存在しないラベル}"); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +node --test test/templateFill.test.js +``` + +Expected: `Cannot find module '../src/lib/templateFill'`で失敗 + +- [ ] **Step 3: 実装を書く** + +```javascript +// src/lib/templateFill.js +function isUnsetSentinel(value) { + return typeof value === "string" && value.startsWith("1899"); +} + +function fillTemplate(template, columns, valueHash) { + const labelToColumnName = new Map(); + for (const column of columns) { + if (column.LabelText) { + labelToColumnName.set(column.LabelText, column.ColumnName); + } + } + + return template.replace(/\{([^{}]+)\}/g, (matched, label) => { + const columnName = labelToColumnName.get(label); + if (!columnName) { + return matched; + } + const value = valueHash[columnName]; + if (value === undefined || value === null || value === "" || isUnsetSentinel(value)) { + return "未設定"; + } + return String(value); + }); +} + +module.exports = { fillTemplate }; +``` + +- [ ] **Step 4: テストが通ることを確認** + +```bash +node --test test/templateFill.test.js +``` + +Expected: 4 tests、全てPASS + +- [ ] **Step 5: コミット** + +```bash +git add src/lib/templateFill.js test/templateFill.test.js +git commit -m "feat: ツールチップ文言のプレースホルダー置換ロジックを追加" +``` + +--- + +### Task 5: processFlow.js — Process抽出・回答照合・追加入力判定 + +Task 2で確認したJSON構造を前提に実装する。以下のコードでは、入力検証対象列が`process.Validations`(各要素`{ColumnName: string}`)に入っている前提で書いている。**Task 2の実機調査結果が異なる構造だった場合は、`getValidationColumnNames`関数のみをその構造に合わせて書き換える**(他の関数への影響はない)。 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/src/lib/processFlow.js` +- Test: `NodeSrv/apps/healthcheck-survey-bot/test/processFlow.test.js` + +**Interfaces:** +- Consumes: なし +- Produces: + - `extractProcessesForStatus(processes: Array, status: number): Array` + - `matchProcessByLabel(processes: Array, text: string): Process | null` + - `classifyAwaitInput(process: Process): { awaitInput: "none" | "date" | "file", column: string | null }` + +- [ ] **Step 1: 失敗するテストを書く** + +```javascript +// test/processFlow.test.js +const { test } = require("node:test"); +const assert = require("node:assert"); +const { + extractProcessesForStatus, + matchProcessByLabel, + classifyAwaitInput, +} = require("../src/lib/processFlow"); + +const processes = [ + { Id: 1, Name: "了承", DisplayName: "了承", CurrentStatus: 100, ChangedStatus: 200 }, + { Id: 2, Name: "日程変更", DisplayName: "日程変更", CurrentStatus: 100, ChangedStatus: 150, Validations: [{ ColumnName: "Date001" }] }, + { Id: 3, Name: "受けた", DisplayName: "受けた", CurrentStatus: 200, ChangedStatus: 300 }, + { Id: 4, Name: "結果受取り", DisplayName: "受け取った", CurrentStatus: 300, ChangedStatus: 900, Validations: [{ ColumnName: "AttachmentsA" }] }, +]; + +test("extractProcessesForStatus: 現在のStatusに一致するProcessのみ返す", () => { + const result = extractProcessesForStatus(processes, 100); + assert.strictEqual(result.length, 2); + assert.deepStrictEqual(result.map((p) => p.Id), [1, 2]); +}); + +test("extractProcessesForStatus: 一致するProcessが無ければ空配列", () => { + assert.deepStrictEqual(extractProcessesForStatus(processes, 999), []); +}); + +test("matchProcessByLabel: DisplayNameが完全一致するProcessを返す", () => { + const candidates = extractProcessesForStatus(processes, 100); + const matched = matchProcessByLabel(candidates, "日程変更"); + assert.strictEqual(matched.Id, 2); +}); + +test("matchProcessByLabel: 前後の空白を無視して一致判定する", () => { + const candidates = extractProcessesForStatus(processes, 100); + const matched = matchProcessByLabel(candidates, " 了承 "); + assert.strictEqual(matched.Id, 1); +}); + +test("matchProcessByLabel: 一致しなければnull", () => { + const candidates = extractProcessesForStatus(processes, 100); + assert.strictEqual(matchProcessByLabel(candidates, "存在しない選択肢"), null); +}); + +test("classifyAwaitInput: Validationsが無いProcessはnone", () => { + assert.deepStrictEqual(classifyAwaitInput(processes[0]), { awaitInput: "none", column: null }); +}); + +test("classifyAwaitInput: Date*列はdate", () => { + assert.deepStrictEqual(classifyAwaitInput(processes[1]), { awaitInput: "date", column: "Date001" }); +}); + +test("classifyAwaitInput: Attachments*列はfile", () => { + assert.deepStrictEqual(classifyAwaitInput(processes[3]), { awaitInput: "file", column: "AttachmentsA" }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +node --test test/processFlow.test.js +``` + +Expected: `Cannot find module '../src/lib/processFlow'`で失敗 + +- [ ] **Step 3: 実装を書く** + +```javascript +// src/lib/processFlow.js +function extractProcessesForStatus(processes, status) { + return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1); +} + +function matchProcessByLabel(processes, text) { + const trimmed = String(text ?? "").trim(); + return processes.find((p) => (p.DisplayName || p.Name) === trimmed) || null; +} + +function getValidationColumnNames(process) { + if (!Array.isArray(process.Validations)) return []; + return process.Validations.map((v) => v.ColumnName).filter(Boolean); +} + +function classifyAwaitInput(process) { + const columnNames = getValidationColumnNames(process); + if (columnNames.length === 0) { + return { awaitInput: "none", column: null }; + } + const column = columnNames[0]; + if (column.startsWith("Date")) { + return { awaitInput: "date", column }; + } + if (column.startsWith("Attachments")) { + return { awaitInput: "file", column }; + } + return { awaitInput: "none", column: null }; +} + +module.exports = { extractProcessesForStatus, matchProcessByLabel, classifyAwaitInput }; +``` + +- [ ] **Step 4: テストが通ることを確認** + +```bash +node --test test/processFlow.test.js +``` + +Expected: 8 tests、全てPASS + +- [ ] **Step 5: コミット** + +```bash +git add src/lib/processFlow.js test/processFlow.test.js +git commit -m "feat: Process抽出・回答照合・追加入力判定ロジックを追加" +``` + +--- + +### Task 6: signatureVerify.js — LINEWORKS Webhook署名検証 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/src/lib/signatureVerify.js` +- Test: `NodeSrv/apps/healthcheck-survey-bot/test/signatureVerify.test.js` + +**Interfaces:** +- Produces: `verifySignature(rawBody: Buffer | string, headerSignature: string, botSecret: string): boolean` + +- [ ] **Step 1: 失敗するテストを書く** + +```javascript +// test/signatureVerify.test.js +const { test } = require("node:test"); +const assert = require("node:assert"); +const crypto = require("node:crypto"); +const { verifySignature } = require("../src/lib/signatureVerify"); + +test("正しい署名はtrueを返す", () => { + const secret = "test-secret"; + const body = JSON.stringify({ hello: "world" }); + const signature = crypto.createHmac("sha256", secret).update(body).digest("base64"); + assert.strictEqual(verifySignature(body, signature, secret), true); +}); + +test("sha256=プレフィックス付き署名も検証できる", () => { + const secret = "test-secret"; + const body = JSON.stringify({ hello: "world" }); + const signature = crypto.createHmac("sha256", secret).update(body).digest("base64"); + assert.strictEqual(verifySignature(body, `sha256=${signature}`, secret), true); +}); + +test("不正な署名はfalseを返す", () => { + assert.strictEqual(verifySignature("body", "invalid-signature", "secret"), false); +}); + +test("署名ヘッダーが空ならfalseを返す", () => { + assert.strictEqual(verifySignature("body", "", "secret"), false); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +node --test test/signatureVerify.test.js +``` + +Expected: `Cannot find module '../src/lib/signatureVerify'`で失敗 + +- [ ] **Step 3: 実装を書く** + +```javascript +// src/lib/signatureVerify.js +const crypto = require("node:crypto"); + +function normalizeSignature(value) { + return String(value || "").trim().replace(/^sha256=/i, ""); +} + +function safeEqual(a, b) { + const ab = Buffer.from(String(a), "utf8"); + const bb = Buffer.from(String(b), "utf8"); + if (ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} + +function verifySignature(rawBody, headerSignature, botSecret) { + const headerSig = normalizeSignature(headerSignature); + if (!headerSig || !botSecret) return false; + + const payload = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), "utf8"); + const expected = crypto.createHmac("sha256", botSecret).update(payload).digest("base64"); + + return safeEqual(headerSig, expected); +} + +module.exports = { verifySignature }; +``` + +- [ ] **Step 4: テストが通ることを確認** + +```bash +node --test test/signatureVerify.test.js +``` + +Expected: 4 tests、全てPASS + +- [ ] **Step 5: 全テストを通しで実行** + +```bash +node --test test/*.test.js +``` + +Expected: 4ファイル・24テスト、全てPASS + +- [ ] **Step 6: コミット** + +```bash +git add src/lib/signatureVerify.js test/signatureVerify.test.js +git commit -m "feat: LINEWORKS Webhook署名検証ロジックを追加" +``` + +--- + +### Task 7: n8n Public API共通ヘルパー+デプロイスクリプト + +n8nワークフロー・Data Tableの作成/更新をコマンドから行うための共通スクリプト。APIキーは`NodeSrv/apps/n8n/docs/n8n-guide.md`記載の値を使う。 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/scripts/n8n-api.js` +- Create: `NodeSrv/apps/healthcheck-survey-bot/scripts/deploy-workflow.js` + +**Interfaces:** +- Produces: + - `n8nApi.js`: `request(method: string, path: string, body?: object): Promise<{status: number, body: any}>` + - `deploy-workflow.js`: CLI `node scripts/deploy-workflow.js [--id=<既存workflowId>]` + +- [ ] **Step 1: n8n-api.js を作成** + +```javascript +// scripts/n8n-api.js +const N8N_BASE_URL = "https://n8n32.next-hd.net/api/v1"; +const N8N_API_KEY = process.env.N8N_API_KEY; + +if (!N8N_API_KEY) { + throw new Error( + "環境変数 N8N_API_KEY が未設定です。NodeSrv/apps/n8n/docs/n8n-guide.md 2章のPublic API Keyを設定してください。" + ); +} + +async function request(method, path, body) { + const res = await fetch(`${N8N_BASE_URL}${path}`, { + method, + headers: { + "X-N8N-API-KEY": N8N_API_KEY, + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = text; + } + return { status: res.status, body: json }; +} + +module.exports = { request }; +``` + +- [ ] **Step 2: deploy-workflow.js を作成** + +```javascript +// scripts/deploy-workflow.js +const fs = require("node:fs"); +const path = require("node:path"); +const { request } = require("./n8n-api"); + +async function main() { + const [, , filePath, ...rest] = process.argv; + if (!filePath) { + console.error("使い方: node scripts/deploy-workflow.js [--id=<既存workflowId>]"); + process.exit(1); + } + + const idArg = rest.find((a) => a.startsWith("--id=")); + const existingId = idArg ? idArg.slice("--id=".length) : null; + + const fullPath = path.resolve(filePath); + const definition = JSON.parse(fs.readFileSync(fullPath, "utf8")); + const body = { + name: definition.name, + nodes: definition.nodes, + connections: definition.connections, + settings: definition.settings || {}, + }; + + const { status, body: result } = existingId + ? await request("PUT", `/workflows/${existingId}`, body) + : await request("POST", "/workflows", body); + + console.log("HTTP status:", status); + console.log(JSON.stringify(result, null, 2)); + + if (status >= 200 && status < 300 && result.id) { + console.log(`\nワークフローID: ${result.id}`); + console.log("README.mdの「n8nリソースID一覧」へ記録すること。"); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +- [ ] **Step 3: 動作確認(既存ワークフローのダミー取得で疎通のみ確認)** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\NodeSrv\apps\healthcheck-survey-bot" +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('GET', '/workflows?limit=1').then(r => console.log(r.status)); +" +``` + +Expected: `200`が出力される + +- [ ] **Step 4: コミット** + +```bash +git add scripts/n8n-api.js scripts/deploy-workflow.js +git commit -m "feat: n8n Public API操作用の共通スクリプトを追加" +``` + +--- + +### Task 8: n8n Data Table「healthcheck_bot_state」作成 + +**Files:** +- Modify: `NodeSrv/apps/healthcheck-survey-bot/README.md`(作成したテーブルIDを記録) + +**Interfaces:** +- Produces: n8n Data Table(テーブル名`healthcheck_bot_state`)。列: `resultId`(string), `targetEmail`(string), `currentStatus`(string), `pendingProcesses`(string, JSON), `awaitInput`(string), `awaitProcessId`(string), `awaitColumn`(string), `updatedAt`(string) + +n8n Public APIの`POST /data-tables`の必須パラメータ(`projectId`要否等)は`n8n-guide.md`に記載が無いため、実機で確認しながら進める。 + +- [ ] **Step 1: 既存Data Table一覧からprojectIdを確認** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\NodeSrv\apps\healthcheck-survey-bot" +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('GET', '/data-tables?limit=10').then(r => console.log(JSON.stringify(r.body, null, 2))); +" +``` + +Expected: 既存テーブル(`workflow_config_values`等)のリストと、それぞれの`projectId`が確認できる + +- [ ] **Step 2: Data Table作成を試みる** + +Step 1で確認した既存`projectId`のいずれか(`org-master-sync`用の`LOcxF69Gm4PvnkqA`等)を指定して作成を試す。 + +```bash +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('POST', '/data-tables', { + name: 'healthcheck_bot_state', + projectId: 'LOcxF69Gm4PvnkqA', + columns: [ + { name: 'resultId', type: 'string' }, + { name: 'targetEmail', type: 'string' }, + { name: 'currentStatus', type: 'string' }, + { name: 'pendingProcesses', type: 'string' }, + { name: 'awaitInput', type: 'string' }, + { name: 'awaitProcessId', type: 'string' }, + { name: 'awaitColumn', type: 'string' }, + { name: 'updatedAt', type: 'string' }, + ], +}).then(r => console.log(r.status, JSON.stringify(r.body, null, 2))); +" +``` + +- [ ] **Step 2a: 作成に失敗した場合の代替手順** + +`projectId`必須エラー等でAPI経由の作成が通らない場合は、n8n UI(`https://n8n32.next-hd.net`、n8n-guide.md 2章のログイン情報)から手動でData Tableを作成する。列構成はStep 2と同じにする。 + +- [ ] **Step 3: 作成結果を確認** + +```bash +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('GET', '/data-tables?limit=20').then(r => { + const t = r.body.data.find(d => d.name === 'healthcheck_bot_state'); + console.log(JSON.stringify(t, null, 2)); +}); +" +``` + +Expected: 作成した8列のテーブルが表示される。表示された`id`を記録する + +- [ ] **Step 4: README.mdへ記録** + +```markdown +## n8nリソースID一覧 + +- Data Table `healthcheck_bot_state`: `` +``` + +- [ ] **Step 5: コミット** + +```bash +git add NodeSrv/apps/healthcheck-survey-bot/README.md +git commit -m "docs: healthcheck_bot_state Data Table作成結果を記録" +``` + +--- + +### Task 9: HC-SUBワークフロー — プロセス実行+案内送信 + +WP・WAの両方から呼ばれる共通ロジック。「(任意)ProcessIdを実行→現在Statusの選択肢を組み立ててLINEWORKSへ送信→Data Table更新」を1本のExecute Workflow Triggerサブワークフローにまとめる。 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/workflows/hc-sub-run-process-and-notify.json` +- Modify: `NodeSrv/apps/healthcheck-survey-bot/README.md` + +**Interfaces:** +- Consumes: Task 3〜6のロジック(Codeノードへ書き写す)、Task 8のData Table ID +- Produces: n8n上のワークフロー(Execute Workflow Trigger、入力`{resultId: string, processId: string | null}`)。WP・WAはこのワークフローIDを`Execute Workflow`ノードで呼び出す + +**ノード構成:** + +1. `Execute Workflow Trigger`(入力: `resultId`, `processId`) +2. `IF`: `processId`が存在するか + - true分岐 → `HTTP Request`「Process実行」: `POST {PLEASANTER_BASE_URL}api/items/{{$json.resultId}}/update`、body `{"ApiVersion":1.1,"ApiKey":"","ProcessId": {{$json.processId}}}` + - false分岐 → そのまま次へ(フォールバック再送のケース) +3. `HTTP Request`「レコード取得」: `POST {PLEASANTER_BASE_URL}api/items/{{$('Execute Workflow Trigger').item.json.resultId}}/get`、body `{"ApiVersion":1.1,"ApiKey":"..."}` +4. `HTTP Request`「サイト設定取得」: `POST {PLEASANTER_BASE_URL}api/items/508971/getsite`、body `{"ApiVersion":1.1,"ApiKey":"..."}` +5. `Code`「選択肢組み立て」: Task 4の`fillTemplate`とTask 5の`extractProcessesForStatus`をそのまま貼り付けて使う + +```javascript +// Codeノード「選択肢組み立て」の中身 +function isUnsetSentinel(value) { + return typeof value === "string" && value.startsWith("1899"); +} +function fillTemplate(template, columns, valueHash) { + const labelToColumnName = new Map(); + for (const column of columns) { + if (column.LabelText) labelToColumnName.set(column.LabelText, column.ColumnName); + } + return template.replace(/\{([^{}]+)\}/g, (matched, label) => { + const columnName = labelToColumnName.get(label); + if (!columnName) return matched; + const value = valueHash[columnName]; + if (value === undefined || value === null || value === "" || isUnsetSentinel(value)) return "未設定"; + return String(value); + }); +} +function extractProcessesForStatus(processes, status) { + return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1); +} + +const record = $('レコード取得').item.json.Response.Data; +const siteSettings = $('サイト設定取得').item.json.Response.Data.SiteSettings; +const columns = siteSettings.Columns || []; +const processes = siteSettings.Processes || []; + +const valueHash = { + ...record.ClassHash, ...record.NumHash, ...record.DateHash, ...record.DescriptionHash, +}; + +const candidates = extractProcessesForStatus(processes, record.Status); +const options = candidates.map((p) => ({ + processId: p.Id, + label: p.DisplayName || p.Name, + tooltip: fillTemplate(p.ToolTip || "", columns, valueHash), +})); + +return [{ + json: { + resultId: record.ResultId, + currentStatus: record.Status, + options, + classCUserId: record.ClassHash.ClassC, + }, +}]; +``` + +(`ToolTip`のキー名はTask 2の実機調査結果で確定させ、異なる場合はここだけ修正する) + +6. `HTTP Request`「対象者メール解決」: `POST {PLEASANTER_BASE_URL}api/users/get`、body `{"ApiVersion":1.1,"ApiKey":"...","View":{"ApiGetMailAddresses":true},"Where":{"UserId":{{$json.classCUserId}}}}` +7. `Code`「JWTクレーム組み立て」: LINEWORKS通知送信ワークフロー(`Yprojk4JTl1vJPkf`)の`Validate & Prepare`ノードと同じパターンでJWTクレームJSON文字列と、選択肢からLINEWORKS `button_template`のactions配列を組み立てる +8. `JWT`ノード(`operation: sign`, `algorithm: RS256`, Credential: 「LINEWORKS Bot Private Key (v4)」) +9. `HTTP Request`「アクセストークン取得」: `POST https://auth.worksmobile.com/oauth2/v2.0/token`(form-urlencoded、`Yprojk4JTl1vJPkf`の`Get Access Token`ノードと同じパラメータ構成) +10. `HTTP Request`「LINEWORKS送信」: `POST https://www.worksapis.com/v1.0/bots/{BOT_ID}/users/{userId}/messages`、`button_template`形式のcontentを送信 +11. `HTTP Request`(n8n内部呼び出し、または`Data Table`ノード)「状態更新」: `healthcheck_bot_state`へ`resultId`をキーに`upsert`(`currentStatus`, `pendingProcesses`=JSON化したoptions, `awaitInput: "none"`, `updatedAt`) + +- [ ] **Step 1: ワークフローJSON雛形を作成** + +`workflows/hc-sub-run-process-and-notify.json`に、上記11ノードの`nodes`配列と`connections`を、Task 7の`n8n-api.js`が期待する`{name, nodes, connections, settings}`形式で書く。各`HTTP Request`ノードの`parameters`は本タスク内の説明文の通りのURL・bodyを設定する(`n8n-nodes-base.httpRequest`, `n8n-nodes-base.code`, `n8n-nodes-base.jwt`, `n8n-nodes-base.executeWorkflowTrigger`, `n8n-nodes-base.if`, `n8n-nodes-base.dataTable`の各ノードタイプを使う)。Pleasanter APIキーは`Pleasanter/config_production.json`の値をそのままCodeノード内の定数として埋め込む(既存の`LINEWORKS通知送信`ワークフローと同じ方式)。 + +- [ ] **Step 2: デプロイ** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\NodeSrv\apps\healthcheck-survey-bot" +N8N_API_KEY="" node scripts/deploy-workflow.js workflows/hc-sub-run-process-and-notify.json +``` + +Expected: HTTP status 200、ワークフローIDが出力される + +- [ ] **Step 3: README.mdへワークフローIDを記録** + +```markdown +- ワークフロー `HC-SUB: プロセス実行と案内送信`: `` +``` + +- [ ] **Step 4: ユーザーへ確認のうえ、テスト実行** + +Task 2で作成・削除したテストProcessとは別に、動作確認用の捨てレコード・テストProcess(`CurrentStatus`をnullまたは既存Statusのどれかにして`ChangedStatus`は同じ値、`processId`無しでの疎通確認から始める)を使い、n8n UIの「Test workflow」または`Execute Workflow`ノード経由で1回実行し、LINEWORKSへメッセージが届くこと・`healthcheck_bot_state`に行が作られることを確認する。**実行前に必ずユーザーへ確認する。** + +- [ ] **Step 5: コミット** + +```bash +git add workflows/hc-sub-run-process-and-notify.json NodeSrv/apps/healthcheck-survey-bot/README.md +git commit -m "feat: HC-SUBワークフロー(プロセス実行+案内送信)を追加" +``` + +--- + +### Task 10: HC-WPワークフロー — Statusプッシュ通知(担当者操作起点) + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/workflows/hc-wp-status-push.json` +- Modify: `NodeSrv/apps/healthcheck-survey-bot/README.md` + +**Interfaces:** +- Consumes: Task 9のワークフローID(`Execute Workflow`ノードで参照) +- Produces: Webhook `POST https://n8n32.next-hd.net/webhook/healthcheck-status-push`(`X-Api-Key`ヘッダー認証、body `{resultId, processId}`) + +**ノード構成:** + +1. `Webhook`(`httpMethod: POST`, `path: healthcheck-status-push`) +2. `Code`「検証」: `X-Api-Key`ヘッダーとbodyの`resultId`/`processId`必須チェック(不正なら`throw new Error(...)`でワークフローを失敗させる) +3. `Execute Workflow`(Task 9のワークフローIDを指定、入力: `resultId`, `processId`) +4. `Respond to Webhook`(`{"result":"ok"}`を返す) + +- [ ] **Step 1: ワークフローJSONを作成** + +`workflows/hc-wp-status-push.json`を作成。Codeノードの中身: + +```javascript +const API_KEY = ""; +const headers = $input.first().json.headers || {}; +if (headers["x-api-key"] !== API_KEY) { + throw new Error("Unauthorized: invalid API key"); +} +const body = $input.first().json.body || {}; +if (!body.resultId || !body.processId) { + throw new Error("Bad Request: resultId, processId は必須です"); +} +return [{ json: { resultId: body.resultId, processId: body.processId } }]; +``` + +- [ ] **Step 2: デプロイ** + +```bash +N8N_API_KEY="" node scripts/deploy-workflow.js workflows/hc-wp-status-push.json +``` + +- [ ] **Step 3: Webhook有効化** + +```bash +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('POST', '/workflows//activate').then(r => console.log(r.status, JSON.stringify(r.body))); +" +``` + +- [ ] **Step 4: README.mdへ記録** + +```markdown +- ワークフロー `HC-WP: Statusプッシュ通知`: ``(Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-status-push`) +``` + +- [ ] **Step 5: ユーザー確認のうえ疎通テスト** + +```bash +curl -X POST "https://n8n32.next-hd.net/webhook/healthcheck-status-push" \ + -H "X-Api-Key: " \ + -H "Content-Type: application/json" \ + -d '{"resultId": <テスト用ResultId>, "processId": null}' +``` + +Expected: `{"result":"ok"}`、かつLINEWORKSへ現在Statusの案内が届く(**実行前に必ずユーザーへ確認する**) + +- [ ] **Step 6: コミット** + +```bash +git add workflows/hc-wp-status-push.json NodeSrv/apps/healthcheck-survey-bot/README.md +git commit -m "feat: HC-WPワークフロー(Statusプッシュ通知)を追加" +``` + +--- + +### Task 11: HC-WAワークフロー — LINEWORKS応答受信(唯一の受信口) + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/workflows/hc-wa-lineworks-response.json` +- Modify: `NodeSrv/apps/healthcheck-survey-bot/README.md` + +**Interfaces:** +- Consumes: Task 6の署名検証ロジック、Task 3の日付パース、Task 5の`matchProcessByLabel`/`classifyAwaitInput`、Task 9のワークフローID +- Produces: Webhook `POST https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`(LINE WORKS本体からの直接コールバック、`x-works-signature`検証) + +**ノード構成:** + +1. `Webhook`(`httpMethod: POST`, `path: healthcheck-lineworks-response`, `options.rawBody: true`) +2. `Code`「署名検証・送信者解決」: Task 6の`verifySignature`を貼り付けて検証。失敗なら`throw`。成功したら`source.userId`(=メールアドレス)と、`content.type`に応じたテキスト/ファイル情報を抽出する + +```javascript +// Codeノード「署名検証・送信者解決」の中身 +const crypto = require("crypto"); +function normalizeSignature(value) { + return String(value || "").trim().replace(/^sha256=/i, ""); +} +function safeEqual(a, b) { + const ab = Buffer.from(String(a), "utf8"); + const bb = Buffer.from(String(b), "utf8"); + if (ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} +function verifySignature(rawBody, headerSignature, botSecret) { + const headerSig = normalizeSignature(headerSignature); + if (!headerSig || !botSecret) return false; + const payload = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), "utf8"); + const expected = crypto.createHmac("sha256", botSecret).update(payload).digest("base64"); + return safeEqual(headerSig, expected); +} + +const LINEWORKS_BOT_SECRET = ""; + +const item = $input.first(); +const headers = item.json.headers || {}; +const rawBody = item.binary && item.binary.data + ? Buffer.from(item.binary.data.data, "base64") + : Buffer.from(JSON.stringify(item.json.body || {}), "utf8"); + +if (!verifySignature(rawBody, headers["x-works-signature"], LINEWORKS_BOT_SECRET)) { + throw new Error("Unauthorized: signature mismatch"); +} + +const body = item.json.body || {}; +const source = body.source || {}; +const content = body.content || {}; + +return [{ + json: { + targetEmail: source.userId, + contentType: content.type, + text: content.type === "text" ? content.text : null, + fileId: content.type === "file" ? content.fileId : null, + }, +}]; +``` + +3. `HTTP Request`「対象レコード検索」: `POST {PLEASANTER_BASE_URL}api/items/508971/get`、body `{"ApiVersion":1.1,"ApiKey":"...","View":{"ColumnFilterHash":{"ClassC":"<メールからPleasanterUserId解決した値>"},"ColumnFilterSearchTypes":{"ClassC":"ExactMatch"}}}` + - 前段として`api/users/get`で`targetEmail`からPleasanterUserIdを引く`HTTP Request`ノードを1つ挟む + - 取得結果を`Status`昇順以外(`900`/`910`除外)でフィルタし、`UpdatedTime`降順で1件選ぶ`Code`ノードを挟む。0件/複数件は`throw`でワークフローを失敗させる(6章の異常系方針通り、自動判定しない) +4. `HTTP Request`(`Data Table`ノード, `operation: get`)「待機状態取得」: `healthcheck_bot_state`から`resultId`一致行を取得 +5. `Switch`「awaitInput分岐」: `none` / `date` / `file` の3分岐 + - **none分岐**: `Code`で`pendingProcesses`(JSON文字列)をパースし、Task 5の`matchProcessByLabel`相当のロジックで`text`と一致するか判定 + - 一致 → `Code`でTask 5の`classifyAwaitInput`相当のロジックを実行 + - `awaitInput: "none"` → `Execute Workflow`(Task 9、`processId`=一致したProcessId) + - `awaitInput: "date"` / `"file"` → `Data Table`ノード(`operation: update`)で`awaitInput`/`awaitProcessId`/`awaitColumn`を保存 → `HTTP Request`で「日付を入力してください」等の追加メッセージを送信(Task 9のノード7〜10と同じLINEWORKS送信パターンを再利用) + - 不一致 → `Execute Workflow`(Task 9、`processId: null`)でフォールバック再送 + - **date分岐**: `Code`でTask 3の`parseDateInput`を実行 + - 成功 → `HTTP Request`「該当列update」: `POST api/items/{resultId}/update`、body `{"ApiVersion":1.1,"ApiKey":"...", "DateHash":{"": ""}}` → `Data Table`で`awaitInput`を`none`に戻す → `Execute Workflow`(Task 9、`processId`=`awaitProcessId`) + - 失敗 → エラーメッセージを再送(待機状態は維持、`Data Table`更新なし) + - **file分岐**: `contentType`が`file`でなければ再送要求。`file`なら`HTTP Request`でLINEWORKSファイルダウンロード→`HTTP Request`でPleasanter添付アップロード(具体的なエンドポイントは実装時にPleasanter公式マニュアル`api-attachment`系を確認する)→`Data Table`更新→`Execute Workflow`(Task 9) +6. `Respond to Webhook`(各分岐の末尾で`{"result":"ok"}`を返す) + +- [ ] **Step 1: ワークフローJSONを作成** + +上記ノード構成に従い`workflows/hc-wa-lineworks-response.json`を作成する。`none`分岐・`date`分岐の`Code`ノードにはTask 3・5のロジックをそのまま貼り付ける。 + +- [ ] **Step 2: デプロイ** + +```bash +N8N_API_KEY="" node scripts/deploy-workflow.js workflows/hc-wa-lineworks-response.json +``` + +- [ ] **Step 3: Webhook有効化** + +```bash +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('POST', '/workflows//activate').then(r => console.log(r.status)); +" +``` + +- [ ] **Step 4: LINEWORKS Developer Console側にBot Callback URLを設定** + +対象Bot(484184で管理しているBotのうち、健康診断管理で使うもの)のCallback URLを`https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`に設定する(**ユーザー確認必須**。他プロジェクトと同じBotを共用している場合、Callback URL変更が既存フローに影響しないか要確認)。 + +- [ ] **Step 5: README.mdへ記録** + +```markdown +- ワークフロー `HC-WA: LINEWORKS応答受信`: ``(Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`) +``` + +- [ ] **Step 6: コミット** + +```bash +git add workflows/hc-wa-lineworks-response.json NodeSrv/apps/healthcheck-survey-bot/README.md +git commit -m "feat: HC-WAワークフロー(LINEWORKS応答受信)を追加" +``` + +--- + +### Task 12: 508971側クライアントスクリプト追加+エンドツーエンド確認 + +**Files:** +- Modify: `Pleasanter/健康診断管理/configs/production/site-508971_健康診断管理/scripts/`配下に新規スクリプトファイルを追加 +- Modify: 508971にテスト用Processを1つ追加(`pleasanter-site-spec`スキルの標準フロー: `get-site-config.js`→編集→`build-desired-config.js`→`apply-site-config.js`ドライラン確認→ユーザー確認後にcurl実行) + +**Interfaces:** +- Consumes: Task 10のWebhook URL + +- [ ] **Step 1: クライアントスクリプトを作成** + +`Pleasanter/健康診断管理/configs/production/site-508971_健康診断管理/scripts/1_プロセス通知送信.js`を新規作成: + +```javascript +const WEBHOOK_URL = "https://n8n32.next-hd.net/webhook/healthcheck-status-push"; +const API_KEY = ""; + +$p.events.on_process = function (processId) { + sendStatusPush(processId); +}; + +async function sendStatusPush(processId) { + const resultId = $p.id(); + try { + const res = await fetch(WEBHOOK_URL, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Api-Key": API_KEY }, + body: JSON.stringify({ resultId, processId }), + }); + if (!res.ok) { + console.error("Status push failed:", res.status, await res.text()); + } + } catch (error) { + console.error("Status push error:", error.message); + } +} +``` + +- [ ] **Step 2: 標準フローで508971へ反映** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\Pleasanter" +node .claude/js/build-desired-config.js --project="健康診断管理" --env=production site-508971 +node .claude/js/apply-site-config.js --project="健康診断管理" --env=production +``` + +Expected: 差分・送信予定Body・curlコマンドが表示される(常にドライラン)。内容をユーザーに提示し、確認を得てから表示されたcurlコマンドを実行する + +- [ ] **Step 3: テスト用Processを追加** + +ユーザー確認のうえ、508971に検証用Process(例: `CurrentStatus`を既存の`900`完了のまま、`ChangedStatus`も`900`、`OnClick`不要、実行種別「追加したボタン」)を1つ追加する。Task 1のクライアントスクリプトと連携させ、押下時に`on_process`イベント経由でTask 10のWebhookが呼ばれることを確認する。 + +- [ ] **Step 4: エンドツーエンド確認** + +1. 508971の捨てレコードでテストProcessボタンを押す → LINEWORKSに案内が届くことを確認 +2. LINEWORKSで選択肢に回答する → `healthcheck_bot_state`の該当行が更新され、Processが実行されて捨てレコードのStatusが変わることを確認(`get-site-config.js`相当で該当レコードを`api/items/{id}/get`し直して確認) +3. n8n Execution History(`GET /executions?workflowId=&limit=5`)でエラーが出ていないことを確認 + +**すべて実データ・実LINEWORKS送信を伴うため、着手前に必ずユーザーへ確認する。** + +- [ ] **Step 5: テスト用Processを削除** + +確認完了後、ユーザーに508971からテスト用Processを削除してもらう(本番のProcess一覧を汚さないため)。 + +- [ ] **Step 6: コミット** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev" +git add "Pleasanter/健康診断管理/configs/production/site-508971_健康診断管理/scripts/1_プロセス通知送信.js" +git commit -m "feat: 508971にStatusプッシュ通知用クライアントスクリプトを追加" +``` + +--- + +## Self-Review + +**Spec coverage:** +- 全体アーキテクチャ(WP/WA + Process流用)→ Task 9〜11でカバー +- Data Table「bot_conversation_state」→ Task 8(実装上は`healthcheck_bot_state`という名前にしたが、設計書の構造要件は満たす。理由: 既存のorg-master-sync用テーブルと並んだ一覧で識別しやすくするため) +- Process定義(ツールチップ・入力検証タブ)→ Task 2(実機調査)・Task 5(ロジック)・Task 9(利用) +- 対象者解決(メール=LINEWORKS userId)→ Task 9・11のPleasanter `api/users/get`呼び出し +- 日付・ファイル入力の追加往復 → Task 11の`date`/`file`分岐 +- タイムアウト監視は「不要」という設計判断 → 本計画にタイムアウト監視ワークフローは含めていない(意図通り) +- エラーハンドリング(複数レコードヒット等)→ Task 11 Step3の異常系(`throw`で失敗させる) + +**Placeholder scan:** 「実装時に確認する」という記述がTask 8(projectId要否)・Task 11(Pleasanter添付アップロードの正確なエンドポイント)に残っている。これはn8n-guide.mdが明記していない未検証事項であり、調査ステップ自体をタスク内の手順として書いてあるため、内容のないプレースホルダーではなく「次に確認すべきこと」を伴う具体的な調査タスクとして扱う。 + +**Type consistency:** `extractProcessesForStatus`/`matchProcessByLabel`/`classifyAwaitInput`の関数名・戻り値の形は Task 5→Task 9→Task 11で一貫させた。`fillTemplate`も同様。 + +--- + +Plan complete and saved to `NodeSrv/docs/superpowers/plans/2026-09-05-healthcheck-lineworks-survey-n8n.md`. 実行方式を選んでほしい。 + +1. **Subagent-Driven(推奨)** — タスクごとに新しいsubagentを立てて実装、タスク間でレビューを挟む +2. **Inline Execution** — このセッション内で`executing-plans`を使い、チェックポイントを挟みながらまとめて実行