# 健康診断管理×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件で疎通確認する - 設計書8章の社員マスタ(504412)によるメールアドレス整合性チェック・フリガナ補完は、Bot対話フローと独立した別タスクとして扱う。本計画には含まない - **【n8nワークフロー構築の基本ルール、必ず守ること】** 以下3点を踏まえてノード数の肥大を避ける(`NodeSrv/apps/n8n/docs/n8n-guide.md` 8-1参照): - **Data Table利用は最小限にする。**`workflow_config_values`からの設定値取得は1回の`Data Table`Getノード(`filters`無し、`returnAll:true`)にまとめ、後続のCodeノードで`configKey`→`configValue`のMapに変換して使う。「1キー=1回のGet」でノードを積み上げない(n8n-guide.md 7-2の注意は619件規模のマスタデータ展開の話であり、config値十数件程度の一括取得には当てはまらない) - **入口はWebhookノード+Codeノードで処理し、なるべくノード数を抑える。** データ整形・分岐判定・業務ロジック(ツールチップ置換、Process抽出、日付パース等)はCodeノードにまとめる - **外部システム(Pleasanter、LINEWORKS等)へのHTTP Requestノードは普通に使ってよい。** 個々のAPI呼び出しをCodeノード内の`this.helpers.httpRequest`に無理に押し込む必要はない - **JWT署名(Credential使用)だけは専用の`n8n-nodes-base.jwt`ノードを残す**(秘密鍵を安全に扱う既存の実証済みパターンのため) - **Data Tableの読み書き自体(`workflow_config_values`の取得、`healthcheck_bot_state`の取得・更新)は専用の`Data Table`ノードのまま残す**(Codeノードから直接Data Tableを操作する手段が無いため) --- ## 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へテストProcessを追加する前に、既存本番プロジェクト「実行予算WF申請」(SiteId 376872)の取得済み`processes.json`に入力検証タブを使ったProcessの実例が見つかり、508971への書き込みなしで構造を確認できた。 **Files:** - Modified: `NodeSrv/apps/healthcheck-survey-bot/README.md`(調査結果を追記済み) **Interfaces:** - Produces: Task 5(processFlow.js)が前提とする、Process内で入力検証対象列を表すJSONキー名とその構造 **確認できた構造:** `Pleasanter/実行予算WF申請/configs/production/site-376872_実行予算WF申請/processes.json`のId:1「入力完了」Processに実例あり: ```json { "Id": 1, "Name": "入力完了", "ValidateInputs": [ { "Id": 1, "ColumnName": "Class021", "Required": true }, { "Id": 2, "ColumnName": "Class022", "Required": true } ] } ``` 配列名は`Validations`ではなく`ValidateInputs`。各要素は`{Id, ColumnName, Required}`。値を設定していない項目(クライアント/サーバ正規表現、エラーメッセージ、最小/最大等)はキー自体が省略される可能性が高い(この実例では未設定のため確認できていない)。 Task 5(processFlow.js)の`getValidationColumnNames`はこの構造(`process.ValidateInputs[].ColumnName`)を前提に実装する。 --- ### 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, 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.ValidateInputs`(各要素`{Id: number, ColumnName: string, Required: boolean}`)に入っている。 **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, ValidateInputs: [{ Id: 1, ColumnName: "Date001", Required: true }] }, { Id: 3, Name: "受けた", DisplayName: "受けた", CurrentStatus: 200, ChangedStatus: 300 }, { Id: 4, Name: "結果受取り", DisplayName: "受け取った", CurrentStatus: 300, ChangedStatus: 900, ValidateInputs: [{ Id: 1, ColumnName: "AttachmentsA", Required: true }] }, ]; 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: ValidateInputsが無い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.ValidateInputs)) return []; return process.ValidateInputs.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`はn8n Data Tableのシステム予約列名のため定義できず、7列で作成した。行の更新日時はn8nが自動管理するメタデータに委ねる。実際に作成したテーブルid: `jqMDa2YZTI4f0iQ7`) 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' }, ], }).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サブワークフローにまとめる。**Global Constraintsのn8nワークフロー構築ルール(Data Table最小限、Webhook+Codeでノード数抑制、HTTP Requestノードは可)に従い、Pleasanter API呼び出し群・選択肢組み立てを1つのCodeノードに集約する。** **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、`workflow_config_values`(id `bNkadTyDgDepYx2p`、既存のorg-master-sync用n8n Data Table)に登録済みの設定値 - Produces: n8n上のワークフロー(Execute Workflow Trigger、入力`{resultId: string, processId: string | null}`)。WP・WAはこのワークフローIDを`Execute Workflow`ノードで呼び出す **秘密情報・設定値の扱い方針(重要):** **PleasanterのApiKeyやLINEWORKS Client Secret等をCodeノードに直接ハードコードしない。** 代わりに`workflow_config_values`(Data Table、id `bNkadTyDgDepYx2p`)を**1回の`Data Table`Getノード(`filters`無し、`returnAll:true`)でまとめて取得**し、直後のCodeノードで`configKey`→`configValue`のMapに変換して使う(個別キーごとにGetノードを分けない)。 このワークフローで使うキー(今回追加登録済み、値は取得済みの全行から後続Codeノードでフィルタする): `PLEASANTER_BASE_URL_PROD` / `PLEASANTER_API_KEY_PROD` / `LW_BOT_CLIENT_ID` / `LW_BOT_CLIENT_SECRET` / `LW_BOT_SERVICE_ACCOUNT` / `HEALTHCHECK_SITE_ID`(=508971)/ `LINEWORKS_BOT_MASTER_SITE_ID`(=484184)/ `HEALTHCHECK_DATA_TABLE_ID`(=`jqMDa2YZTI4f0iQ7`) **ノード構成(7ノード):** 1. `Execute Workflow Trigger`(入力: `resultId`, `processId`) 2. `Data Table`ノード「設定値一括取得」(`operation: "get"`, `dataTableId: bNkadTyDgDepYx2p`, `filters`無し, `returnAll: true`) 3. `Code`「Pleasanter照会・選択肢組み立て」: 設定値のMap化、(任意)Process実行、レコード取得、サイト設定取得(getsite)、Botマスタ取得(484184)、対象者メール解決、選択肢組み立て(Task 4の`fillTemplate`とTask 5の`extractProcessesForStatus`をそのまま使う)までを1つのCodeノードにまとめる。Pleasanter APIへの各呼び出しは`await this.helpers.httpRequest({method:"POST", url, body, json:true})`で行う(n8n Codeノードの公式ヘルパー、外部HTTPリクエストを直接発行できる) 4. `Code`「JWTクレーム組み立て」: `iss`/`sub`に設定値Mapの`LW_BOT_CLIENT_ID`/`LW_BOT_SERVICE_ACCOUNT`を使ってJWTクレームJSON文字列を組み立てる。あわせて選択肢からLINEWORKS `button_template`のactions配列を組み立てる 5. `JWT`ノード(`operation: sign`, `algorithm: RS256`, Credential: 「LINEWORKS Bot Private Key (v4)」、id `Hw0qlEaGfLPnQWp1`) 6. `Code`「アクセストークン取得・LINEWORKS送信」: `POST https://auth.worksmobile.com/oauth2/v2.0/token`(form-urlencoded、`client_id`/`client_secret`は設定値Mapの`LW_BOT_CLIENT_ID`/`LW_BOT_CLIENT_SECRET`)でトークン取得後、続けて`POST https://www.worksapis.com/v1.0/bots/{BOT_ID}/users/{userId}/messages`(`button_template`形式)へ送信するところまでを1つのCodeノードで行う(いずれも`this.helpers.httpRequest`) 7. `Data Table`ノード「状態更新」: `healthcheck_bot_state`(id: `jqMDa2YZTI4f0iQ7`)へ`resultId`をキーに`insert`(`currentStatus`, `pendingProcesses`=JSON化したoptions, `awaitInput: "none"`)。`updatedAt`列は存在しない(Task 8参照)ため送信対象に含めない Step 3のCodeノードの中身(骨格。各`this.helpers.httpRequest`呼び出しの`body`はTask 4〜7で確認したAPI形式に沿って埋める): ```javascript // Codeノード「Pleasanter照会・選択肢組み立て」の中身 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 configRows = $('設定値一括取得').all().map((item) => item.json); const config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue])); const trigger = $('Execute Workflow Trigger').item.json; const baseUrl = config.PLEASANTER_BASE_URL_PROD; const apiKey = config.PLEASANTER_API_KEY_PROD; async function pleasanterPost(path, body) { const res = await this.helpers.httpRequest({ method: "POST", url: `${baseUrl}${path}`, body: { ApiVersion: 1.1, ApiKey: apiKey, ...body }, json: true, }); return res; } if (trigger.processId) { await pleasanterPost.call(this, `api/items/${trigger.resultId}/update`, { ProcessId: trigger.processId }); } const recordRes = await pleasanterPost.call(this, `api/items/${trigger.resultId}/get`, {}); const record = recordRes.Response.Data; const siteRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/getsite`, {}); const siteSettings = siteRes.Response.Data.SiteSettings; const columns = siteSettings.Columns || []; const processes = siteSettings.Processes || []; const botMasterRes = await pleasanterPost.call(this, `api/items/${config.LINEWORKS_BOT_MASTER_SITE_ID}/getsite`, {}); // TODO(Task 12): 484184の実データ構造を見てBotIdの解決方法を確定させる 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), })); const userRes = await pleasanterPost.call(this, `api/users/get`, { View: { ApiGetMailAddresses: true }, Where: { UserId: record.ClassHash.ClassC }, }); return [{ json: { resultId: record.ResultId, currentStatus: record.Status, options, targetEmail: userRes.Response.Data[0]?.MailAddress, config, }, }]; ``` (`ToolTip`のキー名はTask 2の実機調査結果で確定させ、異なる場合はここだけ修正する。上記は骨格であり、実装時にPleasanter API実レスポンスの構造に合わせて調整する) - [ ] **Step 1: ワークフローJSON雛形を作成** `workflows/hc-sub-run-process-and-notify.json`に、上記7ノードの`nodes`配列と`connections`を、Task 7の`n8n-api.js`が期待する`{name, nodes, connections, settings}`形式で書く(`n8n-nodes-base.code`, `n8n-nodes-base.jwt`, `n8n-nodes-base.executeWorkflowTrigger`, `n8n-nodes-base.dataTable`の各ノードタイプを使う)。**秘密情報はCodeノードに直接値として書かず、必ず「設定値一括取得」ノードから得たMapを経由して参照する。** - [ ] **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}`) **秘密情報の扱い**: Webhook認証キーはCodeノードに直接書かず、`workflow_config_values`(`bNkadTyDgDepYx2p`)から`Data Table`ノードで取得して比較する(Task 9と同じ方針。1回の`returnAll:true`取得+後続Codeノードでキーを引く)。 **ノード構成:** 1. `Webhook`(`httpMethod: POST`, `path: healthcheck-status-push`) 2. `Data Table`ノード「設定値一括取得」(`operation: "get"`, `dataTableId: bNkadTyDgDepYx2p`, `filters`無し, `returnAll: true`) 3. `Code`「検証」: `X-Api-Key`ヘッダーとbodyの`resultId`/`processId`必須チェック(不正なら`throw new Error(...)`でワークフローを失敗させる) 4. `Execute Workflow`(Task 9のワークフローIDを指定、入力: `resultId`, `processId`) 5. `Respond to Webhook`(`{"result":"ok"}`を返す) - [ ] **Step 1: ワークフローJSONを作成** `workflows/hc-wp-status-push.json`を作成。Codeノード「検証」の中身: ```javascript const configRows = $('設定値一括取得').all().map((item) => item.json); const expectedApiKey = configRows.find((row) => row.configKey === "HEALTHCHECK_WP_API_KEY")?.configValue; const headers = $input.first().json.headers || {}; if (headers["x-api-key"] !== expectedApiKey) { 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`検証) **Global Constraintsのn8nワークフロー構築ルールに従い、分岐ロジック・Pleasanter/LINEWORKS API呼び出しは可能な限りCodeノードに集約する。** Data Tableの読み書き(設定値取得・待機状態の取得/更新)のみ専用ノードを使う。JWT署名も専用ノードのまま。 **ノード構成(目安11ノード):** 1. `Webhook`(`httpMethod: POST`, `path: healthcheck-lineworks-response`, `options.rawBody: true`) 2. `Data Table`ノード「設定値一括取得」(`operation: "get"`, `dataTableId: bNkadTyDgDepYx2p`, `filters`無し, `returnAll: true`) 3. `Code`「署名検証・対象レコード特定」: Task 6の`verifySignature`で検証(失敗なら`throw`)。成功したら`this.helpers.httpRequest`で対象者メール→PleasanterUserId解決、508971の対象レコード検索(`ColumnFilterHash`)までをこの1ノードで行う。0件/複数件は`throw`(6章の異常系方針、自動判定しない) ```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 configRows = $('設定値一括取得').all().map((item) => item.json); const config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue])); const item = $('Webhook').first(); // この直前ノードは「設定値一括取得」(Data Table)のため、裸の$input.first()だとconfig行を拾ってしまいheaders/bodyが無い 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"], config.LINEWORKS_BOT_SECRET)) { throw new Error("Unauthorized: signature mismatch"); } const body = item.json.body || {}; const source = body.source || {}; const content = body.content || {}; const targetEmail = source.userId; async function pleasanterPost(path, reqBody) { return this.helpers.httpRequest({ method: "POST", url: `${config.PLEASANTER_BASE_URL_PROD}${path}`, body: { ApiVersion: 1.1, ApiKey: config.PLEASANTER_API_KEY_PROD, ...reqBody }, json: true, }); } const userRes = await pleasanterPost.call(this, "api/users/get", { View: { ApiGetMailAddresses: true }, Where: { MailAddress: targetEmail }, }); const userId = userRes.Response.Data[0]?.UserId; if (!userId) throw new Error(`対象者が見つかりません: ${targetEmail}`); const recordRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/get`, { View: { ColumnFilterHash: { ClassC: String(userId) }, ColumnFilterSearchTypes: { ClassC: "ExactMatch" }, }, }); const candidates = (recordRes.Response.Data || []).filter((r) => r.Status !== 900 && r.Status !== 910); if (candidates.length !== 1) { throw new Error(`対象レコードを一意に特定できません: ${candidates.length}件`); } const record = candidates[0]; return [{ json: { config, resultId: record.ResultId, currentStatus: record.Status, contentType: content.type, text: content.type === "text" ? content.text : null, fileId: content.type === "file" ? content.fileId : null, }, }]; ``` 4. `Data Table`ノード「待機状態取得」(`operation: get`, `filters.conditions: [{keyName:"resultId", keyValue:"={{ $json.resultId }}"}]`): `healthcheck_bot_state`から該当resultIdの行を取得 5. `Code`「分岐処理・アクション決定」: `awaitInput`(`none`/`date`/`file`、または待機状態自体が無い場合)に応じた全ロジックをここに集約する。以下を1つのCodeノードで行う: - 待機状態が無い、または`awaitInput`が想定外の値 → `action: "error"` - `awaitInput: "none"`(選択肢待ち): `pendingProcesses`(JSON文字列)をパースし、Task 5の`matchProcessByLabel`相当で`text`と一致するか判定 - 一致・追加入力対象列なし → `action: "execute"`, `processId` - 一致・追加入力対象列が`Date*`/`Attachments*` → `action: "send"`(追加入力を促すメッセージ)、次の待機状態(`awaitInput: "date"|"file"`, `awaitProcessId`, `awaitColumn`) - 不一致 → `action: "execute"`, `processId: null`(フォールバック再送、HC-SUBが現在Statusの案内を再送する) - `awaitInput: "date"`: Task 3の`parseDateInput`を実行 - 成功 → `this.helpers.httpRequest`で該当列(`DateHash`)をupdate → `action: "execute"`, `processId: awaitProcessId`, 待機状態を`none`に戻す - 失敗 → `action: "send"`(エラーメッセージ)、待機状態は維持 - `awaitInput: "file"`: `contentType`が`file`でなければ`action: "send"`(再送要求)、待機状態維持。`file`なら`this.helpers.httpRequest`でLINEWORKSファイルダウンロード→Pleasanter添付アップロード(具体的なエンドポイントは実装時にPleasanter公式マニュアル`api-attachment`系を確認する)→`action: "execute"`, `processId: awaitProcessId`、待機状態を`none`に戻す - 出力に必ず「次に`healthcheck_bot_state`へ書き込むべき状態」(`nextAwaitInput`, `nextAwaitProcessId`, `nextAwaitColumn`)を含める(ノード6で無条件更新するため) 6. `Data Table`ノード「状態更新」(`operation: update`、`resultId`をキーに`nextAwaitInput`等を書き込む。無条件で1回呼ぶ) 7. `IF`「`action == "execute"`」 - true分岐 → `Execute Workflow`(Task 9、`processId`)→ `Respond to Webhook`(成功) - false分岐 → 次へ 8. `IF`「`action == "send"`」 - true分岐 → `Code`「JWTクレーム組み立て」(設定値Mapの`LW_BOT_CLIENT_ID`/`LW_BOT_SERVICE_ACCOUNT`を使用)→ `JWT`ノード(Credential: 「LINEWORKS Bot Private Key (v4)」、id `Hw0qlEaGfLPnQWp1`)→ `Code`「アクセストークン取得・LINEWORKS送信」(Task 9のノード6と同じ`this.helpers.httpRequest`パターン)→ `Respond to Webhook`(成功) - false分岐 → `Respond to Webhook`(`action == "error"`のケース。エラー内容を返す) - [ ] **Step 1: ワークフローJSONを作成** 上記ノード構成に従い`workflows/hc-wa-lineworks-response.json`を作成する。ノード5の`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"; // n8n Data Table workflow_config_values の HEALTHCHECK_WP_API_KEY と同じ値を使う 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プッシュ通知用クライアントスクリプトを追加" ``` --- ### Task 13: employeeMasterCheck.js — 社員マスタ(504412)によるメール整合性チェック・フリガナ補完ロジック 設計書8章(Bot対話フローとは独立した補助機能)に対応。**実行タイミング(保存時/定期バッチ/手動)はまだ決まっていない**ため、本タスクは判定・補完の純粋ロジックとテストのみを作る。504412へのAPI呼び出しをどこから叩くか(クライアントスクリプト/n8n Schedule Trigger/Process)は、タイミングが決まった時点で別タスクとして追加する。 **Files:** - Create: `NodeSrv/apps/healthcheck-survey-bot/src/lib/employeeMasterCheck.js` - Test: `NodeSrv/apps/healthcheck-survey-bot/test/employeeMasterCheck.test.js` **Interfaces:** - Consumes: なし(Task 3〜6とは独立) - Produces: - `checkEmailConsistency(pleasanterEmail: string, masterRecord: {Class036?: string, ClassB?: string}): { consistent: boolean, masterEmail: string | null }` - `resolveKanaFromMaster(masterRecord: {Class003?: string, Class004?: string}): string` - `needsKanaFill(currentKana: string | null | undefined): boolean` `masterRecord`は504412の`api/items/{id}/get`レスポンスの`ClassHash`(`Class011`=ユーザID、`Class036`=PLメールアドレス、`ClassB`=メールアドレス、`Class003`=姓(カナ)、`Class004`=名(カナ)を含む)を想定する。整合性チェックは`Class036`(PLメールアドレス)を優先し、無ければ`ClassB`にフォールバックする。 - [ ] **Step 1: 失敗するテストを書く** ```javascript // test/employeeMasterCheck.test.js const { test } = require("node:test"); const assert = require("node:assert"); const { checkEmailConsistency, resolveKanaFromMaster, needsKanaFill, } = require("../src/lib/employeeMasterCheck"); test("checkEmailConsistency: PLメールアドレス(Class036)と一致すればconsistent:true", () => { const result = checkEmailConsistency("taro.yamada@next-hd.co.jp", { Class036: "taro.yamada@next-hd.co.jp", ClassB: "taro.yamada@example.com", }); assert.deepStrictEqual(result, { consistent: true, masterEmail: "taro.yamada@next-hd.co.jp" }); }); test("checkEmailConsistency: Class036が無ければClassBにフォールバックする", () => { const result = checkEmailConsistency("taro.yamada@example.com", { ClassB: "taro.yamada@example.com", }); assert.deepStrictEqual(result, { consistent: true, masterEmail: "taro.yamada@example.com" }); }); test("checkEmailConsistency: 不一致ならconsistent:false", () => { const result = checkEmailConsistency("taro.yamada@next-hd.co.jp", { Class036: "different@next-hd.co.jp", }); assert.deepStrictEqual(result, { consistent: false, masterEmail: "different@next-hd.co.jp" }); }); test("checkEmailConsistency: マスタ側にメールが無ければmasterEmail:null・consistent:false", () => { const result = checkEmailConsistency("taro.yamada@next-hd.co.jp", {}); assert.deepStrictEqual(result, { consistent: false, masterEmail: null }); }); test("resolveKanaFromMaster: 姓カナ+名カナを空白区切りで結合する", () => { assert.strictEqual( resolveKanaFromMaster({ Class003: "ヤマダ", Class004: "タロウ" }), "ヤマダ タロウ" ); }); test("resolveKanaFromMaster: 片方欠けていても結合できる", () => { assert.strictEqual(resolveKanaFromMaster({ Class003: "ヤマダ" }), "ヤマダ"); }); test("needsKanaFill: 空文字・未定義はtrue", () => { assert.strictEqual(needsKanaFill(""), true); assert.strictEqual(needsKanaFill(undefined), true); assert.strictEqual(needsKanaFill(null), true); }); test("needsKanaFill: 値が入っていればfalse", () => { assert.strictEqual(needsKanaFill("ヤマダ タロウ"), false); }); ``` - [ ] **Step 2: テストが失敗することを確認** ```bash cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\NodeSrv\apps\healthcheck-survey-bot" node --test test/employeeMasterCheck.test.js ``` Expected: `Cannot find module '../src/lib/employeeMasterCheck'`で失敗 - [ ] **Step 3: 実装を書く** ```javascript // src/lib/employeeMasterCheck.js function checkEmailConsistency(pleasanterEmail, masterRecord) { const masterEmail = masterRecord.Class036 || masterRecord.ClassB || null; if (!masterEmail) { return { consistent: false, masterEmail: null }; } return { consistent: masterEmail === pleasanterEmail, masterEmail }; } function resolveKanaFromMaster(masterRecord) { return [masterRecord.Class003, masterRecord.Class004].filter(Boolean).join(" "); } function needsKanaFill(currentKana) { return currentKana === null || currentKana === undefined || currentKana === ""; } module.exports = { checkEmailConsistency, resolveKanaFromMaster, needsKanaFill }; ``` - [ ] **Step 4: テストが通ることを確認** ```bash node --test test/employeeMasterCheck.test.js ``` Expected: 8 tests、全てPASS - [ ] **Step 5: 全テストを通しで実行** ```bash node --test test/*.test.js ``` Expected: 5ファイル・32テスト、全てPASS - [ ] **Step 6: コミット** ```bash git add src/lib/employeeMasterCheck.js test/employeeMasterCheck.test.js git commit -m "feat: 社員マスタ(504412)照合ロジック(メール整合性チェック・フリガナ補完)を追加" ``` **次に必要な作業(本計画のスコープ外):** 実行タイミングが決まったら、(a) 504412から`Class011`=対象PleasanterUserIdでレコードを取得する呼び出し元(クライアントスクリプト/n8nワークフロー/Process)、(b) 不一致・補完が見つかった場合の通知・自動反映方法、を別タスクとして設計する。 --- ## Self-Review **Spec coverage:** - 全体アーキテクチャ(WP/WA + Process流用)→ Task 9〜11でカバー - 社員マスタ(504412)連携(設計書8章)→ Task 13でロジックのみカバー。呼び出しトリガーは実行タイミング未確定のため意図的にスコープ外 - 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`を使い、チェックポイントを挟みながらまとめて実行