ken_nogi/NodeSrv/docs/superpowers/plans/2026-09-05-healthcheck-lineworks-survey-n8n.md

64 KiB
Raw Permalink Blame History

健康診断管理×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.jsnode --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 TableGetードfilters無し、returnAll:trueにまとめ、後続のCodeードでconfigKeyconfigValueの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キー・既存CredentialNodeSrv/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へテスト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に実例あり:

{
  "Id": 1,
  "Name": "入力完了",
  "ValidateInputs": [
    { "Id": 1, "ColumnName": "Class021", "Required": true },
    { "Id": 2, "ColumnName": "Class022", "Required": true }
  ]
}

配列名はValidationsではなくValidateInputs。各要素は{Id, ColumnName, Required}。値を設定していない項目(クライアント/サーバ正規表現、エラーメッセージ、最小/最大等)はキー自体が省略される可能性が高い(この実例では未設定のため確認できていない)。

Task 5processFlow.jsgetValidationColumnNamesはこの構造(process.ValidateInputs[].ColumnName)を前提に実装する。


Task 3: dateParser.js — 日付パースロジック

Express版OldCode/express/modules/lineworksSurvey.jsparseDateInput系ロジックを移植する。

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, 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 getsiteSiteSettings.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.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: 失敗するテストを書く

// 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: テストが失敗することを確認
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.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: テストが通ることを確認
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: CLI node 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はn8n Data Tableのシステム予約列名のため定義できず、7列で作成した。行の更新日時はn8nが自動管理するメタデータに委ねる。実際に作成したテーブルid: jqMDa2YZTI4f0iQ7

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' },
  ],
}).then(r => console.log(r.status, JSON.stringify(r.body, null, 2)));
"
  • Step 2a: 作成に失敗した場合の代替手順

projectId必須エラー等でAPI経由の作成が通らない場合は、n8n UIhttps://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サブワークフローにまとめる。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_valuesid 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_valuesData Table、id bNkadTyDgDepYx2p)を1回のData TableGetードfilters無し、returnAll:true)でまとめて取得し、直後のCodeードでconfigKeyconfigValueの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/tokenform-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}/messagesbutton_template形式へ送信するところまでを1つのCodeードで行ういずれもthis.helpers.httpRequest
  7. Data Tableノード「状態更新」: healthcheck_bot_stateid: jqMDa2YZTI4f0iQ7)へresultIdをキーにinsertcurrentStatus, pendingProcesses=JSON化したoptions, awaitInput: "none")。updatedAt列は存在しないTask 8参照ため送信対象に含めない

Step 3のCodeードの中身骨格。各this.helpers.httpRequest呼び出しのbodyはTask 4〜7で確認したAPI形式に沿って埋める:

// 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: デプロイ
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とは別に、動作確認用の捨てレコード・テストProcessCurrentStatusを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のワークフローIDExecute Workflowノードで参照)
  • Produces: Webhook POST https://n8n32.next-hd.net/webhook/healthcheck-status-pushX-Api-Keyヘッダー認証、body {resultId, processId}

秘密情報の扱い: Webhook認証キーはCodeードに直接書かず、workflow_config_valuesbNkadTyDgDepYx2p)からData Tableードで取得して比較するTask 9と同じ方針。1回のreturnAll:true取得後続Codeードでキーを引く

ノード構成:

  1. WebhookhttpMethod: 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 WorkflowTask 9のワークフローIDを指定、入力: resultId, processId
  5. Respond to Webhook{"result":"ok"}を返す)
  • Step 1: ワークフローJSONを作成

workflows/hc-wp-status-push.jsonを作成。Codeード「検証」の中身:

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: デプロイ
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-responseLINE WORKS本体からの直接コールバック、x-works-signature検証)

Global Constraintsのn8nワークフロー構築ルールに従い、分岐ロジック・Pleasanter/LINEWORKS API呼び出しは可能な限りCodeードに集約する。 Data Tableの読み書き設定値取得・待機状態の取得/更新のみ専用ードを使う。JWT署名も専用ードのまま。

ード構成目安11ード:

  1. WebhookhttpMethod: 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件/複数件はthrow6章の異常系方針、自動判定しない
// Codeード「署名検証・対象レコード特定」の中身
const crypto = require("crypto");
function normalizeSignature(value) {
  return String(value || "").trim().replace(/^sha256=/i, "");
}
function safeEqual(a, b) {
  const ab = Buffer.from(String(a), "utf8");
  const bb = Buffer.from(String(b), "utf8");
  if (ab.length !== bb.length) return false;
  return crypto.timingSafeEqual(ab, bb);
}
function verifySignature(rawBody, headerSignature, botSecret) {
  const headerSig = normalizeSignature(headerSignature);
  if (!headerSig || !botSecret) return false;
  const payload = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), "utf8");
  const expected = crypto.createHmac("sha256", botSecret).update(payload).digest("base64");
  return safeEqual(headerSig, expected);
}

