ken_nogi/NodeSrv/docs/superpowers/plans/2026-09-05-healthcheck-lineworks-survey-n8n.md
Kenichiro NOGI 935e58e384 計画書Task9-11をCodeノード集約方式に全面改訂(基本ルール反映)
ユーザー指示により、n8nワークフロー構築の基本ルールを確定:
(1)Data Table利用は最小限(config値取得は1回のreturnAll取得+
Codeノードでフィルタ、複数のGetノードを並べない)、(2)入口は
Webhook+Codeでノード数を抑える、(3)外部システムへのHTTP
Requestノードは使ってよい(Codeノード内this.helpers.httpRequest
への無理な集約は不要)。JWT署名・Data Table読み書きのみ専用
ノードを維持。

これによりHC-SUB 19→7ノード、HC-WA 60→11ノード目安に再設計。
Task11で発生した「認証チェーンを分岐ごとに複製」問題も、
Codeノード1つに分岐ロジックを集約することで解消。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:02:26 +09:00

1475 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 健康診断管理×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パッケージなし、n8nPublic 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 5processFlow.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 5processFlow.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, unknown>): 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<Process>, status: number): Array<Process>`
- `matchProcessByLabel(processes: Array<Process>, 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 <workflows/xxx.json> [--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 <workflows/xxx.json> [--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="<n8n-guide.md 2章のAPIキー>" 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="<n8n-guide.md 2章のAPIキー>" 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="<n8n-guide.md 2章のAPIキー>" 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="<n8n-guide.md 2章のAPIキー>" 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`: `<Step3で確認したid>`
```
- [ ] **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="<n8n-guide.md 2章のAPIキー>" 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: プロセス実行と案内送信`: `<Step2で確認したid>`
```
- [ ] **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="<n8n-guide.md 2章のAPIキー>" node scripts/deploy-workflow.js workflows/hc-wp-status-push.json
```
- [ ] **Step 3: Webhook有効化**
```bash
N8N_API_KEY="<n8n-guide.md 2章のAPIキー>" node -e "
require('./scripts/n8n-api').request('POST', '/workflows/<Step2で確認したid>/activate').then(r => console.log(r.status, JSON.stringify(r.body)));
"
```
- [ ] **Step 4: README.mdへ記録**
```markdown
- ワークフロー `HC-WP: Statusプッシュ通知`: `<id>`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: <Step1で設定したAPIキー>" \
-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 = $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"], 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="<n8n-guide.md 2章のAPIキー>" node scripts/deploy-workflow.js workflows/hc-wa-lineworks-response.json
```
- [ ] **Step 3: Webhook有効化**
```bash
N8N_API_KEY="<n8n-guide.md 2章のAPIキー>" node -e "
require('./scripts/n8n-api').request('POST', '/workflows/<Step2で確認したid>/activate').then(r => console.log(r.status));
"
```
- [ ] **Step 4: LINEWORKS Developer Console側にBot Callback URLを設定**
対象Bot484184で管理しているBotのうち、健康診断管理で使うもののCallback URLを`https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`に設定する(**ユーザー確認必須**。他プロジェクトと同じBotを共用している場合、Callback URL変更が既存フローに影響しないか要確認
- [ ] **Step 5: README.mdへ記録**
```markdown
- ワークフロー `HC-WA: LINEWORKS応答受信`: `<id>`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 = "<workflow_config_valuesのHEALTHCHECK_WP_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=<HC-WA id>&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 TriggerProcessは、タイミングが決まった時点で別タスクとして追加する。
**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: 6ファイル・36テスト、全て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 8projectId要否・Task 11Pleasanter添付アップロードの正確なエンドポイントに残っている。これは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`を使い、チェックポイントを挟みながらまとめて実行