From bd6a6055399c938c62424d7c9e4d0d327cb6c9af Mon Sep 17 00:00:00 2001 From: Kenichiro NOGI Date: Sun, 6 Sep 2026 09:02:14 +0900 Subject: [PATCH] =?UTF-8?q?=E5=81=A5=E5=BA=B7=E8=A8=BA=E6=96=AD=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E3=82=B7=E3=82=B9=E3=83=86=E3=83=A0=E3=81=AEn8n?= =?UTF-8?q?=E3=83=AF=E3=83=BC=E3=82=AF=E3=83=95=E3=83=AD=E3=83=BC=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=81=A8Pleasanter=E6=93=8D=E4=BD=9C=E3=82=B9=E3=82=AF?= =?UTF-8?q?=E3=83=AA=E3=83=97=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit n8nに構築済みのHC-SUB/HC-WP/HC-WAワークフロー設計・実装計画・エクスポートJSONを Pleasanter/健康診断管理/docs/n8n/に集約管理。508971/513156のColumns/Styles/Summaries 反映用ワンショットスクリプトも合わせて追加。worktree-healthcheck-survey-botブランチを mainへfast-forward mergeし、.claude/worktrees/はgit管理対象外に追加。 Co-Authored-By: Claude Sonnet 5 --- .gitignore | 3 + .../健康診断管理/add-summaries-508971.js | 133 ++ .../apply-508971-columns-to-513156.js | 140 ++ .../apply-num031-038-columns-to-508971.js | 128 ++ .../fix-arishoken-nowrap-508971.js | 113 ++ .../fix-editorcolumnhash-order-513156.js | 129 ++ .../健康診断管理/fix-num-attrs-513156.js | 120 ++ .../rename-numah-to-num031-038-513156.js | 133 ++ .../restructure-site-508971-tab2-counts.js | 160 ++ Pleasanter/健康診断管理/docs/n8n/design.md | 146 ++ Pleasanter/健康診断管理/docs/n8n/plan.md | 1474 +++++++++++++++++ .../健康診断管理/docs/n8n/workflows-status.md | 101 ++ .../workflows/hc-sub-run-process-and-notify.json | 261 +++ .../n8n/workflows/hc-wa-lineworks-response.json | 454 +++++ .../docs/n8n/workflows/hc-wp-status-push.json | 142 ++ ...健康診断プリザンター化_対応エクセル作成途中.xlsx | Bin 0 -> 47726 bytes .../プリザンターテーブル設計書_健康診断.xlsx | Bin 0 -> 59916 bytes 17 files changed, 3637 insertions(+) create mode 100644 Pleasanter/.claude/js/site-scripts/健康診断管理/add-summaries-508971.js create mode 100644 Pleasanter/.claude/js/site-scripts/健康診断管理/apply-508971-columns-to-513156.js create mode 100644 Pleasanter/.claude/js/site-scripts/健康診断管理/apply-num031-038-columns-to-508971.js create mode 100644 Pleasanter/.claude/js/site-scripts/健康診断管理/fix-arishoken-nowrap-508971.js create mode 100644 Pleasanter/.claude/js/site-scripts/健康診断管理/fix-editorcolumnhash-order-513156.js create mode 100644 Pleasanter/.claude/js/site-scripts/健康診断管理/fix-num-attrs-513156.js create mode 100644 Pleasanter/.claude/js/site-scripts/健康診断管理/rename-numah-to-num031-038-513156.js create mode 100644 Pleasanter/.claude/js/site-scripts/健康診断管理/restructure-site-508971-tab2-counts.js create mode 100644 Pleasanter/健康診断管理/docs/n8n/design.md create mode 100644 Pleasanter/健康診断管理/docs/n8n/plan.md create mode 100644 Pleasanter/健康診断管理/docs/n8n/workflows-status.md create mode 100644 Pleasanter/健康診断管理/docs/n8n/workflows/hc-sub-run-process-and-notify.json create mode 100644 Pleasanter/健康診断管理/docs/n8n/workflows/hc-wa-lineworks-response.json create mode 100644 Pleasanter/健康診断管理/docs/n8n/workflows/hc-wp-status-push.json create mode 100644 Pleasanter/健康診断管理/★健康診断プリザンター化_対応エクセル作成途中.xlsx create mode 100644 Pleasanter/健康診断管理/プリザンターテーブル設計書_健康診断.xlsx diff --git a/.gitignore b/.gitignore index ab0a6403..e4df53f9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ logs/ # 巨大バイナリ・アーカイブ(サイズが大きすぎるためgit管理対象外。ローカル/別バックアップで管理) NodeSrv/notepm/export_nextgroup_20260807020526491/ NodeSrv/Keys/Pleasanter_1.5.7.1.zip + +# git worktree実体(別ブランチの作業ツリー。中身は各ブランチ側でコミット管理) +.claude/worktrees/ diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/add-summaries-508971.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/add-summaries-508971.js new file mode 100644 index 00000000..44ded75f --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/add-summaries-508971.js @@ -0,0 +1,133 @@ +// 健康診断管理 SiteId 508971(社員別健康診断管理): Summaries(513156への集計設定)に、 +// 既存のNum031/Num032と同じ条件(SiteId:513156, DestinationReferenceType:Results, +// LinkColumn:ClassZ, Type:Total, SourceColumn=DestinationColumn)でNum033~038/Num061~071/081~091を +// 追加するワンショットスクリプト。updatesite(Mode:full)を使う。 +// --execute指定時のみ実際に送信、指定なければプレビューのみ出力。 +"use strict"; +const fs = require("fs"); +const path = require("path"); +const { findSiteDir, newModifyDir } = require("../../site-paths.js"); + +const PROJECT_NAME = "健康診断管理"; +const ENV = "production"; +const SITE_ID = 508971; +const SITE_NAME = "社員別健康診断管理"; +const DEST_SITE_ID = 513156; +const REQUEST_LABEL = "add-summaries-num033-038-and-061-091"; + +const TARGET_COLUMNS = [ + "Num033", "Num034", "Num035", "Num036", "Num037", "Num038", + "Num061", "Num062", "Num063", "Num064", "Num065", "Num066", "Num067", "Num068", "Num069", "Num070", "Num071", + "Num081", "Num082", "Num083", "Num084", "Num085", "Num086", "Num087", "Num088", "Num089", "Num090", "Num091", +]; + +const REPO_ROOT = path.join(__dirname, "..", "..", "..", ".."); +const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME); +const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV); + +function loadServerConfig() { + const configPath = path.join(REPO_ROOT, `config_${ENV}.json`); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + if (!config.BaseUrl || !config.ApiKey) { + console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`); + process.exit(1); + } + return config; +} + +function loadLatest() { + const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID); + if (!siteDir) { + console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`); + process.exit(1); + } + const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`); + const raw = JSON.parse(fs.readFileSync(p, "utf-8")); + return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") }; +} + +function buildDesiredSiteSettings(current) { + const settings = JSON.parse(JSON.stringify(current.SiteSettings)); + const summaries = settings.Summaries || []; + + const existingIds = summaries.map((s) => s.Id); + let nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1; + + const added = []; + for (const col of TARGET_COLUMNS) { + if (summaries.some((s) => s.SourceColumn === col && s.SiteId === DEST_SITE_ID)) { + console.error(`[エラー] Summaries内に${col}(SiteId${DEST_SITE_ID}向け)が既に存在します(重複防止のため中断)。`); + process.exit(1); + } + const entry = { + SiteId: DEST_SITE_ID, + DestinationReferenceType: "Results", + DestinationColumn: col, + LinkColumn: "ClassZ", + Type: "Total", + SourceColumn: col, + Id: nextId, + }; + summaries.push(entry); + added.push(entry); + nextId++; + } + settings.Summaries = summaries; + + // Links省略厳禁(全置換のため既存値をそのまま含める) + settings.Links = current.SiteSettings.Links || []; + + return { settings, added }; +} + +function buildUpdateBody(apiKey, current, desiredSettings) { + return { + ApiVersion: "1.1", + ApiKey: apiKey, + SiteId: SITE_ID, + Title: current.Title, + ReferenceType: current.ReferenceType, + ParentId: current.ParentId, + InheritPermission: current.InheritPermission, + SiteSettings: desiredSettings, + }; +} + +const execute = process.argv.includes("--execute"); +const server = loadServerConfig(); + +(async () => { + const { current, siteDir, rawText } = loadLatest(); + const { settings: desiredSettings, added } = buildDesiredSiteSettings(current); + const body = buildUpdateBody(server.ApiKey, current, desiredSettings); + + const modifyDir = newModifyDir(siteDir, REQUEST_LABEL); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + + fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8"); + const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`); + fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8"); + + console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`); + console.log(`追加するSummaries(SiteId${DEST_SITE_ID}向け):`); + for (const s of added) console.log(` - Id${s.Id}: ${s.SourceColumn} → ${s.DestinationColumn} (Type:${s.Type}, LinkColumn:${s.LinkColumn})`); + console.log(`\nSummaries件数: 変更前${(current.SiteSettings.Summaries || []).length} → 変更後${desiredSettings.Summaries.length}`); + console.log(`プレビュー保存先: ${updatedPath}`); + + if (!execute) { + console.log("\n(--execute 未指定のためプレビューのみ。送信していません)"); + return; + } + + const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`; + console.log(`送信先: ${url}`); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await res.json(); + console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json)); + const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`); + fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8"); +})(); diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/apply-508971-columns-to-513156.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/apply-508971-columns-to-513156.js new file mode 100644 index 00000000..82efa299 --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/apply-508971-columns-to-513156.js @@ -0,0 +1,140 @@ +// 健康診断管理 SiteId 513156(実施年度-集計): site-508971_社員別健康診断管理のNum061~071/081~091の +// Column設定(LabelText/ControlType/NoWrap/Min/Max/Description)をそのまま複製して新規追加し、 +// 508971のStyles(Id1:基本スタイル調整→Id2:数値エリア調整の順)も複製して適用するワンショットスクリプト。 +// EditorColumnHash.General(Num061~091)は画面上で追加済みのため変更しない。updatesite(Mode:full)を使う。 +// --execute指定時のみ実際に送信、指定なければプレビュー(送信Body・diff)のみ出力。 +"use strict"; +const fs = require("fs"); +const path = require("path"); +const { findSiteDir, newModifyDir } = require("../../site-paths.js"); + +const PROJECT_NAME = "健康診断管理"; +const ENV = "production"; +const SRC_SITE_ID = 508971; +const DST_SITE_ID = 513156; +const DST_SITE_NAME = "実施年度-集計"; +const REQUEST_LABEL = "apply-508971-num-columns-and-styles"; + +const NUM_COLUMN_NAMES = [ + "Num061", "Num062", "Num063", "Num064", "Num065", "Num066", "Num067", "Num068", "Num069", "Num070", "Num071", + "Num081", "Num082", "Num083", "Num084", "Num085", "Num086", "Num087", "Num088", "Num089", "Num090", "Num091", +]; + +const REPO_ROOT = path.join(__dirname, "..", "..", "..", ".."); +const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME); +const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV); + +function loadServerConfig() { + const configPath = path.join(REPO_ROOT, `config_${ENV}.json`); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + if (!config.BaseUrl || !config.ApiKey) { + console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`); + process.exit(1); + } + return config; +} + +function loadLatest(siteId) { + const siteDir = findSiteDir(CONFIGS_DIR, siteId); + if (!siteDir) { + console.error(`[エラー] configs/${ENV}/site-${siteId}_* が見つかりません。先にget-site-config.jsを実行してください。`); + process.exit(1); + } + const p = path.join(siteDir, "sitesettings", `site-${siteId}_latest.json`); + const raw = JSON.parse(fs.readFileSync(p, "utf-8")); + return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") }; +} + +function buildDesiredSiteSettings(srcCurrent, dstCurrent) { + const settings = JSON.parse(JSON.stringify(dstCurrent.SiteSettings)); + + const addedColumns = []; + for (const name of NUM_COLUMN_NAMES) { + if (settings.Columns.some((c) => c.ColumnName === name)) { + console.error(`[エラー] 移行先Columns内に${name}が既に存在します(重複追加防止のため中断)。`); + process.exit(1); + } + const src = srcCurrent.SiteSettings.Columns.find((c) => c.ColumnName === name); + if (!src) { + console.error(`[エラー] 移行元(508971)Columns内に${name}が見つかりません。`); + process.exit(1); + } + const col = JSON.parse(JSON.stringify(src)); + settings.Columns.push(col); + addedColumns.push(col); + } + + // Styles: 508971のId1(基本スタイル調整)→Id2(数値エリア調整)の順で複製 + const srcStyles = srcCurrent.SiteSettings.Styles || []; + const style1 = srcStyles.find((s) => s.Id === 1); + const style2 = srcStyles.find((s) => s.Id === 2); + if (!style1 || !style2) { + console.error("[エラー] 移行元(508971)にStyle Id1/Id2が見つかりません。"); + process.exit(1); + } + settings.Styles = [ + { ...style1 }, + { ...style2 }, + ]; + + // Links省略厳禁(全置換のため既存値をそのまま含める。移行先に元々設定が無ければ空配列) + settings.Links = dstCurrent.SiteSettings.Links || []; + + return { settings, addedColumns }; +} + +function buildUpdateBody(apiKey, current, desiredSettings) { + return { + ApiVersion: "1.1", + ApiKey: apiKey, + SiteId: DST_SITE_ID, + Title: current.Title, + ReferenceType: current.ReferenceType, + ParentId: current.ParentId, + InheritPermission: current.InheritPermission, + SiteSettings: desiredSettings, + }; +} + +const execute = process.argv.includes("--execute"); +const server = loadServerConfig(); + +(async () => { + const { current: srcCurrent } = loadLatest(SRC_SITE_ID); + const { current: dstCurrent, siteDir: dstSiteDir, rawText: dstRawText } = loadLatest(DST_SITE_ID); + + const { settings: desiredSettings, addedColumns } = buildDesiredSiteSettings(srcCurrent, dstCurrent); + const body = buildUpdateBody(server.ApiKey, dstCurrent, desiredSettings); + + const modifyDir = newModifyDir(dstSiteDir, REQUEST_LABEL); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + + fs.writeFileSync(path.join(modifyDir, `before_site-${DST_SITE_ID}_latest.json`), dstRawText, "utf-8"); + const updatedPath = path.join(modifyDir, `site-${DST_SITE_ID}_updated_${timestamp}.json`); + fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8"); + + console.log(`\n==== SiteId ${DST_SITE_ID} (${DST_SITE_NAME}) ====`); + console.log("追加するColumns(508971から複製):"); + for (const c of addedColumns) console.log(` - ${c.ColumnName}: ${c.LabelText}`); + console.log("適用するStyles(508971から複製、順序: Id1→Id2):"); + for (const s of desiredSettings.Styles) console.log(` - Id${s.Id}: ${s.Title}`); + console.log(`\nColumns件数: 変更前${dstCurrent.SiteSettings.Columns.length} → 変更後${desiredSettings.Columns.length}`); + console.log(`プレビュー保存先: ${updatedPath}`); + + if (!execute) { + console.log("\n(--execute 未指定のためプレビューのみ。送信していません)"); + return; + } + + const url = `${server.BaseUrl}api/items/${DST_SITE_ID}/updatesite`; + console.log(`送信先: ${url}`); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await res.json(); + console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json)); + const resultPath = path.join(modifyDir, `site-${DST_SITE_ID}_result_${timestamp}.json`); + fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8"); +})(); diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/apply-num031-038-columns-to-508971.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/apply-num031-038-columns-to-508971.js new file mode 100644 index 00000000..d8bf9f05 --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/apply-num031-038-columns-to-508971.js @@ -0,0 +1,128 @@ +// 健康診断管理 SiteId 508971(社員別健康診断管理): 513156(実施年度-集計)側で +// NumA~H→Num031~038へ置き換えた後の設定(LabelText/Unit/TextAlign/NoWrap等)をそのまま複製し、 +// 508971のColumnsへ新規追加するワンショットスクリプト。EditorColumnHash._Tab-2(カウンタータブ)には +// 画面上で既にNum031~038が配置済み(Columns定義のみ未設定)のため、EditorColumnHashは変更しない。 +// 508971の既存Num項目(NumA=年齢, Num001~003, Num061~091)には一切触れない。 +// updatesite(Mode:full)を使う。--execute指定時のみ実際に送信、指定なければプレビューのみ出力。 +"use strict"; +const fs = require("fs"); +const path = require("path"); +const { findSiteDir, newModifyDir } = require("../../site-paths.js"); + +const PROJECT_NAME = "健康診断管理"; +const ENV = "production"; +const SRC_SITE_ID = 513156; +const DST_SITE_ID = 508971; +const DST_SITE_NAME = "社員別健康診断管理"; +const REQUEST_LABEL = "apply-num031-038-columns"; + +const NUM_COLUMN_NAMES = ["Num031", "Num032", "Num033", "Num034", "Num035", "Num036", "Num037", "Num038"]; + +const REPO_ROOT = path.join(__dirname, "..", "..", "..", ".."); +const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME); +const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV); + +function loadServerConfig() { + const configPath = path.join(REPO_ROOT, `config_${ENV}.json`); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + if (!config.BaseUrl || !config.ApiKey) { + console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`); + process.exit(1); + } + return config; +} + +function loadLatest(siteId) { + const siteDir = findSiteDir(CONFIGS_DIR, siteId); + if (!siteDir) { + console.error(`[エラー] configs/${ENV}/site-${siteId}_* が見つかりません。先にget-site-config.jsを実行してください。`); + process.exit(1); + } + const p = path.join(siteDir, "sitesettings", `site-${siteId}_latest.json`); + const raw = JSON.parse(fs.readFileSync(p, "utf-8")); + return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") }; +} + +function buildDesiredSiteSettings(srcCurrent, dstCurrent) { + const settings = JSON.parse(JSON.stringify(dstCurrent.SiteSettings)); + + const tab2 = settings.EditorColumnHash["_Tab-2"] || []; + const addedColumns = []; + for (const name of NUM_COLUMN_NAMES) { + if (settings.Columns.some((c) => c.ColumnName === name)) { + console.error(`[エラー] 移行先(508971)Columns内に${name}が既に存在します(重複防止のため中断)。`); + process.exit(1); + } + if (!tab2.includes(name)) { + console.error(`[エラー] EditorColumnHash._Tab-2内に${name}が見つかりません。画面上の配置状況を確認してください。`); + process.exit(1); + } + const src = srcCurrent.SiteSettings.Columns.find((c) => c.ColumnName === name); + if (!src) { + console.error(`[エラー] 移行元(513156)Columns内に${name}が見つかりません。`); + process.exit(1); + } + const col = JSON.parse(JSON.stringify(src)); + settings.Columns.push(col); + addedColumns.push(col); + } + + // Links省略厳禁(全置換のため既存値をそのまま含める) + settings.Links = dstCurrent.SiteSettings.Links || []; + + return { settings, addedColumns }; +} + +function buildUpdateBody(apiKey, current, desiredSettings) { + return { + ApiVersion: "1.1", + ApiKey: apiKey, + SiteId: DST_SITE_ID, + Title: current.Title, + ReferenceType: current.ReferenceType, + ParentId: current.ParentId, + InheritPermission: current.InheritPermission, + SiteSettings: desiredSettings, + }; +} + +const execute = process.argv.includes("--execute"); +const server = loadServerConfig(); + +(async () => { + const { current: srcCurrent } = loadLatest(SRC_SITE_ID); + const { current: dstCurrent, siteDir: dstSiteDir, rawText: dstRawText } = loadLatest(DST_SITE_ID); + + const { settings: desiredSettings, addedColumns } = buildDesiredSiteSettings(srcCurrent, dstCurrent); + const body = buildUpdateBody(server.ApiKey, dstCurrent, desiredSettings); + + const modifyDir = newModifyDir(dstSiteDir, REQUEST_LABEL); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + + fs.writeFileSync(path.join(modifyDir, `before_site-${DST_SITE_ID}_latest.json`), dstRawText, "utf-8"); + const updatedPath = path.join(modifyDir, `site-${DST_SITE_ID}_updated_${timestamp}.json`); + fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8"); + + console.log(`\n==== SiteId ${DST_SITE_ID} (${DST_SITE_NAME}) ====`); + console.log("追加するColumns(513156から複製):"); + for (const c of addedColumns) console.log(` - ${JSON.stringify(c)}`); + console.log(`\nColumns件数: 変更前${dstCurrent.SiteSettings.Columns.length} → 変更後${desiredSettings.Columns.length}`); + console.log(`プレビュー保存先: ${updatedPath}`); + + if (!execute) { + console.log("\n(--execute 未指定のためプレビューのみ。送信していません)"); + return; + } + + const url = `${server.BaseUrl}api/items/${DST_SITE_ID}/updatesite`; + console.log(`送信先: ${url}`); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await res.json(); + console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json)); + const resultPath = path.join(modifyDir, `site-${DST_SITE_ID}_result_${timestamp}.json`); + fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8"); +})(); diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-arishoken-nowrap-508971.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-arishoken-nowrap-508971.js new file mode 100644 index 00000000..ec032d3e --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-arishoken-nowrap-508971.js @@ -0,0 +1,113 @@ +// 健康診断管理 SiteId 508971: 有所見者数側(Num081~091)のNoWrapをfalseに修正するワンショットスクリプト。 +// restructure-site-508971-tab2-counts.jsで追加した際、実施人数側(Num061~071)のNoWrap:trueを +// そのまま複製していたため、有所見者数側のみfalseへ訂正する。updatesite(Mode:full)を使う。 +// --execute指定時のみ実際に送信、指定なければプレビュー(送信Body・diff)のみ出力。 +"use strict"; +const fs = require("fs"); +const path = require("path"); +const { findSiteDir, newModifyDir } = require("../../site-paths.js"); + +const PROJECT_NAME = "健康診断管理"; +const ENV = "production"; +const SITE_ID = 508971; +const SITE_NAME = "健康診断管理"; +const REQUEST_LABEL = "fix-arishoken-nowrap"; + +const REPO_ROOT = path.join(__dirname, "..", "..", "..", ".."); +const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME); +const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV); + +const TARGET_COLUMNS = [ + "Num081", "Num082", "Num083", "Num084", "Num085", + "Num086", "Num087", "Num088", "Num089", "Num090", "Num091", +]; + +function loadServerConfig() { + const configPath = path.join(REPO_ROOT, `config_${ENV}.json`); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + if (!config.BaseUrl || !config.ApiKey) { + console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`); + process.exit(1); + } + return config; +} + +function loadLatest() { + const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID); + if (!siteDir) { + console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`); + process.exit(1); + } + const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`); + const raw = JSON.parse(fs.readFileSync(p, "utf-8")); + return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") }; +} + +function buildDesiredSiteSettings(current) { + const settings = JSON.parse(JSON.stringify(current.SiteSettings)); + const changed = []; + for (const name of TARGET_COLUMNS) { + const col = settings.Columns.find((c) => c.ColumnName === name); + if (!col) { + console.error(`[エラー] Columns内に${name}が見つかりません。`); + process.exit(1); + } + const before = col.NoWrap; + col.NoWrap = false; + changed.push({ name, before, after: col.NoWrap, label: col.LabelText }); + } + // Links省略厳禁(全置換のため既存値をそのまま含める) + settings.Links = current.SiteSettings.Links || []; + return { settings, changed }; +} + +function buildUpdateBody(apiKey, current, desiredSettings) { + return { + ApiVersion: "1.1", + ApiKey: apiKey, + SiteId: SITE_ID, + Title: current.Title, + ReferenceType: current.ReferenceType, + ParentId: current.ParentId, + InheritPermission: current.InheritPermission, + SiteSettings: desiredSettings, + }; +} + +const execute = process.argv.includes("--execute"); +const server = loadServerConfig(); + +(async () => { + const { current, siteDir, rawText } = loadLatest(); + const { settings: desiredSettings, changed } = buildDesiredSiteSettings(current); + const body = buildUpdateBody(server.ApiKey, current, desiredSettings); + + const modifyDir = newModifyDir(siteDir, REQUEST_LABEL); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + + fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8"); + const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`); + fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8"); + + console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`); + console.log("NoWrap変更:"); + for (const c of changed) console.log(` - ${c.name} (${c.label}): ${c.before} → ${c.after}`); + console.log(`プレビュー保存先: ${updatedPath}`); + + if (!execute) { + console.log("\n(--execute 未指定のためプレビューのみ。送信していません)"); + return; + } + + const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`; + console.log(`送信先: ${url}`); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await res.json(); + console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json)); + const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`); + fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8"); +})(); diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-editorcolumnhash-order-513156.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-editorcolumnhash-order-513156.js new file mode 100644 index 00000000..34d3068e --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-editorcolumnhash-order-513156.js @@ -0,0 +1,129 @@ +// 健康診断管理 SiteId 513156(実施年度-集計): EditorColumnHash.Generalの並びを +// Num061→Num081→Num062→Num082…と交互配置に組み替えるワンショットスクリプト +// (508971の並びに合わせる。508971にはNum061~071→Num081~091の連続配置で反映されており見た目が揃わないため)。 +// Columns自体の変更は無く配置順のみ変更。updatesite(Mode:full)を使う。 +// --execute指定時のみ実際に送信、指定なければプレビュー(送信Body・diff)のみ出力。 +"use strict"; +const fs = require("fs"); +const path = require("path"); +const { findSiteDir, newModifyDir } = require("../../site-paths.js"); + +const PROJECT_NAME = "健康診断管理"; +const ENV = "production"; +const SITE_ID = 513156; +const SITE_NAME = "実施年度-集計"; +const REQUEST_LABEL = "fix-editorcolumnhash-order"; + +const REPO_ROOT = path.join(__dirname, "..", "..", "..", ".."); +const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME); +const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV); + +// [実施人数側, 有所見者数側] のペア。左の直後に右を差し込む +const PAIRS = [ + ["Num061", "Num081"], + ["Num062", "Num082"], + ["Num063", "Num083"], + ["Num064", "Num084"], + ["Num065", "Num085"], + ["Num066", "Num086"], + ["Num067", "Num087"], + ["Num068", "Num088"], + ["Num069", "Num089"], + ["Num070", "Num090"], + ["Num071", "Num091"], +]; + +function loadServerConfig() { + const configPath = path.join(REPO_ROOT, `config_${ENV}.json`); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + if (!config.BaseUrl || !config.ApiKey) { + console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`); + process.exit(1); + } + return config; +} + +function loadLatest() { + const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID); + if (!siteDir) { + console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`); + process.exit(1); + } + const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`); + const raw = JSON.parse(fs.readFileSync(p, "utf-8")); + return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") }; +} + +function buildDesiredSiteSettings(current) { + const settings = JSON.parse(JSON.stringify(current.SiteSettings)); + + const general = settings.EditorColumnHash.General; + const pairSrcSet = new Set(PAIRS.map(([src]) => src)); + const arishokenSet = new Set(PAIRS.map(([, dst]) => dst)); + const rebuilt = []; + for (const name of general) { + if (arishokenSet.has(name)) continue; // 有所見者数側は元位置から除去し、対応する実施人数側の直後へ差し込む + rebuilt.push(name); + if (pairSrcSet.has(name)) { + const pair = PAIRS.find(([src]) => src === name); + rebuilt.push(pair[1]); + } + } + settings.EditorColumnHash.General = rebuilt; + + // Links省略厳禁(全置換のため既存値をそのまま含める) + settings.Links = current.SiteSettings.Links || []; + + return { settings, rebuilt }; +} + +function buildUpdateBody(apiKey, current, desiredSettings) { + return { + ApiVersion: "1.1", + ApiKey: apiKey, + SiteId: SITE_ID, + Title: current.Title, + ReferenceType: current.ReferenceType, + ParentId: current.ParentId, + InheritPermission: current.InheritPermission, + SiteSettings: desiredSettings, + }; +} + +const execute = process.argv.includes("--execute"); +const server = loadServerConfig(); + +(async () => { + const { current, siteDir, rawText } = loadLatest(); + const { settings: desiredSettings, rebuilt } = buildDesiredSiteSettings(current); + const body = buildUpdateBody(server.ApiKey, current, desiredSettings); + + const modifyDir = newModifyDir(siteDir, REQUEST_LABEL); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + + fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8"); + const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`); + fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8"); + + console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`); + console.log("EditorColumnHash.General 変更後の並び:"); + console.log(` [${rebuilt.join(", ")}]`); + console.log(`プレビュー保存先: ${updatedPath}`); + + if (!execute) { + console.log("\n(--execute 未指定のためプレビューのみ。送信していません)"); + return; + } + + const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`; + console.log(`送信先: ${url}`); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await res.json(); + console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json)); + const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`); + fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8"); +})(); diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-num-attrs-513156.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-num-attrs-513156.js new file mode 100644 index 00000000..5fc7535e --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/fix-num-attrs-513156.js @@ -0,0 +1,120 @@ +// 健康診断管理 SiteId 513156(実施年度-集計): 診断結果集計Num項目(Num061~071/081~091)の +// ControlType/Min/Maxを削除し、Unit:"人"/TextAlign:20を付与するワンショットスクリプト +// (NumA~Hは既にUnit:"人"/TextAlign:20設定済みのため対象外。508971側のSpinner/Min0/Max1指定は +// このサイトの集計項目には不要なため、508971とは別設定にする)。 +// updatesite(Mode:full)を使う。--execute指定時のみ実際に送信、指定なければプレビューのみ出力。 +"use strict"; +const fs = require("fs"); +const path = require("path"); +const { findSiteDir, newModifyDir } = require("../../site-paths.js"); + +const PROJECT_NAME = "健康診断管理"; +const ENV = "production"; +const SITE_ID = 513156; +const SITE_NAME = "実施年度-集計"; +const REQUEST_LABEL = "fix-num-attrs-unit-textalign"; + +const TARGET_COLUMNS = [ + "Num061", "Num062", "Num063", "Num064", "Num065", "Num066", "Num067", "Num068", "Num069", "Num070", "Num071", + "Num081", "Num082", "Num083", "Num084", "Num085", "Num086", "Num087", "Num088", "Num089", "Num090", "Num091", +]; + +const REPO_ROOT = path.join(__dirname, "..", "..", "..", ".."); +const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME); +const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV); + +function loadServerConfig() { + const configPath = path.join(REPO_ROOT, `config_${ENV}.json`); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + if (!config.BaseUrl || !config.ApiKey) { + console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`); + process.exit(1); + } + return config; +} + +function loadLatest() { + const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID); + if (!siteDir) { + console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`); + process.exit(1); + } + const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`); + const raw = JSON.parse(fs.readFileSync(p, "utf-8")); + return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") }; +} + +function buildDesiredSiteSettings(current) { + const settings = JSON.parse(JSON.stringify(current.SiteSettings)); + const changed = []; + for (const name of TARGET_COLUMNS) { + const col = settings.Columns.find((c) => c.ColumnName === name); + if (!col) { + console.error(`[エラー] Columns内に${name}が見つかりません。`); + process.exit(1); + } + const before = JSON.parse(JSON.stringify(col)); + delete col.ControlType; + delete col.Min; + delete col.Max; + col.Unit = "人"; + col.TextAlign = 20; + changed.push({ name, before, after: JSON.parse(JSON.stringify(col)) }); + } + // Links省略厳禁(全置換のため既存値をそのまま含める) + settings.Links = current.SiteSettings.Links || []; + return { settings, changed }; +} + +function buildUpdateBody(apiKey, current, desiredSettings) { + return { + ApiVersion: "1.1", + ApiKey: apiKey, + SiteId: SITE_ID, + Title: current.Title, + ReferenceType: current.ReferenceType, + ParentId: current.ParentId, + InheritPermission: current.InheritPermission, + SiteSettings: desiredSettings, + }; +} + +const execute = process.argv.includes("--execute"); +const server = loadServerConfig(); + +(async () => { + const { current, siteDir, rawText } = loadLatest(); + const { settings: desiredSettings, changed } = buildDesiredSiteSettings(current); + const body = buildUpdateBody(server.ApiKey, current, desiredSettings); + + const modifyDir = newModifyDir(siteDir, REQUEST_LABEL); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + + fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8"); + const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`); + fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8"); + + console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`); + console.log("変更内容:"); + for (const c of changed) { + console.log(` - ${c.name}: ${JSON.stringify(c.before)} → ${JSON.stringify(c.after)}`); + } + console.log(`プレビュー保存先: ${updatedPath}`); + + if (!execute) { + console.log("\n(--execute 未指定のためプレビューのみ。送信していません)"); + return; + } + + const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`; + console.log(`送信先: ${url}`); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await res.json(); + console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json)); + const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`); + fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8"); +})(); diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/rename-numah-to-num031-038-513156.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/rename-numah-to-num031-038-513156.js new file mode 100644 index 00000000..8eaf1a2d --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/rename-numah-to-num031-038-513156.js @@ -0,0 +1,133 @@ +// 健康診断管理 SiteId 513156(実施年度-集計): NumA~NumH(社員集計項目)をNum031~038へ置き換える +// ワンショットスクリプト。設定(LabelText/Unit/TextAlign/NoWrap等)はそのまま維持し、ColumnNameのみ変更。 +// EditorColumnHash.General内の配置位置もNumA~H→Num031~038に置換。既存アイテム件数0件を確認済みのため +// データ移行は行わない(ColumnNameはPleasanterの物理列名に直結するため、データがあれば別途移行が必要)。 +// updatesite(Mode:full)を使う。--execute指定時のみ実際に送信、指定なければプレビューのみ出力。 +"use strict"; +const fs = require("fs"); +const path = require("path"); +const { findSiteDir, newModifyDir } = require("../../site-paths.js"); + +const PROJECT_NAME = "健康診断管理"; +const ENV = "production"; +const SITE_ID = 513156; +const SITE_NAME = "実施年度-集計"; +const REQUEST_LABEL = "rename-numah-to-num031-038"; + +// [旧ColumnName, 新ColumnName] +const RENAME_PAIRS = [ + ["NumA", "Num031"], + ["NumB", "Num032"], + ["NumC", "Num033"], + ["NumD", "Num034"], + ["NumE", "Num035"], + ["NumF", "Num036"], + ["NumG", "Num037"], + ["NumH", "Num038"], +]; + +const REPO_ROOT = path.join(__dirname, "..", "..", "..", ".."); +const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME); +const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV); + +function loadServerConfig() { + const configPath = path.join(REPO_ROOT, `config_${ENV}.json`); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + if (!config.BaseUrl || !config.ApiKey) { + console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`); + process.exit(1); + } + return config; +} + +function loadLatest() { + const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID); + if (!siteDir) { + console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`); + process.exit(1); + } + const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`); + const raw = JSON.parse(fs.readFileSync(p, "utf-8")); + return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") }; +} + +function buildDesiredSiteSettings(current) { + const settings = JSON.parse(JSON.stringify(current.SiteSettings)); + const renamedColumns = []; + + for (const [oldName, newName] of RENAME_PAIRS) { + const col = settings.Columns.find((c) => c.ColumnName === oldName); + if (!col) { + console.error(`[エラー] Columns内に${oldName}が見つかりません。`); + process.exit(1); + } + if (settings.Columns.some((c) => c.ColumnName === newName)) { + console.error(`[エラー] Columns内に${newName}が既に存在します(重複防止のため中断)。`); + process.exit(1); + } + col.ColumnName = newName; + renamedColumns.push({ oldName, newName, label: col.LabelText }); + } + + const general = settings.EditorColumnHash.General; + const renameMap = new Map(RENAME_PAIRS); + settings.EditorColumnHash.General = general.map((name) => renameMap.get(name) || name); + + // Links省略厳禁(全置換のため既存値をそのまま含める) + settings.Links = current.SiteSettings.Links || []; + + return { settings, renamedColumns }; +} + +function buildUpdateBody(apiKey, current, desiredSettings) { + return { + ApiVersion: "1.1", + ApiKey: apiKey, + SiteId: SITE_ID, + Title: current.Title, + ReferenceType: current.ReferenceType, + ParentId: current.ParentId, + InheritPermission: current.InheritPermission, + SiteSettings: desiredSettings, + }; +} + +const execute = process.argv.includes("--execute"); +const server = loadServerConfig(); + +(async () => { + const { current, siteDir, rawText } = loadLatest(); + const { settings: desiredSettings, renamedColumns } = buildDesiredSiteSettings(current); + const body = buildUpdateBody(server.ApiKey, current, desiredSettings); + + const modifyDir = newModifyDir(siteDir, REQUEST_LABEL); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + + fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8"); + const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`); + fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8"); + + console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`); + console.log("置き換え内容:"); + for (const c of renamedColumns) console.log(` - ${c.oldName} → ${c.newName} (${c.label})`); + console.log("EditorColumnHash.General 変更後:"); + console.log(` [${desiredSettings.EditorColumnHash.General.join(", ")}]`); + console.log(`プレビュー保存先: ${updatedPath}`); + + if (!execute) { + console.log("\n(--execute 未指定のためプレビューのみ。送信していません)"); + return; + } + + const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`; + console.log(`送信先: ${url}`); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await res.json(); + console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json)); + const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`); + fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8"); +})(); diff --git a/Pleasanter/.claude/js/site-scripts/健康診断管理/restructure-site-508971-tab2-counts.js b/Pleasanter/.claude/js/site-scripts/健康診断管理/restructure-site-508971-tab2-counts.js new file mode 100644 index 00000000..d22278ca --- /dev/null +++ b/Pleasanter/.claude/js/site-scripts/健康診断管理/restructure-site-508971-tab2-counts.js @@ -0,0 +1,160 @@ +// 健康診断管理 SiteId 508971: タブ「診断結果」のNum061~071(検査項目)に +// ラベル末尾" (実施人数)"を付与し、同じ設定(ControlType/NoWrap/Min/Max/Description)を +// 複製したNum081~091(ラベル末尾" (有所見者数)")を新規追加、EditorColumnHash._Tab-1の並びを +// Num061→Num081→Num062→Num082…のように交互配置へ組み替えるワンショットスクリプト。 +// updatesite(Mode:full)を使う。--execute指定時のみ実際に送信、指定なければプレビュー(送信Body・diff)のみ出力。 +"use strict"; +const fs = require("fs"); +const path = require("path"); +const { findSiteDir, newModifyDir } = require("../../site-paths.js"); + +const PROJECT_NAME = "健康診断管理"; +const ENV = "production"; +const SITE_ID = 508971; +const SITE_NAME = "健康診断管理"; +const REQUEST_LABEL = "tab2-jisshi-arishoken-counts"; + +const REPO_ROOT = path.join(__dirname, "..", "..", "..", ".."); +const PROJECT_ROOT = path.join(REPO_ROOT, PROJECT_NAME); +const CONFIGS_DIR = path.join(PROJECT_ROOT, "configs", ENV); + +// [実施人数側(既存), 有所見者数側(新規)] のペア。左の設定をそのまま右へ複製する +const PAIRS = [ + ["Num061", "Num081"], + ["Num062", "Num082"], + ["Num063", "Num083"], + ["Num064", "Num084"], + ["Num065", "Num085"], + ["Num066", "Num086"], + ["Num067", "Num087"], + ["Num068", "Num088"], + ["Num069", "Num089"], + ["Num070", "Num090"], + ["Num071", "Num091"], +]; + +function loadServerConfig() { + const configPath = path.join(REPO_ROOT, `config_${ENV}.json`); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + if (!config.BaseUrl || !config.ApiKey) { + console.error(`[エラー] ${configPath} に BaseUrl / ApiKey を設定してください。`); + process.exit(1); + } + return config; +} + +function loadLatest() { + const siteDir = findSiteDir(CONFIGS_DIR, SITE_ID); + if (!siteDir) { + console.error(`[エラー] configs/${ENV}/site-${SITE_ID}_* が見つかりません。先にget-site-config.jsを実行してください。`); + process.exit(1); + } + const p = path.join(siteDir, "sitesettings", `site-${SITE_ID}_latest.json`); + const raw = JSON.parse(fs.readFileSync(p, "utf-8")); + return { current: raw.Response.Data, siteDir, rawText: fs.readFileSync(p, "utf-8") }; +} + +function buildDesiredSiteSettings(current) { + const settings = JSON.parse(JSON.stringify(current.SiteSettings)); + const columns = settings.Columns; + + const addedColumns = []; + for (const [srcName, newName] of PAIRS) { + const src = columns.find((c) => c.ColumnName === srcName); + if (!src) { + console.error(`[エラー] Columns内に${srcName}が見つかりません。`); + process.exit(1); + } + if (columns.some((c) => c.ColumnName === newName)) { + console.error(`[エラー] Columns内に${newName}が既に存在します(重複追加防止のため中断)。`); + process.exit(1); + } + const originalLabel = src.LabelText; + src.LabelText = `${originalLabel} (実施人数)`; + + const newCol = JSON.parse(JSON.stringify(src)); + newCol.ColumnName = newName; + newCol.LabelText = `${originalLabel} (有所見者数)`; + columns.push(newCol); + addedColumns.push(newCol); + } + + // EditorColumnHash._Tab-1 の並びをNum061→Num081→Num062→Num082…と交互に組み替える + const tab1 = settings.EditorColumnHash["_Tab-1"]; + const pairSrcSet = new Set(PAIRS.map(([src]) => src)); + const rebuilt = []; + for (const name of tab1) { + rebuilt.push(name); + if (pairSrcSet.has(name)) { + const pair = PAIRS.find(([src]) => src === name); + rebuilt.push(pair[1]); + } + } + settings.EditorColumnHash["_Tab-1"] = rebuilt; + + // Links省略厳禁(全置換のため既存値をそのまま含める) + settings.Links = current.SiteSettings.Links || []; + + return { settings, addedColumns, rebuiltTab1: rebuilt }; +} + +function buildUpdateBody(apiKey, current, desiredSettings) { + return { + ApiVersion: "1.1", + ApiKey: apiKey, + SiteId: SITE_ID, + Title: current.Title, + ReferenceType: current.ReferenceType, + ParentId: current.ParentId, + InheritPermission: current.InheritPermission, + SiteSettings: desiredSettings, + }; +} + +const execute = process.argv.includes("--execute"); +const server = loadServerConfig(); + +(async () => { + const { current, siteDir, rawText } = loadLatest(); + const { settings: desiredSettings, addedColumns, rebuiltTab1 } = buildDesiredSiteSettings(current); + const body = buildUpdateBody(server.ApiKey, current, desiredSettings); + + const modifyDir = newModifyDir(siteDir, REQUEST_LABEL); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + + fs.writeFileSync(path.join(modifyDir, `before_site-${SITE_ID}_latest.json`), rawText, "utf-8"); + const updatedPath = path.join(modifyDir, `site-${SITE_ID}_updated_${timestamp}.json`); + fs.writeFileSync(updatedPath, JSON.stringify(body, null, 2), "utf-8"); + + console.log(`\n==== SiteId ${SITE_ID} (${SITE_NAME}) ====`); + console.log("ラベル変更(実施人数):"); + for (const [srcName] of PAIRS) { + const c = desiredSettings.Columns.find((x) => x.ColumnName === srcName); + console.log(` - ${srcName}: ${c.LabelText}`); + } + console.log("新規追加(有所見者数):"); + for (const c of addedColumns) { + console.log(` - ${c.ColumnName}: ${c.LabelText}`); + } + console.log("EditorColumnHash._Tab-1 変更後の並び:"); + console.log(` [${rebuiltTab1.join(", ")}]`); + console.log(`\nColumns件数: 変更前${current.SiteSettings.Columns.length} → 変更後${desiredSettings.Columns.length}`); + console.log(`プレビュー保存先: ${updatedPath}`); + + if (!execute) { + console.log("\n(--execute 未指定のためプレビューのみ。送信していません)"); + return; + } + + const url = `${server.BaseUrl}api/items/${SITE_ID}/updatesite`; + console.log(`送信先: ${url}`); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await res.json(); + console.log(`[結果] HTTP ${res.status}:`, JSON.stringify(json)); + const resultPath = path.join(modifyDir, `site-${SITE_ID}_result_${timestamp}.json`); + fs.writeFileSync(resultPath, JSON.stringify(json, null, 2), "utf-8"); +})(); diff --git a/Pleasanter/健康診断管理/docs/n8n/design.md b/Pleasanter/健康診断管理/docs/n8n/design.md new file mode 100644 index 00000000..f9b1f6bf --- /dev/null +++ b/Pleasanter/健康診断管理/docs/n8n/design.md @@ -0,0 +1,146 @@ +# 健康診断管理×LINEWORKS Bot連携 n8n化 設計書 + +- 作成日: 2026-09-05 +- 対象: プリザンター「健康診断管理」プロジェクト(SiteId 508971)へLINEWORKS Bot経由の対話型ステータス管理機能を実装 +- 位置付け: 既存Express実装(`OldCode/express/modules/lineworksSurvey.js`、仕様書`Pleasanter/LINEWORKSアンケート管理/docs/lineworks-survey-scheme.md`)とは別物としてn8nで新規構築。単純な順次質問アンケートではなく、**プリザンターのProcess機能をそのままフロー定義として使うステータス駆動型対話ボット** + +## 1. 背景・既存Express実装との違い + +Express版(ケアセブンプロジェクト向け)は「アンケート開始→設問を順番に送信→全問回答で1レコードcreate」という一方向の順次アンケートだった。今回の健康診断管理版は要件が異なる。 + +- 508971は1レコード=1回の健診。フローは「日程通知→了承/変更→受診確認→結果受取り」のようにレコードのStatusを段階的に進める対話であり、設問の連続ではない +- LINE WORKS Bot APIの制約上、**「トークを開いた瞬間」を検知するイベントは存在しない**(コールバックイベントは`message`/`join`/`leave`/`joined`/`left`/`postback`のみ)。そのため「初期表示切替」は、①Statusが変わった瞬間にBotから能動的にメッセージを送る(プッシュ型)、②ユーザーが何か送信したら現在Statusの案内を返す(フォールバック)、の組み合わせで実現する +- アンケート実行管理・Bot・対象者マスタは新設せず、既存資産を流用する(後述) +- **フロー定義(Statusごとの案内文・選択肢・遷移先)を管理する専用マスタは新設しない。508971自体の`SiteSettings.Processes`(プロセス機能)をそのままフロー定義として使う** +- サーバー実装はExpressでなくn8n。会話の待機状態はn8nプロセスのメモリではなく、n8n Data Table(新規`bot_conversation_state`)で保持する + +n8n環境自体の詳細(URL・API・既存ワークフロー・過去の罠)は`NodeSrv/apps/n8n/docs/n8n-guide.md`参照。以下の設計はこのガイドの制約(コンテナメモリ768MB、Data Table操作の罠、Schedule Trigger運用方針等)を踏まえている。 + +## 2. 前提とした既存資産の流用 + +| 用途 | 流用元 | +|---|---| +| Bot | 既存「LINEWORKSアンケート管理」プロジェクトのSiteId 484184(LINEWORKS Bot管理)をそのまま参照。健康診断管理側にBotマスタは新設しない | +| 対象者解決 | プリザンター標準Usersのメールアドレスを、そのままLINEWORKS宛先ID(userId)として使う。LINE WORKS側からの受信時も`source.userId`=メールアドレスという前提で扱う(専用マッピングマスタは不要) | +| 対象レコード特定 | LINEWORKSから届いたメッセージの送信者メール→508971の中で、そのユーザーが紐づくレコードのうち**未完了(Status 900/910以外)の最新レコード**を対象とする。複数該当時の自動判定は行わず、実装上は「複数ヒット」を異常系として扱う | + +対象者解決は基本的にプリザンター標準Usersのメールアドレスだけで足りる想定。データ品質の裏付けチェック・補完に社員・組織マスタ管理テーブル(SiteId 504412)を使う件は8章参照(Bot対話フローとは独立した補助機能)。 + +## 3. 全体アーキテクチャ + +``` +[プリザンター508971] Statusが変わる契機は2種類 + (a) 担当者がProcessボタンを押す(例:①日程通知発行、日程確定時) + (b) Bot対話の結果としてProcessが実行される(例:②→③) + +(a)の場合 + ▼ +n8n: WP「Statusプッシュ通知」(Webhook, 508971クライアントスクリプトから起動) + └ resultId・processIdを受け取りレコード取得 + └ 該当Processの案内文(ツールチップ欄)をレコード値で置換 + └ 対象者メール解決 → LINEWORKS Bot APIで案内+選択肢を送信 + └ n8n Data Table「bot_conversation_state」に提示内容を記録 + ▼ +LINEWORKS トーク + │ ユーザー返信(ボタン押下/テキスト/ファイル) + ▼ +n8n: WA「LINEWORKS応答受信」(Webhook、唯一の受信口、署名検証) + └ 送信者メール解決 → 508971の対象レコード特定 + └ bot_conversation_stateの待機状態を見て回答を処理 + └ 該当Processを`ProcessId`指定でapi/items/updateへ実行 → Status遷移 + └ 遷移後の新Statusに紐づく次のProcess群を取得し、次の案内を送信(WPと共通ロジック) +``` + +## 4. フロー定義: 508971のProcess機能をそのまま流用 + +新規マスタサイト・新規テーブルは作らない。プリザンター標準の「プロセス」設定画面(画面種別・現在の状況・変更後の状況・表示名・ツールチップ・入力検証タブ等)を、Bot対話の定義としてそのまま使う。 + +| Processの項目 | Bot連携での役割 | +|---|---| +| 現在の状況(CurrentStatus)/変更後の状況(ChangedStatus) | 既存のStatus遷移定義をそのまま使う | +| 表示名(DisplayName) | Botが提示する選択肢ボタンのラベル | +| ツールチップ | Bot案内文言。`{検査機関}` `{日程}`のようなプレースホルダーを書いておくと、n8nがレコードの実際の値に置換してから送信する | +| 入力検証タブの「項目」 | このProcess実行に追加入力を伴うかの判定に流用。列名プレフィックスで種別を判定する(`Date*`→日付入力を1往復挟む、`Attachments*`→ファイル受信を1往復挟む、項目なし→即実行) | +| ProcessId | n8nが`POST /api/items/{resultId}/update`に`ProcessId`パラメータとして渡して実行する。公式マニュアル記載の通り、APIからのプロセス実行では入力検証(プリザンター標準の検証機能)は適用されない点に注意 | + +新フローを追加する際の運用手順: +1. 508971のProcessを1つ追加(現在の状況・変更後の状況・表示名・ツールチップ文言を設定) +2. 追加入力が必要なら、入力検証タブの「項目」に対象列(`Date*`または`Attachments*`)を登録 +3. n8nワークフロー(WP/WA)は無改修。現在Statusに紐づくProcess一覧を都度`getsite`から動的に取得する設計のため、Process追加だけで新フローを反映できる + +## 5. n8n側詳細 + +### Data Table「bot_conversation_state」(新規) + +| 列 | 内容 | +|---|---| +| resultId | 508971のResultId | +| targetEmail | 対象者メールアドレス(LINEWORKS userId) | +| currentStatus | 直近提示時点のStatus値 | +| pendingProcesses | 直近提示した選択肢一覧(`[{processId, label, validateColumn}]`のJSON文字列) | +| awaitInput | `none`(選択肢待ち)/`date`(日付入力待ち)/`file`(ファイル受信待ち) | +| awaitProcessId | 追加入力完了後に実行すべきProcessId(`awaitInput`が`date`/`file`の間のみ使用) | +| awaitColumn | 追加入力先の列名(例: `Date001`) | +| updatedAt | 最終更新日時 | + +n8n-guide.md 7-1(Clear出力の握り潰し)・7-2(複数行が後続へそのまま渡ると行数分繰り返し実行される)の罠を踏まえ、1レコード=1行の読み書きに限定し、複数行を横断する集約処理は入れない。セッションタイムアウトの概念は持たない(ユーザーの都合のいいタイミングで返信されればよい性質のフローのため、待機状態は無期限に保持する)。 + +### ワークフロー構成 + +- **WP Statusプッシュ通知**: Webhookトリガー(`X-API-Key`ヘッダー認証、508971のクライアントスクリプト専用)。担当者がProcessボタンを押した時に起動 +- **WA LINEWORKS応答受信**: Webhookトリガー(LINE WORKS本体からの直接コールバック、`x-works-signature`をHMAC-SHA256検証)。唯一の受信口 +- タイムアウト監視ワークフローは持たない(3節参照) + +### WAの処理詳細 + +1. 署名検証 → 送信内容(テキスト/ボタン応答/ファイル)を判定 +2. 送信者メール解決 → 508971の対象レコード(未完了の最新1件)を特定 +3. `bot_conversation_state`から該当resultIdの待機状態を取得 +4. **待機状態なし、または`awaitInput=none`で回答が選択肢と不一致** → 現在Statusに紐づくProcess群を`getsite`から再取得し、案内を再送(フォールバック) +5. **`awaitInput=none`で回答が選択肢(DisplayName)と一致**: + - 対象列なし → 即座に`ProcessId`実行 → 新Statusの次Process群を取得 → 次の案内を送信 + - 対象列が`Date*` → `awaitInput=date`/`awaitProcessId`/`awaitColumn`を記録し、「日付を入力してください」を追加送信(Process実行は保留) + - 対象列が`Attachments*` → 同様に`awaitInput=file`で保留し、ファイル送信を促す +6. **`awaitInput=date`** → 受信テキストを日付として検証(Express版の和暦・月日省略対応ロジックを踏襲)→ `awaitColumn`をupdate → `awaitProcessId`を`ProcessId`実行 → 新Status提示 → 待機状態を`none`へ戻す +7. **`awaitInput=file`** → 受信がファイルでなければ再送要求。ファイルならLINEWORKS Bot APIでダウンロード → Pleasanter添付ファイルAPIで`awaitColumn`へアップロード → `awaitProcessId`を`ProcessId`実行 → 新Status提示 → 待機状態を`none`へ戻す + +### 認証まわり + +- LINEWORKS Bot APIメッセージ送信: 既存Credential「LINEWORKS Bot Private Key (v4)」(`Hw0qlEaGfLPnQWp1`)が流用できるか、BotIdとの対応関係を実装時に確認する +- 対象者メール解決: `POST api/users/get`(`View:{ApiGetMailAddresses:true}`)。org-master-sync③で実績のある実装パターンを流用 + +## 6. エラーハンドリング・異常系 + +- 対象レコードが複数ヒット(同一ユーザーの未完了レコードが2件以上)→ 自動判定せず、担当者確認が必要な異常系として扱う(Bot応答は保留し、通知等は今後の実装計画で検討) +- 選択肢に一致しない回答 → 選択肢を再提示(4節のフォールバックと同じ経路) +- 日付形式不正/ファイル未送信 → エラーメッセージ+再送、待機状態は維持 +- LINEWORKS送信失敗・508971 update失敗 → n8n Execution History(Postgres保存)に残す。追加のログ実装はしない + +## 7. 導入・検証方針 + +- n8n運用ルール(n8n-guide.md 9章)に従い、ワークフローの構築・編集自体は確認不要。**Webhook実行によるプリザンター書き込み・LINEWORKS送信を伴うテストは都度事前確認** +- 初回検証は1Process・1件のみで実施し、疎通確認後にフロー全体(①〜③)の通し検証に進める +- 508971は本番の健診データそのものなので、検証は既存レコードを壊さない捨てレコードを用意して行う + +## 8. 補助機能(Bot対話フローとは独立): 社員マスタ(504412)によるデータ品質チェック・補完 + +3〜7章のBot対話フローとは関係のない、508971のデータ品質を担保するための補助機能。n8nのHC-SUB/HC-WP/HC-WAワークフローには組み込まず、別途の仕組み(バッチ・Pleasanter Process/ServerScript等、実装方式は別途検討)として扱う。 + +社員・組織マスタ管理テーブル(SiteId 504412、2章参照)の列構成(`NodeSrv/apps/org-master-sync/configs/site-504412_社員・組織マスタ管理テーブル/`で取得済み): + +| 504412の列 | 内容 | +|---|---| +| `Class011` | ユーザID(PleasanterUserId) | +| `ClassB` | メールアドレス | +| `Class036` | PLメールアドレス | +| `Class003` / `Class004` | 姓(カナ)/名(カナ) | + +- **メールアドレス整合性チェック**: 508971の`ClassC`(Users参照)から解決した対象者のPleasanterUserIdを軸に504412の`Class011`と突き合わせ、その社員の`Class036`(PLメールアドレス)と、`api/users/get`で解決した実際のメールアドレスが一致するかを検証する +- **フリガナ補完**: 508971の`ClassD`(フリガナ、必須項目)が空欄の場合、504412の`Class003`(姓(カナ))+`Class004`(名(カナ))から補完する + +## 9. 未確定事項(実装時に個別確定) + +- 具体的なProcess定義(①日程通知〜③検査結果受取りの各Process内容、Status値の追加・修正)は508971のStatus設計がまだ未完成のため、実装着手時に個別に設計する +- LINEWORKS Bot Private Key CredentialとBotId(484184側)の対応関係の実機確認 +- LINEWORKS Bot APIでのファイル受信(ダウンロードURL取得)とPleasanter添付ファイルAPIへのアップロードの具体的な実装方法 +- 今後フロー内容の詳細が追加判明する前提のため、4節の枠組み(Process流用)の汎用性を保ったまま個別Processを増やしていく diff --git a/Pleasanter/健康診断管理/docs/n8n/plan.md b/Pleasanter/健康診断管理/docs/n8n/plan.md new file mode 100644 index 00000000..35107723 --- /dev/null +++ b/Pleasanter/健康診断管理/docs/n8n/plan.md @@ -0,0 +1,1474 @@ +# 健康診断管理×LINEWORKS Bot連携 n8n実装 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** プリザンター「健康診断管理」(SiteId 508971)のProcess機能を定義源として、LINEWORKS Botとの対話でStatusを段階的に進める仕組みをn8n上に構築する。 + +**Architecture:** n8nに3本のワークフロー(HC-SUB: プロセス実行+案内送信の共通ロジック/HC-WP: 担当者操作起点のプッシュ通知/HC-WA: LINEWORKS応答受信の唯一の受信口)を構築する。フロー定義は508971の`SiteSettings.Processes`(現在の状況/変更後の状況/表示名/ツールチップ/入力検証タブ)をそのまま使い、新規マスタは作らない。会話の待機状態はn8n Data Table(新規)で保持する。純粋ロジック(日付パース・プレースホルダー置換・Process抽出・署名検証)はNode.jsモジュールとしてTDDで開発し、動作確認済みのコードをn8n Codeノードへ書き写す。 + +**Tech Stack:** Node.js(`node --test`によるユニットテスト、追加npmパッケージなし)、n8n(Public API経由でのワークフロー・Data Table構築)、LINE WORKS Bot API、Pleasanter API + +## Global Constraints + +- n8nはコンテナメモリ768MB制限。複数行データの一括展開・集約は行わない(`NodeSrv/apps/n8n/docs/n8n-guide.md` 7-2参照) +- n8n Data Table操作は「Clear→Insert」を直列に繋がない。後続ノードは前段ノードを`$('ノード名')`で明示的に再参照する(同ガイド7-1参照) +- サーバー環境の指定は本番のみ対象(`https://nextoffice.next-hd.co.jp/pleasanter/`)。テスト環境は今回のスコープ外 +- n8nワークフローの構築・編集(PUT/POST)は確認不要。**Webhookを実際に叩く・508971へ書き込みを伴うテスト実行は毎回ユーザーへ事前確認**(同ガイド9章) +- 508971は本番の健診データそのもの。検証は既存レコードを壊さない捨てレコードで行う +- Pleasanter日本語ボディを含むリクエストはシェル引数に直書きせず、Writeツールでファイル化してから`curl --data-binary "@file"`で送る(同ガイド7-6参照) +- 具体的なProcess内容(①日程通知〜③検査結果受取り等の本番仕様)は本計画のスコープ外。本計画は「Process流用型フロー定義」の枠組みを動かすことがゴールで、テスト用Process1件で疎通確認する +- 設計書8章の社員マスタ(504412)によるメールアドレス整合性チェック・フリガナ補完は、Bot対話フローと独立した別タスクとして扱う。本計画には含まない +- **【n8nワークフロー構築の基本ルール、必ず守ること】** 以下3点を踏まえてノード数の肥大を避ける(`NodeSrv/apps/n8n/docs/n8n-guide.md` 8-1参照): + - **Data Table利用は最小限にする。**`workflow_config_values`からの設定値取得は1回の`Data Table`Getノード(`filters`無し、`returnAll:true`)にまとめ、後続のCodeノードで`configKey`→`configValue`のMapに変換して使う。「1キー=1回のGet」でノードを積み上げない(n8n-guide.md 7-2の注意は619件規模のマスタデータ展開の話であり、config値十数件程度の一括取得には当てはまらない) + - **入口はWebhookノード+Codeノードで処理し、なるべくノード数を抑える。** データ整形・分岐判定・業務ロジック(ツールチップ置換、Process抽出、日付パース等)はCodeノードにまとめる + - **外部システム(Pleasanter、LINEWORKS等)へのHTTP Requestノードは普通に使ってよい。** 個々のAPI呼び出しをCodeノード内の`this.helpers.httpRequest`に無理に押し込む必要はない + - **JWT署名(Credential使用)だけは専用の`n8n-nodes-base.jwt`ノードを残す**(秘密鍵を安全に扱う既存の実証済みパターンのため) + - **Data Tableの読み書き自体(`workflow_config_values`の取得、`healthcheck_bot_state`の取得・更新)は専用の`Data Table`ノードのまま残す**(Codeノードから直接Data Tableを操作する手段が無いため) + +--- + +## File Structure + +``` +NodeSrv/apps/healthcheck-survey-bot/ + package.json + README.md -- 実機調査結果、n8nリソースID一覧を記録 + src/lib/ + dateParser.js -- 日付文字列パース(和暦・月日省略対応) + templateFill.js -- ツールチップ文言の{ラベル名}プレースホルダー置換 + processFlow.js -- Processes配列からの選択肢抽出・回答照合・追加入力種別判定 + signatureVerify.js -- LINEWORKS Webhook署名検証(HMAC-SHA256) + test/ + dateParser.test.js + templateFill.test.js + processFlow.test.js + signatureVerify.test.js + scripts/ + n8n-api.js -- n8n Public API共通fetchヘルパー + deploy-workflow.js -- workflows/*.json を n8n へPUT/POSTするCLI + workflows/ + hc-sub-run-process-and-notify.json + hc-wp-status-push.json + hc-wa-lineworks-response.json +``` + +n8n環境の接続情報(URL・APIキー・既存Credential)は`NodeSrv/apps/n8n/docs/n8n-guide.md`参照。Pleasanter本番APIキーは`Pleasanter/config_production.json`参照。 + +--- + +### Task 1: プロジェクト雛形作成 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/package.json` +- Create: `NodeSrv/apps/healthcheck-survey-bot/README.md` +- Create: `NodeSrv/apps/healthcheck-survey-bot/.gitignore` + +**Interfaces:** +- Produces: `node --test test/*.test.js`で実行できるテスト環境 + +- [ ] **Step 1: package.json作成** + +```json +{ + "name": "healthcheck-survey-bot", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "scripts": { + "test": "node --test test/*.test.js", + "deploy-workflow": "node scripts/deploy-workflow.js" + } +} +``` + +- [ ] **Step 2: README.md作成** + +```markdown +# healthcheck-survey-bot + +健康診断管理(SiteId 508971)×LINEWORKS Bot連携。508971の`SiteSettings.Processes` +をフロー定義として使い、n8n上でBot対話型のStatus管理を行う。 + +設計書: `NodeSrv/docs/superpowers/specs/2026-09-05-healthcheck-lineworks-survey-n8n-design.md` +実装計画: `NodeSrv/docs/superpowers/plans/2026-09-05-healthcheck-lineworks-survey-n8n.md` + +## 実機調査メモ + +(Task 2完了後、ここにPleasanterのProcess入力検証タブのJSON構造を記録する) + +## n8nリソースID一覧 + +(Task 8〜11完了後、ここに作成したData Table ID・ワークフローIDを記録する) +``` + +- [ ] **Step 3: .gitignore作成** + +``` +node_modules/ +``` + +- [ ] **Step 4: コミット** + +```bash +git add NodeSrv/apps/healthcheck-survey-bot/package.json NodeSrv/apps/healthcheck-survey-bot/README.md NodeSrv/apps/healthcheck-survey-bot/.gitignore +git commit -m "feat: healthcheck-survey-botプロジェクト雛形を追加" +``` + +--- + +### Task 2: 実機調査 — Processの入力検証タブのJSON構造を確認(完了) + +Pleasanter公式マニュアルには構造の記載がなく、508971の既存Processesにも入力検証を使った実例がなかったが、508971へテストProcessを追加する前に、既存本番プロジェクト「実行予算WF申請」(SiteId 376872)の取得済み`processes.json`に入力検証タブを使ったProcessの実例が見つかり、508971への書き込みなしで構造を確認できた。 + +**Files:** +- Modified: `NodeSrv/apps/healthcheck-survey-bot/README.md`(調査結果を追記済み) + +**Interfaces:** +- Produces: Task 5(processFlow.js)が前提とする、Process内で入力検証対象列を表すJSONキー名とその構造 + +**確認できた構造:** + +`Pleasanter/実行予算WF申請/configs/production/site-376872_実行予算WF申請/processes.json`のId:1「入力完了」Processに実例あり: + +```json +{ + "Id": 1, + "Name": "入力完了", + "ValidateInputs": [ + { "Id": 1, "ColumnName": "Class021", "Required": true }, + { "Id": 2, "ColumnName": "Class022", "Required": true } + ] +} +``` + +配列名は`Validations`ではなく`ValidateInputs`。各要素は`{Id, ColumnName, Required}`。値を設定していない項目(クライアント/サーバ正規表現、エラーメッセージ、最小/最大等)はキー自体が省略される可能性が高い(この実例では未設定のため確認できていない)。 + +Task 5(processFlow.js)の`getValidationColumnNames`はこの構造(`process.ValidateInputs[].ColumnName`)を前提に実装する。 + +--- + +### Task 3: dateParser.js — 日付パースロジック + +Express版`OldCode/express/modules/lineworksSurvey.js`の`parseDateInput`系ロジックを移植する。 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/src/lib/dateParser.js` +- Test: `NodeSrv/apps/healthcheck-survey-bot/test/dateParser.test.js` + +**Interfaces:** +- Produces: `parseDateInput(value: string): string`(`"YYYY-MM-DD"`形式を返す。パース不能なら`Error`をthrow) + +- [ ] **Step 1: 失敗するテストを書く** + +```javascript +// test/dateParser.test.js +const { test } = require("node:test"); +const assert = require("node:assert"); +const { parseDateInput } = require("../src/lib/dateParser"); + +test("ISO形式の日付をそのまま解釈する", () => { + assert.strictEqual(parseDateInput("2026-03-01"), "2026-03-01"); +}); + +test("スラッシュ区切りの日付を解釈する", () => { + assert.strictEqual(parseDateInput("2026/3/1"), "2026-03-01"); +}); + +test("和暦(令和)を西暦に変換する", () => { + assert.strictEqual(parseDateInput("令和6年3月1日"), "2024-03-01"); +}); + +test("和暦の略記(R)を西暦に変換する", () => { + assert.strictEqual(parseDateInput("R6.3.1"), "2024-03-01"); +}); + +test("月日のみの入力は今年として解釈する", () => { + const currentYear = new Date().getFullYear(); + assert.strictEqual(parseDateInput("3/1"), `${currentYear}-03-01`); +}); + +test("空文字はエラーになる", () => { + assert.throws(() => parseDateInput(""), /日付が空です/); +}); + +test("存在しない日付はエラーになる", () => { + assert.throws(() => parseDateInput("2026-02-30"), /存在しない日付です/); +}); + +test("解釈不能な文字列はエラーになる", () => { + assert.throws(() => parseDateInput("あいうえお")); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\NodeSrv\apps\healthcheck-survey-bot" +node --test test/dateParser.test.js +``` + +Expected: `Cannot find module '../src/lib/dateParser'`で失敗 + +- [ ] **Step 3: 実装を書く** + +```javascript +// src/lib/dateParser.js +const ERA_INFO = { + "令和": 2018, + "平成": 1988, + "昭和": 1925, + "大正": 1911, +}; + +const ERA_ALIASES = { R: "令和", H: "平成", S: "昭和", T: "大正" }; + +function pad2(value) { + return String(value).padStart(2, "0"); +} + +function finalizeDateParts(year, month, day) { + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { + throw new Error("日付形式で回答してください(例: 2026-03-01)"); + } + const date = new Date(year, month - 1, day); + if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { + throw new Error("存在しない日付です"); + } + return `${year}-${pad2(month)}-${pad2(day)}`; +} + +function tryParseEraDate(compact) { + const match = compact.match(/^(令和|平成|昭和|大正|R|H|S|T)(.+)$/i); + if (!match) return null; + + let era = match[1]; + if (/^[RHST]$/i.test(era)) { + era = ERA_ALIASES[era.toUpperCase()] || era; + } + if (!ERA_INFO[era]) return null; + + const normalized = match[2] + .replace(/年/g, "-") + .replace(/月/g, "-") + .replace(/日/g, "") + .replace(/[.\/]/g, "-"); + const parts = normalized.split("-").filter((part) => part.length > 0); + if (parts.length < 3) { + throw new Error("月と日まで入力してください(例: 令和6年3月1日)"); + } + + const eraYear = Number(parts[0]); + const month = Number(parts[1]); + const day = Number(parts[2]); + if (!Number.isFinite(eraYear) || !Number.isFinite(month) || !Number.isFinite(day)) { + throw new Error("日付形式で回答してください(例: 令和6年3月1日)"); + } + + return finalizeDateParts(ERA_INFO[era] + eraYear, month, day); +} + +function tryParseMonthDay(compact) { + const match = compact.match(/^(\d{1,2})(?:月|\/|-|\.)(\d{1,2})(?:日)?$/); + if (!match) return null; + const currentYear = new Date().getFullYear(); + return finalizeDateParts(currentYear, Number(match[1]), Number(match[2])); +} + +function parseDateInput(value) { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + throw new Error("日付が空です"); + } + const compact = trimmed.replace(/\s+/g, ""); + + const eraResult = tryParseEraDate(compact); + if (eraResult) return eraResult; + + const monthDayResult = tryParseMonthDay(compact); + if (monthDayResult) return monthDayResult; + + const normalized = compact + .replace(/年/g, "-") + .replace(/月/g, "-") + .replace(/日/g, "") + .replace(/[.\/]/g, "-"); + const isoParts = normalized.split("-").filter((part) => part.length > 0); + if (isoParts.length === 3 && isoParts[0].length >= 4) { + return finalizeDateParts(Number(isoParts[0]), Number(isoParts[1]), Number(isoParts[2])); + } + + const parsed = new Date(trimmed); + if (Number.isNaN(parsed.getTime())) { + throw new Error("日付形式で回答してください(例: 2026-03-01 や 令和6年3月1日)"); + } + return finalizeDateParts(parsed.getFullYear(), parsed.getMonth() + 1, parsed.getDate()); +} + +module.exports = { parseDateInput }; +``` + +- [ ] **Step 4: テストが通ることを確認** + +```bash +node --test test/dateParser.test.js +``` + +Expected: 8 tests、全てPASS + +- [ ] **Step 5: コミット** + +```bash +git add src/lib/dateParser.js test/dateParser.test.js +git commit -m "feat: 日付パースロジックを追加" +``` + +--- + +### Task 4: templateFill.js — ツールチップ文言のプレースホルダー置換 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/src/lib/templateFill.js` +- Test: `NodeSrv/apps/healthcheck-survey-bot/test/templateFill.test.js` + +**Interfaces:** +- Consumes: なし(Task 3とは独立) +- Produces: `fillTemplate(template: string, columns: Array<{ColumnName: string, LabelText?: string}>, valueHash: Record): string` + +`columns`はPleasanter `getsite`の`SiteSettings.Columns`配列そのもの。`valueHash`はレコードの`ClassHash`/`NumHash`/`DateHash`/`DescriptionHash`を`{...ClassHash, ...NumHash, ...DateHash, ...DescriptionHash}`のようにマージしたフラットオブジェクト(呼び出し側で用意する)。日付の未設定センチネル値(`"1899-12-30..."`で始まる文字列)は「未設定」として扱う。 + +- [ ] **Step 1: 失敗するテストを書く** + +```javascript +// test/templateFill.test.js +const { test } = require("node:test"); +const assert = require("node:assert"); +const { fillTemplate } = require("../src/lib/templateFill"); + +const columns = [ + { ColumnName: "Class003", LabelText: "検査機関" }, + { ColumnName: "Date001", LabelText: "検査日" }, +]; + +test("プレースホルダーをレコード値で置換する", () => { + const result = fillTemplate( + "検査機関: {検査機関}\n日程: {検査日}", + columns, + { Class003: "next健診クリニック", Date001: "2026-04-01T00:00:00" } + ); + assert.strictEqual(result, "検査機関: next健診クリニック\n日程: 2026-04-01T00:00:00"); +}); + +test("未設定の日付センチネル値は「未設定」に変換する", () => { + const result = fillTemplate("日程: {検査日}", columns, { + Date001: "1899-12-30T00:00:00", + }); + assert.strictEqual(result, "日程: 未設定"); +}); + +test("値が無い列は「未設定」に変換する", () => { + const result = fillTemplate("検査機関: {検査機関}", columns, {}); + assert.strictEqual(result, "検査機関: 未設定"); +}); + +test("対応するラベルが見つからないプレースホルダーはそのまま残す", () => { + const result = fillTemplate("不明: {存在しないラベル}", columns, {}); + assert.strictEqual(result, "不明: {存在しないラベル}"); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +node --test test/templateFill.test.js +``` + +Expected: `Cannot find module '../src/lib/templateFill'`で失敗 + +- [ ] **Step 3: 実装を書く** + +```javascript +// src/lib/templateFill.js +function isUnsetSentinel(value) { + return typeof value === "string" && value.startsWith("1899"); +} + +function fillTemplate(template, columns, valueHash) { + const labelToColumnName = new Map(); + for (const column of columns) { + if (column.LabelText) { + labelToColumnName.set(column.LabelText, column.ColumnName); + } + } + + return template.replace(/\{([^{}]+)\}/g, (matched, label) => { + const columnName = labelToColumnName.get(label); + if (!columnName) { + return matched; + } + const value = valueHash[columnName]; + if (value === undefined || value === null || value === "" || isUnsetSentinel(value)) { + return "未設定"; + } + return String(value); + }); +} + +module.exports = { fillTemplate }; +``` + +- [ ] **Step 4: テストが通ることを確認** + +```bash +node --test test/templateFill.test.js +``` + +Expected: 4 tests、全てPASS + +- [ ] **Step 5: コミット** + +```bash +git add src/lib/templateFill.js test/templateFill.test.js +git commit -m "feat: ツールチップ文言のプレースホルダー置換ロジックを追加" +``` + +--- + +### Task 5: processFlow.js — Process抽出・回答照合・追加入力判定 + +Task 2で確認したJSON構造を前提に実装する。入力検証対象列は`process.ValidateInputs`(各要素`{Id: number, ColumnName: string, Required: boolean}`)に入っている。 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/src/lib/processFlow.js` +- Test: `NodeSrv/apps/healthcheck-survey-bot/test/processFlow.test.js` + +**Interfaces:** +- Consumes: なし +- Produces: + - `extractProcessesForStatus(processes: Array, status: number): Array` + - `matchProcessByLabel(processes: Array, text: string): Process | null` + - `classifyAwaitInput(process: Process): { awaitInput: "none" | "date" | "file", column: string | null }` + +- [ ] **Step 1: 失敗するテストを書く** + +```javascript +// test/processFlow.test.js +const { test } = require("node:test"); +const assert = require("node:assert"); +const { + extractProcessesForStatus, + matchProcessByLabel, + classifyAwaitInput, +} = require("../src/lib/processFlow"); + +const processes = [ + { Id: 1, Name: "了承", DisplayName: "了承", CurrentStatus: 100, ChangedStatus: 200 }, + { Id: 2, Name: "日程変更", DisplayName: "日程変更", CurrentStatus: 100, ChangedStatus: 150, ValidateInputs: [{ Id: 1, ColumnName: "Date001", Required: true }] }, + { Id: 3, Name: "受けた", DisplayName: "受けた", CurrentStatus: 200, ChangedStatus: 300 }, + { Id: 4, Name: "結果受取り", DisplayName: "受け取った", CurrentStatus: 300, ChangedStatus: 900, ValidateInputs: [{ Id: 1, ColumnName: "AttachmentsA", Required: true }] }, +]; + +test("extractProcessesForStatus: 現在のStatusに一致するProcessのみ返す", () => { + const result = extractProcessesForStatus(processes, 100); + assert.strictEqual(result.length, 2); + assert.deepStrictEqual(result.map((p) => p.Id), [1, 2]); +}); + +test("extractProcessesForStatus: 一致するProcessが無ければ空配列", () => { + assert.deepStrictEqual(extractProcessesForStatus(processes, 999), []); +}); + +test("matchProcessByLabel: DisplayNameが完全一致するProcessを返す", () => { + const candidates = extractProcessesForStatus(processes, 100); + const matched = matchProcessByLabel(candidates, "日程変更"); + assert.strictEqual(matched.Id, 2); +}); + +test("matchProcessByLabel: 前後の空白を無視して一致判定する", () => { + const candidates = extractProcessesForStatus(processes, 100); + const matched = matchProcessByLabel(candidates, " 了承 "); + assert.strictEqual(matched.Id, 1); +}); + +test("matchProcessByLabel: 一致しなければnull", () => { + const candidates = extractProcessesForStatus(processes, 100); + assert.strictEqual(matchProcessByLabel(candidates, "存在しない選択肢"), null); +}); + +test("classifyAwaitInput: ValidateInputsが無いProcessはnone", () => { + assert.deepStrictEqual(classifyAwaitInput(processes[0]), { awaitInput: "none", column: null }); +}); + +test("classifyAwaitInput: Date*列はdate", () => { + assert.deepStrictEqual(classifyAwaitInput(processes[1]), { awaitInput: "date", column: "Date001" }); +}); + +test("classifyAwaitInput: Attachments*列はfile", () => { + assert.deepStrictEqual(classifyAwaitInput(processes[3]), { awaitInput: "file", column: "AttachmentsA" }); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +node --test test/processFlow.test.js +``` + +Expected: `Cannot find module '../src/lib/processFlow'`で失敗 + +- [ ] **Step 3: 実装を書く** + +```javascript +// src/lib/processFlow.js +function extractProcessesForStatus(processes, status) { + return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1); +} + +function matchProcessByLabel(processes, text) { + const trimmed = String(text ?? "").trim(); + return processes.find((p) => (p.DisplayName || p.Name) === trimmed) || null; +} + +function getValidationColumnNames(process) { + if (!Array.isArray(process.ValidateInputs)) return []; + return process.ValidateInputs.map((v) => v.ColumnName).filter(Boolean); +} + +function classifyAwaitInput(process) { + const columnNames = getValidationColumnNames(process); + if (columnNames.length === 0) { + return { awaitInput: "none", column: null }; + } + const column = columnNames[0]; + if (column.startsWith("Date")) { + return { awaitInput: "date", column }; + } + if (column.startsWith("Attachments")) { + return { awaitInput: "file", column }; + } + return { awaitInput: "none", column: null }; +} + +module.exports = { extractProcessesForStatus, matchProcessByLabel, classifyAwaitInput }; +``` + +- [ ] **Step 4: テストが通ることを確認** + +```bash +node --test test/processFlow.test.js +``` + +Expected: 8 tests、全てPASS + +- [ ] **Step 5: コミット** + +```bash +git add src/lib/processFlow.js test/processFlow.test.js +git commit -m "feat: Process抽出・回答照合・追加入力判定ロジックを追加" +``` + +--- + +### Task 6: signatureVerify.js — LINEWORKS Webhook署名検証 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/src/lib/signatureVerify.js` +- Test: `NodeSrv/apps/healthcheck-survey-bot/test/signatureVerify.test.js` + +**Interfaces:** +- Produces: `verifySignature(rawBody: Buffer | string, headerSignature: string, botSecret: string): boolean` + +- [ ] **Step 1: 失敗するテストを書く** + +```javascript +// test/signatureVerify.test.js +const { test } = require("node:test"); +const assert = require("node:assert"); +const crypto = require("node:crypto"); +const { verifySignature } = require("../src/lib/signatureVerify"); + +test("正しい署名はtrueを返す", () => { + const secret = "test-secret"; + const body = JSON.stringify({ hello: "world" }); + const signature = crypto.createHmac("sha256", secret).update(body).digest("base64"); + assert.strictEqual(verifySignature(body, signature, secret), true); +}); + +test("sha256=プレフィックス付き署名も検証できる", () => { + const secret = "test-secret"; + const body = JSON.stringify({ hello: "world" }); + const signature = crypto.createHmac("sha256", secret).update(body).digest("base64"); + assert.strictEqual(verifySignature(body, `sha256=${signature}`, secret), true); +}); + +test("不正な署名はfalseを返す", () => { + assert.strictEqual(verifySignature("body", "invalid-signature", "secret"), false); +}); + +test("署名ヘッダーが空ならfalseを返す", () => { + assert.strictEqual(verifySignature("body", "", "secret"), false); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +node --test test/signatureVerify.test.js +``` + +Expected: `Cannot find module '../src/lib/signatureVerify'`で失敗 + +- [ ] **Step 3: 実装を書く** + +```javascript +// src/lib/signatureVerify.js +const crypto = require("node:crypto"); + +function normalizeSignature(value) { + return String(value || "").trim().replace(/^sha256=/i, ""); +} + +function safeEqual(a, b) { + const ab = Buffer.from(String(a), "utf8"); + const bb = Buffer.from(String(b), "utf8"); + if (ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} + +function verifySignature(rawBody, headerSignature, botSecret) { + const headerSig = normalizeSignature(headerSignature); + if (!headerSig || !botSecret) return false; + + const payload = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), "utf8"); + const expected = crypto.createHmac("sha256", botSecret).update(payload).digest("base64"); + + return safeEqual(headerSig, expected); +} + +module.exports = { verifySignature }; +``` + +- [ ] **Step 4: テストが通ることを確認** + +```bash +node --test test/signatureVerify.test.js +``` + +Expected: 4 tests、全てPASS + +- [ ] **Step 5: 全テストを通しで実行** + +```bash +node --test test/*.test.js +``` + +Expected: 4ファイル・24テスト、全てPASS + +- [ ] **Step 6: コミット** + +```bash +git add src/lib/signatureVerify.js test/signatureVerify.test.js +git commit -m "feat: LINEWORKS Webhook署名検証ロジックを追加" +``` + +--- + +### Task 7: n8n Public API共通ヘルパー+デプロイスクリプト + +n8nワークフロー・Data Tableの作成/更新をコマンドから行うための共通スクリプト。APIキーは`NodeSrv/apps/n8n/docs/n8n-guide.md`記載の値を使う。 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/scripts/n8n-api.js` +- Create: `NodeSrv/apps/healthcheck-survey-bot/scripts/deploy-workflow.js` + +**Interfaces:** +- Produces: + - `n8nApi.js`: `request(method: string, path: string, body?: object): Promise<{status: number, body: any}>` + - `deploy-workflow.js`: CLI `node scripts/deploy-workflow.js [--id=<既存workflowId>]` + +- [ ] **Step 1: n8n-api.js を作成** + +```javascript +// scripts/n8n-api.js +const N8N_BASE_URL = "https://n8n32.next-hd.net/api/v1"; +const N8N_API_KEY = process.env.N8N_API_KEY; + +if (!N8N_API_KEY) { + throw new Error( + "環境変数 N8N_API_KEY が未設定です。NodeSrv/apps/n8n/docs/n8n-guide.md 2章のPublic API Keyを設定してください。" + ); +} + +async function request(method, path, body) { + const res = await fetch(`${N8N_BASE_URL}${path}`, { + method, + headers: { + "X-N8N-API-KEY": N8N_API_KEY, + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = text; + } + return { status: res.status, body: json }; +} + +module.exports = { request }; +``` + +- [ ] **Step 2: deploy-workflow.js を作成** + +```javascript +// scripts/deploy-workflow.js +const fs = require("node:fs"); +const path = require("node:path"); +const { request } = require("./n8n-api"); + +async function main() { + const [, , filePath, ...rest] = process.argv; + if (!filePath) { + console.error("使い方: node scripts/deploy-workflow.js [--id=<既存workflowId>]"); + process.exit(1); + } + + const idArg = rest.find((a) => a.startsWith("--id=")); + const existingId = idArg ? idArg.slice("--id=".length) : null; + + const fullPath = path.resolve(filePath); + const definition = JSON.parse(fs.readFileSync(fullPath, "utf8")); + const body = { + name: definition.name, + nodes: definition.nodes, + connections: definition.connections, + settings: definition.settings || {}, + }; + + const { status, body: result } = existingId + ? await request("PUT", `/workflows/${existingId}`, body) + : await request("POST", "/workflows", body); + + console.log("HTTP status:", status); + console.log(JSON.stringify(result, null, 2)); + + if (status >= 200 && status < 300 && result.id) { + console.log(`\nワークフローID: ${result.id}`); + console.log("README.mdの「n8nリソースID一覧」へ記録すること。"); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +- [ ] **Step 3: 動作確認(既存ワークフローのダミー取得で疎通のみ確認)** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\NodeSrv\apps\healthcheck-survey-bot" +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('GET', '/workflows?limit=1').then(r => console.log(r.status)); +" +``` + +Expected: `200`が出力される + +- [ ] **Step 4: コミット** + +```bash +git add scripts/n8n-api.js scripts/deploy-workflow.js +git commit -m "feat: n8n Public API操作用の共通スクリプトを追加" +``` + +--- + +### Task 8: n8n Data Table「healthcheck_bot_state」作成 + +**Files:** +- Modify: `NodeSrv/apps/healthcheck-survey-bot/README.md`(作成したテーブルIDを記録) + +**Interfaces:** +- Produces: n8n Data Table(テーブル名`healthcheck_bot_state`)。列: `resultId`(string), `targetEmail`(string), `currentStatus`(string), `pendingProcesses`(string, JSON), `awaitInput`(string), `awaitProcessId`(string), `awaitColumn`(string)(`updatedAt`はn8n Data Tableのシステム予約列名のため定義できず、7列で作成した。行の更新日時はn8nが自動管理するメタデータに委ねる。実際に作成したテーブルid: `jqMDa2YZTI4f0iQ7`) + +n8n Public APIの`POST /data-tables`の必須パラメータ(`projectId`要否等)は`n8n-guide.md`に記載が無いため、実機で確認しながら進める。 + +- [ ] **Step 1: 既存Data Table一覧からprojectIdを確認** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\NodeSrv\apps\healthcheck-survey-bot" +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('GET', '/data-tables?limit=10').then(r => console.log(JSON.stringify(r.body, null, 2))); +" +``` + +Expected: 既存テーブル(`workflow_config_values`等)のリストと、それぞれの`projectId`が確認できる + +- [ ] **Step 2: Data Table作成を試みる** + +Step 1で確認した既存`projectId`のいずれか(`org-master-sync`用の`LOcxF69Gm4PvnkqA`等)を指定して作成を試す。 + +```bash +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('POST', '/data-tables', { + name: 'healthcheck_bot_state', + projectId: 'LOcxF69Gm4PvnkqA', + columns: [ + { name: 'resultId', type: 'string' }, + { name: 'targetEmail', type: 'string' }, + { name: 'currentStatus', type: 'string' }, + { name: 'pendingProcesses', type: 'string' }, + { name: 'awaitInput', type: 'string' }, + { name: 'awaitProcessId', type: 'string' }, + { name: 'awaitColumn', type: 'string' }, + ], +}).then(r => console.log(r.status, JSON.stringify(r.body, null, 2))); +" +``` + +- [ ] **Step 2a: 作成に失敗した場合の代替手順** + +`projectId`必須エラー等でAPI経由の作成が通らない場合は、n8n UI(`https://n8n32.next-hd.net`、n8n-guide.md 2章のログイン情報)から手動でData Tableを作成する。列構成はStep 2と同じにする。 + +- [ ] **Step 3: 作成結果を確認** + +```bash +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('GET', '/data-tables?limit=20').then(r => { + const t = r.body.data.find(d => d.name === 'healthcheck_bot_state'); + console.log(JSON.stringify(t, null, 2)); +}); +" +``` + +Expected: 作成した8列のテーブルが表示される。表示された`id`を記録する + +- [ ] **Step 4: README.mdへ記録** + +```markdown +## n8nリソースID一覧 + +- Data Table `healthcheck_bot_state`: `` +``` + +- [ ] **Step 5: コミット** + +```bash +git add NodeSrv/apps/healthcheck-survey-bot/README.md +git commit -m "docs: healthcheck_bot_state Data Table作成結果を記録" +``` + +--- + +### Task 9: HC-SUBワークフロー — プロセス実行+案内送信 + +WP・WAの両方から呼ばれる共通ロジック。「(任意)ProcessIdを実行→現在Statusの選択肢を組み立ててLINEWORKSへ送信→Data Table更新」を1本のExecute Workflow Triggerサブワークフローにまとめる。**Global Constraintsのn8nワークフロー構築ルール(Data Table最小限、Webhook+Codeでノード数抑制、HTTP Requestノードは可)に従い、Pleasanter API呼び出し群・選択肢組み立てを1つのCodeノードに集約する。** + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/workflows/hc-sub-run-process-and-notify.json` +- Modify: `NodeSrv/apps/healthcheck-survey-bot/README.md` + +**Interfaces:** +- Consumes: Task 3〜6のロジック(Codeノードへ書き写す)、Task 8のData Table ID、`workflow_config_values`(id `bNkadTyDgDepYx2p`、既存のorg-master-sync用n8n Data Table)に登録済みの設定値 +- Produces: n8n上のワークフロー(Execute Workflow Trigger、入力`{resultId: string, processId: string | null}`)。WP・WAはこのワークフローIDを`Execute Workflow`ノードで呼び出す + +**秘密情報・設定値の扱い方針(重要):** **PleasanterのApiKeyやLINEWORKS Client Secret等をCodeノードに直接ハードコードしない。** 代わりに`workflow_config_values`(Data Table、id `bNkadTyDgDepYx2p`)を**1回の`Data Table`Getノード(`filters`無し、`returnAll:true`)でまとめて取得**し、直後のCodeノードで`configKey`→`configValue`のMapに変換して使う(個別キーごとにGetノードを分けない)。 + +このワークフローで使うキー(今回追加登録済み、値は取得済みの全行から後続Codeノードでフィルタする): +`PLEASANTER_BASE_URL_PROD` / `PLEASANTER_API_KEY_PROD` / `LW_BOT_CLIENT_ID` / `LW_BOT_CLIENT_SECRET` / `LW_BOT_SERVICE_ACCOUNT` / `HEALTHCHECK_SITE_ID`(=508971)/ `LINEWORKS_BOT_MASTER_SITE_ID`(=484184)/ `HEALTHCHECK_DATA_TABLE_ID`(=`jqMDa2YZTI4f0iQ7`) + +**ノード構成(7ノード):** + +1. `Execute Workflow Trigger`(入力: `resultId`, `processId`) +2. `Data Table`ノード「設定値一括取得」(`operation: "get"`, `dataTableId: bNkadTyDgDepYx2p`, `filters`無し, `returnAll: true`) +3. `Code`「Pleasanter照会・選択肢組み立て」: 設定値のMap化、(任意)Process実行、レコード取得、サイト設定取得(getsite)、Botマスタ取得(484184)、対象者メール解決、選択肢組み立て(Task 4の`fillTemplate`とTask 5の`extractProcessesForStatus`をそのまま使う)までを1つのCodeノードにまとめる。Pleasanter APIへの各呼び出しは`await this.helpers.httpRequest({method:"POST", url, body, json:true})`で行う(n8n Codeノードの公式ヘルパー、外部HTTPリクエストを直接発行できる) +4. `Code`「JWTクレーム組み立て」: `iss`/`sub`に設定値Mapの`LW_BOT_CLIENT_ID`/`LW_BOT_SERVICE_ACCOUNT`を使ってJWTクレームJSON文字列を組み立てる。あわせて選択肢からLINEWORKS `button_template`のactions配列を組み立てる +5. `JWT`ノード(`operation: sign`, `algorithm: RS256`, Credential: 「LINEWORKS Bot Private Key (v4)」、id `Hw0qlEaGfLPnQWp1`) +6. `Code`「アクセストークン取得・LINEWORKS送信」: `POST https://auth.worksmobile.com/oauth2/v2.0/token`(form-urlencoded、`client_id`/`client_secret`は設定値Mapの`LW_BOT_CLIENT_ID`/`LW_BOT_CLIENT_SECRET`)でトークン取得後、続けて`POST https://www.worksapis.com/v1.0/bots/{BOT_ID}/users/{userId}/messages`(`button_template`形式)へ送信するところまでを1つのCodeノードで行う(いずれも`this.helpers.httpRequest`) +7. `Data Table`ノード「状態更新」: `healthcheck_bot_state`(id: `jqMDa2YZTI4f0iQ7`)へ`resultId`をキーに`insert`(`currentStatus`, `pendingProcesses`=JSON化したoptions, `awaitInput: "none"`)。`updatedAt`列は存在しない(Task 8参照)ため送信対象に含めない + +Step 3のCodeノードの中身(骨格。各`this.helpers.httpRequest`呼び出しの`body`はTask 4〜7で確認したAPI形式に沿って埋める): + +```javascript +// Codeノード「Pleasanter照会・選択肢組み立て」の中身 +function isUnsetSentinel(value) { + return typeof value === "string" && value.startsWith("1899"); +} +function fillTemplate(template, columns, valueHash) { + const labelToColumnName = new Map(); + for (const column of columns) { + if (column.LabelText) labelToColumnName.set(column.LabelText, column.ColumnName); + } + return template.replace(/\{([^{}]+)\}/g, (matched, label) => { + const columnName = labelToColumnName.get(label); + if (!columnName) return matched; + const value = valueHash[columnName]; + if (value === undefined || value === null || value === "" || isUnsetSentinel(value)) return "未設定"; + return String(value); + }); +} +function extractProcessesForStatus(processes, status) { + return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1); +} + +const configRows = $('設定値一括取得').all().map((item) => item.json); +const config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue])); + +const trigger = $('Execute Workflow Trigger').item.json; +const baseUrl = config.PLEASANTER_BASE_URL_PROD; +const apiKey = config.PLEASANTER_API_KEY_PROD; + +async function pleasanterPost(path, body) { + const res = await this.helpers.httpRequest({ + method: "POST", + url: `${baseUrl}${path}`, + body: { ApiVersion: 1.1, ApiKey: apiKey, ...body }, + json: true, + }); + return res; +} + +if (trigger.processId) { + await pleasanterPost.call(this, `api/items/${trigger.resultId}/update`, { ProcessId: trigger.processId }); +} + +const recordRes = await pleasanterPost.call(this, `api/items/${trigger.resultId}/get`, {}); +const record = recordRes.Response.Data; + +const siteRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/getsite`, {}); +const siteSettings = siteRes.Response.Data.SiteSettings; +const columns = siteSettings.Columns || []; +const processes = siteSettings.Processes || []; + +const botMasterRes = await pleasanterPost.call(this, `api/items/${config.LINEWORKS_BOT_MASTER_SITE_ID}/getsite`, {}); +// TODO(Task 12): 484184の実データ構造を見てBotIdの解決方法を確定させる + +const valueHash = { + ...record.ClassHash, ...record.NumHash, ...record.DateHash, ...record.DescriptionHash, +}; + +const candidates = extractProcessesForStatus(processes, record.Status); +const options = candidates.map((p) => ({ + processId: p.Id, + label: p.DisplayName || p.Name, + tooltip: fillTemplate(p.ToolTip || "", columns, valueHash), +})); + +const userRes = await pleasanterPost.call(this, `api/users/get`, { + View: { ApiGetMailAddresses: true }, + Where: { UserId: record.ClassHash.ClassC }, +}); + +return [{ + json: { + resultId: record.ResultId, + currentStatus: record.Status, + options, + targetEmail: userRes.Response.Data[0]?.MailAddress, + config, + }, +}]; +``` + +(`ToolTip`のキー名はTask 2の実機調査結果で確定させ、異なる場合はここだけ修正する。上記は骨格であり、実装時にPleasanter API実レスポンスの構造に合わせて調整する) + +- [ ] **Step 1: ワークフローJSON雛形を作成** + +`workflows/hc-sub-run-process-and-notify.json`に、上記7ノードの`nodes`配列と`connections`を、Task 7の`n8n-api.js`が期待する`{name, nodes, connections, settings}`形式で書く(`n8n-nodes-base.code`, `n8n-nodes-base.jwt`, `n8n-nodes-base.executeWorkflowTrigger`, `n8n-nodes-base.dataTable`の各ノードタイプを使う)。**秘密情報はCodeノードに直接値として書かず、必ず「設定値一括取得」ノードから得たMapを経由して参照する。** + +- [ ] **Step 2: デプロイ** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\NodeSrv\apps\healthcheck-survey-bot" +N8N_API_KEY="" node scripts/deploy-workflow.js workflows/hc-sub-run-process-and-notify.json +``` + +Expected: HTTP status 200、ワークフローIDが出力される + +- [ ] **Step 3: README.mdへワークフローIDを記録** + +```markdown +- ワークフロー `HC-SUB: プロセス実行と案内送信`: `` +``` + +- [ ] **Step 4: ユーザーへ確認のうえ、テスト実行** + +Task 2で作成・削除したテストProcessとは別に、動作確認用の捨てレコード・テストProcess(`CurrentStatus`をnullまたは既存Statusのどれかにして`ChangedStatus`は同じ値、`processId`無しでの疎通確認から始める)を使い、n8n UIの「Test workflow」または`Execute Workflow`ノード経由で1回実行し、LINEWORKSへメッセージが届くこと・`healthcheck_bot_state`に行が作られることを確認する。**実行前に必ずユーザーへ確認する。** + +- [ ] **Step 5: コミット** + +```bash +git add workflows/hc-sub-run-process-and-notify.json NodeSrv/apps/healthcheck-survey-bot/README.md +git commit -m "feat: HC-SUBワークフロー(プロセス実行+案内送信)を追加" +``` + +--- + +### Task 10: HC-WPワークフロー — Statusプッシュ通知(担当者操作起点) + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/workflows/hc-wp-status-push.json` +- Modify: `NodeSrv/apps/healthcheck-survey-bot/README.md` + +**Interfaces:** +- Consumes: Task 9のワークフローID(`Execute Workflow`ノードで参照) +- Produces: Webhook `POST https://n8n32.next-hd.net/webhook/healthcheck-status-push`(`X-Api-Key`ヘッダー認証、body `{resultId, processId}`) + +**秘密情報の扱い**: Webhook認証キーはCodeノードに直接書かず、`workflow_config_values`(`bNkadTyDgDepYx2p`)から`Data Table`ノードで取得して比較する(Task 9と同じ方針。1回の`returnAll:true`取得+後続Codeノードでキーを引く)。 + +**ノード構成:** + +1. `Webhook`(`httpMethod: POST`, `path: healthcheck-status-push`) +2. `Data Table`ノード「設定値一括取得」(`operation: "get"`, `dataTableId: bNkadTyDgDepYx2p`, `filters`無し, `returnAll: true`) +3. `Code`「検証」: `X-Api-Key`ヘッダーとbodyの`resultId`/`processId`必須チェック(不正なら`throw new Error(...)`でワークフローを失敗させる) +4. `Execute Workflow`(Task 9のワークフローIDを指定、入力: `resultId`, `processId`) +5. `Respond to Webhook`(`{"result":"ok"}`を返す) + +- [ ] **Step 1: ワークフローJSONを作成** + +`workflows/hc-wp-status-push.json`を作成。Codeノード「検証」の中身: + +```javascript +const configRows = $('設定値一括取得').all().map((item) => item.json); +const expectedApiKey = configRows.find((row) => row.configKey === "HEALTHCHECK_WP_API_KEY")?.configValue; +const headers = $input.first().json.headers || {}; +if (headers["x-api-key"] !== expectedApiKey) { + throw new Error("Unauthorized: invalid API key"); +} +const body = $input.first().json.body || {}; +if (!body.resultId || !body.processId) { + throw new Error("Bad Request: resultId, processId は必須です"); +} +return [{ json: { resultId: body.resultId, processId: body.processId } }]; +``` + +- [ ] **Step 2: デプロイ** + +```bash +N8N_API_KEY="" node scripts/deploy-workflow.js workflows/hc-wp-status-push.json +``` + +- [ ] **Step 3: Webhook有効化** + +```bash +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('POST', '/workflows//activate').then(r => console.log(r.status, JSON.stringify(r.body))); +" +``` + +- [ ] **Step 4: README.mdへ記録** + +```markdown +- ワークフロー `HC-WP: Statusプッシュ通知`: ``(Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-status-push`) +``` + +- [ ] **Step 5: ユーザー確認のうえ疎通テスト** + +```bash +curl -X POST "https://n8n32.next-hd.net/webhook/healthcheck-status-push" \ + -H "X-Api-Key: " \ + -H "Content-Type: application/json" \ + -d '{"resultId": <テスト用ResultId>, "processId": null}' +``` + +Expected: `{"result":"ok"}`、かつLINEWORKSへ現在Statusの案内が届く(**実行前に必ずユーザーへ確認する**) + +- [ ] **Step 6: コミット** + +```bash +git add workflows/hc-wp-status-push.json NodeSrv/apps/healthcheck-survey-bot/README.md +git commit -m "feat: HC-WPワークフロー(Statusプッシュ通知)を追加" +``` + +--- + +### Task 11: HC-WAワークフロー — LINEWORKS応答受信(唯一の受信口) + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/workflows/hc-wa-lineworks-response.json` +- Modify: `NodeSrv/apps/healthcheck-survey-bot/README.md` + +**Interfaces:** +- Consumes: Task 6の署名検証ロジック、Task 3の日付パース、Task 5の`matchProcessByLabel`/`classifyAwaitInput`、Task 9のワークフローID +- Produces: Webhook `POST https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`(LINE WORKS本体からの直接コールバック、`x-works-signature`検証) + +**Global Constraintsのn8nワークフロー構築ルールに従い、分岐ロジック・Pleasanter/LINEWORKS API呼び出しは可能な限りCodeノードに集約する。** Data Tableの読み書き(設定値取得・待機状態の取得/更新)のみ専用ノードを使う。JWT署名も専用ノードのまま。 + +**ノード構成(目安11ノード):** + +1. `Webhook`(`httpMethod: POST`, `path: healthcheck-lineworks-response`, `options.rawBody: true`) +2. `Data Table`ノード「設定値一括取得」(`operation: "get"`, `dataTableId: bNkadTyDgDepYx2p`, `filters`無し, `returnAll: true`) +3. `Code`「署名検証・対象レコード特定」: Task 6の`verifySignature`で検証(失敗なら`throw`)。成功したら`this.helpers.httpRequest`で対象者メール→PleasanterUserId解決、508971の対象レコード検索(`ColumnFilterHash`)までをこの1ノードで行う。0件/複数件は`throw`(6章の異常系方針、自動判定しない) + +```javascript +// Codeノード「署名検証・対象レコード特定」の中身 +const crypto = require("crypto"); +function normalizeSignature(value) { + return String(value || "").trim().replace(/^sha256=/i, ""); +} +function safeEqual(a, b) { + const ab = Buffer.from(String(a), "utf8"); + const bb = Buffer.from(String(b), "utf8"); + if (ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} +function verifySignature(rawBody, headerSignature, botSecret) { + const headerSig = normalizeSignature(headerSignature); + if (!headerSig || !botSecret) return false; + const payload = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), "utf8"); + const expected = crypto.createHmac("sha256", botSecret).update(payload).digest("base64"); + return safeEqual(headerSig, expected); +} + +const configRows = $('設定値一括取得').all().map((item) => item.json); +const config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue])); + +const item = $('Webhook').first(); // この直前ノードは「設定値一括取得」(Data Table)のため、裸の$input.first()だとconfig行を拾ってしまいheaders/bodyが無い +const headers = item.json.headers || {}; +const rawBody = item.binary && item.binary.data + ? Buffer.from(item.binary.data.data, "base64") + : Buffer.from(JSON.stringify(item.json.body || {}), "utf8"); + +if (!verifySignature(rawBody, headers["x-works-signature"], config.LINEWORKS_BOT_SECRET)) { + throw new Error("Unauthorized: signature mismatch"); +} + +const body = item.json.body || {}; +const source = body.source || {}; +const content = body.content || {}; +const targetEmail = source.userId; + +async function pleasanterPost(path, reqBody) { + return this.helpers.httpRequest({ + method: "POST", + url: `${config.PLEASANTER_BASE_URL_PROD}${path}`, + body: { ApiVersion: 1.1, ApiKey: config.PLEASANTER_API_KEY_PROD, ...reqBody }, + json: true, + }); +} + +const userRes = await pleasanterPost.call(this, "api/users/get", { + View: { ApiGetMailAddresses: true }, + Where: { MailAddress: targetEmail }, +}); +const userId = userRes.Response.Data[0]?.UserId; +if (!userId) throw new Error(`対象者が見つかりません: ${targetEmail}`); + +const recordRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/get`, { + View: { + ColumnFilterHash: { ClassC: String(userId) }, + ColumnFilterSearchTypes: { ClassC: "ExactMatch" }, + }, +}); +const candidates = (recordRes.Response.Data || []).filter((r) => r.Status !== 900 && r.Status !== 910); +if (candidates.length !== 1) { + throw new Error(`対象レコードを一意に特定できません: ${candidates.length}件`); +} +const record = candidates[0]; + +return [{ + json: { + config, + resultId: record.ResultId, + currentStatus: record.Status, + contentType: content.type, + text: content.type === "text" ? content.text : null, + fileId: content.type === "file" ? content.fileId : null, + }, +}]; +``` + +4. `Data Table`ノード「待機状態取得」(`operation: get`, `filters.conditions: [{keyName:"resultId", keyValue:"={{ $json.resultId }}"}]`): `healthcheck_bot_state`から該当resultIdの行を取得 +5. `Code`「分岐処理・アクション決定」: `awaitInput`(`none`/`date`/`file`、または待機状態自体が無い場合)に応じた全ロジックをここに集約する。以下を1つのCodeノードで行う: + - 待機状態が無い、または`awaitInput`が想定外の値 → `action: "error"` + - `awaitInput: "none"`(選択肢待ち): `pendingProcesses`(JSON文字列)をパースし、Task 5の`matchProcessByLabel`相当で`text`と一致するか判定 + - 一致・追加入力対象列なし → `action: "execute"`, `processId` + - 一致・追加入力対象列が`Date*`/`Attachments*` → `action: "send"`(追加入力を促すメッセージ)、次の待機状態(`awaitInput: "date"|"file"`, `awaitProcessId`, `awaitColumn`) + - 不一致 → `action: "execute"`, `processId: null`(フォールバック再送、HC-SUBが現在Statusの案内を再送する) + - `awaitInput: "date"`: Task 3の`parseDateInput`を実行 + - 成功 → `this.helpers.httpRequest`で該当列(`DateHash`)をupdate → `action: "execute"`, `processId: awaitProcessId`, 待機状態を`none`に戻す + - 失敗 → `action: "send"`(エラーメッセージ)、待機状態は維持 + - `awaitInput: "file"`: `contentType`が`file`でなければ`action: "send"`(再送要求)、待機状態維持。`file`なら`this.helpers.httpRequest`でLINEWORKSファイルダウンロード→Pleasanter添付アップロード(具体的なエンドポイントは実装時にPleasanter公式マニュアル`api-attachment`系を確認する)→`action: "execute"`, `processId: awaitProcessId`、待機状態を`none`に戻す + - 出力に必ず「次に`healthcheck_bot_state`へ書き込むべき状態」(`nextAwaitInput`, `nextAwaitProcessId`, `nextAwaitColumn`)を含める(ノード6で無条件更新するため) +6. `Data Table`ノード「状態更新」(`operation: update`、`resultId`をキーに`nextAwaitInput`等を書き込む。無条件で1回呼ぶ) +7. `IF`「`action == "execute"`」 + - true分岐 → `Execute Workflow`(Task 9、`processId`)→ `Respond to Webhook`(成功) + - false分岐 → 次へ +8. `IF`「`action == "send"`」 + - true分岐 → `Code`「JWTクレーム組み立て」(設定値Mapの`LW_BOT_CLIENT_ID`/`LW_BOT_SERVICE_ACCOUNT`を使用)→ `JWT`ノード(Credential: 「LINEWORKS Bot Private Key (v4)」、id `Hw0qlEaGfLPnQWp1`)→ `Code`「アクセストークン取得・LINEWORKS送信」(Task 9のノード6と同じ`this.helpers.httpRequest`パターン)→ `Respond to Webhook`(成功) + - false分岐 → `Respond to Webhook`(`action == "error"`のケース。エラー内容を返す) + +- [ ] **Step 1: ワークフローJSONを作成** + +上記ノード構成に従い`workflows/hc-wa-lineworks-response.json`を作成する。ノード5の`Code`ノードにはTask 3・5のロジックをそのまま貼り付ける。 + +- [ ] **Step 2: デプロイ** + +```bash +N8N_API_KEY="" node scripts/deploy-workflow.js workflows/hc-wa-lineworks-response.json +``` + +- [ ] **Step 3: Webhook有効化** + +```bash +N8N_API_KEY="" node -e " +require('./scripts/n8n-api').request('POST', '/workflows//activate').then(r => console.log(r.status)); +" +``` + +- [ ] **Step 4: LINEWORKS Developer Console側にBot Callback URLを設定** + +対象Bot(484184で管理しているBotのうち、健康診断管理で使うもの)のCallback URLを`https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`に設定する(**ユーザー確認必須**。他プロジェクトと同じBotを共用している場合、Callback URL変更が既存フローに影響しないか要確認)。 + +- [ ] **Step 5: README.mdへ記録** + +```markdown +- ワークフロー `HC-WA: LINEWORKS応答受信`: ``(Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`) +``` + +- [ ] **Step 6: コミット** + +```bash +git add workflows/hc-wa-lineworks-response.json NodeSrv/apps/healthcheck-survey-bot/README.md +git commit -m "feat: HC-WAワークフロー(LINEWORKS応答受信)を追加" +``` + +--- + +### Task 12: 508971側クライアントスクリプト追加+エンドツーエンド確認 + +**Files:** +- Modify: `Pleasanter/健康診断管理/configs/production/site-508971_健康診断管理/scripts/`配下に新規スクリプトファイルを追加 +- Modify: 508971にテスト用Processを1つ追加(`pleasanter-site-spec`スキルの標準フロー: `get-site-config.js`→編集→`build-desired-config.js`→`apply-site-config.js`ドライラン確認→ユーザー確認後にcurl実行) + +**Interfaces:** +- Consumes: Task 10のWebhook URL + +- [ ] **Step 1: クライアントスクリプトを作成** + +`Pleasanter/健康診断管理/configs/production/site-508971_健康診断管理/scripts/1_プロセス通知送信.js`を新規作成: + +```javascript +const WEBHOOK_URL = "https://n8n32.next-hd.net/webhook/healthcheck-status-push"; +// n8n Data Table workflow_config_values の HEALTHCHECK_WP_API_KEY と同じ値を使う +const API_KEY = ""; + +$p.events.on_process = function (processId) { + sendStatusPush(processId); +}; + +async function sendStatusPush(processId) { + const resultId = $p.id(); + try { + const res = await fetch(WEBHOOK_URL, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Api-Key": API_KEY }, + body: JSON.stringify({ resultId, processId }), + }); + if (!res.ok) { + console.error("Status push failed:", res.status, await res.text()); + } + } catch (error) { + console.error("Status push error:", error.message); + } +} +``` + +- [ ] **Step 2: 標準フローで508971へ反映** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\Pleasanter" +node .claude/js/build-desired-config.js --project="健康診断管理" --env=production site-508971 +node .claude/js/apply-site-config.js --project="健康診断管理" --env=production +``` + +Expected: 差分・送信予定Body・curlコマンドが表示される(常にドライラン)。内容をユーザーに提示し、確認を得てから表示されたcurlコマンドを実行する + +- [ ] **Step 3: テスト用Processを追加** + +ユーザー確認のうえ、508971に検証用Process(例: `CurrentStatus`を既存の`900`完了のまま、`ChangedStatus`も`900`、`OnClick`不要、実行種別「追加したボタン」)を1つ追加する。Task 1のクライアントスクリプトと連携させ、押下時に`on_process`イベント経由でTask 10のWebhookが呼ばれることを確認する。 + +- [ ] **Step 4: エンドツーエンド確認** + +1. 508971の捨てレコードでテストProcessボタンを押す → LINEWORKSに案内が届くことを確認 +2. LINEWORKSで選択肢に回答する → `healthcheck_bot_state`の該当行が更新され、Processが実行されて捨てレコードのStatusが変わることを確認(`get-site-config.js`相当で該当レコードを`api/items/{id}/get`し直して確認) +3. n8n Execution History(`GET /executions?workflowId=&limit=5`)でエラーが出ていないことを確認 + +**すべて実データ・実LINEWORKS送信を伴うため、着手前に必ずユーザーへ確認する。** + +- [ ] **Step 5: テスト用Processを削除** + +確認完了後、ユーザーに508971からテスト用Processを削除してもらう(本番のProcess一覧を汚さないため)。 + +- [ ] **Step 6: コミット** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev" +git add "Pleasanter/健康診断管理/configs/production/site-508971_健康診断管理/scripts/1_プロセス通知送信.js" +git commit -m "feat: 508971にStatusプッシュ通知用クライアントスクリプトを追加" +``` + +--- + +### Task 13: employeeMasterCheck.js — 社員マスタ(504412)によるメール整合性チェック・フリガナ補完ロジック + +設計書8章(Bot対話フローとは独立した補助機能)に対応。**実行タイミング(保存時/定期バッチ/手動)はまだ決まっていない**ため、本タスクは判定・補完の純粋ロジックとテストのみを作る。504412へのAPI呼び出しをどこから叩くか(クライアントスクリプト/n8n Schedule Trigger/Process)は、タイミングが決まった時点で別タスクとして追加する。 + +**Files:** +- Create: `NodeSrv/apps/healthcheck-survey-bot/src/lib/employeeMasterCheck.js` +- Test: `NodeSrv/apps/healthcheck-survey-bot/test/employeeMasterCheck.test.js` + +**Interfaces:** +- Consumes: なし(Task 3〜6とは独立) +- Produces: + - `checkEmailConsistency(pleasanterEmail: string, masterRecord: {Class036?: string, ClassB?: string}): { consistent: boolean, masterEmail: string | null }` + - `resolveKanaFromMaster(masterRecord: {Class003?: string, Class004?: string}): string` + - `needsKanaFill(currentKana: string | null | undefined): boolean` + +`masterRecord`は504412の`api/items/{id}/get`レスポンスの`ClassHash`(`Class011`=ユーザID、`Class036`=PLメールアドレス、`ClassB`=メールアドレス、`Class003`=姓(カナ)、`Class004`=名(カナ)を含む)を想定する。整合性チェックは`Class036`(PLメールアドレス)を優先し、無ければ`ClassB`にフォールバックする。 + +- [ ] **Step 1: 失敗するテストを書く** + +```javascript +// test/employeeMasterCheck.test.js +const { test } = require("node:test"); +const assert = require("node:assert"); +const { + checkEmailConsistency, + resolveKanaFromMaster, + needsKanaFill, +} = require("../src/lib/employeeMasterCheck"); + +test("checkEmailConsistency: PLメールアドレス(Class036)と一致すればconsistent:true", () => { + const result = checkEmailConsistency("taro.yamada@next-hd.co.jp", { + Class036: "taro.yamada@next-hd.co.jp", + ClassB: "taro.yamada@example.com", + }); + assert.deepStrictEqual(result, { consistent: true, masterEmail: "taro.yamada@next-hd.co.jp" }); +}); + +test("checkEmailConsistency: Class036が無ければClassBにフォールバックする", () => { + const result = checkEmailConsistency("taro.yamada@example.com", { + ClassB: "taro.yamada@example.com", + }); + assert.deepStrictEqual(result, { consistent: true, masterEmail: "taro.yamada@example.com" }); +}); + +test("checkEmailConsistency: 不一致ならconsistent:false", () => { + const result = checkEmailConsistency("taro.yamada@next-hd.co.jp", { + Class036: "different@next-hd.co.jp", + }); + assert.deepStrictEqual(result, { consistent: false, masterEmail: "different@next-hd.co.jp" }); +}); + +test("checkEmailConsistency: マスタ側にメールが無ければmasterEmail:null・consistent:false", () => { + const result = checkEmailConsistency("taro.yamada@next-hd.co.jp", {}); + assert.deepStrictEqual(result, { consistent: false, masterEmail: null }); +}); + +test("resolveKanaFromMaster: 姓カナ+名カナを空白区切りで結合する", () => { + assert.strictEqual( + resolveKanaFromMaster({ Class003: "ヤマダ", Class004: "タロウ" }), + "ヤマダ タロウ" + ); +}); + +test("resolveKanaFromMaster: 片方欠けていても結合できる", () => { + assert.strictEqual(resolveKanaFromMaster({ Class003: "ヤマダ" }), "ヤマダ"); +}); + +test("needsKanaFill: 空文字・未定義はtrue", () => { + assert.strictEqual(needsKanaFill(""), true); + assert.strictEqual(needsKanaFill(undefined), true); + assert.strictEqual(needsKanaFill(null), true); +}); + +test("needsKanaFill: 値が入っていればfalse", () => { + assert.strictEqual(needsKanaFill("ヤマダ タロウ"), false); +}); +``` + +- [ ] **Step 2: テストが失敗することを確認** + +```bash +cd "C:\Users\k.nogi\#GitHub\ken_nogi\dev\NodeSrv\apps\healthcheck-survey-bot" +node --test test/employeeMasterCheck.test.js +``` + +Expected: `Cannot find module '../src/lib/employeeMasterCheck'`で失敗 + +- [ ] **Step 3: 実装を書く** + +```javascript +// src/lib/employeeMasterCheck.js +function checkEmailConsistency(pleasanterEmail, masterRecord) { + const masterEmail = masterRecord.Class036 || masterRecord.ClassB || null; + if (!masterEmail) { + return { consistent: false, masterEmail: null }; + } + return { consistent: masterEmail === pleasanterEmail, masterEmail }; +} + +function resolveKanaFromMaster(masterRecord) { + return [masterRecord.Class003, masterRecord.Class004].filter(Boolean).join(" "); +} + +function needsKanaFill(currentKana) { + return currentKana === null || currentKana === undefined || currentKana === ""; +} + +module.exports = { checkEmailConsistency, resolveKanaFromMaster, needsKanaFill }; +``` + +- [ ] **Step 4: テストが通ることを確認** + +```bash +node --test test/employeeMasterCheck.test.js +``` + +Expected: 8 tests、全てPASS + +- [ ] **Step 5: 全テストを通しで実行** + +```bash +node --test test/*.test.js +``` + +Expected: 5ファイル・32テスト、全てPASS + +- [ ] **Step 6: コミット** + +```bash +git add src/lib/employeeMasterCheck.js test/employeeMasterCheck.test.js +git commit -m "feat: 社員マスタ(504412)照合ロジック(メール整合性チェック・フリガナ補完)を追加" +``` + +**次に必要な作業(本計画のスコープ外):** 実行タイミングが決まったら、(a) 504412から`Class011`=対象PleasanterUserIdでレコードを取得する呼び出し元(クライアントスクリプト/n8nワークフロー/Process)、(b) 不一致・補完が見つかった場合の通知・自動反映方法、を別タスクとして設計する。 + +--- + +## Self-Review + +**Spec coverage:** +- 全体アーキテクチャ(WP/WA + Process流用)→ Task 9〜11でカバー +- 社員マスタ(504412)連携(設計書8章)→ Task 13でロジックのみカバー。呼び出しトリガーは実行タイミング未確定のため意図的にスコープ外 +- Data Table「bot_conversation_state」→ Task 8(実装上は`healthcheck_bot_state`という名前にしたが、設計書の構造要件は満たす。理由: 既存のorg-master-sync用テーブルと並んだ一覧で識別しやすくするため) +- Process定義(ツールチップ・入力検証タブ)→ Task 2(実機調査)・Task 5(ロジック)・Task 9(利用) +- 対象者解決(メール=LINEWORKS userId)→ Task 9・11のPleasanter `api/users/get`呼び出し +- 日付・ファイル入力の追加往復 → Task 11の`date`/`file`分岐 +- タイムアウト監視は「不要」という設計判断 → 本計画にタイムアウト監視ワークフローは含めていない(意図通り) +- エラーハンドリング(複数レコードヒット等)→ Task 11 Step3の異常系(`throw`で失敗させる) + +**Placeholder scan:** 「実装時に確認する」という記述がTask 8(projectId要否)・Task 11(Pleasanter添付アップロードの正確なエンドポイント)に残っている。これはn8n-guide.mdが明記していない未検証事項であり、調査ステップ自体をタスク内の手順として書いてあるため、内容のないプレースホルダーではなく「次に確認すべきこと」を伴う具体的な調査タスクとして扱う。 + +**Type consistency:** `extractProcessesForStatus`/`matchProcessByLabel`/`classifyAwaitInput`の関数名・戻り値の形は Task 5→Task 9→Task 11で一貫させた。`fillTemplate`も同様。 + +--- + +Plan complete and saved to `NodeSrv/docs/superpowers/plans/2026-09-05-healthcheck-lineworks-survey-n8n.md`. 実行方式を選んでほしい。 + +1. **Subagent-Driven(推奨)** — タスクごとに新しいsubagentを立てて実装、タスク間でレビューを挟む +2. **Inline Execution** — このセッション内で`executing-plans`を使い、チェックポイントを挟みながらまとめて実行 diff --git a/Pleasanter/健康診断管理/docs/n8n/workflows-status.md b/Pleasanter/健康診断管理/docs/n8n/workflows-status.md new file mode 100644 index 00000000..d2515770 --- /dev/null +++ b/Pleasanter/健康診断管理/docs/n8n/workflows-status.md @@ -0,0 +1,101 @@ +# 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` + +## 実機調査メモ(2026-09-05確認) + +508971への新規テストProcess追加なしで、既存本番プロジェクト「実行予算WF申請」(SiteId 376872)の +取得済み`processes.json`(`Pleasanter/実行予算WF申請/configs/production/site-376872_実行予算WF申請/processes.json`) +に入力検証タブを使ったProcessの実例があり、そこからJSON構造を確認できた。 + +Processの入力検証タブは`SiteSettings.Processes[].ValidateInputs`配列として保存される。 +各要素は`{"Id": number, "ColumnName": string, "Required": boolean}`(実例: `{"Id": 1, "ColumnName": "Class021", "Required": true}`)。 +配列名は`Validations`ではなく`ValidateInputs`。値を設定していない項目(クライアント/サーバ正規表現、エラーメッセージ、最小/最大等)は +キー自体が省略される可能性が高い(今回の実例では未設定のため確認できていない)。 + +Task 5(processFlow.js)の`getValidationColumnNames`はこの構造(`process.ValidateInputs[].ColumnName`)を前提に実装する。 + +## n8nリソースID一覧 + +(Task 8〜11完了後、ここに作成したData Table ID・ワークフローIDを記録する) + +- Data Table `healthcheck_bot_state`: `jqMDa2YZTI4f0iQ7` + - projectId: `LOcxF69Gm4PvnkqA`(`org-master-sync`用プロジェクトを流用) + - 列: `resultId`, `targetEmail`, `currentStatus`, `pendingProcesses`, `awaitInput`, `awaitProcessId`, `awaitColumn`(すべてstring型) + - 注記: 設計上の8列目`updatedAt`はn8n Data Tableのシステム予約列名(`POST /data-tables`が`400 Column name "updatedAt" is reserved as a system column name.`を返す)のため、列定義には含めていない。各行の更新日時はn8nが自動管理する`updatedAt`メタデータで代替する。 + +- ワークフロー `HC-SUB: プロセス実行と案内送信`: `XRqcykbG2LuAjGG2` + - ファイル: `workflows/hc-sub-run-process-and-notify.json`(Execute Workflow Trigger、入力`{resultId, processId}`) + - **プロジェクト全体のn8nワークフロー構築ルール(2026-09-05改訂)に合わせて7ノード構成へ全面リビルド。旧19ノード版(個別キーごとのData Table Getノード×7、HTTP Requestノード×6を並べた構成)を置き換え、同じワークフローID`XRqcykbG2LuAjGG2`へPUTで上書き済み(2026-09-05、コントローラー実施、HTTP 200)。** + - active化済み(Execute Workflow Triggerサブワークフローは呼び出し元から実行可能にするためactive必須、`asKvC5LSVg3q8tuU`と同じ運用) + - ノード構成(7ノード、リニア接続): + 1. `Execute Workflow Trigger`(入力: `resultId`, `processId`) + 2. `Data Table`「設定値一括取得」: `workflow_config_values`(`bNkadTyDgDepYx2p`)を`filters`無し・`returnAll:true`で一括取得(個別キーごとのGetノードを廃止) + 3. `Code`「Pleasanter照会・選択肢組み立て」: 設定値のMap化、(任意)Process実行、レコード取得、サイト設定取得(getsite)、Botマスタ取得(484184)、対象者メール解決、選択肢組み立てまでを1ノードに集約。Pleasanter API呼び出しはすべて`this.helpers.httpRequest`(n8n Codeノード公式ヘルパー)で発行 + 4. `Code`「JWTクレーム組み立て」: JWTクレームJSON文字列の組み立てとLINEWORKS `button_template`のactions配列組み立てを1ノードに集約 + 5. `JWT`ノード「Sign JWT」(`operation: sign`, `algorithm: RS256`, Credential: 「LINEWORKS Bot Private Key (v4)」id `Hw0qlEaGfLPnQWp1`) + 6. `Code`「アクセストークン取得・LINEWORKS送信」: LINEWORKSアクセストークン取得とメッセージ送信を1ノードに集約(`this.helpers.httpRequest`) + 7. `Data Table`「状態更新」: `healthcheck_bot_state`(`jqMDa2YZTI4f0iQ7`)へ`insert` + - 秘密情報・SiteId等はすべて`workflow_config_values`(`bNkadTyDgDepYx2p`)から「設定値一括取得」1ノードで取得し、直後のCodeノードで`configKey`→`configValue`のMapに変換して参照。ワークフローJSONに秘密値のハードコードなし + - Execute Workflow Triggerの入力(`resultId`/`processId`)は、後続ノードの直接の入力でない箇所では必ず`$('Execute Workflow Trigger').item.json...`のようにノード名を明示して参照している(旧版にあった「Data Tableノード通過後に裸の`$json`を参照してしまうバグ」の再発防止) + - 未検証・要判断の暫定実装あり(Task 12以降で要確認): + ①484184(`LINEWORKS_BOT_MASTER_SITE_ID`)からのBotId解決ロジック(実データ構造未調査のため`SiteSettings.BotIds`または`Processes[].BotId`を仮定し、無ければ例外を投げる。「JWTクレーム組み立て」ノードに実装) + ②`/api/users/get`のレスポンス形状(配列/単一オブジェクト両対応の防御的実装、「Pleasanter照会・選択肢組み立て」ノードに実装) + ③LINEWORKSトークン取得は`URLSearchParams`インスタンスをそのまま`body`に渡す形に修正済み(`.toString()`化や`json:true`併用はn8nのform-urlencoded自動処理と衝突する懸念があったため回避)。レスポンスは文字列/オブジェクトいずれでも`access_token`を読めるよう防御的にパースしている。ただしn8n本体での実機動作は本タスクでは未検証 + ④`Data Table`Insertノードの`columns.mappingMode`/`value`/`schema`の形状(旧19ノード版から流用。旧版はデプロイ実績があるためスキーマ自体は疑わしくないが、実書き込みでの動作は本タスクでは未再検証) + - テスト実行(Step 4相当)は本タスクでは未実施。次回、捨てレコードでの疎通確認とユーザー確認が必要 + +- ワークフロー `HC-WP: Statusプッシュ通知`: `swXfpoDtwZDT3Hwk` + - ファイル: `workflows/hc-wp-status-push.json` + - Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-status-push`(`X-Api-Key`ヘッダー認証、body `{resultId, processId}`) + - active化済み + - `X-Api-Key`は`workflow_config_values`の`HEALTHCHECK_WP_API_KEY`と照合。秘密値のハードコードなし + - 実際のcurl疎通テスト(本番508971書き込み・LINEWORKS実送信を伴う)は未実施。ユーザー確認後に実施する + - デプロイ・活性化はサブエージェントのBashサンドボックスが実APIキー使用を一律ブロックしたためコントローラーが直接実行した(詳細: `task-10-report.md`)。設定値取得もfilter付き単一キーGetから一括取得+Codeフィルタへ変更済み + +- ワークフロー `HC-WA: LINEWORKS応答受信`: `0i0Ze3Gq0Waof3Fa` + - ファイル: `workflows/hc-wa-lineworks-response.json` + - Webhook: `https://n8n32.next-hd.net/webhook/healthcheck-lineworks-response`(LINE WORKS本体からの直接コールバック、`x-works-signature`検証) + - active化済み(2026-09-05、コントローラーが新規デプロイ・activate、HTTP 200) + - **プロジェクト全体のn8nワークフロー構築ルール(2026-09-05改訂)に合わせて14ノード構成で全面リビルド。旧60ノード版(個別キーごとのData Table/HTTP Requestノードを積み上げ、awaitInput分岐をSwitch+各分岐に案内送信一式を重複配置した構成)を置き換えた。**IF・JWT・Execute Workflow・Data Tableは各ルールに従い専用ノードのままだが、それらは機械的に必要な最小限(IF×2, JWT×1, Execute Workflow×1, Data Table×3)であり、本来Codeノードに集約可能な分岐ロジック・Pleasanter/LINEWORKS API呼び出しはすべて4つのCodeノードに集約した(ブリーフの目安「11ノード」に対し実際は14ノードだが、これはIF/JWT/Execute Workflowを専用ノードに保つというルール自体が要求する固定オーバーヘッドであり、60ノードからの削減という趣旨は達成している) + - LINEWORKS Developer Console側のBot Callback URL設定・疎通テストは未実施。ユーザー確認後に実施する + - ノード構成(14ノード): + 1. `Webhook`(`healthcheck-lineworks-response`, `rawBody:true`) + 2. `Data Table`「設定値一括取得」: `workflow_config_values`を`filters`無し・`returnAll:true`で一括取得 + 3. `Code`「署名検証・対象レコード特定」: HMAC-SHA256署名検証、対象者メール→PleasanterUserId解決、508971の対象レコード検索(`ColumnFilterHash`)までを1ノードに集約。0件/複数件はthrow(自動判定しない) + 4. `Data Table`「待機状態取得」: `healthcheck_bot_state`から該当resultIdの行を取得 + 5. `Code`「分岐処理・アクション決定」: `awaitInput`(none/date/file、または待機状態自体が無い場合)に応じた分岐ロジック全体を1ノードに集約。`action`(execute/send/error)と次の待機状態(`nextAwaitInput`/`nextAwaitProcessId`/`nextAwaitColumn`)を1つのJSONオブジェクトとして出力する + 6. `Data Table`「状態更新」: 5の出力を使い`healthcheck_bot_state`を無条件で`update` + 7. `IF`「action==execute」: true→8、false→9 + 8. `Execute Workflow`「HC-SUB実行」(Task 9, `XRqcykbG2LuAjGG2`)→13 + 9. `IF`「action==send」: true→10、false→14 + 10. `Code`「JWTクレーム組み立て」: 484184からのBotId解決とJWTクレーム・LINEWORKSメッセージ内容の組み立てを1ノードに集約 + 11. `JWT`「Sign JWT」(`operation:sign`, `RS256`, Credential: 「LINEWORKS Bot Private Key (v4)」`Hw0qlEaGfLPnQWp1`) + 12. `Code`「アクセストークン取得・LINEWORKS送信」: LINEWORKSアクセストークン取得とメッセージ送信を1ノードに集約→13 + 13. `Respond to Webhook(成功)`: 8と12の両方から接続される共有の成功応答ノード + 14. `Respond to Webhook(異常系)`: 9のfalse分岐(action=="error")専用 + - **分岐ノードの出力の流れ方(設計上の重要ポイント)**: ノード5は「今回どうするか(action/processId/messageText)」と「次に書き込む待機状態(next*)」を1つの出力オブジェクトにまとめて返す。ノード6(状態更新)はノード5の直接の後続ノードなのでそのまま`$json.next*`を参照して無条件更新するが、ノード6通過後の`$json`はData Table Updateの返り値(更新後の行データ)に置き換わり、ノード5の`action`/`processId`/`messageText`等は失われる。そのため、ノード7以降(IF・Execute Workflow・JWTクレーム組み立て・Respond to Webhook(異常系))はすべて裸の`$json`ではなく`$('分岐処理・アクション決定').item.json...`とノード名を明示して参照している(HC-SUBの`$('Execute Workflow Trigger').item.json`と同じ規約) + - 秘密情報・SiteId等はすべて`workflow_config_values`(`bNkadTyDgDepYx2p`)から「設定値一括取得」1ノードで取得し、直後のCodeノードで`configKey`→`configValue`のMapに変換して参照。ワークフローJSONに秘密値のハードコードなし + - 未検証・要判断の暫定実装・既知の未実装あり(詳細は`task-11-rebuild-report.md`): + ①Data Table `update`操作のパラメータ形状(get/insertは実機確認済みだが、updateは本タスクで初採用のため未検証、ブリーフ記載のサンプル形状をそのまま採用) + ②`/api/users/get`・`api/items/{SiteId}/get`(ColumnFilterHash検索)のレスポンス形状はHC-SUB・旧WA版と同じ想定を踏襲 + ③484184(`LINEWORKS_BOT_MASTER_SITE_ID`)からのBotId解決ロジックはHC-SUBと同じ暫定実装(`SiteSettings.BotIds`または`Processes[].BotId`を仮定) + ④**LINEWORKSファイル添付(`awaitInput:"file"`分岐)の実処理は未実装。** ファイルダウンロード(`GET /v1.0/bots/{botId}/attachments/{fileId}`)にはLINEWORKS Bot APIのアクセストークンが必要だが、JWT署名は専用のJWTノード(ノード11、action=="send"経路にのみ存在しノード5より後段)でしか行えないため、ノード5内ではトークンを取得する手段がない。現状はこのケースを検出すると`action:"send"`でユーザーに「準備中」メッセージを返し、待機状態は`file`のまま維持する(ユーザーが詰まらないための安全側実装)。実装するにはグラフ構成の見直し(ファイル受信専用のJWT/Tokenペアを追加する等)が必要で、フォローアップタスクで対応すること + - 設計上の注記: HC-SUB(Task 9)が`healthcheck_bot_state`に保存する`pendingProcesses`は`{processId, label, tooltip}`のみで`ValidateInputs`を持たないため、none分岐での`classifyAwaitInput`実行時はHC-WA内で`サイト設定取得`(getsite)を再実行し、ProcessId一致でフルのProcess定義を引き直す設計にした(Task 9側のファイルは変更していない) + +## n8nフォルダ + +- フォルダ `健康診断ワークフロー`: `hOx8WKrdOucllbcU`(projectId `LOcxF69Gm4PvnkqA`配下に2026-09-05作成) +- HC-SUB/HC-WP/HC-WAの3ワークフローをこのフォルダへ移動する予定だが、Public APIの`/workflows/{id}/transfer`は別プロジェクトへの移動専用(同一プロジェクト内移動は拒否される)で、実行するとactive状態が解除される副作用があるため、**ユーザーがn8n UI上で手動ドラッグして移動する**運用にした(コントローラーからは未実施) + +## workflow_config_values 追加登録キー(2026-09-05、healthcheck-survey-bot用) + +既存の`workflow_config_values`(`bNkadTyDgDepYx2p`、org-master-sync用と共用)に以下を追加登録済み: +- `LW_BOT_CLIENT_ID` / `LW_BOT_CLIENT_SECRET` / `LW_BOT_SERVICE_ACCOUNT`(LINEWORKS通知送信ワークフローと同じBot認証情報) +- `HEALTHCHECK_SITE_ID`(508971)/ `LINEWORKS_BOT_MASTER_SITE_ID`(484184)/ `HEALTHCHECK_DATA_TABLE_ID`(`jqMDa2YZTI4f0iQ7`) +- `HEALTHCHECK_WP_API_KEY`(HC-WP Webhook認証用に新規生成) +- `LINEWORKS_BOT_SECRET`(署名検証用。値は現状プレースホルダー`UNSET_PENDING_BOT_CALLBACK_CONFIRMATION`。LINEWORKS Developer ConsoleでBot Callback URL設定時に実際のBot Secretへ更新が必要) diff --git a/Pleasanter/健康診断管理/docs/n8n/workflows/hc-sub-run-process-and-notify.json b/Pleasanter/健康診断管理/docs/n8n/workflows/hc-sub-run-process-and-notify.json new file mode 100644 index 00000000..60898f98 --- /dev/null +++ b/Pleasanter/健康診断管理/docs/n8n/workflows/hc-sub-run-process-and-notify.json @@ -0,0 +1,261 @@ +{ + "name": "HC-SUB: プロセス実行と案内送信", + "nodes": [ + { + "id": "trigger-1", + "name": "Execute Workflow Trigger", + "type": "n8n-nodes-base.executeWorkflowTrigger", + "typeVersion": 1.1, + "position": [ + 220, + 300 + ], + "parameters": { + "workflowInputs": { + "values": [ + { + "name": "resultId" + }, + { + "name": "processId" + } + ] + } + } + }, + { + "id": "dt-get-config", + "name": "設定値一括取得", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1, + "position": [ + 440, + 300 + ], + "parameters": { + "operation": "get", + "dataTableId": { + "__rl": true, + "mode": "id", + "value": "bNkadTyDgDepYx2p" + }, + "returnAll": true + } + }, + { + "id": "code-pleasanter-query", + "name": "Pleasanter照会・選択肢組み立て", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 660, + 300 + ], + "parameters": { + "jsCode": "function isUnsetSentinel(value) {\n return typeof value === \"string\" && value.startsWith(\"1899\");\n}\nfunction fillTemplate(template, columns, valueHash) {\n const labelToColumnName = new Map();\n for (const column of columns) {\n if (column.LabelText) labelToColumnName.set(column.LabelText, column.ColumnName);\n }\n return template.replace(/\\{([^{}]+)\\}/g, (matched, label) => {\n const columnName = labelToColumnName.get(label);\n if (!columnName) return matched;\n const value = valueHash[columnName];\n if (value === undefined || value === null || value === \"\" || isUnsetSentinel(value)) return \"未設定\";\n return String(value);\n });\n}\nfunction extractProcessesForStatus(processes, status) {\n return processes.filter((p) => p.CurrentStatus === status || p.CurrentStatus === -1);\n}\n\nconst configRows = $('設定値一括取得').all().map((item) => item.json);\nconst config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue]));\n\nconst trigger = $('Execute Workflow Trigger').item.json;\nconst baseUrl = config.PLEASANTER_BASE_URL_PROD;\nconst apiKey = config.PLEASANTER_API_KEY_PROD;\n\nasync function pleasanterPost(path, body) {\n const res = await this.helpers.httpRequest({\n method: \"POST\",\n url: `${baseUrl}${path}`,\n body: { ApiVersion: 1.1, ApiKey: apiKey, ...body },\n json: true,\n });\n return res;\n}\n\nif (trigger.processId) {\n await pleasanterPost.call(this, `api/items/${trigger.resultId}/update`, { ProcessId: trigger.processId });\n}\n\nconst recordRes = await pleasanterPost.call(this, `api/items/${trigger.resultId}/get`, {});\nconst record = recordRes.Response.Data;\n\nconst siteRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/getsite`, {});\nconst siteSettings = siteRes.Response.Data.SiteSettings;\nconst columns = siteSettings.Columns || [];\nconst processes = siteSettings.Processes || [];\n\nconst botMasterRes = await pleasanterPost.call(this, `api/items/${config.LINEWORKS_BOT_MASTER_SITE_ID}/getsite`, {});\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造を見てBotIdの解決方法を確定させる。\n// 暫定: SiteSettings.BotIds配列 または Processes[].BotId のいずれかを想定し、\n// 生データのまま後段(JWTクレーム組み立て)へ渡して解決する。\nconst botMasterSiteSettings = botMasterRes.Response.Data.SiteSettings;\n\nconst valueHash = {\n ...record.ClassHash, ...record.NumHash, ...record.DateHash, ...record.DescriptionHash,\n};\n\nconst candidates = extractProcessesForStatus(processes, record.Status);\nconst options = candidates.map((p) => ({\n processId: p.Id,\n label: p.DisplayName || p.Name,\n tooltip: fillTemplate(p.ToolTip || \"\", columns, valueHash),\n}));\n\nconst userRes = await pleasanterPost.call(this, `api/users/get`, {\n View: { ApiGetMailAddresses: true },\n Where: { UserId: record.ClassHash.ClassC },\n});\n// Pleasanter /api/users/get のレスポンス形状は実機未検証のため、配列/単一\n// オブジェクトどちらでも動くように防御的に処理する。\nconst usersResponseData = userRes.Response.Data;\nconst userRecord = Array.isArray(usersResponseData) ? usersResponseData[0] : usersResponseData;\nconst targetEmail = userRecord && (userRecord.MailAddress\n || (Array.isArray(userRecord.MailAddresses) && userRecord.MailAddresses[0]));\nif (!targetEmail) {\n throw new Error('対象者のメールアドレスを解決できませんでした。');\n}\n\nreturn [{\n json: {\n resultId: record.ResultId,\n currentStatus: record.Status,\n options,\n targetEmail,\n config,\n botMasterSiteSettings,\n },\n}];" + } + }, + { + "id": "code-jwt-claims", + "name": "JWTクレーム組み立て", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 880, + 300 + ], + "parameters": { + "jsCode": "const now = Math.floor(Date.now() / 1000);\nconst { config, botMasterSiteSettings, options, resultId, currentStatus, targetEmail } = $json;\n\nconst jwtClaims = JSON.stringify({\n iss: config.LW_BOT_CLIENT_ID,\n sub: config.LW_BOT_SERVICE_ACCOUNT,\n iat: now,\n exp: now + 3600,\n aud: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n});\n\n// --- Bot ID解決 ---\n// TODO(Task 12): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の実データ構造は未調査\n// (このワークフローは実データ確認前に構築している)。\n// Task 12で508971のProcess⇔Bot対応表を設計する際、実データに合わせて\n// この解決方法を書き換えるか、Execute Workflow Triggerの入力にbotIdを追加して\n// 呼び出し元(WP/WA)から明示的に渡す方式へ変更すること。\n// 暫定実装: getsiteのSiteSettings直下にBotIds配列がある、または\n// Processes[].BotIdが定義されているケースを想定し、最初の1件を採用する。\n// どちらの構造も無ければ、誤ったBotへの送信を避けるため明示的にエラーとする。\nconst candidateBotIds = botMasterSiteSettings.BotIds\n || (botMasterSiteSettings.Processes || []).map((p) => p.BotId).filter(Boolean);\nconst botId = candidateBotIds && candidateBotIds[0];\nif (!botId) {\n throw new Error('BotIdを484184(LINEWORKS_BOT_MASTER_SITE_ID)から解決できませんでした。Task 12でBot選択ロジックを実装してください。');\n}\n\nconst apiUrl = `https://www.worksapis.com/v1.0/bots/${botId}/users/${targetEmail}/messages`;\n\n// --- button_template組み立て ---\nconst actions = options.map((opt) => ({\n type: 'message',\n label: opt.label,\n postback: JSON.stringify({ resultId, processId: opt.processId }),\n displayText: opt.label,\n}));\n\nconst messageContent = {\n type: 'button_template',\n contentText: options.map((opt) => `${opt.label}: ${opt.tooltip}`).join('\\n') || '対応可能な操作がありません',\n actions,\n};\n\nreturn [{\n json: {\n jwtClaims,\n apiUrl,\n messageContent,\n targetEmail,\n resultId,\n currentStatus,\n options,\n config,\n },\n}];" + } + }, + { + "id": "jwt-sign", + "name": "Sign JWT", + "type": "n8n-nodes-base.jwt", + "typeVersion": 1, + "position": [ + 1100, + 300 + ], + "parameters": { + "operation": "sign", + "useJson": true, + "claimsJson": "={{ $json.jwtClaims }}", + "options": { + "algorithm": "RS256" + } + }, + "credentials": { + "jwtAuth": { + "id": "Hw0qlEaGfLPnQWp1", + "name": "LINEWORKS Bot Private Key (v4)" + } + } + }, + { + "id": "code-lineworks-send", + "name": "アクセストークン取得・LINEWORKS送信", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1320, + 300 + ], + "parameters": { + "jsCode": "const claims = $('JWTクレーム組み立て').item.json;\nconst config = claims.config;\nconst assertion = $json.token;\n\n// LINEWORKS tokenエンドポイントはx-www-form-urlencodedのみ受け付ける。\n// this.helpers.httpRequestはbodyにURLSearchParamsインスタンスを渡すと\n// application/x-www-form-urlencodedへの変換とContent-Type設定を自動で\n// 行うため、素のURLSearchParamsを渡す(.toString()しない、ヘッダーも手動指定しない)。\n// jsonオプションは指定しないため、レスポンスは自前でJSON.parseする。\nconst tokenBody = new URLSearchParams({\n assertion,\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n client_id: config.LW_BOT_CLIENT_ID,\n client_secret: config.LW_BOT_CLIENT_SECRET,\n scope: 'bot',\n});\n\nconst tokenRes = await this.helpers.httpRequest({\n method: 'POST',\n url: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n body: tokenBody,\n});\nconst tokenJson = typeof tokenRes === 'string' ? JSON.parse(tokenRes) : tokenRes;\nconst accessToken = tokenJson.access_token;\nif (!accessToken) {\n throw new Error('LINEWORKSアクセストークンの取得に失敗しました。');\n}\n\nconst sendRes = await this.helpers.httpRequest({\n method: 'POST',\n url: claims.apiUrl,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json;charset=UTF-8',\n },\n body: { content: claims.messageContent },\n json: true,\n});\n\nreturn [{\n json: {\n resultId: claims.resultId,\n currentStatus: claims.currentStatus,\n options: claims.options,\n targetEmail: claims.targetEmail,\n lineworksResponse: sendRes,\n },\n}];" + } + }, + { + "id": "dt-state-insert", + "name": "状態更新", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1, + "position": [ + 1540, + 300 + ], + "parameters": { + "operation": "insert", + "dataTableId": { + "__rl": true, + "mode": "id", + "value": "jqMDa2YZTI4f0iQ7" + }, + "columns": { + "mappingMode": "defineBelow", + "value": { + "resultId": "={{ $json.resultId }}", + "targetEmail": "={{ $json.targetEmail }}", + "currentStatus": "={{ String($json.currentStatus) }}", + "pendingProcesses": "={{ JSON.stringify($json.options) }}", + "awaitInput": "none", + "awaitProcessId": "", + "awaitColumn": "" + }, + "schema": [ + { + "id": "resultId", + "displayName": "resultId", + "required": false, + "type": "string", + "canBeUsedToMatch": true + }, + { + "id": "targetEmail", + "displayName": "targetEmail", + "required": false, + "type": "string", + "canBeUsedToMatch": true + }, + { + "id": "currentStatus", + "displayName": "currentStatus", + "required": false, + "type": "string", + "canBeUsedToMatch": true + }, + { + "id": "pendingProcesses", + "displayName": "pendingProcesses", + "required": false, + "type": "string", + "canBeUsedToMatch": true + }, + { + "id": "awaitInput", + "displayName": "awaitInput", + "required": false, + "type": "string", + "canBeUsedToMatch": true + }, + { + "id": "awaitProcessId", + "displayName": "awaitProcessId", + "required": false, + "type": "string", + "canBeUsedToMatch": true + }, + { + "id": "awaitColumn", + "displayName": "awaitColumn", + "required": false, + "type": "string", + "canBeUsedToMatch": true + } + ] + } + } + } + ], + "connections": { + "Execute Workflow Trigger": { + "main": [ + [ + { + "node": "設定値一括取得", + "type": "main", + "index": 0 + } + ] + ] + }, + "設定値一括取得": { + "main": [ + [ + { + "node": "Pleasanter照会・選択肢組み立て", + "type": "main", + "index": 0 + } + ] + ] + }, + "Pleasanter照会・選択肢組み立て": { + "main": [ + [ + { + "node": "JWTクレーム組み立て", + "type": "main", + "index": 0 + } + ] + ] + }, + "JWTクレーム組み立て": { + "main": [ + [ + { + "node": "Sign JWT", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sign JWT": { + "main": [ + [ + { + "node": "アクセストークン取得・LINEWORKS送信", + "type": "main", + "index": 0 + } + ] + ] + }, + "アクセストークン取得・LINEWORKS送信": { + "main": [ + [ + { + "node": "状態更新", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Pleasanter/健康診断管理/docs/n8n/workflows/hc-wa-lineworks-response.json b/Pleasanter/健康診断管理/docs/n8n/workflows/hc-wa-lineworks-response.json new file mode 100644 index 00000000..6ee2ac06 --- /dev/null +++ b/Pleasanter/健康診断管理/docs/n8n/workflows/hc-wa-lineworks-response.json @@ -0,0 +1,454 @@ +{ + "name": "HC-WA: LINEWORKS応答受信", + "nodes": [ + { + "id": "webhook-lineworks-response", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 200, + 400 + ], + "webhookId": "healthcheck-lineworks-response", + "parameters": { + "httpMethod": "POST", + "path": "healthcheck-lineworks-response", + "responseMode": "responseNode", + "options": { + "rawBody": true + } + } + }, + { + "id": "dt-get-config", + "name": "設定値一括取得", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1, + "position": [ + 420, + 400 + ], + "parameters": { + "operation": "get", + "dataTableId": { + "__rl": true, + "mode": "id", + "value": "bNkadTyDgDepYx2p" + }, + "returnAll": true + } + }, + { + "id": "code-verify-and-identify", + "name": "署名検証・対象レコード特定", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 640, + 400 + ], + "parameters": { + "jsCode": "const crypto = require(\"crypto\");\nfunction normalizeSignature(value) {\n return String(value || \"\").trim().replace(/^sha256=/i, \"\");\n}\nfunction safeEqual(a, b) {\n const ab = Buffer.from(String(a), \"utf8\");\n const bb = Buffer.from(String(b), \"utf8\");\n if (ab.length !== bb.length) return false;\n return crypto.timingSafeEqual(ab, bb);\n}\nfunction verifySignature(rawBody, headerSignature, botSecret) {\n const headerSig = normalizeSignature(headerSignature);\n if (!headerSig || !botSecret) return false;\n const payload = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), \"utf8\");\n const expected = crypto.createHmac(\"sha256\", botSecret).update(payload).digest(\"base64\");\n return safeEqual(headerSig, expected);\n}\n\nconst configRows = $('設定値一括取得').all().map((item) => item.json);\nconst config = Object.fromEntries(configRows.map((row) => [row.configKey, row.configValue]));\n\nconst item = $('Webhook').first();\nconst headers = item.json.headers || {};\nconst rawBody = item.binary && item.binary.data\n ? Buffer.from(item.binary.data.data, \"base64\")\n : Buffer.from(JSON.stringify(item.json.body || {}), \"utf8\");\n\nif (!verifySignature(rawBody, headers[\"x-works-signature\"], config.LINEWORKS_BOT_SECRET)) {\n throw new Error(\"Unauthorized: signature mismatch\");\n}\n\nconst body = item.json.body || {};\nconst source = body.source || {};\nconst content = body.content || {};\nconst targetEmail = source.userId;\n\nasync function pleasanterPost(path, reqBody) {\n return this.helpers.httpRequest({\n method: \"POST\",\n url: `${config.PLEASANTER_BASE_URL_PROD}${path}`,\n body: { ApiVersion: 1.1, ApiKey: config.PLEASANTER_API_KEY_PROD, ...reqBody },\n json: true,\n });\n}\n\nconst userRes = await pleasanterPost.call(this, \"api/users/get\", {\n View: { ApiGetMailAddresses: true },\n Where: { MailAddress: targetEmail },\n});\nconst userId = userRes.Response.Data[0]?.UserId;\nif (!userId) throw new Error(`対象者が見つかりません: ${targetEmail}`);\n\nconst recordRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/get`, {\n View: {\n ColumnFilterHash: { ClassC: String(userId) },\n ColumnFilterSearchTypes: { ClassC: \"ExactMatch\" },\n },\n});\nconst candidates = (recordRes.Response.Data || []).filter((r) => r.Status !== 900 && r.Status !== 910);\nif (candidates.length !== 1) {\n throw new Error(`対象レコードを一意に特定できません: ${candidates.length}件`);\n}\nconst record = candidates[0];\n\nreturn [{\n json: {\n config,\n resultId: record.ResultId,\n currentStatus: record.Status,\n contentType: content.type,\n text: content.type === \"text\" ? content.text : null,\n fileId: content.type === \"file\" ? content.fileId : null,\n },\n}];" + } + }, + { + "id": "dt-get-state", + "name": "待機状態取得", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1, + "position": [ + 860, + 400 + ], + "alwaysOutputData": true, + "parameters": { + "operation": "get", + "dataTableId": { + "__rl": true, + "mode": "id", + "value": "jqMDa2YZTI4f0iQ7" + }, + "filters": { + "conditions": [ + { + "keyName": "resultId", + "keyValue": "={{ $json.resultId }}" + } + ] + }, + "returnAll": true + } + }, + { + "id": "code-decide-action", + "name": "分岐処理・アクション決定", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1080, + 400 + ], + "parameters": { + "jsCode": "function matchProcessByLabel(processes, text) {\n const trimmed = String(text ?? \"\").trim();\n return processes.find((p) => (p.label || p.DisplayName || p.Name) === trimmed) || null;\n}\nfunction getValidationColumnNames(process) {\n if (!Array.isArray(process.ValidateInputs)) return [];\n return process.ValidateInputs.map((v) => v.ColumnName).filter(Boolean);\n}\nfunction classifyAwaitInput(process) {\n const columnNames = getValidationColumnNames(process);\n if (columnNames.length === 0) return { awaitInput: \"none\", column: null };\n const column = columnNames[0];\n if (column.startsWith(\"Date\")) return { awaitInput: \"date\", column };\n if (column.startsWith(\"Attachments\")) return { awaitInput: \"file\", column };\n return { awaitInput: \"none\", column: null };\n}\n\nconst ERA_INFO = { \"令和\": 2018, \"平成\": 1988, \"昭和\": 1925, \"大正\": 1911 };\nconst ERA_ALIASES = { R: \"令和\", H: \"平成\", S: \"昭和\", T: \"大正\" };\nfunction pad2(value) { return String(value).padStart(2, \"0\"); }\nfunction finalizeDateParts(year, month, day) {\n if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {\n throw new Error(\"日付形式で回答してください(例: 2026-03-01)\");\n }\n const date = new Date(year, month - 1, day);\n if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {\n throw new Error(\"存在しない日付です\");\n }\n return `${year}-${pad2(month)}-${pad2(day)}`;\n}\nfunction tryParseEraDate(compact) {\n const match = compact.match(/^(令和|平成|昭和|大正|R|H|S|T)(.+)$/i);\n if (!match) return null;\n let era = match[1];\n if (/^[RHST]$/i.test(era)) era = ERA_ALIASES[era.toUpperCase()] || era;\n if (!ERA_INFO[era]) return null;\n const normalized = match[2].replace(/年/g, \"-\").replace(/月/g, \"-\").replace(/日/g, \"\").replace(/[.\\/]/g, \"-\");\n const parts = normalized.split(\"-\").filter((part) => part.length > 0);\n if (parts.length < 3) throw new Error(\"月と日まで入力してください(例: 令和6年3月1日)\");\n const eraYear = Number(parts[0]);\n const month = Number(parts[1]);\n const day = Number(parts[2]);\n if (!Number.isFinite(eraYear) || !Number.isFinite(month) || !Number.isFinite(day)) {\n throw new Error(\"日付形式で回答してください(例: 令和6年3月1日)\");\n }\n return finalizeDateParts(ERA_INFO[era] + eraYear, month, day);\n}\nfunction tryParseMonthDay(compact) {\n const match = compact.match(/^(\\d{1,2})(?:月|\\/|-|\\.)(\\d{1,2})(?:日)?$/);\n if (!match) return null;\n const currentYear = new Date().getFullYear();\n return finalizeDateParts(currentYear, Number(match[1]), Number(match[2]));\n}\nfunction parseDateInput(value) {\n const trimmed = String(value ?? \"\").trim();\n if (!trimmed) throw new Error(\"日付が空です\");\n const compact = trimmed.replace(/\\s+/g, \"\");\n const eraResult = tryParseEraDate(compact);\n if (eraResult) return eraResult;\n const monthDayResult = tryParseMonthDay(compact);\n if (monthDayResult) return monthDayResult;\n const normalized = compact.replace(/年/g, \"-\").replace(/月/g, \"-\").replace(/日/g, \"\").replace(/[.\\/]/g, \"-\");\n const isoParts = normalized.split(\"-\").filter((part) => part.length > 0);\n if (isoParts.length === 3 && isoParts[0].length >= 4) {\n return finalizeDateParts(Number(isoParts[0]), Number(isoParts[1]), Number(isoParts[2]));\n }\n const parsed = new Date(trimmed);\n if (Number.isNaN(parsed.getTime())) {\n throw new Error(\"日付形式で回答してください(例: 2026-03-01 や 令和6年3月1日)\");\n }\n return finalizeDateParts(parsed.getFullYear(), parsed.getMonth() + 1, parsed.getDate());\n}\n\nconst identity = $('署名検証・対象レコード特定').item.json;\nconst { config, resultId, contentType, text } = identity;\n\nconst stateRows = $('待機状態取得').all().map((item) => item.json);\nconst state = stateRows[0] || null;\n\nasync function pleasanterPost(path, body) {\n return this.helpers.httpRequest({\n method: \"POST\",\n url: `${config.PLEASANTER_BASE_URL_PROD}${path}`,\n body: { ApiVersion: 1.1, ApiKey: config.PLEASANTER_API_KEY_PROD, ...body },\n json: true,\n });\n}\n\nfunction buildResult(action, extra, nextState) {\n const fallbackNext = state\n ? { awaitInput: state.awaitInput, awaitProcessId: state.awaitProcessId, awaitColumn: state.awaitColumn }\n : { awaitInput: \"none\", awaitProcessId: \"\", awaitColumn: \"\" };\n const next = nextState || fallbackNext;\n return [{\n json: {\n action,\n resultId,\n config,\n targetEmail: state ? state.targetEmail : null,\n processId: null,\n messageText: null,\n ...extra,\n nextAwaitInput: next.awaitInput,\n nextAwaitProcessId: String(next.awaitProcessId ?? \"\"),\n nextAwaitColumn: next.column !== undefined ? (next.column || \"\") : (next.awaitColumn || \"\"),\n },\n }];\n}\n\nif (!state || ![\"none\", \"date\", \"file\"].includes(state.awaitInput)) {\n return buildResult(\"error\", {\n messageText: `待機状態が不正です(resultId=${resultId})。healthcheck_bot_stateの該当行を確認してください。`,\n }, { awaitInput: \"none\", awaitProcessId: \"\", awaitColumn: \"\" });\n}\n\nif (state.awaitInput === \"none\") {\n const pendingProcesses = JSON.parse(state.pendingProcesses || \"[]\");\n const matched = matchProcessByLabel(pendingProcesses, text);\n\n if (!matched) {\n // 不一致 → フォールバック再送(HC-SUBが現在Statusの選択肢案内を再送する)\n return buildResult(\"execute\", { processId: null }, { awaitInput: \"none\", awaitProcessId: \"\", awaitColumn: \"\" });\n }\n\n // HC-SUBが保存するpendingProcessesは{processId, label, tooltip}のみでValidateInputsを\n // 持たないため、追加入力の要否判定にはサイト設定(getsite)からフルのProcess定義を\n // 引き直す必要がある(README.md記載の設計注記のとおり)。\n const siteRes = await pleasanterPost.call(this, `api/items/${config.HEALTHCHECK_SITE_ID}/getsite`, {});\n const siteSettings = siteRes.Response.Data.SiteSettings;\n const processes = siteSettings.Processes || [];\n const fullProcess = processes.find((p) => p.Id === matched.processId);\n if (!fullProcess) {\n throw new Error(`ProcessId ${matched.processId} がサイト設定内に見つかりません`);\n }\n const classification = classifyAwaitInput(fullProcess);\n\n if (classification.awaitInput === \"none\") {\n return buildResult(\"execute\", { processId: matched.processId }, { awaitInput: \"none\", awaitProcessId: \"\", awaitColumn: \"\" });\n }\n\n const promptText = classification.awaitInput === \"date\"\n ? \"日付を入力してください(例: 2026-03-01)\"\n : \"ファイルを送信してください\";\n return buildResult(\"send\", { messageText: promptText }, {\n awaitInput: classification.awaitInput,\n awaitProcessId: String(matched.processId),\n column: classification.column || \"\",\n });\n}\n\nif (state.awaitInput === \"date\") {\n try {\n const parsedDate = parseDateInput(text);\n await pleasanterPost.call(this, `api/items/${resultId}/update`, {\n DateHash: { [state.awaitColumn]: parsedDate },\n });\n return buildResult(\"execute\", { processId: Number(state.awaitProcessId) }, { awaitInput: \"none\", awaitProcessId: \"\", awaitColumn: \"\" });\n } catch (err) {\n return buildResult(\"send\", { messageText: err.message }, {\n awaitInput: \"date\", awaitProcessId: state.awaitProcessId, column: state.awaitColumn,\n });\n }\n}\n\n// state.awaitInput === \"file\"\nif (contentType !== \"file\") {\n return buildResult(\"send\", { messageText: \"ファイルを送信してください\" }, {\n awaitInput: \"file\", awaitProcessId: state.awaitProcessId, column: state.awaitColumn,\n });\n}\n\n// TODO(要フォローアップ、未実装): LINEWORKSファイル添付のダウンロード\n// (GET https://www.worksapis.com/v1.0/bots/{botId}/attachments/{fileId})には\n// LINEWORKS Bot APIのアクセストークン(JWTアサーション経由)が必要だが、プロジェクトルールにより\n// JWT署名は専用のJWTノード(このワークフローではaction:\"send\"経路のノードのみに存在し、\n// このCodeノードより後段に位置する)でしか行えない。そのため本Codeノード内では\n// トークンを取得する手段がなく、ダウンロード〜Pleasanter添付アップロード\n// (Pleasanter公式マニュアルapi-attachment系、具体的なエンドポイントも実装時要確認)は未実装。\n// フォローアップタスクでグラフ構成の見直し(ファイル受信専用のJWT/Tokenペアを追加する等)を検討すること。\nreturn buildResult(\"send\", {\n messageText: \"現在、ファイル添付の処理は準備中です。しばらくお待ちいただくか、担当者にご連絡ください。\",\n}, { awaitInput: \"file\", awaitProcessId: state.awaitProcessId, column: state.awaitColumn });" + } + }, + { + "id": "dt-update-state", + "name": "状態更新", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1, + "position": [ + 1300, + 400 + ], + "parameters": { + "operation": "update", + "dataTableId": { + "__rl": true, + "mode": "id", + "value": "jqMDa2YZTI4f0iQ7" + }, + "filters": { + "conditions": [ + { + "keyName": "resultId", + "keyValue": "={{ $json.resultId }}" + } + ] + }, + "columns": { + "mappingMode": "defineBelow", + "value": { + "awaitInput": "={{ $json.nextAwaitInput }}", + "awaitProcessId": "={{ $json.nextAwaitProcessId }}", + "awaitColumn": "={{ $json.nextAwaitColumn }}" + } + } + } + }, + { + "id": "if-action-execute", + "name": "IF: action==execute", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1520, + 400 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "cond-execute", + "leftValue": "={{ $('分岐処理・アクション決定').item.json.action === 'execute' }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + } + }, + { + "id": "execute-hc-sub", + "name": "HC-SUB実行", + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.2, + "position": [ + 1740, + 260 + ], + "parameters": { + "workflowId": { + "__rl": true, + "mode": "id", + "value": "XRqcykbG2LuAjGG2" + }, + "workflowInputs": { + "mappingMode": "defineBelow", + "value": { + "resultId": "={{ $('分岐処理・アクション決定').item.json.resultId }}", + "processId": "={{ $('分岐処理・アクション決定').item.json.processId }}" + } + } + } + }, + { + "id": "if-action-send", + "name": "IF: action==send", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1740, + 540 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "cond-send", + "leftValue": "={{ $('分岐処理・アクション決定').item.json.action === 'send' }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + } + }, + { + "id": "code-jwt-claims", + "name": "JWTクレーム組み立て", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1960, + 460 + ], + "parameters": { + "jsCode": "const decision = $('分岐処理・アクション決定').item.json;\nconst config = decision.config;\nconst now = Math.floor(Date.now() / 1000);\n\nconst jwtClaims = JSON.stringify({\n iss: config.LW_BOT_CLIENT_ID,\n sub: config.LW_BOT_SERVICE_ACCOUNT,\n iat: now,\n exp: now + 3600,\n aud: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n});\n\nasync function pleasanterPost(path, body) {\n return this.helpers.httpRequest({\n method: 'POST',\n url: `${config.PLEASANTER_BASE_URL_PROD}${path}`,\n body: { ApiVersion: 1.1, ApiKey: config.PLEASANTER_API_KEY_PROD, ...body },\n json: true,\n });\n}\n\n// TODO(Task 12で要確認・HC-SUBと同一の暫定実装): 484184(LINEWORKS_BOT_MASTER_SITE_ID)の\n// 実データ構造が未調査のため、SiteSettings.BotIds または Processes[].BotId のいずれかを想定する。\nconst botMasterRes = await pleasanterPost.call(this, `api/items/${config.LINEWORKS_BOT_MASTER_SITE_ID}/getsite`, {});\nconst botMasterSiteSettings = botMasterRes.Response.Data.SiteSettings;\nconst candidateBotIds = botMasterSiteSettings.BotIds\n || (botMasterSiteSettings.Processes || []).map((p) => p.BotId).filter(Boolean);\nconst botId = candidateBotIds && candidateBotIds[0];\nif (!botId) {\n throw new Error('BotIdを484184(LINEWORKS_BOT_MASTER_SITE_ID)から解決できませんでした。');\n}\n\nconst apiUrl = `https://www.worksapis.com/v1.0/bots/${botId}/users/${decision.targetEmail}/messages`;\n\nconst messageContent = {\n type: 'text',\n text: decision.messageText || '',\n};\n\nreturn [{\n json: {\n jwtClaims,\n apiUrl,\n messageContent,\n config,\n resultId: decision.resultId,\n },\n}];" + } + }, + { + "id": "jwt-sign", + "name": "Sign JWT", + "type": "n8n-nodes-base.jwt", + "typeVersion": 1, + "position": [ + 2180, + 460 + ], + "parameters": { + "operation": "sign", + "useJson": true, + "claimsJson": "={{ $json.jwtClaims }}", + "options": { + "algorithm": "RS256" + } + }, + "credentials": { + "jwtAuth": { + "id": "Hw0qlEaGfLPnQWp1", + "name": "LINEWORKS Bot Private Key (v4)" + } + } + }, + { + "id": "code-lineworks-send", + "name": "アクセストークン取得・LINEWORKS送信", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2400, + 460 + ], + "parameters": { + "jsCode": "const claims = $('JWTクレーム組み立て').item.json;\nconst config = claims.config;\nconst assertion = $json.token;\n\n// LINEWORKS tokenエンドポイントはx-www-form-urlencodedのみ受け付ける。\n// this.helpers.httpRequestはbodyにURLSearchParamsインスタンスを渡すと\n// application/x-www-form-urlencodedへの変換とContent-Type設定を自動で行うため、\n// 素のURLSearchParamsを渡す(.toString()しない、ヘッダーも手動指定しない)。\nconst tokenBody = new URLSearchParams({\n assertion,\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n client_id: config.LW_BOT_CLIENT_ID,\n client_secret: config.LW_BOT_CLIENT_SECRET,\n scope: 'bot',\n});\n\nconst tokenRes = await this.helpers.httpRequest({\n method: 'POST',\n url: 'https://auth.worksmobile.com/oauth2/v2.0/token',\n body: tokenBody,\n});\nconst tokenJson = typeof tokenRes === 'string' ? JSON.parse(tokenRes) : tokenRes;\nconst accessToken = tokenJson.access_token;\nif (!accessToken) {\n throw new Error('LINEWORKSアクセストークンの取得に失敗しました。');\n}\n\nconst sendRes = await this.helpers.httpRequest({\n method: 'POST',\n url: claims.apiUrl,\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json;charset=UTF-8',\n },\n body: { content: claims.messageContent },\n json: true,\n});\n\nreturn [{ json: { result: 'ok', resultId: claims.resultId, lineworksResponse: sendRes } }];" + } + }, + { + "id": "respond-ok", + "name": "Respond to Webhook(成功)", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 2620, + 320 + ], + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ result: \"ok\" }) }}", + "options": {} + } + }, + { + "id": "respond-error", + "name": "Respond to Webhook(異常系)", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 1960, + 680 + ], + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ result: \"error\", message: $('分岐処理・アクション決定').item.json.messageText || \"unknown error\" }) }}", + "options": {} + } + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "設定値一括取得", + "type": "main", + "index": 0 + } + ] + ] + }, + "設定値一括取得": { + "main": [ + [ + { + "node": "署名検証・対象レコード特定", + "type": "main", + "index": 0 + } + ] + ] + }, + "署名検証・対象レコード特定": { + "main": [ + [ + { + "node": "待機状態取得", + "type": "main", + "index": 0 + } + ] + ] + }, + "待機状態取得": { + "main": [ + [ + { + "node": "分岐処理・アクション決定", + "type": "main", + "index": 0 + } + ] + ] + }, + "分岐処理・アクション決定": { + "main": [ + [ + { + "node": "状態更新", + "type": "main", + "index": 0 + } + ] + ] + }, + "状態更新": { + "main": [ + [ + { + "node": "IF: action==execute", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF: action==execute": { + "main": [ + [ + { + "node": "HC-SUB実行", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "IF: action==send", + "type": "main", + "index": 0 + } + ] + ] + }, + "HC-SUB実行": { + "main": [ + [ + { + "node": "Respond to Webhook(成功)", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF: action==send": { + "main": [ + [ + { + "node": "JWTクレーム組み立て", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Respond to Webhook(異常系)", + "type": "main", + "index": 0 + } + ] + ] + }, + "JWTクレーム組み立て": { + "main": [ + [ + { + "node": "Sign JWT", + "type": "main", + "index": 0 + } + ] + ] + }, + "Sign JWT": { + "main": [ + [ + { + "node": "アクセストークン取得・LINEWORKS送信", + "type": "main", + "index": 0 + } + ] + ] + }, + "アクセストークン取得・LINEWORKS送信": { + "main": [ + [ + { + "node": "Respond to Webhook(成功)", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Pleasanter/健康診断管理/docs/n8n/workflows/hc-wp-status-push.json b/Pleasanter/健康診断管理/docs/n8n/workflows/hc-wp-status-push.json new file mode 100644 index 00000000..cc37724b --- /dev/null +++ b/Pleasanter/健康診断管理/docs/n8n/workflows/hc-wp-status-push.json @@ -0,0 +1,142 @@ +{ + "name": "HC-WP: Statusプッシュ通知", + "nodes": [ + { + "id": "webhook-status-push", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 220, + 300 + ], + "webhookId": "healthcheck-status-push", + "parameters": { + "httpMethod": "POST", + "path": "healthcheck-status-push", + "responseMode": "responseNode", + "options": {} + } + }, + { + "id": "dt-get-healthcheck_wp_api_key", + "name": "設定値一括取得", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1, + "position": [ + 440, + 300 + ], + "parameters": { + "operation": "get", + "dataTableId": { + "__rl": true, + "mode": "id", + "value": "bNkadTyDgDepYx2p" + }, + "returnAll": true + } + }, + { + "id": "code-validate", + "name": "検証", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 660, + 300 + ], + "parameters": { + "jsCode": "const configRows = $('設定値一括取得').all().map((item) => item.json);\nconst expectedApiKey = configRows.find((row) => row.configKey === \"HEALTHCHECK_WP_API_KEY\")?.configValue;\nconst headers = $input.first().json.headers || {};\nif (headers[\"x-api-key\"] !== expectedApiKey) {\n throw new Error(\"Unauthorized: invalid API key\");\n}\nconst body = $input.first().json.body || {};\nif (!body.resultId || !body.processId) {\n throw new Error(\"Bad Request: resultId, processId は必須です\");\n}\nreturn [{ json: { resultId: body.resultId, processId: body.processId } }];" + } + }, + { + "id": "execute-hc-sub", + "name": "HC-SUB実行", + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.2, + "position": [ + 880, + 300 + ], + "parameters": { + "workflowId": { + "__rl": true, + "mode": "id", + "value": "XRqcykbG2LuAjGG2" + }, + "workflowInputs": { + "mappingMode": "defineBelow", + "value": { + "resultId": "={{ $json.resultId }}", + "processId": "={{ $json.processId }}" + } + } + } + }, + { + "id": "respond-ok", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 1100, + 300 + ], + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ result: \"ok\" }) }}", + "options": {} + } + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "設定値一括取得", + "type": "main", + "index": 0 + } + ] + ] + }, + "設定値一括取得": { + "main": [ + [ + { + "node": "検証", + "type": "main", + "index": 0 + } + ] + ] + }, + "検証": { + "main": [ + [ + { + "node": "HC-SUB実行", + "type": "main", + "index": 0 + } + ] + ] + }, + "HC-SUB実行": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Pleasanter/健康診断管理/★健康診断プリザンター化_対応エクセル作成途中.xlsx b/Pleasanter/健康診断管理/★健康診断プリザンター化_対応エクセル作成途中.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..577f490235133a84aee1c7ba0a94b3174c9a8c08 GIT binary patch literal 47726 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 literal 0 HcmV?d00001 diff --git a/Pleasanter/健康診断管理/プリザンターテーブル設計書_健康診断.xlsx b/Pleasanter/健康診断管理/プリザンターテーブル設計書_健康診断.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..0a1a35d9a24149d35d87e7b3524b260cbc762650 GIT binary patch literal 59916 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%0ALU~;!^fkci;$^=9_+5Sh z{4QnoTJ&`>mEWRAfZHL!|HS@vX_eOquM?sDMzBlz@6w{Z#(15+<2MFS)@wcfF`36} zz}Lwxegl$Z|8D?)r@nX%_&PJhZ$QzU{|(^pJQ1$}U%M;(1~kw81^6n7!XJ|=yas(8 z+V>ljw)_|9zXtyN3iNx8@H$xNHv(?;e<1vEe*X6tHcy)#QI5{GwG~kKr}B^&3HF<`=@hQC+X4|9yS*TNd!Mxb!dc|G!pCuf_lUuJvEV g!B+o8{2%wQvJ&8cr$azMxPZST!0+F$q(4Ca3r-xX2LJ#7 literal 0 HcmV?d00001