const configRows = $('設定値一括取得').all().map((item) => item.json);
const config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue]));

const item = $('Webhook').first(); // この直前ノードは「設定値一括取得」(Data Table)のため、裸の$input.first()だとconfig行を拾ってしまいheaders/bodyが無い
const headers = item.json.headers || {};
const rawBody = item.binary && item.binary.data
  ? Buffer.from(item.binary.data.data, "base64")
  : Buffer.from(JSON.stringify(item.json.body || {}), "utf8");

if (!verifySignature(rawBody, headers["x-works-signature"], config.LINEWORKS_BOT_SECRET)) {
  throw new Error("Unauthorized: signature mismatch");
}

const body = item.json.body || {};
const source = body.source || {};
const content = body.content || {};
const targetEmail = source.userId;

async function pleasanterPost(path, reqBody) {
  return this.helpers.httpRequest({
    method: "POST",
    url: `${config.PLEASANTER_BASE_URL_PROD}${path}`,
    body: { ApiVersion: 1.1, ApiKey: config.PLEASANTER_API_KEY_PROD, ...reqBody },
    json: true,
  });
}

const userRes = await pleasanterPost.call(this, "api/users/get", {
  View: { ApiGetMailAddresses: true },
  Where: { MailAddress: targetEmail },
});
const userId = userRes.Response.Data[0]?.UserId;
if (!userId) throw new Error(`対象者が見つかりません: ${targetEmail}`);

const recordRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/get`, {
  View: {
    ColumnFilterHash: { ClassC: String(userId) },
    ColumnFilterSearchTypes: { ClassC: "ExactMatch" },
  },
});
const candidates = (recordRes.Response.Data || []).filter((r) => r.Status !== 900 && r.Status !== 910);
if (candidates.length !== 1) {
  throw new Error(`対象レコードを一意に特定できません: ${candidates.length}件`);
}
const record = candidates[0];

return [{
  json: {
    config,
    resultId: record.ResultId,
    currentStatus: record.Status,
    contentType: content.type,
    text: content.type === "text" ? content.text : null,
    fileId: content.type === "file" ? content.fileId : null,
  },
}];
  1. Data Tableノード「待機状態取得」(operation: get, filters.conditions: [{keyName:"resultId", keyValue:"={{ $json.resultId }}"}]: healthcheck_bot_stateから該当resultIdの行を取得
  2. Code「分岐処理・アクション決定」: awaitInputnone/date/file、または待機状態自体が無い場合に応じた全ロジックをここに集約する。以下を1つのCodeードで行う:
    • 待機状態が無い、またはawaitInputが想定外の値 → action: "error"
    • awaitInput: "none"(選択肢待ち): pendingProcessesJSON文字列をパースし、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": contentTypefileでなければaction: "send"(再送要求)、待機状態維持。fileならthis.helpers.httpRequestでLINEWORKSファイルダウンロード→Pleasanter添付アップロード具体的なエンドポイントは実装時にPleasanter公式マニュアルapi-attachment系を確認する)→action: "execute", processId: awaitProcessId、待機状態をnoneに戻す
    • 出力に必ず「次にhealthcheck_bot_stateへ書き込むべき状態」(nextAwaitInput, nextAwaitProcessId, nextAwaitColumnを含めるード6で無条件更新するため
  3. Data Tableノード「状態更新」(operation: updateresultIdをキーにnextAwaitInput等を書き込む。無条件で1回呼ぶ
  4. IFaction == "execute"
    • true分岐 → Execute WorkflowTask 9、processId)→ Respond to Webhook(成功)
    • false分岐 → 次へ
  5. IFaction == "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 Webhookaction == "error"のケース。エラー内容を返す)
  • Step 1: ワークフローJSONを作成

上記ノード構成に従いworkflows/hc-wa-lineworks-response.jsonを作成する。ード5の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を設定

対象Bot484184で管理している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.jsapply-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";
// 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へ反映
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完了のまま、ChangedStatus900OnClick不要、実行種別「追加したボタン」を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 HistoryGET /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 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レスポンスのClassHashClass011=ユーザID、Class036=PLメールアドレス、ClassB=メールアドレス、Class003=姓(カナ)、Class004=名(カナ)を含む)を想定する。整合性チェックはClass036PLメールアドレスを優先し、無ければ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: 5ファイル・32テスト、全て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 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を使い、チェックポイントを挟みながらまとめて実行