メールアドレス整合性チェック・フリガナ補完の判定/補完ロジックを TDDで実装するタスクを追加。実行タイミング(保存時/定期バッチ/ 手動)は未確定のため、呼び出しトリガーは意図的にスコープ外とし、 決まり次第別タスクとして追加する方針を明記した。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
59 KiB
健康診断管理×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.md7-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対話フローと独立した別タスクとして扱う。本計画には含まない
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作成
{
"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作成
# 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: コミット
git add NodeSrv/apps/healthcheck-survey-bot/package.json NodeSrv/apps/healthcheck-survey-bot/README.md NodeSrv/apps/healthcheck-survey-bot/.gitignore
git commit -m "feat: healthcheck-survey-botプロジェクト雛形を追加"
Task 2: 実機調査 — Processの入力検証タブのJSON構造を確認
Pleasanter公式マニュアルにも構造の記載がなく、508971の既存Processesには入力検証を使った実例がないため、実機で確認する。508971(本番)へのSiteSettings変更を伴うため、着手前に必ずユーザーへ確認する。
Files:
- Modify:
NodeSrv/apps/healthcheck-survey-bot/README.md(調査結果を追記)
Interfaces:
-
Produces: Task 5(processFlow.js)が前提とする、Process内で入力検証対象列を表すJSONキー名とその構造
-
Step 1: ユーザーへテストProcess作成を依頼
以下をプリザンター508971の「プロセス」設定画面で新規作成してもらうよう、ユーザーに依頼する(本番Statusに影響しないよう、現在の状況・変更後の状況は同じ値のままにしてもらう)。
-
名称:
テスト_入力検証構造確認 -
表示名:
テスト -
現在の状況/変更後の状況: 触らず空欄のまま(全状況共通ボタンにしてもらう)
-
入力検証タブ: 「新規作成」→項目に
日付001(Date001)を選択して保存 -
Process自体を保存
-
Step 2: 最新のSiteSettingsを取得
cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\Pleasanter"
node .claude/js/get-site-config.js --project="健康診断管理" --env=production
Expected: 健康診断管理/configs/production/site-508971_健康診断管理/sitesettings/site-508971_latest.jsonが更新される
- Step 3: Processes配列の該当エントリを確認
site-508971_latest.json内のResponse.Data.SiteSettings.Processes配列から、Step1で追加したProcess(Nameがテスト_入力検証構造確認のもの)を探し、入力検証設定に対応するキー(配列名・各要素のプロパティ名)を特定する。
- Step 4: 発見した構造をREADME.mdに記録
例(実際のキー名は実機確認結果に置き換える):
## 実機調査メモ(2026-09-05確認)
Processの入力検証タブは`SiteSettings.Processes[].Validations`配列として保存される。
各要素は`{"Id": 1, "ColumnName": "Date001"}`形式(フィールド名は実機確認結果で確定)。
- Step 5: テストProcessを削除
ユーザーに508971の管理画面からテストProcessを削除してもらう(本番のProcess一覧を汚さないため)。
- Step 6: コミット
git add NodeSrv/apps/healthcheck-survey-bot/README.md
git commit -m "docs: Process入力検証タブのJSON構造を調査結果として記録"
Task 3: dateParser.js — 日付パースロジック
Express版OldCode/express/modules/lineworksSurvey.jsのparseDateInput系ロジックを移植する。
Files:
- Create:
NodeSrv/apps/healthcheck-survey-bot/src/lib/dateParser.js - Test:
NodeSrv/apps/healthcheck-survey-bot/test/dateParser.test.js
Interfaces:
-
Produces:
parseDateInput(value: string): string("YYYY-MM-DD"形式を返す。パース不能ならErrorをthrow) -
Step 1: 失敗するテストを書く
// 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: テストが失敗することを確認
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: 実装を書く
// src/lib/dateParser.js
const ERA_INFO = {
"令和": 2018,
"平成": 1988,
"昭和": 1925,
"大正": 1911,
};
const ERA_ALIASES = { R: "令和", H: "平成", S: "昭和", T: "大正" };
function pad2(value) {
return String(value).padStart(2, "0");
}
function finalizeDateParts(year, month, day) {
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
throw new Error("日付形式で回答してください(例: 2026-03-01)");
}
const date = new Date(year, month - 1, day);
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
throw new Error("存在しない日付です");
}
return `${year}-${pad2(month)}-${pad2(day)}`;
}
function tryParseEraDate(compact) {
const match = compact.match(/^(令和|平成|昭和|大正|R|H|S|T)(.+)$/i);
if (!match) return null;
let era = match[1];
if (/^[RHST]$/i.test(era)) {
era = ERA_ALIASES[era.toUpperCase()] || era;
}
if (!ERA_INFO[era]) return null;
const normalized = match[2]
.replace(/年/g, "-")
.replace(/月/g, "-")
.replace(/日/g, "")
.replace(/[.\/]/g, "-");
const parts = normalized.split("-").filter((part) => part.length > 0);
if (parts.length < 3) {
throw new Error("月と日まで入力してください(例: 令和6年3月1日)");
}
const eraYear = Number(parts[0]);
const month = Number(parts[1]);
const day = Number(parts[2]);
if (!Number.isFinite(eraYear) || !Number.isFinite(month) || !Number.isFinite(day)) {
throw new Error("日付形式で回答してください(例: 令和6年3月1日)");
}
return finalizeDateParts(ERA_INFO[era] + eraYear - 1, month, day);
}
function tryParseMonthDay(compact) {
const match = compact.match(/^(\d{1,2})(?:月|\/|-|\.)(\d{1,2})(?:日)?$/);
if (!match) return null;
const currentYear = new Date().getFullYear();
return finalizeDateParts(currentYear, Number(match[1]), Number(match[2]));
}
function parseDateInput(value) {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
throw new Error("日付が空です");
}
const compact = trimmed.replace(/\s+/g, "");
const eraResult = tryParseEraDate(compact);
if (eraResult) return eraResult;
const monthDayResult = tryParseMonthDay(compact);
if (monthDayResult) return monthDayResult;
const normalized = compact
.replace(/年/g, "-")
.replace(/月/g, "-")
.replace(/日/g, "")
.replace(/[.\/]/g, "-");
const isoParts = normalized.split("-").filter((part) => part.length > 0);
if (isoParts.length === 3 && isoParts[0].length >= 4) {
return finalizeDateParts(Number(isoParts[0]), Number(isoParts[1]), Number(isoParts[2]));
}
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) {
throw new Error("日付形式で回答してください(例: 2026-03-01 や 令和6年3月1日)");
}
return finalizeDateParts(parsed.getFullYear(), parsed.getMonth() + 1, parsed.getDate());
}
module.exports = { parseDateInput };
- Step 4: テストが通ることを確認
node --test test/dateParser.test.js
Expected: 8 tests、全てPASS
- Step 5: コミット
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: 失敗するテストを書く
// 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: テストが失敗することを確認
node --test test/templateFill.test.js
Expected: Cannot find module '../src/lib/templateFill'で失敗
- Step 3: 実装を書く
// 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: テストが通ることを確認
node --test test/templateFill.test.js
Expected: 4 tests、全てPASS
- Step 5: コミット
git add src/lib/templateFill.js test/templateFill.test.js
git commit -m "feat: ツールチップ文言のプレースホルダー置換ロジックを追加"
Task 5: processFlow.js — Process抽出・回答照合・追加入力判定
Task 2で確認したJSON構造を前提に実装する。以下のコードでは、入力検証対象列がprocess.Validations(各要素{ColumnName: string})に入っている前提で書いている。Task 2の実機調査結果が異なる構造だった場合は、getValidationColumnNames関数のみをその構造に合わせて書き換える(他の関数への影響はない)。
Files:
- Create:
NodeSrv/apps/healthcheck-survey-bot/src/lib/processFlow.js - Test:
NodeSrv/apps/healthcheck-survey-bot/test/processFlow.test.js
Interfaces:
-
Consumes: なし
-
Produces:
extractProcessesForStatus(processes: Array<Process>, status: number): Array<Process>matchProcessByLabel(processes: Array<Process>, text: string): Process | nullclassifyAwaitInput(process: Process): { awaitInput: "none" | "date" | "file", column: string | null }
-
Step 1: 失敗するテストを書く
// test/processFlow.test.js
const { test } = require("node:test");
const assert = require("node:assert");
const {
extractProcessesForStatus,
matchProcessByLabel,
classifyAwaitInput,
} = require("../src/lib/processFlow");
const processes = [
{ Id: 1, Name: "了承", DisplayName: "了承", CurrentStatus: 100, ChangedStatus: 200 },
{ Id: 2, Name: "日程変更", DisplayName: "日程変更", CurrentStatus: 100, ChangedStatus: 150, Validations: [{ ColumnName: "Date001" }] },
{ Id: 3, Name: "受けた", DisplayName: "受けた", CurrentStatus: 200, ChangedStatus: 300 },
{ Id: 4, Name: "結果受取り", DisplayName: "受け取った", CurrentStatus: 300, ChangedStatus: 900, Validations: [{ ColumnName: "AttachmentsA" }] },
];
test("extractProcessesForStatus: 現在のStatusに一致するProcessのみ返す", () => {
const result = extractProcessesForStatus(processes, 100);
assert.strictEqual(result.length, 2);
assert.deepStrictEqual(result.map((p) => p.Id), [1, 2]);
});
test("extractProcessesForStatus: 一致するProcessが無ければ空配列", () => {
assert.deepStrictEqual(extractProcessesForStatus(processes, 999), []);
});
test("matchProcessByLabel: DisplayNameが完全一致するProcessを返す", () => {
const candidates = extractProcessesForStatus(processes, 100);
const matched = matchProcessByLabel(candidates, "日程変更");
assert.strictEqual(matched.Id, 2);
});
test("matchProcessByLabel: 前後の空白を無視して一致判定する", () => {
const candidates = extractProcessesForStatus(processes, 100);
const matched = matchProcessByLabel(candidates, " 了承 ");
assert.strictEqual(matched.Id, 1);
});
test("matchProcessByLabel: 一致しなければnull", () => {
const candidates = extractProcessesForStatus(processes, 100);
assert.strictEqual(matchProcessByLabel(candidates, "存在しない選択肢"), null);
});
test("classifyAwaitInput: Validationsが無いProcessはnone", () => {
assert.deepStrictEqual(classifyAwaitInput(processes[0]), { awaitInput: "none", column: null });
});
test("classifyAwaitInput: Date*列はdate", () => {
assert.deepStrictEqual(classifyAwaitInput(processes[1]), { awaitInput: "date", column: "Date001" });
});
test("classifyAwaitInput: Attachments*列はfile", () => {
assert.deepStrictEqual(classifyAwaitInput(processes[3]), { awaitInput: "file", column: "AttachmentsA" });
});
- Step 2: テストが失敗することを確認
node --test test/processFlow.test.js
Expected: Cannot find module '../src/lib/processFlow'で失敗
- Step 3: 実装を書く
// src/lib/processFlow.js
function extractProcessesForStatus(processes, status) {
return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1);
}
function matchProcessByLabel(processes, text) {
const trimmed = String(text ?? "").trim();
return processes.find((p) => (p.DisplayName || p.Name) === trimmed) || null;
}
function getValidationColumnNames(process) {
if (!Array.isArray(process.Validations)) return [];
return process.Validations.map((v) => v.ColumnName).filter(Boolean);
}
function classifyAwaitInput(process) {
const columnNames = getValidationColumnNames(process);
if (columnNames.length === 0) {
return { awaitInput: "none", column: null };
}
const column = columnNames[0];
if (column.startsWith("Date")) {
return { awaitInput: "date", column };
}
if (column.startsWith("Attachments")) {
return { awaitInput: "file", column };
}
return { awaitInput: "none", column: null };
}
module.exports = { extractProcessesForStatus, matchProcessByLabel, classifyAwaitInput };
- Step 4: テストが通ることを確認
node --test test/processFlow.test.js
Expected: 8 tests、全てPASS
- Step 5: コミット
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: 失敗するテストを書く
// 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: テストが失敗することを確認
node --test test/signatureVerify.test.js
Expected: Cannot find module '../src/lib/signatureVerify'で失敗
- Step 3: 実装を書く
// 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: テストが通ることを確認
node --test test/signatureVerify.test.js
Expected: 4 tests、全てPASS
- Step 5: 全テストを通しで実行
node --test test/*.test.js
Expected: 4ファイル・24テスト、全てPASS
- Step 6: コミット
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: CLInode scripts/deploy-workflow.js <workflows/xxx.json> [--id=<既存workflowId>]
-
Step 1: n8n-api.js を作成
// 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 を作成
// 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: 動作確認(既存ワークフローのダミー取得で疎通のみ確認)
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: コミット
git add scripts/n8n-api.js scripts/deploy-workflow.js
git commit -m "feat: n8n Public API操作用の共通スクリプトを追加"
Task 8: n8n Data Table「healthcheck_bot_state」作成
Files:
- Modify:
NodeSrv/apps/healthcheck-survey-bot/README.md(作成したテーブルIDを記録)
Interfaces:
- Produces: n8n Data Table(テーブル名
healthcheck_bot_state)。列:resultId(string),targetEmail(string),currentStatus(string),pendingProcesses(string, JSON),awaitInput(string),awaitProcessId(string),awaitColumn(string),updatedAt(string)
n8n Public APIのPOST /data-tablesの必須パラメータ(projectId要否等)はn8n-guide.mdに記載が無いため、実機で確認しながら進める。
- Step 1: 既存Data Table一覧からprojectIdを確認
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等)を指定して作成を試す。
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' },
{ name: 'updatedAt', type: 'string' },
],
}).then(r => console.log(r.status, JSON.stringify(r.body, null, 2)));
"
- Step 2a: 作成に失敗した場合の代替手順
projectId必須エラー等でAPI経由の作成が通らない場合は、n8n UI(https://n8n32.next-hd.net、n8n-guide.md 2章のログイン情報)から手動でData Tableを作成する。列構成はStep 2と同じにする。
- Step 3: 作成結果を確認
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へ記録
## n8nリソースID一覧
- Data Table `healthcheck_bot_state`: `<Step3で確認したid>`
- Step 5: コミット
git add NodeSrv/apps/healthcheck-survey-bot/README.md
git commit -m "docs: healthcheck_bot_state Data Table作成結果を記録"
Task 9: HC-SUBワークフロー — プロセス実行+案内送信
WP・WAの両方から呼ばれる共通ロジック。「(任意)ProcessIdを実行→現在Statusの選択肢を組み立ててLINEWORKSへ送信→Data Table更新」を1本のExecute Workflow Triggerサブワークフローにまとめる。
Files:
- Create:
NodeSrv/apps/healthcheck-survey-bot/workflows/hc-sub-run-process-and-notify.json - Modify:
NodeSrv/apps/healthcheck-survey-bot/README.md
Interfaces:
- Consumes: Task 3〜6のロジック(Codeノードへ書き写す)、Task 8のData Table ID
- Produces: n8n上のワークフロー(Execute Workflow Trigger、入力
{resultId: string, processId: string | null})。WP・WAはこのワークフローIDをExecute Workflowノードで呼び出す
ノード構成:
Execute Workflow Trigger(入力:resultId,processId)IF:processIdが存在するか- true分岐 →
HTTP Request「Process実行」:POST {PLEASANTER_BASE_URL}api/items/{{$json.resultId}}/update、body{"ApiVersion":1.1,"ApiKey":"<config_production.jsonのApiKey>","ProcessId": {{$json.processId}}} - false分岐 → そのまま次へ(フォールバック再送のケース)
- true分岐 →
HTTP Request「レコード取得」:POST {PLEASANTER_BASE_URL}api/items/{{$('Execute Workflow Trigger').item.json.resultId}}/get、body{"ApiVersion":1.1,"ApiKey":"..."}HTTP Request「サイト設定取得」:POST {PLEASANTER_BASE_URL}api/items/508971/getsite、body{"ApiVersion":1.1,"ApiKey":"..."}Code「選択肢組み立て」: Task 4のfillTemplateとTask 5のextractProcessesForStatusをそのまま貼り付けて使う
// Codeノード「選択肢組み立て」の中身
function isUnsetSentinel(value) {
return typeof value === "string" && value.startsWith("1899");
}
function fillTemplate(template, columns, valueHash) {
const labelToColumnName = new Map();
for (const column of columns) {
if (column.LabelText) labelToColumnName.set(column.LabelText, column.ColumnName);
}
return template.replace(/\{([^{}]+)\}/g, (matched, label) => {
const columnName = labelToColumnName.get(label);
if (!columnName) return matched;
const value = valueHash[columnName];
if (value === undefined || value === null || value === "" || isUnsetSentinel(value)) return "未設定";
return String(value);
});
}
function extractProcessesForStatus(processes, status) {
return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1);
}
const record = $('レコード取得').item.json.Response.Data;
const siteSettings = $('サイト設定取得').item.json.Response.Data.SiteSettings;
const columns = siteSettings.Columns || [];
const processes = siteSettings.Processes || [];
const valueHash = {
...record.ClassHash, ...record.NumHash, ...record.DateHash, ...record.DescriptionHash,
};
const candidates = extractProcessesForStatus(processes, record.Status);
const options = candidates.map((p) => ({
processId: p.Id,
label: p.DisplayName || p.Name,
tooltip: fillTemplate(p.ToolTip || "", columns, valueHash),
}));
return [{
json: {
resultId: record.ResultId,
currentStatus: record.Status,
options,
classCUserId: record.ClassHash.ClassC,
},
}];
(ToolTipのキー名はTask 2の実機調査結果で確定させ、異なる場合はここだけ修正する)
HTTP Request「対象者メール解決」:POST {PLEASANTER_BASE_URL}api/users/get、body{"ApiVersion":1.1,"ApiKey":"...","View":{"ApiGetMailAddresses":true},"Where":{"UserId":{{$json.classCUserId}}}}Code「JWTクレーム組み立て」: LINEWORKS通知送信ワークフロー(Yprojk4JTl1vJPkf)のValidate & Prepareノードと同じパターンでJWTクレームJSON文字列と、選択肢からLINEWORKSbutton_templateのactions配列を組み立てるJWTノード(operation: sign,algorithm: RS256, Credential: 「LINEWORKS Bot Private Key (v4)」)HTTP Request「アクセストークン取得」:POST https://auth.worksmobile.com/oauth2/v2.0/token(form-urlencoded、Yprojk4JTl1vJPkfのGet Access Tokenノードと同じパラメータ構成)HTTP Request「LINEWORKS送信」:POST https://www.worksapis.com/v1.0/bots/{BOT_ID}/users/{userId}/messages、button_template形式のcontentを送信HTTP Request(n8n内部呼び出し、またはData Tableノード)「状態更新」:healthcheck_bot_stateへresultIdをキーにupsert(currentStatus,pendingProcesses=JSON化したoptions,awaitInput: "none",updatedAt)
- Step 1: ワークフローJSON雛形を作成
workflows/hc-sub-run-process-and-notify.jsonに、上記11ノードのnodes配列とconnectionsを、Task 7のn8n-api.jsが期待する{name, nodes, connections, settings}形式で書く。各HTTP Requestノードのparametersは本タスク内の説明文の通りのURL・bodyを設定する(n8n-nodes-base.httpRequest, n8n-nodes-base.code, n8n-nodes-base.jwt, n8n-nodes-base.executeWorkflowTrigger, n8n-nodes-base.if, n8n-nodes-base.dataTableの各ノードタイプを使う)。Pleasanter APIキーはPleasanter/config_production.jsonの値をそのままCodeノード内の定数として埋め込む(既存のLINEWORKS通知送信ワークフローと同じ方式)。
- Step 2: デプロイ
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を記録
- ワークフロー `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: コミット
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(httpMethod: POST,path: healthcheck-status-push)Code「検証」:X-Api-KeyヘッダーとbodyのresultId/processId必須チェック(不正ならthrow new Error(...)でワークフローを失敗させる)Execute Workflow(Task 9のワークフローIDを指定、入力:resultId,processId)Respond to Webhook({"result":"ok"}を返す)
- Step 1: ワークフローJSONを作成
workflows/hc-wp-status-push.jsonを作成。Codeノードの中身:
const API_KEY = "<healthcheck-status-push用に新規発行するAPIキー文字列>";
const headers = $input.first().json.headers || {};
if (headers["x-api-key"] !== API_KEY) {
throw new Error("Unauthorized: invalid API key");
}
const body = $input.first().json.body || {};
if (!body.resultId || !body.processId) {
throw new Error("Bad Request: resultId, processId は必須です");
}
return [{ json: { resultId: body.resultId, processId: body.processId } }];
- Step 2: デプロイ
N8N_API_KEY="<n8n-guide.md 2章のAPIキー>" node scripts/deploy-workflow.js workflows/hc-wp-status-push.json
- Step 3: Webhook有効化
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へ記録
- ワークフロー `HC-WP: Statusプッシュ通知`: `<id>`(Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-status-push`)
- Step 5: ユーザー確認のうえ疎通テスト
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: コミット
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検証)
ノード構成:
Webhook(httpMethod: POST,path: healthcheck-lineworks-response,options.rawBody: true)Code「署名検証・送信者解決」: Task 6のverifySignatureを貼り付けて検証。失敗ならthrow。成功したらsource.userId(=メールアドレス)と、content.typeに応じたテキスト/ファイル情報を抽出する
// Codeノード「署名検証・送信者解決」の中身
const crypto = require("crypto");
function normalizeSignature(value) {
return String(value || "").trim().replace(/^sha256=/i, "");
}
function safeEqual(a, b) {
const ab = Buffer.from(String(a), "utf8");
const bb = Buffer.from(String(b), "utf8");
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}
function verifySignature(rawBody, headerSignature, botSecret) {
const headerSig = normalizeSignature(headerSignature);
if (!headerSig || !botSecret) return false;
const payload = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), "utf8");
const expected = crypto.createHmac("sha256", botSecret).update(payload).digest("base64");
return safeEqual(headerSig, expected);
}
const LINEWORKS_BOT_SECRET = "<LINEWORKS Developer Consoleで確認するBot Secret>";
const item = $input.first();
const headers = item.json.headers || {};
const rawBody = item.binary && item.binary.data
? Buffer.from(item.binary.data.data, "base64")
: Buffer.from(JSON.stringify(item.json.body || {}), "utf8");
if (!verifySignature(rawBody, headers["x-works-signature"], LINEWORKS_BOT_SECRET)) {
throw new Error("Unauthorized: signature mismatch");
}
const body = item.json.body || {};
const source = body.source || {};
const content = body.content || {};
return [{
json: {
targetEmail: source.userId,
contentType: content.type,
text: content.type === "text" ? content.text : null,
fileId: content.type === "file" ? content.fileId : null,
},
}];
HTTP Request「対象レコード検索」:POST {PLEASANTER_BASE_URL}api/items/508971/get、body{"ApiVersion":1.1,"ApiKey":"...","View":{"ColumnFilterHash":{"ClassC":"<メールからPleasanterUserId解決した値>"},"ColumnFilterSearchTypes":{"ClassC":"ExactMatch"}}}- 前段として
api/users/getでtargetEmailからPleasanterUserIdを引くHTTP Requestノードを1つ挟む - 取得結果を
Status昇順以外(900/910除外)でフィルタし、UpdatedTime降順で1件選ぶCodeノードを挟む。0件/複数件はthrowでワークフローを失敗させる(6章の異常系方針通り、自動判定しない)
- 前段として
HTTP Request(Data Tableノード,operation: get)「待機状態取得」:healthcheck_bot_stateからresultId一致行を取得Switch「awaitInput分岐」:none/date/fileの3分岐- none分岐:
CodeでpendingProcesses(JSON文字列)をパースし、Task 5のmatchProcessByLabel相当のロジックでtextと一致するか判定- 一致 →
CodeでTask 5のclassifyAwaitInput相当のロジックを実行awaitInput: "none"→Execute Workflow(Task 9、processId=一致したProcessId)awaitInput: "date"/"file"→Data Tableノード(operation: update)でawaitInput/awaitProcessId/awaitColumnを保存 →HTTP Requestで「日付を入力してください」等の追加メッセージを送信(Task 9のノード7〜10と同じLINEWORKS送信パターンを再利用)
- 不一致 →
Execute Workflow(Task 9、processId: null)でフォールバック再送
- 一致 →
- date分岐:
CodeでTask 3のparseDateInputを実行- 成功 →
HTTP Request「該当列update」:POST api/items/{resultId}/update、body{"ApiVersion":1.1,"ApiKey":"...", "DateHash":{"<awaitColumn>": "<parseDateInputの結果>"}}→Data TableでawaitInputをnoneに戻す →Execute Workflow(Task 9、processId=awaitProcessId) - 失敗 → エラーメッセージを再送(待機状態は維持、
Data Table更新なし)
- 成功 →
- file分岐:
contentTypeがfileでなければ再送要求。fileならHTTP RequestでLINEWORKSファイルダウンロード→HTTP RequestでPleasanter添付アップロード(具体的なエンドポイントは実装時にPleasanter公式マニュアルapi-attachment系を確認する)→Data Table更新→Execute Workflow(Task 9)
- none分岐:
Respond to Webhook(各分岐の末尾で{"result":"ok"}を返す)
- Step 1: ワークフローJSONを作成
上記ノード構成に従いworkflows/hc-wa-lineworks-response.jsonを作成する。none分岐・date分岐のCodeノードにはTask 3・5のロジックをそのまま貼り付ける。
- Step 2: デプロイ
N8N_API_KEY="<n8n-guide.md 2章のAPIキー>" node scripts/deploy-workflow.js workflows/hc-wa-lineworks-response.json
- Step 3: Webhook有効化
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を設定
対象Bot(484184で管理しているBotのうち、健康診断管理で使うもの)のCallback URLをhttps://n8n32.next-hd.net/webhook/healthcheck-lineworks-responseに設定する(ユーザー確認必須。他プロジェクトと同じBotを共用している場合、Callback URL変更が既存フローに影響しないか要確認)。
- Step 5: README.mdへ記録
- ワークフロー `HC-WA: LINEWORKS応答受信`: `<id>`(Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`)
- Step 6: コミット
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を新規作成:
const WEBHOOK_URL = "https://n8n32.next-hd.net/webhook/healthcheck-status-push";
const API_KEY = "<Task10 Step1で設定したAPIキー>";
$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へ反映
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: エンドツーエンド確認
- 508971の捨てレコードでテストProcessボタンを押す → LINEWORKSに案内が届くことを確認
- LINEWORKSで選択肢に回答する →
healthcheck_bot_stateの該当行が更新され、Processが実行されて捨てレコードのStatusが変わることを確認(get-site-config.js相当で該当レコードをapi/items/{id}/getし直して確認) - n8n Execution History(
GET /executions?workflowId=<HC-WA id>&limit=5)でエラーが出ていないことを確認
すべて実データ・実LINEWORKS送信を伴うため、着手前に必ずユーザーへ確認する。
- Step 5: テスト用Processを削除
確認完了後、ユーザーに508971からテスト用Processを削除してもらう(本番のProcess一覧を汚さないため)。
- Step 6: コミット
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}): stringneedsKanaFill(currentKana: string | null | undefined): boolean
masterRecordは504412のapi/items/{id}/getレスポンスのClassHash(Class011=ユーザID、Class036=PLメールアドレス、ClassB=メールアドレス、Class003=姓(カナ)、Class004=名(カナ)を含む)を想定する。整合性チェックはClass036(PLメールアドレス)を優先し、無ければClassBにフォールバックする。
- Step 1: 失敗するテストを書く
// 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: テストが失敗することを確認
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: 実装を書く
// 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: テストが通ることを確認
node --test test/employeeMasterCheck.test.js
Expected: 8 tests、全てPASS
- Step 5: 全テストを通しで実行
node --test test/*.test.js
Expected: 6ファイル・36テスト、全てPASS
- Step 6: コミット
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. 実行方式を選んでほしい。
- Subagent-Driven(推奨) — タスクごとに新しいsubagentを立てて実装、タスク間でレビューを挟む
- Inline Execution — このセッション内で
executing-plansを使い、チェックポイントを挟みながらまとめて実行