GitHub(nextgroup2706/ken_nogi)は今後使わず自社Gitea運用に切替え。 NodeSrvは旧リポジトリの履歴を破棄しファイルのみ統合(Dokploy用サービスアカウントは 別途mygit-admin/NodeSrv.gitに履歴あり)。notepmエクスポート(12GB)とPleasanter インストーラzip(208MB)はサイズが大きいため.gitignoreで除外。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
94 KiB
LINEWORKS Form → プリザンター連携 Implementation Plan
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: LINEWORKS Formのアンケート回答をプリザンターへ自動連携するNode.jsアプリapps/lineworks-form-syncを構築する。フォームごとに連携キー・自動実行周期を手動実行画面で選択・記憶し、質問→列マッピングもプリザンター側で動的管理する。
Architecture: Express製Webアプリ。/manage/*はMaster Key+セッションCookie(60分限定一時キー対応)保護のブラウザUIで、フォームごとの連携キー・周期選択、今すぐ実行、質問マッピング雛形自動生成を行う。/executeはn8n Scheduleトリガー(15分ごと)向けAPIで、対象フォームをループし周期・手動実行済みフラグを見て実行要否を判定、実行対象はrespondent.emailで回答格納先テーブルを検索しUpsertする。状態(連携キー・周期・マッピング・実行フラグ)は全てプリザンター「フォーム管理テーブル」で一元管理し、n8nはステートレスなトリガーに徹する。
Tech Stack: Node.js 22(CommonJS)、Express 4、node:test。既存アプリapps/lineworks-user-authのコード(adminAuth.js/adminView.js/pleasanterClient.js/executionKeys.js)をベースに移植・拡張する。
Global Constraints
- 設計書:
docs/superpowers/specs/2026-08-24-lineworks-form-pleasanter-sync-design.md(このプランの元仕様) - 1アプリ1フォルダ1Dokploy Compose、他アプリと完全独立(共通コードはコピーする、既存方針踏襲)
- Node.js CommonJS、
node --testでテスト実行、外部I/OはfetchImpl = fetch引数でDI /healthエンドポイントは削除・変更しない- 機密情報を絶対にログ・コミット・チャット出力に含めない
- n8nはトリガーのみ、状態は一切持たない(feedback_n8n_datatable_prohibition)
- 認証は[project_lineworks_user_auth]の
GET /token任せ、本アプリはOAuthを一切実装しない
事前準備(ユーザー側、コード実装と並行して進めてよい)
- DNS: 本アプリのホスト先ドメインを決定・登録(Node A、
52.193.142.134) - プリザンター「フォーム管理テーブル」を新規作成(以下の物理列構成)
| 物理列 | 用途 |
|---|---|
| ClassA | FormId(LINEWORKS Form ID) |
| ClassB | アンケート名(人間可読、管理画面の一覧表示用) |
| Class001 | 回答格納先SiteId(数値を文字列として保存) |
| Class002 | ユーザー照合列(保存先テーブルの物理列名、例:"Class005") |
| Class003 | 連携キー |
| Num001 | 周期(分単位: 15/30/60/180/360/1440) |
| Description001 | 質問マッピングJSON |
| Check001 | 手動実行済みフラグ |
| Date001 | 最終手動実行日時 |
| Date002 | 最終自動実行日時 |
- 対象フォームごとに回答格納先テーブルを作成。ユーザー照合列(メールアドレス保存用の物理列、例:"Class005")と、質問マッピングで使う
targetColumn列群(Class/Description/Date、添付ファイルはAttachmentsプレフィックスの物理列、例:"AttachmentsA")を用意する - LINEWORKS連携専用アカウントを対象フォームへ「共同管理者」として追加(フォーム作成時のルール化)。project_lineworks_user_authの認証UIで連携キーとして登録
- lineworks-user-authのCONSUMER_EXECUTION_KEYSへ本アプリ用の実行キーを追加(Task 1完了後、キー名は任意の1エントリでよい、値のみが検証される)
Part A: lineworks-user-auth側の変更
Task 1: 実行キーモデルを「連携キーごと」から「有効な実行キーの集合」に変更
現状CONSUMER_EXECUTION_KEYSは{連携キー: 実行キー}で、消費側は自分が使う連携キーごとに専用実行キーが要る。lineworks-form-syncはフォームごとに動的に連携キーを選ぶため、どの連携キーでも同じ実行キーで取得できるよう変更する。
Files:
- Modify:
apps/lineworks-user-auth/src/lib/executionKeys.js - Modify:
apps/lineworks-user-auth/test/executionKeys.test.js - Modify:
apps/lineworks-user-auth/src/index.js:49-52(/tokenルート。他タスクの変更で行番号がずれている場合はapp.get("/token", ...)を検索して特定する)
Interfaces:
-
Produces:
verifyExecutionKey({ providedKey, configJson }) -> boolean(key引数を廃止。configJson内のいずれかの値と一致すればtrue) -
Step 1: 失敗するテストに書き換え
apps/lineworks-user-auth/test/executionKeys.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { verifyExecutionKey } = require("../src/lib/executionKeys");
const CONFIG = JSON.stringify({ "lineworks-form-sync": "exec-key-abc", "other-consumer": "exec-key-xyz" });
test("returns true when providedKey matches any registered consumer's key", () => {
assert.strictEqual(verifyExecutionKey({ providedKey: "exec-key-abc", configJson: CONFIG }), true);
assert.strictEqual(verifyExecutionKey({ providedKey: "exec-key-xyz", configJson: CONFIG }), true);
});
test("returns false when providedKey matches nothing", () => {
assert.strictEqual(verifyExecutionKey({ providedKey: "wrong", configJson: CONFIG }), false);
});
test("returns false when providedKey is missing", () => {
assert.strictEqual(verifyExecutionKey({ providedKey: undefined, configJson: CONFIG }), false);
});
test("returns false when configJson is invalid", () => {
assert.strictEqual(verifyExecutionKey({ providedKey: "exec-key-abc", configJson: "not-json" }), false);
});
- Step 2: テスト失敗を確認
cd apps/lineworks-user-auth && node --test test/executionKeys.test.js
Expected: FAIL(key未指定でも呼べてしまう現行実装により、期待するfalse/trueの分岐が崩れる)
- Step 3: 実装変更
apps/lineworks-user-auth/src/lib/executionKeys.js:
"use strict";
const crypto = require("node:crypto");
function timingSafeEqualStrings(a, b) {
const bufA = Buffer.from(String(a));
const bufB = Buffer.from(String(b));
if (bufA.length !== bufB.length) {
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
function verifyExecutionKey({ providedKey, configJson }) {
if (!providedKey) return false;
let config;
try {
config = JSON.parse(configJson || "{}");
} catch {
return false;
}
return Object.values(config).some((expected) => typeof expected === "string" && timingSafeEqualStrings(providedKey, expected));
}
module.exports = { verifyExecutionKey };
- Step 4: テスト成功を確認
node --test test/executionKeys.test.js
Expected: PASS(4 tests)
- Step 5:
/tokenルートの呼び出しを更新
apps/lineworks-user-auth/src/index.jsの/tokenルート(47-53行目付近)を編集:
app.get("/token", async (req, res) => {
const key = req.query.key;
const providedKey = req.header("X-Execution-Key");
if (!key || !verifyExecutionKey({ providedKey, configJson: CONSUMER_EXECUTION_KEYS })) {
res.sendStatus(401);
return;
}
(key: key,の引数渡しを削除するだけ。他は変更なし)
- Step 6: 全テスト実行して確認
node --test
Expected: 全PASS(既存45テスト、変更分含む)
- Step 7: Commit
git add apps/lineworks-user-auth/src/lib/executionKeys.js apps/lineworks-user-auth/test/executionKeys.test.js apps/lineworks-user-auth/src/index.js
git commit -m "feat(lineworks-user-auth): 実行キーモデルを連携キー単位から消費側システム単位に変更"
Task 2: GET /keys(登録済み連携キー一覧API)を実装
Files:
- Create:
apps/lineworks-user-auth/src/lib/keyList.js - Create:
apps/lineworks-user-auth/test/keyList.test.js - Modify:
apps/lineworks-user-auth/src/index.js
Interfaces:
-
Produces:
listKeys({baseUrl, apiKey, siteId, fetchImpl}) -> Promise<Array<{key, account, status}>>(tokenStatus.computeStatusを使い、accessToken/refreshTokenは含めない) -
Step 1: 失敗するテストを書く
apps/lineworks-user-auth/test/keyList.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { listKeys } = require("../src/lib/keyList");
test("returns key/account/status for each registered record, without exposing tokens", async () => {
const fetchImpl = async () => ({
ok: true,
json: async () => ({
StatusCode: 200,
Response: {
Data: [{
ResultId: 5,
ClassHash: { ClassA: "lineworks-form-sync", Class001: "svc@example.co.jp" },
DescriptionHash: { Description001: "encrypted-access", Description002: "encrypted-refresh" },
DateHash: { Date002: "2026-08-24T08:00:17.000Z" },
}],
TotalCount: 1,
},
}),
});
const keys = await listKeys({ baseUrl: "https://example.test/", apiKey: "k", siteId: 1, fetchImpl });
assert.deepStrictEqual(keys, [{ key: "lineworks-form-sync", account: "svc@example.co.jp", status: "有効" }]);
});
- Step 2: テスト失敗を確認
node --test test/keyList.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-user-auth/src/lib/keyList.js:
"use strict";
const { getSiteItems } = require("./pleasanterClient");
const { computeStatus } = require("./tokenStatus");
const COLS = require("../config/pleasanterColumns");
async function listKeys({ baseUrl, apiKey, siteId, fetchImpl = fetch }) {
const items = await getSiteItems({ baseUrl, apiKey, siteId, fetchImpl });
return items.map((item) => ({
key: item[COLS.KEY],
account: item[COLS.ACCOUNT],
status: computeStatus({ refreshedAt: item[COLS.REFRESHED_AT] }),
}));
}
module.exports = { listKeys };
- Step 4: テスト成功を確認
node --test test/keyList.test.js
Expected: PASS(1 test)
- Step 5: ルート追加
apps/lineworks-user-auth/src/index.jsのrequire群へ追加:
const { listKeys } = require("./lib/keyList");
/tokenルートの直後に追加:
app.get("/keys", async (req, res) => {
const providedKey = req.header("X-Execution-Key");
if (!verifyExecutionKey({ providedKey, configJson: CONSUMER_EXECUTION_KEYS })) {
res.sendStatus(401);
return;
}
try {
const keys = await listKeys({ baseUrl: PLEASANTER.baseUrl, apiKey: PLEASANTER.apiKey, siteId: PLEASANTER.siteId });
res.json({ keys });
} catch (err) {
console.error("keys fetch failed", err.message);
res.status(502).json({ error: err.message });
}
});
- Step 6: ローカルDocker等での再検証は不要(Task 1完了時点の動作確認に含まれる)。全テスト実行
node --test
Expected: 全PASS
- Step 7: Commit
git add apps/lineworks-user-auth/src/lib/keyList.js apps/lineworks-user-auth/test/keyList.test.js apps/lineworks-user-auth/src/index.js
git commit -m "feat(lineworks-user-auth): GET /keys(登録済み連携キー一覧API)を追加"
Task 3: Part A完了後の再デプロイ
Files: なし
- Step 1: Giteaへpush、Dokployへ再デプロイ(ユーザー確認の上で実行)
git push gitea main
dokploy compose deploy --composeId "axfqMkjwcRWYEaYMKiIwv" --title "実行キーモデル変更・GET /keys追加" --json
- Step 2: 反映確認
curl -s https://lwauth29.next-hd.net/health
Expected: {"status":"healthy"}
- Step 3: CONSUMER_EXECUTION_KEYSへ本アプリ用エントリを追加(Dokploy環境変数、ユーザー確認の上で実行・再デプロイ)。キー名は任意(例:
"lineworks-form-sync")、値が本アプリの実行キーになる
Part B: lineworks-form-syncアプリ本体
Task 4: アプリ雛形作成
Files:
-
Create:
apps/lineworks-form-sync/package.json -
Create:
apps/lineworks-form-sync/src/index.js -
Create:
apps/lineworks-form-sync/Dockerfile -
Create:
apps/lineworks-form-sync/docker-compose.local.yml -
Create:
apps/lineworks-form-sync/docker-compose.yml -
Create:
apps/lineworks-form-sync/.env.example -
Step 1:
_templateをコピー
cp -r apps/_template apps/lineworks-form-sync
rm -rf apps/lineworks-form-sync/node_modules
- Step 2: package.json編集
apps/lineworks-form-sync/package.jsonのnameをlineworks-form-syncに変更(他は_templateのまま):
{
"name": "lineworks-form-sync",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --test"
},
"dependencies": {
"express": "^4.21.0"
}
}
- Step 3:
.env.example作成
apps/lineworks-form-sync/.env.example:
PORT=3000
NODE_ENV=development
# lineworks-user-auth連携
LINEWORKS_USER_AUTH_BASE_URL=https://lwauth29.next-hd.net
LINEWORKS_USER_AUTH_EXECUTION_KEY=
# プリザンター(フォーム管理テーブル)
PLEASANTER_BASE_URL=
PLEASANTER_API_KEY=
PLEASANTER_FORM_MANAGEMENT_SITE_ID=
# /manage 管理UIのマスターキー・セッション署名鍵
AUTH_MASTER_KEY=
# /execute(n8nから呼ぶ)の実行キー
EXECUTE_KEY=
- Step 4: docker-compose.ymlのプレースホルダー置換(ホスト先ドメイン確定後に実施、事前準備1.参照)
cd apps/lineworks-form-sync
sed -i 's/your-app-name/lineworks-form-sync/g; s/yourapp\.apps\.next-hd\.net/<確定したドメイン>/g' docker-compose.yml
- Step 5: ローカル動作確認
npm install
cp .env.example .env
npm run dev
別ターミナル:
curl -s http://localhost:3000/health
Expected: {"status":"healthy"}。確認後devサーバー停止。
- Step 6: Commit
git add apps/lineworks-form-sync/package.json apps/lineworks-form-sync/package-lock.json apps/lineworks-form-sync/src/index.js apps/lineworks-form-sync/Dockerfile apps/lineworks-form-sync/docker-compose.local.yml apps/lineworks-form-sync/docker-compose.yml apps/lineworks-form-sync/.env.example apps/lineworks-form-sync/.dockerignore apps/lineworks-form-sync/README.md
git commit -m "feat(lineworks-form-sync): アプリ雛形作成"
Task 5: プリザンターAPIクライアント(Num/Check/Attachments対応)
apps/lineworks-user-auth/src/lib/pleasanterClient.jsをベースに、Num/Check/Attachmentsプレフィックスを追加移植する。
Files:
- Create:
apps/lineworks-form-sync/src/lib/pleasanterClient.js - Test:
apps/lineworks-form-sync/test/pleasanterClient.test.js
Interfaces:
-
Produces:
getSiteItems/createSiteItem/updateSiteItem/toHashPayload(lineworks-user-auth版と同シグネチャ、対応プレフィックスのみ拡張) -
Step 1: 失敗するテストを書く
apps/lineworks-form-sync/test/pleasanterClient.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { getSiteItems, createSiteItem, updateSiteItem, toHashPayload } = require("../src/lib/pleasanterClient");
test("toHashPayload nests Attachments/Description/Class/Num/Date/Check-prefixed keys under their Hash", () => {
const payload = toHashPayload({
ClassA: "key1",
Num001: 15,
Date001: "2026-08-24T00:00:00.000Z",
Check001: true,
Description001: "[]",
AttachmentsA: [{ ContentType: "text/plain", Name: "a.txt", Base64: "abc" }],
});
assert.deepStrictEqual(payload, {
ClassHash: { ClassA: "key1" },
NumHash: { Num001: 15 },
DateHash: { Date001: "2026-08-24T00:00:00.000Z" },
CheckHash: { Check001: true },
DescriptionHash: { Description001: "[]" },
AttachmentsHash: { AttachmentsA: [{ ContentType: "text/plain", Name: "a.txt", Base64: "abc" }] },
});
});
test("getSiteItems pages through results using top-level Offset and flattens all Hash kinds", async () => {
let call = 0;
const fetchImpl = async (url, opts) => {
const body = JSON.parse(opts.body);
call++;
if (body.Offset === 0) {
return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [{ ResultId: 1, ClassHash: { ClassA: "a" }, NumHash: { Num001: 15 }, CheckHash: { Check001: true } }], TotalCount: 2 } }) };
}
return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [{ ResultId: 2, ClassHash: { ClassA: "b" }, NumHash: { Num001: 60 }, CheckHash: { Check001: false } }], TotalCount: 2 } }) };
};
const items = await getSiteItems({ baseUrl: "https://example.test/", apiKey: "k", siteId: 1, fetchImpl });
assert.strictEqual(call, 2);
assert.deepStrictEqual(items.map((i) => [i.ClassA, i.Num001, i.Check001]), [["a", 15, true], ["b", 60, false]]);
});
test("createSiteItem sends fields nested under Hash keys and returns the new item Id", async () => {
let capturedBody;
const fetchImpl = async (url, opts) => { capturedBody = JSON.parse(opts.body); return { ok: true, json: async () => ({ Id: 42, StatusCode: 200 }) }; };
const id = await createSiteItem({ baseUrl: "https://example.test/", apiKey: "k", siteId: 1, fields: { ClassA: "key1", Num001: 15 }, fetchImpl });
assert.strictEqual(id, 42);
assert.deepStrictEqual(capturedBody.NumHash, { Num001: 15 });
});
test("updateSiteItem posts Hash-nested fields to /api/items/{itemId}/update", async () => {
let capturedUrl, capturedBody;
const fetchImpl = async (url, opts) => { capturedUrl = url; capturedBody = JSON.parse(opts.body); return { ok: true, json: async () => ({ Id: 7, StatusCode: 200 }) }; };
await updateSiteItem({ baseUrl: "https://example.test/", apiKey: "k", itemId: 7, fields: { Check001: true }, fetchImpl });
assert.strictEqual(capturedUrl, "https://example.test/api/items/7/update");
assert.deepStrictEqual(capturedBody.CheckHash, { Check001: true });
});
test("throws when StatusCode is not 200", async () => {
const fetchImpl = async () => ({ ok: true, json: async () => ({ StatusCode: 400, Message: "bad" }) });
await assert.rejects(() => getSiteItems({ baseUrl: "https://example.test/", apiKey: "k", siteId: 1, fetchImpl }));
});
- Step 2: テスト失敗を確認
cd apps/lineworks-form-sync && node --test test/pleasanterClient.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-form-sync/src/lib/pleasanterClient.js:
"use strict";
// Pleasanter Items APIは分類・数値・日付・説明・チェック・添付項目をフラットなキーでなく
// 各Hashへネストして送る必要がある(apps/org-master-sync/src/lib/pleasanterClient.js、
// apps/lineworks-user-auth/src/lib/pleasanterClient.js参照)。本アプリはAttachmentsも扱うため
// Num/Check/Attachmentsを含むフル版として移植する。
const HASH_PREFIXES = [
["Attachments", "AttachmentsHash"],
["Description", "DescriptionHash"],
["Class", "ClassHash"],
["Num", "NumHash"],
["Date", "DateHash"],
["Check", "CheckHash"],
];
function toHashPayload(fields) {
const result = {};
for (const [key, value] of Object.entries(fields)) {
const entry = HASH_PREFIXES.find(([prefix]) => key.startsWith(prefix));
if (entry) {
const hashName = entry[1];
if (!result[hashName]) result[hashName] = {};
result[hashName][key] = value;
} else {
result[key] = value;
}
}
return result;
}
function buildUrl(baseUrl, pathname) {
return baseUrl.replace(/\/$/, "") + pathname;
}
async function callApi({ baseUrl, apiKey, pathname, body, fetchImpl = fetch }) {
const res = await fetchImpl(buildUrl(baseUrl, pathname), {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ ApiVersion: "1.1", ApiKey: apiKey, ...body }),
});
const data = await res.json();
if (!res.ok || data.StatusCode !== 200) {
throw new Error(`${pathname} failed: ${JSON.stringify(data)}`);
}
return data;
}
function flattenHashes(item) {
const { AttachmentsHash, DescriptionHash, ClassHash, NumHash, DateHash, CheckHash, ...rest } = item;
return { ...rest, ...AttachmentsHash, ...DescriptionHash, ...ClassHash, ...NumHash, ...DateHash, ...CheckHash };
}
async function getSiteItems({ baseUrl, apiKey, siteId, fetchImpl = fetch }) {
const items = [];
let offset = 0;
while (true) {
const data = await callApi({ baseUrl, apiKey, pathname: `/api/items/${siteId}/get`, body: { Offset: offset }, fetchImpl });
const page = data.Response.Data || [];
items.push(...page.map(flattenHashes));
if (page.length === 0 || items.length >= data.Response.TotalCount) break;
offset += page.length;
}
return items;
}
async function createSiteItem({ baseUrl, apiKey, siteId, fields, fetchImpl = fetch }) {
const data = await callApi({ baseUrl, apiKey, pathname: `/api/items/${siteId}/create`, body: toHashPayload(fields), fetchImpl });
return data.Id;
}
async function updateSiteItem({ baseUrl, apiKey, itemId, fields, fetchImpl = fetch }) {
await callApi({ baseUrl, apiKey, pathname: `/api/items/${itemId}/update`, body: toHashPayload(fields), fetchImpl });
}
module.exports = { getSiteItems, createSiteItem, updateSiteItem, toHashPayload };
- Step 4: テスト成功を確認
node --test test/pleasanterClient.test.js
Expected: PASS(5 tests)
- Step 5: Commit
git add apps/lineworks-form-sync/src/lib/pleasanterClient.js apps/lineworks-form-sync/test/pleasanterClient.test.js
git commit -m "feat(lineworks-form-sync): プリザンターAPIクライアント(Num/Check/Attachments対応)を実装"
Task 6: lineworks-user-authクライアント
Files:
- Create:
apps/lineworks-form-sync/src/lib/userAuthClient.js - Test:
apps/lineworks-form-sync/test/userAuthClient.test.js
Interfaces:
-
Produces:
listConnectionKeys({baseUrl, executionKey, fetchImpl}) -> Promise<Array<{key, account, status}>>、fetchAccessToken({baseUrl, executionKey, key, fetchImpl}) -> Promise<string> -
Step 1: 失敗するテストを書く
apps/lineworks-form-sync/test/userAuthClient.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { listConnectionKeys, fetchAccessToken } = require("../src/lib/userAuthClient");
test("listConnectionKeys calls GET /keys with X-Execution-Key and returns the keys array", async () => {
let capturedUrl, capturedHeaders;
const fetchImpl = async (url, opts) => {
capturedUrl = url;
capturedHeaders = opts.headers;
return { ok: true, json: async () => ({ keys: [{ key: "lineworks-form-sync", account: "svc@example.co.jp", status: "有効" }] }) };
};
const keys = await listConnectionKeys({ baseUrl: "https://lwauth29.next-hd.net", executionKey: "exec-1", fetchImpl });
assert.strictEqual(capturedUrl, "https://lwauth29.next-hd.net/keys");
assert.strictEqual(capturedHeaders["X-Execution-Key"], "exec-1");
assert.deepStrictEqual(keys, [{ key: "lineworks-form-sync", account: "svc@example.co.jp", status: "有効" }]);
});
test("fetchAccessToken calls GET /token?key=... and returns accessToken", async () => {
let capturedUrl;
const fetchImpl = async (url) => { capturedUrl = url; return { ok: true, json: async () => ({ accessToken: "at-1" }) }; };
const token = await fetchAccessToken({ baseUrl: "https://lwauth29.next-hd.net", executionKey: "exec-1", key: "lineworks-form-sync", fetchImpl });
assert.strictEqual(token, "at-1");
assert.strictEqual(capturedUrl, "https://lwauth29.next-hd.net/token?key=lineworks-form-sync");
});
test("fetchAccessToken throws with a descriptive message when the response is not ok", async () => {
const fetchImpl = async () => ({ ok: false, status: 401, json: async () => ({ error: "unauthorized" }) });
await assert.rejects(
() => fetchAccessToken({ baseUrl: "https://lwauth29.next-hd.net", executionKey: "bad", key: "lineworks-form-sync", fetchImpl }),
/401/
);
});
- Step 2: テスト失敗を確認
node --test test/userAuthClient.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-form-sync/src/lib/userAuthClient.js:
"use strict";
function buildUrl(baseUrl, pathname) {
return baseUrl.replace(/\/$/, "") + pathname;
}
async function listConnectionKeys({ baseUrl, executionKey, fetchImpl = fetch }) {
const res = await fetchImpl(buildUrl(baseUrl, "/keys"), { headers: { "X-Execution-Key": executionKey } });
const data = await res.json();
if (!res.ok) {
throw new Error(`lineworks-user-auth /keys failed: ${res.status} ${JSON.stringify(data)}`);
}
return data.keys;
}
async function fetchAccessToken({ baseUrl, executionKey, key, fetchImpl = fetch }) {
const url = buildUrl(baseUrl, `/token?key=${encodeURIComponent(key)}`);
const res = await fetchImpl(url, { headers: { "X-Execution-Key": executionKey } });
const data = await res.json();
if (!res.ok) {
throw new Error(`lineworks-user-auth /token failed: ${res.status} ${JSON.stringify(data)}`);
}
return data.accessToken;
}
module.exports = { listConnectionKeys, fetchAccessToken };
- Step 4: テスト成功を確認
node --test test/userAuthClient.test.js
Expected: PASS(3 tests)
- Step 5: Commit
git add apps/lineworks-form-sync/src/lib/userAuthClient.js apps/lineworks-form-sync/test/userAuthClient.test.js
git commit -m "feat(lineworks-form-sync): lineworks-user-authクライアントを実装"
Task 7: フォーム管理テーブル物理列マッピングとストア
Files:
- Create:
apps/lineworks-form-sync/src/config/formManagementColumns.js - Create:
apps/lineworks-form-sync/src/lib/formManagementStore.js - Test:
apps/lineworks-form-sync/test/formManagementStore.test.js
Interfaces:
-
Consumes:
getSiteItems/updateSiteItem(Task 5) -
Produces:
listAll({baseUrl, apiKey, siteId, fetchImpl}) -> Promise<FormConfig[]>findByFormId({baseUrl, apiKey, siteId, formId, fetchImpl}) -> Promise<FormConfig|null>saveManualRunSettings({baseUrl, apiKey, itemId, connectionKey, intervalMinutes, fetchImpl, now}) -> Promise<void>(連携キー・周期保存+手動実行済みフラグON+最終手動実行日時更新)saveQuestionMapping({baseUrl, apiKey, itemId, mapping, fetchImpl}) -> Promise<void>touchAutoRun({baseUrl, apiKey, itemId, fetchImpl, now}) -> Promise<void>FormConfigの形:{ itemId, formId, formName, answerSiteId(Number), userMatchColumn, connectionKey, intervalMinutes(Number|null), questionMapping(Array), manualRunDone(Boolean), lastManualRunAt, lastAutoRunAt }
-
Step 1: 物理列マッピング定数を書く
apps/lineworks-form-sync/src/config/formManagementColumns.js:
"use strict";
// プリザンター「フォーム管理テーブル」の物理列マッピング。
// テーブル自体はユーザー側が下記構成で作成する(docs/superpowers/plans/2026-08-24-lineworks-form-pleasanter-sync.md「事前準備」参照)。
module.exports = {
FORM_ID: "ClassA",
FORM_NAME: "ClassB",
ANSWER_SITE_ID: "Class001",
USER_MATCH_COLUMN: "Class002",
CONNECTION_KEY: "Class003",
INTERVAL_MINUTES: "Num001",
QUESTION_MAPPING: "Description001",
MANUAL_RUN_DONE: "Check001",
LAST_MANUAL_RUN_AT: "Date001",
LAST_AUTO_RUN_AT: "Date002",
};
- Step 2: 失敗するテストを書く
apps/lineworks-form-sync/test/formManagementStore.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { listAll, findByFormId, saveManualRunSettings, saveQuestionMapping, touchAutoRun } = require("../src/lib/formManagementStore");
const BASE = { baseUrl: "https://example.test/", apiKey: "k", siteId: 1 };
function rawItem(overrides = {}) {
return {
ResultId: 10,
ClassHash: { ClassA: "form-1", ClassB: "サンプルアンケート", Class001: "5001", Class002: "Class005", Class003: "lineworks-form-sync" },
NumHash: { Num001: 60 },
DescriptionHash: { Description001: JSON.stringify([{ questionId: "q1", questionType: "TEXT", title: "t", targetColumn: "Class010" }]) },
CheckHash: { Check001: true },
DateHash: { Date001: "2026-08-24T00:00:00.000Z", Date002: "2026-08-24T01:00:00.000Z" },
...overrides,
};
}
test("listAll maps physical columns into FormConfig objects", async () => {
const fetchImpl = async () => ({ ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [rawItem()], TotalCount: 1 } }) });
const configs = await listAll({ ...BASE, fetchImpl });
assert.strictEqual(configs.length, 1);
assert.strictEqual(configs[0].formId, "form-1");
assert.strictEqual(configs[0].formName, "サンプルアンケート");
assert.strictEqual(configs[0].answerSiteId, 5001);
assert.strictEqual(configs[0].intervalMinutes, 60);
assert.strictEqual(configs[0].manualRunDone, true);
assert.deepStrictEqual(configs[0].questionMapping, [{ questionId: "q1", questionType: "TEXT", title: "t", targetColumn: "Class010" }]);
});
test("findByFormId returns null when no matching formId", async () => {
const fetchImpl = async () => ({ ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [], TotalCount: 0 } }) });
const config = await findByFormId({ ...BASE, formId: "unknown", fetchImpl });
assert.strictEqual(config, null);
});
test("findByFormId returns the matching FormConfig with manualRunDone false when Check001 is absent", async () => {
const fetchImpl = async () => ({ ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [rawItem({ CheckHash: {} })], TotalCount: 1 } }) });
const config = await findByFormId({ ...BASE, formId: "form-1", fetchImpl });
assert.strictEqual(config.manualRunDone, false);
});
test("treats Pleasanter's empty-date sentinel (1899-12-30T00:00:00) as null, and 0 interval as unconfigured", async () => {
const fetchImpl = async () => ({
ok: true,
json: async () => ({
StatusCode: 200,
Response: {
Data: [rawItem({
NumHash: { Num001: 0 },
DateHash: { Date001: "1899-12-30T00:00:00", Date002: "1899-12-30T00:00:00" },
})],
TotalCount: 1,
},
}),
});
const config = await findByFormId({ ...BASE, formId: "form-1", fetchImpl });
assert.strictEqual(config.lastManualRunAt, null);
assert.strictEqual(config.lastAutoRunAt, null);
assert.strictEqual(config.intervalMinutes, null);
});
test("saveManualRunSettings updates connectionKey/interval/flag/lastManualRunAt in one call", async () => {
let capturedUrl, capturedBody;
const fetchImpl = async (url, opts) => { capturedUrl = url; capturedBody = JSON.parse(opts.body); return { ok: true, json: async () => ({ Id: 10, StatusCode: 200 }) }; };
await saveManualRunSettings({ baseUrl: "https://example.test/", apiKey: "k", itemId: 10, connectionKey: "lineworks-form-sync", intervalMinutes: 30, fetchImpl, now: () => new Date("2026-08-24T02:00:00.000Z") });
assert.strictEqual(capturedUrl, "https://example.test/api/items/10/update");
assert.strictEqual(capturedBody.ClassHash.Class003, "lineworks-form-sync");
assert.strictEqual(capturedBody.NumHash.Num001, 30);
assert.strictEqual(capturedBody.CheckHash.Check001, true);
assert.strictEqual(capturedBody.DateHash.Date001, "2026-08-24T02:00:00.000Z");
});
test("saveQuestionMapping stores the mapping array as a pretty-printed JSON string (2-space indent, readable when editing targetColumn directly in Pleasanter)", async () => {
let capturedBody;
const fetchImpl = async (url, opts) => { capturedBody = JSON.parse(opts.body); return { ok: true, json: async () => ({ Id: 10, StatusCode: 200 }) }; };
const mapping = [{ questionId: "q1", questionType: "TEXT", title: "t", targetColumn: "" }];
await saveQuestionMapping({ baseUrl: "https://example.test/", apiKey: "k", itemId: 10, mapping, fetchImpl });
assert.strictEqual(capturedBody.DescriptionHash.Description001, JSON.stringify(mapping, null, 2));
});
test("touchAutoRun updates only lastAutoRunAt", async () => {
let capturedBody;
const fetchImpl = async (url, opts) => { capturedBody = JSON.parse(opts.body); return { ok: true, json: async () => ({ Id: 10, StatusCode: 200 }) }; };
await touchAutoRun({ baseUrl: "https://example.test/", apiKey: "k", itemId: 10, fetchImpl, now: () => new Date("2026-08-24T03:00:00.000Z") });
assert.deepStrictEqual(capturedBody.DateHash, { Date002: "2026-08-24T03:00:00.000Z" });
});
- Step 3: テスト失敗を確認
node --test test/formManagementStore.test.js
Expected: FAIL(モジュール未存在)
- Step 4: 実装
apps/lineworks-form-sync/src/lib/formManagementStore.js:
未設定のDate型列はnull/空文字でなく、プリザンター内部のNULL相当表現"1899-12-30T00:00:00"が返る(実機確認済み、2026-08-24)。このセンチネル値を「未設定」として扱う変換を行う。
"use strict";
const COLS = require("../config/formManagementColumns");
const { getSiteItems, updateSiteItem } = require("./pleasanterClient");
const EMPTY_DATE_SENTINEL = "1899-12-30T00:00:00";
function normalizeDate(value) {
if (!value || value.startsWith(EMPTY_DATE_SENTINEL)) return null;
return value;
}
function toConfig(item) {
return {
itemId: item.ResultId,
formId: item[COLS.FORM_ID],
formName: item[COLS.FORM_NAME] || "",
answerSiteId: Number(item[COLS.ANSWER_SITE_ID]),
userMatchColumn: item[COLS.USER_MATCH_COLUMN],
connectionKey: item[COLS.CONNECTION_KEY] || null,
intervalMinutes: item[COLS.INTERVAL_MINUTES] ? Number(item[COLS.INTERVAL_MINUTES]) : null,
questionMapping: item[COLS.QUESTION_MAPPING] ? JSON.parse(item[COLS.QUESTION_MAPPING]) : [],
manualRunDone: !!item[COLS.MANUAL_RUN_DONE],
lastManualRunAt: normalizeDate(item[COLS.LAST_MANUAL_RUN_AT]),
lastAutoRunAt: normalizeDate(item[COLS.LAST_AUTO_RUN_AT]),
};
}
async function listAll({ baseUrl, apiKey, siteId, fetchImpl = fetch }) {
const items = await getSiteItems({ baseUrl, apiKey, siteId, fetchImpl });
return items.map(toConfig);
}
async function findByFormId({ baseUrl, apiKey, siteId, formId, fetchImpl = fetch }) {
const items = await getSiteItems({ baseUrl, apiKey, siteId, fetchImpl });
const match = items.find((item) => item[COLS.FORM_ID] === formId);
return match ? toConfig(match) : null;
}
async function saveManualRunSettings({ baseUrl, apiKey, itemId, connectionKey, intervalMinutes, fetchImpl = fetch, now = () => new Date() }) {
await updateSiteItem({
baseUrl, apiKey, itemId,
fields: {
[COLS.CONNECTION_KEY]: connectionKey,
[COLS.INTERVAL_MINUTES]: intervalMinutes,
[COLS.MANUAL_RUN_DONE]: true,
[COLS.LAST_MANUAL_RUN_AT]: now().toISOString(),
},
fetchImpl,
});
}
async function saveQuestionMapping({ baseUrl, apiKey, itemId, mapping, fetchImpl = fetch }) {
// 整形JSON(2スペースインデント)で保存する。プリザンター上でtargetColumnを直接編集する運用のため、
// 1行に詰まったJSONより可読性を優先する(2026-08-29、ユーザー確認済み)。
await updateSiteItem({ baseUrl, apiKey, itemId, fields: { [COLS.QUESTION_MAPPING]: JSON.stringify(mapping, null, 2) }, fetchImpl });
}
async function touchAutoRun({ baseUrl, apiKey, itemId, fetchImpl = fetch, now = () => new Date() }) {
await updateSiteItem({ baseUrl, apiKey, itemId, fields: { [COLS.LAST_AUTO_RUN_AT]: now().toISOString() }, fetchImpl });
}
module.exports = { listAll, findByFormId, saveManualRunSettings, saveQuestionMapping, touchAutoRun };
- Step 5: テスト成功を確認
node --test test/formManagementStore.test.js
Expected: PASS(7 tests)
- Step 6: Commit
git add apps/lineworks-form-sync/src/config/formManagementColumns.js apps/lineworks-form-sync/src/lib/formManagementStore.js apps/lineworks-form-sync/test/formManagementStore.test.js
git commit -m "feat(lineworks-form-sync): フォーム管理テーブルストアを実装"
Task 8: LINEWORKS Form APIクライアント
Files:
- Create:
apps/lineworks-form-sync/src/lib/formsClient.js - Test:
apps/lineworks-form-sync/test/formsClient.test.js
Interfaces:
-
Produces:
getResponses({accessToken, formId, cursor, count, fetchImpl}) -> Promise<{responses, nextCursor}>、getAttachment({accessToken, formId, responseId, attachmentId, fetchImpl}) -> Promise<{contentType, arrayBuffer}> -
Step 1: 失敗するテストを書く
apps/lineworks-form-sync/test/formsClient.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { getResponses, getAttachment } = require("../src/lib/formsClient");
test("getResponses calls the correct endpoint with Bearer auth and returns responses + nextCursor", async () => {
let capturedUrl, capturedHeaders;
const fetchImpl = async (url, opts) => {
capturedUrl = url;
capturedHeaders = opts.headers;
return { ok: true, json: async () => ({ responses: [{ formId: "f1" }], responseMetaData: { nextCursor: "abc" } }) };
};
const result = await getResponses({ accessToken: "at-1", formId: "f1", fetchImpl });
assert.strictEqual(capturedUrl, "https://www.worksapis.com/v1.0/forms/f1/responses?count=500");
assert.strictEqual(capturedHeaders.Authorization, "Bearer at-1");
assert.deepStrictEqual(result.responses, [{ formId: "f1" }]);
assert.strictEqual(result.nextCursor, "abc");
});
test("getResponses includes cursor param when provided", async () => {
let capturedUrl;
const fetchImpl = async (url) => { capturedUrl = url; return { ok: true, json: async () => ({ responses: [], responseMetaData: { nextCursor: null } }) }; };
await getResponses({ accessToken: "at-1", formId: "f1", cursor: "xyz", fetchImpl });
assert.ok(capturedUrl.includes("cursor=xyz"));
});
test("getResponses throws when the response is not ok", async () => {
const fetchImpl = async () => ({ ok: false, status: 403, json: async () => ({ error: "forbidden" }) });
await assert.rejects(() => getResponses({ accessToken: "bad", formId: "f1", fetchImpl }), /403/);
});
test("getAttachment fetches the binary endpoint and returns contentType + arrayBuffer", async () => {
let capturedUrl;
const fetchImpl = async (url) => {
capturedUrl = url;
return { ok: true, headers: new Map([["content-type", "image/png"]]), arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer };
};
const result = await getAttachment({ accessToken: "at-1", formId: "f1", responseId: "r1", attachmentId: "a1", fetchImpl });
assert.strictEqual(capturedUrl, "https://www.worksapis.com/v1.0/forms/f1/responses/r1/attachments/a1");
assert.strictEqual(result.contentType, "image/png");
assert.strictEqual(result.arrayBuffer.byteLength, 3);
});
- Step 2: テスト失敗を確認
node --test test/formsClient.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-form-sync/src/lib/formsClient.js:
"use strict";
const BASE_URL = "https://www.worksapis.com/v1.0";
async function getResponses({ accessToken, formId, cursor, count = 500, fetchImpl = fetch }) {
const url = new URL(`${BASE_URL}/forms/${formId}/responses`);
if (cursor) url.searchParams.set("cursor", cursor);
url.searchParams.set("count", String(count));
const res = await fetchImpl(url.toString(), { headers: { Authorization: `Bearer ${accessToken}` } });
const data = await res.json();
if (!res.ok) {
throw new Error(`LINEWORKS form responses fetch failed: ${res.status} ${JSON.stringify(data)}`);
}
return { responses: data.responses, nextCursor: data.responseMetaData ? data.responseMetaData.nextCursor : null };
}
async function getAttachment({ accessToken, formId, responseId, attachmentId, fetchImpl = fetch }) {
const url = `${BASE_URL}/forms/${formId}/responses/${responseId}/attachments/${attachmentId}`;
const res = await fetchImpl(url, { headers: { Authorization: `Bearer ${accessToken}` } });
if (!res.ok) {
throw new Error(`LINEWORKS attachment fetch failed: ${res.status}`);
}
return { contentType: res.headers.get("content-type"), arrayBuffer: await res.arrayBuffer() };
}
module.exports = { getResponses, getAttachment };
- Step 4: テスト成功を確認
node --test test/formsClient.test.js
Expected: PASS(4 tests)
- Step 5: Commit
git add apps/lineworks-form-sync/src/lib/formsClient.js apps/lineworks-form-sync/test/formsClient.test.js
git commit -m "feat(lineworks-form-sync): LINEWORKS Form APIクライアントを実装"
Task 9: 回答値変換ロジック(answerMapper)
questionType別のプリザンター格納値変換。設計書「questionType別 格納ルール」参照。
Files:
- Create:
apps/lineworks-form-sync/src/lib/answerMapper.js - Test:
apps/lineworks-form-sync/test/answerMapper.test.js
Interfaces:
-
Produces:
mapAnswerValue({questionType, answers, targetColumn}) -> string|null(ATTACHMENT型は呼び出し禁止、呼んだらthrow) -
Step 1: 失敗するテストを書く
apps/lineworks-form-sync/test/answerMapper.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { mapAnswerValue } = require("../src/lib/answerMapper");
test("single-value types return answers[0] as-is for a Class column", () => {
assert.strictEqual(mapAnswerValue({ questionType: "SINGLE_CHOICE", answers: ["A"], targetColumn: "Class010" }), "A");
assert.strictEqual(mapAnswerValue({ questionType: "DROPDOWN", answers: ["A"], targetColumn: "Class010" }), "A");
assert.strictEqual(mapAnswerValue({ questionType: "TEXT", answers: ["あ\nい"], targetColumn: "Description001" }), "あ\nい");
assert.strictEqual(mapAnswerValue({ questionType: "RATING", answers: ["3"], targetColumn: "Class010" }), "3");
});
test("multi-value types join answers with a comma for a Class/Description column", () => {
assert.strictEqual(mapAnswerValue({ questionType: "MULTI_CHOICE", answers: ["B", "A"], targetColumn: "Class010" }), "B,A");
assert.strictEqual(mapAnswerValue({ questionType: "MULTIPLE_DATE", answers: ["2026-08-25T00:00:00+09:00", "2026-08-24T00:00:00+09:00"], targetColumn: "Description001" }), "2026-08-25T00:00:00+09:00,2026-08-24T00:00:00+09:00");
});
test("SINGLE_DATE (schedule poll) is treated as text, not a Date column, even with a single answer", () => {
assert.strictEqual(mapAnswerValue({ questionType: "SINGLE_DATE", answers: ["2026-08-24T00:00:00+09:00"], targetColumn: "Class010" }), "2026-08-24T00:00:00+09:00");
});
test("DATE_INPUT/DATETIME_INPUT return answers[0] when targetColumn is a Date column", () => {
assert.strictEqual(mapAnswerValue({ questionType: "DATE_INPUT", answers: ["2026-08-12T00:00:00+09:00"], targetColumn: "Date001" }), "2026-08-12T00:00:00+09:00");
assert.strictEqual(mapAnswerValue({ questionType: "DATETIME_INPUT", answers: ["2026-08-24T02:30:00+09:00"], targetColumn: "Date001" }), "2026-08-24T02:30:00+09:00");
});
test("DATE_INPUT/DATETIME_INPUT also work when targetColumn is a Class/Description column", () => {
assert.strictEqual(mapAnswerValue({ questionType: "DATE_INPUT", answers: ["2026-08-12T00:00:00+09:00"], targetColumn: "Class010" }), "2026-08-12T00:00:00+09:00");
});
test("throws when targetColumn is a Date column but questionType is not DATE_INPUT/DATETIME_INPUT", () => {
assert.throws(() => mapAnswerValue({ questionType: "SINGLE_DATE", answers: ["2026-08-24T00:00:00+09:00"], targetColumn: "Date001" }), /Date001/);
});
test("throws when called with ATTACHMENT questionType (handled by a separate route)", () => {
assert.throws(() => mapAnswerValue({ questionType: "ATTACHMENT", answers: ["123"], targetColumn: "AttachmentsA" }), /ATTACHMENT/);
});
test("returns empty string when answers is empty (unanswered optional question)", () => {
assert.strictEqual(mapAnswerValue({ questionType: "TEXT", answers: [], targetColumn: "Class010" }), "");
});
- Step 2: テスト失敗を確認
node --test test/answerMapper.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-form-sync/src/lib/answerMapper.js:
"use strict";
const DATE_INPUT_TYPES = new Set(["DATE_INPUT", "DATETIME_INPUT"]);
function mapAnswerValue({ questionType, answers, targetColumn }) {
if (questionType === "ATTACHMENT") {
throw new Error("ATTACHMENT型はmapAnswerValueでなく添付ファイル専用ルートで処理する");
}
const isDateColumn = targetColumn.startsWith("Date");
const isDateInputType = DATE_INPUT_TYPES.has(questionType);
if (isDateColumn && !isDateInputType) {
throw new Error(`targetColumn ${targetColumn} はDate型列だが questionType ${questionType} は対応していません(DATE_INPUT/DATETIME_INPUTのみ対応)`);
}
return (answers || []).join(",");
}
module.exports = { mapAnswerValue };
- Step 4: テスト成功を確認
node --test test/answerMapper.test.js
Expected: PASS(8 tests)
- Step 5: Commit
git add apps/lineworks-form-sync/src/lib/answerMapper.js apps/lineworks-form-sync/test/answerMapper.test.js
git commit -m "feat(lineworks-form-sync): questionType別回答値変換ロジックを実装"
Task 10: マッピング雛形生成・マージロジック
Files:
- Create:
apps/lineworks-form-sync/src/lib/mappingGenerator.js - Test:
apps/lineworks-form-sync/test/mappingGenerator.test.js
Interfaces:
-
Produces:
buildQuestionMapping({existingMapping, fetchedQuestions}) -> Array<{questionId, questionType, title, targetColumn}>(fetchedQuestionsはLINEWORKS APIのquestions配列そのもの) -
Step 1: 失敗するテストを書く
apps/lineworks-form-sync/test/mappingGenerator.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { buildQuestionMapping } = require("../src/lib/mappingGenerator");
test("builds a fresh mapping with empty targetColumn when there is no existing mapping", () => {
const fetchedQuestions = [{ questionId: "q1", questionType: "TEXT", title: "質問1", description: "長文", required: true, answers: ["x"] }];
const mapping = buildQuestionMapping({ existingMapping: [], fetchedQuestions });
assert.deepStrictEqual(mapping, [{ questionId: "q1", questionType: "TEXT", title: "質問1", targetColumn: "" }]);
});
test("preserves targetColumn for questionIds that already exist in the mapping", () => {
const existingMapping = [{ questionId: "q1", questionType: "TEXT", title: "質問1", targetColumn: "Class010" }];
const fetchedQuestions = [{ questionId: "q1", questionType: "TEXT", title: "質問1(改題)", answers: ["x"] }];
const mapping = buildQuestionMapping({ existingMapping, fetchedQuestions });
assert.strictEqual(mapping[0].targetColumn, "Class010");
assert.strictEqual(mapping[0].title, "質問1(改題)");
});
test("appends newly-added questions with an empty targetColumn while keeping existing ones", () => {
const existingMapping = [{ questionId: "q1", questionType: "TEXT", title: "質問1", targetColumn: "Class010" }];
const fetchedQuestions = [
{ questionId: "q1", questionType: "TEXT", title: "質問1", answers: ["x"] },
{ questionId: "q2", questionType: "SINGLE_CHOICE", title: "質問2", answers: ["A"] },
];
const mapping = buildQuestionMapping({ existingMapping, fetchedQuestions });
assert.strictEqual(mapping.length, 2);
assert.strictEqual(mapping[1].targetColumn, "");
});
- Step 2: テスト失敗を確認
node --test test/mappingGenerator.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-form-sync/src/lib/mappingGenerator.js:
"use strict";
function buildQuestionMapping({ existingMapping = [], fetchedQuestions }) {
const existingByQid = new Map(existingMapping.map((q) => [q.questionId, q]));
return fetchedQuestions.map((q) => {
const existing = existingByQid.get(q.questionId);
return {
questionId: q.questionId,
questionType: q.questionType,
title: q.title,
targetColumn: existing ? existing.targetColumn : "",
};
});
}
module.exports = { buildQuestionMapping };
- Step 4: テスト成功を確認
node --test test/mappingGenerator.test.js
Expected: PASS(3 tests)
- Step 5: Commit
git add apps/lineworks-form-sync/src/lib/mappingGenerator.js apps/lineworks-form-sync/test/mappingGenerator.test.js
git commit -m "feat(lineworks-form-sync): マッピング雛形生成・マージロジックを実装"
Task 11: 添付ファイルアップロード変換
LINEWORKS添付ファイル(バイナリ)をプリザンターのAttachmentsHash形式(Base64)へ変換する。
Files:
- Create:
apps/lineworks-form-sync/src/lib/attachmentUploader.js - Test:
apps/lineworks-form-sync/test/attachmentUploader.test.js
Interfaces:
-
Consumes:
getAttachment(Task 8) -
Produces:
buildAttachmentField({accessToken, formId, responseId, attachmentId, fileName, fetchImpl}) -> Promise<{ContentType, Name, Base64}> -
Step 1: 失敗するテストを書く
apps/lineworks-form-sync/test/attachmentUploader.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { buildAttachmentField } = require("../src/lib/attachmentUploader");
test("downloads the attachment and returns a Pleasanter AttachmentsHash entry (Base64-encoded)", async () => {
const fetchImpl = async () => ({
ok: true,
headers: new Map([["content-type", "image/png"]]),
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
});
const field = await buildAttachmentField({ accessToken: "at-1", formId: "f1", responseId: "r1", attachmentId: "a1", fileName: "photo.png", fetchImpl });
assert.strictEqual(field.ContentType, "image/png");
assert.strictEqual(field.Name, "photo.png");
assert.strictEqual(field.Base64, Buffer.from([1, 2, 3]).toString("base64"));
});
- Step 2: テスト失敗を確認
node --test test/attachmentUploader.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-form-sync/src/lib/attachmentUploader.js:
"use strict";
const { getAttachment } = require("./formsClient");
async function buildAttachmentField({ accessToken, formId, responseId, attachmentId, fileName, fetchImpl = fetch }) {
const { contentType, arrayBuffer } = await getAttachment({ accessToken, formId, responseId, attachmentId, fetchImpl });
return {
ContentType: contentType,
Name: fileName,
Base64: Buffer.from(arrayBuffer).toString("base64"),
};
}
module.exports = { buildAttachmentField };
- Step 4: テスト成功を確認
node --test test/attachmentUploader.test.js
Expected: PASS(1 test)
- Step 5: Commit
git add apps/lineworks-form-sync/src/lib/attachmentUploader.js apps/lineworks-form-sync/test/attachmentUploader.test.js
git commit -m "feat(lineworks-form-sync): 添付ファイルのプリザンター形式変換を実装"
Task 12: Upsert投入ロジック(responseSync)
回答1件をプリザンター回答格納先テーブルへUpsertするコア処理。
Files:
- Create:
apps/lineworks-form-sync/src/lib/responseSync.js - Test:
apps/lineworks-form-sync/test/responseSync.test.js
Interfaces:
-
Consumes:
getSiteItems/createSiteItem/updateSiteItem(Task 5)、mapAnswerValue(Task 9)、buildAttachmentField(Task 11) -
Produces:
syncResponse({response, formConfig, accessToken, pleasanter, lineworksFetchImpl, pleasanterFetchImpl}) -> Promise<"created"|"updated">response: LINEWORKS APIの1回答オブジェクト(formId/responseId/respondent/questionsを含む)formConfig:{formId, answerSiteId, userMatchColumn, questionMapping}(Task 7のFormConfigのサブセット)pleasanter:{baseUrl, apiKey}
-
Step 1: 失敗するテストを書く
apps/lineworks-form-sync/test/responseSync.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { syncResponse } = require("../src/lib/responseSync");
const FORM_CONFIG = {
formId: "f1",
answerSiteId: 5001,
userMatchColumn: "Class005",
questionMapping: [
{ questionId: "q1", questionType: "TEXT", title: "自由記述", targetColumn: "Class010" },
{ questionId: "q2", questionType: "ATTACHMENT", title: "添付", targetColumn: "AttachmentsA" },
],
};
function response(overrides = {}) {
return {
formId: "f1",
responseId: "r1",
respondent: { email: "taro@example.co.jp" },
questions: [
{ questionId: "q1", questionType: "TEXT", answers: ["回答テキスト"] },
{ questionId: "q2", questionType: "ATTACHMENT", answers: ["att-1"] },
],
...overrides,
};
}
test("creates a new item when no existing record matches the respondent email", async () => {
let createBody;
const pleasanterFetchImpl = async (url, opts) => {
if (url.endsWith("/get")) return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [], TotalCount: 0 } }) };
if (url.endsWith("/create")) { createBody = JSON.parse(opts.body); return { ok: true, json: async () => ({ Id: 99, StatusCode: 200 }) }; }
throw new Error(`unexpected ${url}`);
};
const lineworksFetchImpl = async () => ({ ok: true, headers: new Map([["content-type", "image/png"]]), arrayBuffer: async () => new Uint8Array([9]).buffer });
const result = await syncResponse({
response: response(), formConfig: FORM_CONFIG, accessToken: "at-1",
pleasanter: { baseUrl: "https://example.test/", apiKey: "k" },
lineworksFetchImpl, pleasanterFetchImpl,
});
assert.strictEqual(result, "created");
assert.strictEqual(createBody.ClassHash.Class005, "taro@example.co.jp");
assert.strictEqual(createBody.ClassHash.Class010, "回答テキスト");
assert.strictEqual(createBody.AttachmentsHash.AttachmentsA[0].Base64, Buffer.from([9]).toString("base64"));
});
test("updates the existing item when the respondent email already matches a record", async () => {
let capturedUrl, updateBody;
const pleasanterFetchImpl = async (url, opts) => {
if (url.endsWith("/get")) return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [{ ResultId: 7, ClassHash: { Class005: "taro@example.co.jp" } }], TotalCount: 1 } }) };
capturedUrl = url;
updateBody = JSON.parse(opts.body);
return { ok: true, json: async () => ({ Id: 7, StatusCode: 200 }) };
};
const lineworksFetchImpl = async () => ({ ok: true, headers: new Map([["content-type", "image/png"]]), arrayBuffer: async () => new Uint8Array([9]).buffer });
const result = await syncResponse({
response: response(), formConfig: FORM_CONFIG, accessToken: "at-1",
pleasanter: { baseUrl: "https://example.test/", apiKey: "k" },
lineworksFetchImpl, pleasanterFetchImpl,
});
assert.strictEqual(result, "updated");
assert.strictEqual(capturedUrl, "https://example.test/api/items/7/update");
assert.strictEqual(updateBody.ClassHash.Class010, "回答テキスト");
});
test("skips questions whose questionId has no mapping entry or an empty targetColumn", async () => {
const formConfig = { ...FORM_CONFIG, questionMapping: [{ questionId: "q-unmapped", questionType: "TEXT", title: "t", targetColumn: "" }] };
let createBody;
const pleasanterFetchImpl = async (url, opts) => {
if (url.endsWith("/get")) return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [], TotalCount: 0 } }) };
createBody = JSON.parse(opts.body);
return { ok: true, json: async () => ({ Id: 99, StatusCode: 200 }) };
};
await syncResponse({
response: response(), formConfig, accessToken: "at-1",
pleasanter: { baseUrl: "https://example.test/", apiKey: "k" },
lineworksFetchImpl: async () => { throw new Error("should not be called"); }, pleasanterFetchImpl,
});
assert.strictEqual(createBody.ClassHash.Class005, "taro@example.co.jp");
assert.strictEqual(Object.keys(createBody.ClassHash).length, 1);
});
- Step 2: テスト失敗を確認
node --test test/responseSync.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-form-sync/src/lib/responseSync.js:
"use strict";
const { getSiteItems, createSiteItem, updateSiteItem } = require("./pleasanterClient");
const { mapAnswerValue } = require("./answerMapper");
const { buildAttachmentField } = require("./attachmentUploader");
async function buildFields({ response, formConfig, accessToken, lineworksFetchImpl }) {
const fields = { [formConfig.userMatchColumn]: response.respondent.email };
for (const question of response.questions) {
const mapping = formConfig.questionMapping.find((m) => m.questionId === question.questionId);
if (!mapping || !mapping.targetColumn) continue;
if (question.questionType === "ATTACHMENT") {
const attachmentId = question.answers[0];
if (!attachmentId) continue;
fields[mapping.targetColumn] = [
await buildAttachmentField({
accessToken, formId: formConfig.formId, responseId: response.responseId, attachmentId,
fileName: attachmentId, fetchImpl: lineworksFetchImpl,
}),
];
continue;
}
fields[mapping.targetColumn] = mapAnswerValue({ questionType: question.questionType, answers: question.answers, targetColumn: mapping.targetColumn });
}
return fields;
}
async function syncResponse({ response, formConfig, accessToken, pleasanter, lineworksFetchImpl = fetch, pleasanterFetchImpl = fetch }) {
const fields = await buildFields({ response, formConfig, accessToken, lineworksFetchImpl });
const items = await getSiteItems({ baseUrl: pleasanter.baseUrl, apiKey: pleasanter.apiKey, siteId: formConfig.answerSiteId, fetchImpl: pleasanterFetchImpl });
const existing = items.find((item) => item[formConfig.userMatchColumn] === response.respondent.email);
if (existing) {
await updateSiteItem({ baseUrl: pleasanter.baseUrl, apiKey: pleasanter.apiKey, itemId: existing.ResultId, fields, fetchImpl: pleasanterFetchImpl });
return "updated";
}
await createSiteItem({ baseUrl: pleasanter.baseUrl, apiKey: pleasanter.apiKey, siteId: formConfig.answerSiteId, fields, fetchImpl: pleasanterFetchImpl });
return "created";
}
module.exports = { syncResponse };
- Step 4: テスト成功を確認
node --test test/responseSync.test.js
Expected: PASS(3 tests)
- Step 5: Commit
git add apps/lineworks-form-sync/src/lib/responseSync.js apps/lineworks-form-sync/test/responseSync.test.js
git commit -m "feat(lineworks-form-sync): 回答Upsert投入ロジックを実装"
Task 13: フォーム同期オーケストレーション(全ページ処理)
1フォーム分の全ページを回してsyncResponseを呼ぶ。
Files:
- Create:
apps/lineworks-form-sync/src/lib/formSyncRunner.js - Test:
apps/lineworks-form-sync/test/formSyncRunner.test.js
Interfaces:
-
Consumes:
getResponses(Task 8)、syncResponse(Task 12) -
Produces:
runFormSync({formConfig, accessToken, pleasanter, lineworksFetchImpl, pleasanterFetchImpl}) -> Promise<{processed, created, updated}> -
Step 1: 失敗するテストを書く
apps/lineworks-form-sync/test/formSyncRunner.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { runFormSync } = require("../src/lib/formSyncRunner");
const FORM_CONFIG = {
formId: "f1", answerSiteId: 5001, userMatchColumn: "Class005",
questionMapping: [{ questionId: "q1", questionType: "TEXT", title: "t", targetColumn: "Class010" }],
};
test("pages through all responses via cursor and syncs each one", async () => {
let page = 0;
const lineworksFetchImpl = async (url) => {
page++;
if (page === 1) {
return { ok: true, json: async () => ({ responses: [{ formId: "f1", responseId: "r1", respondent: { email: "a@x.co.jp" }, questions: [{ questionId: "q1", questionType: "TEXT", answers: ["a"] }] }], responseMetaData: { nextCursor: "c2" } }) };
}
return { ok: true, json: async () => ({ responses: [{ formId: "f1", responseId: "r2", respondent: { email: "b@x.co.jp" }, questions: [{ questionId: "q1", questionType: "TEXT", answers: ["b"] }] }], responseMetaData: { nextCursor: null } }) };
};
let createCount = 0;
const pleasanterFetchImpl = async (url) => {
if (url.endsWith("/get")) return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [], TotalCount: 0 } }) };
createCount++;
return { ok: true, json: async () => ({ Id: 100 + createCount, StatusCode: 200 }) };
};
const result = await runFormSync({
formConfig: FORM_CONFIG, accessToken: "at-1",
pleasanter: { baseUrl: "https://example.test/", apiKey: "k" },
lineworksFetchImpl, pleasanterFetchImpl,
});
assert.strictEqual(result.processed, 2);
assert.strictEqual(result.created, 2);
assert.strictEqual(page, 2);
});
- Step 2: テスト失敗を確認
node --test test/formSyncRunner.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-form-sync/src/lib/formSyncRunner.js:
"use strict";
const { getResponses } = require("./formsClient");
const { syncResponse } = require("./responseSync");
async function runFormSync({ formConfig, accessToken, pleasanter, lineworksFetchImpl = fetch, pleasanterFetchImpl = fetch }) {
const result = { processed: 0, created: 0, updated: 0 };
let cursor = null;
do {
const { responses, nextCursor } = await getResponses({ accessToken, formId: formConfig.formId, cursor, fetchImpl: lineworksFetchImpl });
for (const response of responses) {
const outcome = await syncResponse({ response, formConfig, accessToken, pleasanter, lineworksFetchImpl, pleasanterFetchImpl });
result.processed++;
result[outcome]++;
}
cursor = nextCursor;
} while (cursor);
return result;
}
module.exports = { runFormSync };
- Step 4: テスト成功を確認
node --test test/formSyncRunner.test.js
Expected: PASS(1 test)
- Step 5: Commit
git add apps/lineworks-form-sync/src/lib/formSyncRunner.js apps/lineworks-form-sync/test/formSyncRunner.test.js
git commit -m "feat(lineworks-form-sync): フォーム同期オーケストレーション(cursorページング)を実装"
Task 14: /execute実行キー検証
Files:
- Create:
apps/lineworks-form-sync/src/lib/executeAuth.js - Test:
apps/lineworks-form-sync/test/executeAuth.test.js
Interfaces:
-
Produces:
verifyExecuteKey(providedKey, expectedKey) -> boolean -
Step 1: 失敗するテストを書く
apps/lineworks-form-sync/test/executeAuth.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { verifyExecuteKey } = require("../src/lib/executeAuth");
test("returns true only for an exact match", () => {
assert.strictEqual(verifyExecuteKey("secret", "secret"), true);
assert.strictEqual(verifyExecuteKey("wrong", "secret"), false);
});
test("returns false when providedKey is missing", () => {
assert.strictEqual(verifyExecuteKey(undefined, "secret"), false);
});
- Step 2: テスト失敗を確認
node --test test/executeAuth.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-form-sync/src/lib/executeAuth.js:
"use strict";
const crypto = require("node:crypto");
function verifyExecuteKey(providedKey, expectedKey) {
if (!providedKey || !expectedKey) return false;
const bufA = Buffer.from(String(providedKey));
const bufB = Buffer.from(String(expectedKey));
if (bufA.length !== bufB.length) {
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
module.exports = { verifyExecuteKey };
- Step 4: テスト成功を確認
node --test test/executeAuth.test.js
Expected: PASS(2 tests)
- Step 5: Commit
git add apps/lineworks-form-sync/src/lib/executeAuth.js apps/lineworks-form-sync/test/executeAuth.test.js
git commit -m "feat(lineworks-form-sync): /execute実行キー検証を実装"
Task 15: /executeオーケストレーション(全フォームループ・周期判定)
n8n Scheduleから呼ばれるメインロジック。
Files:
- Create:
apps/lineworks-form-sync/src/lib/executeService.js - Test:
apps/lineworks-form-sync/test/executeService.test.js
Interfaces:
-
Consumes:
listAll/touchAutoRun(Task 7)、fetchAccessToken(Task 6)、runFormSync(Task 13) -
Produces:
runScheduledExecution({formConfigStore, userAuth, pleasanter, now, lineworksFetchImpl, pleasanterFetchImpl, userAuthFetchImpl}) -> Promise<Array<{formId, status, detail}>>statusは"skipped-not-manually-run"/"skipped-interval-not-elapsed"/"synced"/"error"
-
Step 1: 失敗するテストを書く
apps/lineworks-form-sync/test/executeService.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { runScheduledExecution } = require("../src/lib/executeService");
function baseFormConfig(overrides = {}) {
return {
itemId: 10, formId: "f1", answerSiteId: 5001, userMatchColumn: "Class005",
connectionKey: "lineworks-form-sync", intervalMinutes: 60,
questionMapping: [], manualRunDone: true,
lastManualRunAt: "2026-08-24T00:00:00.000Z", lastAutoRunAt: null,
...overrides,
};
}
const PLEASANTER = { baseUrl: "https://example.test/", apiKey: "k" };
const USER_AUTH = { baseUrl: "https://lwauth29.next-hd.net", executionKey: "exec-1" };
test("skips forms that have never been manually run", async () => {
const formConfigStore = { listAll: async () => [baseFormConfig({ manualRunDone: false })] };
const results = await runScheduledExecution({ formConfigStore, userAuth: USER_AUTH, pleasanter: PLEASANTER, now: () => new Date() });
assert.strictEqual(results[0].status, "skipped-not-manually-run");
});
test("skips forms whose interval has not elapsed since lastAutoRunAt", async () => {
const formConfigStore = { listAll: async () => [baseFormConfig({ lastAutoRunAt: "2026-08-24T00:30:00.000Z", intervalMinutes: 60 })] };
const now = () => new Date("2026-08-24T00:45:00.000Z");
const results = await runScheduledExecution({ formConfigStore, userAuth: USER_AUTH, pleasanter: PLEASANTER, now });
assert.strictEqual(results[0].status, "skipped-interval-not-elapsed");
});
test("runs sync when the interval has elapsed, and records the outcome, touching lastAutoRunAt", async () => {
let touched = false;
const formConfigStore = {
listAll: async () => [baseFormConfig({ lastAutoRunAt: "2026-08-24T00:00:00.000Z", intervalMinutes: 60 })],
touchAutoRun: async () => { touched = true; },
};
const userAuthFetchImpl = async () => ({ ok: true, json: async () => ({ accessToken: "at-1" }) });
const lineworksFetchImpl = async () => ({ ok: true, json: async () => ({ responses: [], responseMetaData: { nextCursor: null } }) });
const now = () => new Date("2026-08-24T01:01:00.000Z");
const results = await runScheduledExecution({ formConfigStore, userAuth: USER_AUTH, pleasanter: PLEASANTER, now, userAuthFetchImpl, lineworksFetchImpl });
assert.strictEqual(results[0].status, "synced");
assert.strictEqual(touched, true);
});
test("runs immediately when lastAutoRunAt is null (first automatic run after manual setup)", async () => {
const formConfigStore = {
listAll: async () => [baseFormConfig({ lastAutoRunAt: null })],
touchAutoRun: async () => {},
};
const userAuthFetchImpl = async () => ({ ok: true, json: async () => ({ accessToken: "at-1" }) });
const lineworksFetchImpl = async () => ({ ok: true, json: async () => ({ responses: [], responseMetaData: { nextCursor: null } }) });
const results = await runScheduledExecution({ formConfigStore, userAuth: USER_AUTH, pleasanter: PLEASANTER, now: () => new Date(), userAuthFetchImpl, lineworksFetchImpl });
assert.strictEqual(results[0].status, "synced");
});
test("records an error status without throwing when sync fails for one form", async () => {
const formConfigStore = { listAll: async () => [baseFormConfig({ lastAutoRunAt: null })], touchAutoRun: async () => {} };
const userAuthFetchImpl = async () => ({ ok: false, status: 502, json: async () => ({ error: "down" }) });
const results = await runScheduledExecution({ formConfigStore, userAuth: USER_AUTH, pleasanter: PLEASANTER, now: () => new Date(), userAuthFetchImpl });
assert.strictEqual(results[0].status, "error");
assert.ok(results[0].detail.includes("502"));
});
- Step 2: テスト失敗を確認
node --test test/executeService.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装
apps/lineworks-form-sync/src/lib/executeService.js:
"use strict";
const { fetchAccessToken } = require("./userAuthClient");
const { runFormSync } = require("./formSyncRunner");
function intervalElapsed({ lastAutoRunAt, intervalMinutes, now }) {
if (!lastAutoRunAt) return true;
const elapsedMs = now.getTime() - new Date(lastAutoRunAt).getTime();
return elapsedMs >= intervalMinutes * 60 * 1000;
}
async function runScheduledExecution({ formConfigStore, userAuth, pleasanter, now = () => new Date(), userAuthFetchImpl = fetch, lineworksFetchImpl = fetch, pleasanterFetchImpl = fetch }) {
const formConfigs = await formConfigStore.listAll();
const results = [];
const nowDate = now();
for (const formConfig of formConfigs) {
if (!formConfig.manualRunDone) {
results.push({ formId: formConfig.formId, status: "skipped-not-manually-run" });
continue;
}
if (!intervalElapsed({ lastAutoRunAt: formConfig.lastAutoRunAt, intervalMinutes: formConfig.intervalMinutes, now: nowDate })) {
results.push({ formId: formConfig.formId, status: "skipped-interval-not-elapsed" });
continue;
}
try {
const accessToken = await fetchAccessToken({ baseUrl: userAuth.baseUrl, executionKey: userAuth.executionKey, key: formConfig.connectionKey, fetchImpl: userAuthFetchImpl });
await runFormSync({ formConfig, accessToken, pleasanter, lineworksFetchImpl, pleasanterFetchImpl });
await formConfigStore.touchAutoRun({ itemId: formConfig.itemId, now });
results.push({ formId: formConfig.formId, status: "synced" });
} catch (err) {
results.push({ formId: formConfig.formId, status: "error", detail: err.message });
}
}
return results;
}
module.exports = { runScheduledExecution };
- Step 4: テスト成功を確認
node --test test/executeService.test.js
Expected: PASS(5 tests)
- Step 5: Commit
git add apps/lineworks-form-sync/src/lib/executeService.js apps/lineworks-form-sync/test/executeService.test.js
git commit -m "feat(lineworks-form-sync): /execute全フォームループ・周期判定ロジックを実装"
Task 16: 管理画面認証(adminAuth.js)とHTML(adminView.js)
apps/lineworks-user-auth/src/adminAuth.jsを完全移植(Path等をこのアプリ用に変更)。adminView.jsはフォーム一覧・連携キー/周期選択・今すぐ実行・テスト回答取得を持つ新規HTML。
Files:
- Create:
apps/lineworks-form-sync/src/adminAuth.js - Create:
apps/lineworks-form-sync/src/adminView.js - Test:
apps/lineworks-form-sync/test/adminAuth.test.js - Test:
apps/lineworks-form-sync/test/adminView.test.js
Interfaces:
-
Produces(
adminAuth.js): project_lineworks_user_authのadminAuth.jsと同一のエクスポート一式(verifyMasterKey/createSessionToken/createTempKey/verifySessionToken/parseCookies/buildSessionCookieHeader/buildLogoutCookieHeader/SESSION_COOKIE_NAME/TEMP_KEY_TTL_MS)。Cookie名はlwform_admin_session、Path/manage -
Produces(
adminView.js):renderLoginPage(errorMessage)、renderDashboardPage({forms, connectionKeys, message})(formsはFormConfig[]+status表示用の整形済み値、connectionKeysはlineworks-user-authのGET /keys結果)、renderTempKeyPage({url}) -
Step 1: 失敗するテストを書く(adminAuth、lineworks-user-auth版のPathをこのアプリ用に変更しただけの内容)
apps/lineworks-form-sync/test/adminAuth.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { verifyMasterKey, createSessionToken, createTempKey, verifySessionToken, buildSessionCookieHeader, parseCookies, SESSION_COOKIE_NAME } = require("../src/adminAuth");
test("verifyMasterKey returns true only for the exact configured key", () => {
assert.strictEqual(verifyMasterKey("correct", "correct"), true);
assert.strictEqual(verifyMasterKey("wrong", "correct"), false);
});
test("createSessionToken then verifySessionToken succeeds within TTL", () => {
const now = new Date("2026-08-24T00:00:00.000Z").getTime();
const token = createSessionToken("secret", now);
assert.strictEqual(verifySessionToken(token, "secret", now + 1000), true);
});
test("createTempKey then verifySessionToken succeeds within the 60-minute temp TTL and fails after", () => {
const now = new Date("2026-08-24T00:00:00.000Z").getTime();
const token = createTempKey("secret", now);
assert.strictEqual(verifySessionToken(token, "secret", now + 59 * 60 * 1000), true);
assert.strictEqual(verifySessionToken(token, "secret", now + 61 * 60 * 1000), false);
});
test("buildSessionCookieHeader scopes the cookie to /manage", () => {
const header = buildSessionCookieHeader("tok");
assert.ok(header.includes("Path=/manage"));
assert.ok(header.startsWith(`${SESSION_COOKIE_NAME}=`));
});
test("parseCookies parses a raw Cookie header into a key-value map", () => {
assert.deepStrictEqual(parseCookies(`${SESSION_COOKIE_NAME}=abc; other=1`), { [SESSION_COOKIE_NAME]: "abc", other: "1" });
});
- Step 2: テスト失敗を確認
node --test test/adminAuth.test.js
Expected: FAIL(モジュール未存在)
- Step 3: 実装(lineworks-user-authから移植、Cookie名・Pathのみ変更)
apps/lineworks-form-sync/src/adminAuth.js:
"use strict";
const crypto = require("node:crypto");
const SESSION_COOKIE_NAME = "lwform_admin_session";
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
const TEMP_KEY_TTL_MS = 60 * 60 * 1000;
const KIND_TTL_MS = { session: SESSION_TTL_MS, temp: TEMP_KEY_TTL_MS };
function timingSafeEqualStrings(a, b) {
const bufA = Buffer.from(String(a));
const bufB = Buffer.from(String(b));
if (bufA.length !== bufB.length) {
crypto.timingSafeEqual(bufA, bufA);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
function verifyMasterKey(input, masterKey) {
if (!masterKey || !input) return false;
return timingSafeEqualStrings(input, masterKey);
}
function sign(payload, secret) {
return crypto.createHmac("sha256", secret).update(payload).digest("hex");
}
function buildToken(kind, secret, now) {
const payload = `${kind}:${now}`;
return `${payload}.${sign(payload, secret)}`;
}
function createSessionToken(secret, now = Date.now()) {
return buildToken("session", secret, now);
}
function createTempKey(secret, now = Date.now()) {
return buildToken("temp", secret, now);
}
function verifySessionToken(token, secret, now = Date.now()) {
if (!token || typeof token !== "string") return false;
const parts = token.split(".");
if (parts.length !== 2) return false;
const [payload, signature] = parts;
if (!timingSafeEqualStrings(signature, sign(payload, secret))) return false;
const [kind, tsStr] = payload.split(":");
const ttl = KIND_TTL_MS[kind];
if (!ttl) return false;
const issuedAt = Number(tsStr);
if (!Number.isFinite(issuedAt)) return false;
return now - issuedAt < ttl;
}
function parseCookies(cookieHeader) {
const result = {};
if (!cookieHeader) return result;
cookieHeader.split(";").forEach((pair) => {
const idx = pair.indexOf("=");
if (idx === -1) return;
const key = pair.slice(0, idx).trim();
const value = pair.slice(idx + 1).trim();
if (key) result[key] = decodeURIComponent(value);
});
return result;
}
function buildSessionCookieHeader(token, maxAgeMs = SESSION_TTL_MS) {
return [
`${SESSION_COOKIE_NAME}=${encodeURIComponent(token)}`,
"HttpOnly",
"Secure",
"Path=/manage",
"SameSite=Strict",
`Max-Age=${Math.floor(maxAgeMs / 1000)}`,
].join("; ");
}
function buildLogoutCookieHeader() {
return [`${SESSION_COOKIE_NAME}=`, "HttpOnly", "Secure", "Path=/manage", "SameSite=Strict", "Max-Age=0"].join("; ");
}
module.exports = {
SESSION_COOKIE_NAME,
SESSION_TTL_MS,
TEMP_KEY_TTL_MS,
verifyMasterKey,
createSessionToken,
createTempKey,
verifySessionToken,
parseCookies,
buildSessionCookieHeader,
buildLogoutCookieHeader,
};
- Step 4: テスト成功を確認(adminAuth)
node --test test/adminAuth.test.js
Expected: PASS(5 tests)
- Step 5: 失敗するテストを書く(adminView)
apps/lineworks-form-sync/test/adminView.test.js:
const { test } = require("node:test");
const assert = require("node:assert");
const { renderLoginPage, renderDashboardPage, renderTempKeyPage } = require("../src/adminView");
test("renderLoginPage includes a master key input form", () => {
const html = renderLoginPage();
assert.ok(html.includes('name="masterKey"'));
});
test("renderDashboardPage lists each form's name/formId, connection key select, interval select, and action buttons", () => {
const html = renderDashboardPage({
forms: [{ itemId: 10, formId: "f1", formName: "健診アンケート", connectionKey: "lineworks-form-sync", intervalMinutes: 60, manualRunDone: true, lastManualRunAt: "2026-08-24T00:00:00.000Z", lastAutoRunAt: null }],
connectionKeys: [{ key: "lineworks-form-sync", account: "svc@example.co.jp", status: "有効" }],
message: null,
});
assert.ok(html.includes("健診アンケート"));
assert.ok(html.includes("f1"));
assert.ok(html.includes('name="formItemId" value="10"'));
assert.ok(html.includes('<option value="lineworks-form-sync"'));
assert.ok(html.includes('<option value="15"'));
assert.ok(html.includes("今すぐ実行"));
assert.ok(html.includes("テスト回答取得"));
});
test("renderDashboardPage includes a temp-key issuance button", () => {
const html = renderDashboardPage({ forms: [], connectionKeys: [], message: null });
assert.ok(html.includes('action="/manage/temp-key"'));
});
test("renderTempKeyPage shows the shareable one-time login URL", () => {
const html = renderTempKeyPage({ url: "https://example.test/manage/login?tempKey=abc" });
assert.ok(html.includes("https://example.test/manage/login?tempKey=abc"));
});
- Step 6: テスト失敗を確認(adminView)
node --test test/adminView.test.js
Expected: FAIL(モジュール未存在)
- Step 7: 実装(adminView.js)
apps/lineworks-form-sync/src/adminView.js:
"use strict";
const INTERVAL_OPTIONS = [
{ minutes: 15, label: "15分ごと" },
{ minutes: 30, label: "30分ごと" },
{ minutes: 60, label: "1時間ごと" },
{ minutes: 180, label: "3時間ごと" },
{ minutes: 360, label: "6時間ごと" },
{ minutes: 1440, label: "1日ごと" },
];
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
function renderLoginPage(errorMessage) {
return `<!doctype html>
<html lang="ja"><head><meta charset="utf-8"><title>lineworks-form-sync ログイン</title></head>
<body>
<h1>lineworks-form-sync</h1>
${errorMessage ? `<p style="color:red">${escapeHtml(errorMessage)}</p>` : ""}
<form method="post" action="/manage/login">
<label>Master Key: <input type="password" name="masterKey" /></label>
<button type="submit">ログイン</button>
</form>
</body></html>`;
}
function connectionKeyOptions(connectionKeys, selected) {
return connectionKeys
.map((k) => `<option value="${escapeHtml(k.key)}"${k.key === selected ? " selected" : ""}>${escapeHtml(k.key)}(${escapeHtml(k.account)}, ${escapeHtml(k.status)})</option>`)
.join("");
}
function intervalOptions(selected) {
return INTERVAL_OPTIONS.map((o) => `<option value="${o.minutes}"${o.minutes === selected ? " selected" : ""}>${o.label}</option>`).join("");
}
function renderDashboardPage({ forms, connectionKeys, message }) {
const rows = forms
.map(
(f) => `<tr>
<td>${escapeHtml(f.formName)}<br/><small>${escapeHtml(f.formId)}</small></td>
<td>${f.manualRunDone ? "手動実行済み" : "未実行(自動実行されません)"}</td>
<td>${escapeHtml(f.lastManualRunAt)}</td>
<td>${escapeHtml(f.lastAutoRunAt)}</td>
<td>
<form method="post" action="/manage/run-now" style="display:inline">
<input type="hidden" name="formItemId" value="${f.itemId}" />
<select name="connectionKey">${connectionKeyOptions(connectionKeys, f.connectionKey)}</select>
<select name="intervalMinutes">${intervalOptions(f.intervalMinutes)}</select>
<button type="submit">今すぐ実行</button>
</form>
</td>
<td>
<form method="post" action="/manage/generate-mapping" style="display:inline">
<input type="hidden" name="formItemId" value="${f.itemId}" />
<button type="submit">テスト回答取得</button>
</form>
</td>
</tr>`
)
.join("");
return `<!doctype html>
<html lang="ja"><head><meta charset="utf-8"><title>lineworks-form-sync</title></head>
<body>
<h1>LINEWORKS Form連携 管理画面</h1>
${message ? `<p>${escapeHtml(message)}</p>` : ""}
<table border="1"><thead><tr><th>アンケート名/FormId</th><th>手動実行状態</th><th>最終手動実行</th><th>最終自動実行</th><th>連携キー/周期設定・今すぐ実行</th><th>マッピング雛形生成</th></tr></thead>
<tbody>${rows}</tbody></table>
<form method="post" action="/manage/temp-key" style="display:inline"><button type="submit">一時アクセスキー発行(60分・Master Key不要で共有可)</button></form>
<form method="post" action="/manage/logout" style="display:inline"><button type="submit">ログアウト</button></form>
</body></html>`;
}
function renderTempKeyPage({ url }) {
return `<!doctype html>
<html lang="ja"><head><meta charset="utf-8"><title>一時アクセスキー発行</title></head>
<body>
<h1>一時アクセスキーを発行しました</h1>
<p>有効期限60分。このURLを共有してください(Master Keyは含まれません):</p>
<p><code>${escapeHtml(url)}</code></p>
<p><a href="/manage">ダッシュボードへ戻る</a></p>
</body></html>`;
}
module.exports = { renderLoginPage, renderDashboardPage, renderTempKeyPage };
- Step 8: テスト成功を確認(adminView)
node --test test/adminView.test.js
Expected: PASS(4 tests)
- Step 9: Commit
git add apps/lineworks-form-sync/src/adminAuth.js apps/lineworks-form-sync/src/adminView.js apps/lineworks-form-sync/test/adminAuth.test.js apps/lineworks-form-sync/test/adminView.test.js
git commit -m "feat(lineworks-form-sync): 管理画面認証とHTML画面を実装"
Task 17: ルーティング統合(src/index.js)
Files:
- Modify:
apps/lineworks-form-sync/src/index.js(Task 4の雛形を全面書き換え)
Interfaces:
- Consumes: 全ライブラリ(Task 5〜16)
- Produces:
GET /health、POST /execute、GET /manage/login・POST /manage/login・POST /manage/logout・POST /manage/temp-key・GET /manage・POST /manage/run-now・POST /manage/generate-mapping
ユニットテストは設けず(既存app-portal/lineworks-user-auth同様の慣習)、Task 18のローカルDocker起動時に手動疎通確認する。
- Step 1: 実装
apps/lineworks-form-sync/src/index.js:
"use strict";
const express = require("express");
const {
verifyMasterKey,
createSessionToken,
createTempKey,
verifySessionToken,
parseCookies,
buildSessionCookieHeader,
buildLogoutCookieHeader,
SESSION_COOKIE_NAME,
TEMP_KEY_TTL_MS,
} = require("./adminAuth");
const { renderLoginPage, renderDashboardPage, renderTempKeyPage } = require("./adminView");
const { verifyExecuteKey } = require("./lib/executeAuth");
const { listAll, findByFormId, saveManualRunSettings, saveQuestionMapping, touchAutoRun } = require("./lib/formManagementStore");
const { listConnectionKeys, fetchAccessToken } = require("./lib/userAuthClient");
const { runFormSync } = require("./lib/formSyncRunner");
const { getResponses } = require("./lib/formsClient");
const { buildQuestionMapping } = require("./lib/mappingGenerator");
const { runScheduledExecution } = require("./lib/executeService");
const app = express();
const PORT = process.env.PORT || 3000;
const MASTER_KEY = process.env.AUTH_MASTER_KEY;
const EXECUTE_KEY = process.env.EXECUTE_KEY;
const PLEASANTER_FORM_MGMT = {
baseUrl: process.env.PLEASANTER_BASE_URL,
apiKey: process.env.PLEASANTER_API_KEY,
siteId: Number(process.env.PLEASANTER_FORM_MANAGEMENT_SITE_ID),
};
const USER_AUTH = {
baseUrl: process.env.LINEWORKS_USER_AUTH_BASE_URL,
executionKey: process.env.LINEWORKS_USER_AUTH_EXECUTION_KEY,
};
app.use(express.urlencoded({ extended: false }));
app.get("/health", (req, res) => {
res.status(200).json({ status: "healthy" });
});
// ---- n8n Scheduleトリガー向け ----
app.post("/execute", async (req, res) => {
if (!verifyExecuteKey(req.header("X-Execute-Key"), EXECUTE_KEY)) {
res.sendStatus(401);
return;
}
try {
const results = await runScheduledExecution({
formConfigStore: {
listAll: () => listAll(PLEASANTER_FORM_MGMT),
touchAutoRun: ({ itemId, now }) => touchAutoRun({ ...PLEASANTER_FORM_MGMT, itemId, now }),
},
userAuth: USER_AUTH,
pleasanter: PLEASANTER_FORM_MGMT,
});
res.json({ results });
} catch (err) {
console.error("execute failed", err.message);
res.status(502).json({ error: err.message });
}
});
// ---- 管理画面(社内限定) ----
function requireAdminSession(req, res, next) {
const cookies = parseCookies(req.headers.cookie);
if (!verifySessionToken(cookies[SESSION_COOKIE_NAME], MASTER_KEY)) {
res.redirect("/manage/login");
return;
}
next();
}
app.get("/manage/login", (req, res) => {
const tempKey = req.query.tempKey;
if (tempKey && verifySessionToken(tempKey, MASTER_KEY)) {
res.set("Set-Cookie", buildSessionCookieHeader(tempKey, TEMP_KEY_TTL_MS));
res.redirect("/manage");
return;
}
res.set("Content-Type", "text/html; charset=utf-8").send(renderLoginPage());
});
app.post("/manage/login", (req, res) => {
if (!verifyMasterKey(req.body.masterKey, MASTER_KEY)) {
res.set("Content-Type", "text/html; charset=utf-8").send(renderLoginPage("Master Keyが正しくありません"));
return;
}
res.set("Set-Cookie", buildSessionCookieHeader(createSessionToken(MASTER_KEY)));
res.redirect("/manage");
});
app.post("/manage/logout", (req, res) => {
res.set("Set-Cookie", buildLogoutCookieHeader());
res.redirect("/manage/login");
});
app.post("/manage/temp-key", requireAdminSession, (req, res) => {
const tempKey = createTempKey(MASTER_KEY);
const url = `${req.protocol}://${req.get("host")}/manage/login?tempKey=${encodeURIComponent(tempKey)}`;
res.set("Content-Type", "text/html; charset=utf-8").send(renderTempKeyPage({ url }));
});
app.get("/manage", requireAdminSession, async (req, res) => {
try {
const [forms, connectionKeys] = await Promise.all([
listAll(PLEASANTER_FORM_MGMT),
listConnectionKeys({ baseUrl: USER_AUTH.baseUrl, executionKey: USER_AUTH.executionKey }),
]);
res.set("Content-Type", "text/html; charset=utf-8").send(renderDashboardPage({ forms, connectionKeys, message: null }));
} catch (err) {
console.error("dashboard render failed", err.message);
res.status(500).send("一覧取得に失敗しました");
}
});
app.post("/manage/run-now", requireAdminSession, async (req, res) => {
const { formItemId, connectionKey, intervalMinutes } = req.body;
try {
await saveManualRunSettings({ ...PLEASANTER_FORM_MGMT, itemId: Number(formItemId), connectionKey, intervalMinutes: Number(intervalMinutes) });
const formConfig = await findByFormIdByItemId(Number(formItemId));
const accessToken = await fetchAccessToken({ baseUrl: USER_AUTH.baseUrl, executionKey: USER_AUTH.executionKey, key: connectionKey });
await runFormSync({ formConfig, accessToken, pleasanter: PLEASANTER_FORM_MGMT });
res.redirect("/manage");
} catch (err) {
console.error("run-now failed", err.message);
res.status(502).send(`実行に失敗しました: ${err.message}`);
}
});
app.post("/manage/generate-mapping", requireAdminSession, async (req, res) => {
const { formItemId } = req.body;
try {
const formConfig = await findByFormIdByItemId(Number(formItemId));
if (!formConfig.connectionKey) {
res.status(400).send("先に連携キーを選択してください");
return;
}
const accessToken = await fetchAccessToken({ baseUrl: USER_AUTH.baseUrl, executionKey: USER_AUTH.executionKey, key: formConfig.connectionKey });
const { responses } = await getResponses({ accessToken, formId: formConfig.formId, count: 1 });
if (responses.length === 0) {
res.status(400).send("テスト回答が1件もありません。先にフォームへ回答してください");
return;
}
const mapping = buildQuestionMapping({ existingMapping: formConfig.questionMapping, fetchedQuestions: responses[0].questions });
await saveQuestionMapping({ ...PLEASANTER_FORM_MGMT, itemId: formConfig.itemId, mapping });
res.redirect("/manage");
} catch (err) {
console.error("generate-mapping failed", err.message);
res.status(502).send(`マッピング生成に失敗しました: ${err.message}`);
}
});
async function findByFormIdByItemId(itemId) {
const forms = await listAll(PLEASANTER_FORM_MGMT);
const found = forms.find((f) => f.itemId === itemId);
if (!found) throw new Error(`フォーム管理レコード(itemId=${itemId})が見つかりません`);
return found;
}
app.listen(PORT, () => {
console.log(`lineworks-form-sync listening on port ${PORT}`);
});
- Step 2: Commit
git add apps/lineworks-form-sync/src/index.js
git commit -m "feat(lineworks-form-sync): ルーティングを統合(/health /execute /manage/*)"
Task 18: ローカルDocker動作確認
Files: なし
- Step 1:
.envを用意(ダミー値、外部API呼び出しをしない範囲で確認)
cd apps/lineworks-form-sync
cp .env.example .env
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
出力値を.envのAUTH_MASTER_KEYへ、EXECUTE_KEYにも任意の文字列を設定。
- Step 2: Dockerでビルド・起動
docker compose -f docker-compose.local.yml up --build -d
- Step 3:
/health確認
curl -s http://localhost:3000/health
Expected: {"status":"healthy"}
- Step 4:
/manage/login画面確認
curl -s http://localhost:3000/manage/login | grep -o 'name="masterKey"'
Expected: name="masterKey"
- Step 5:
/executeが未設定実行キーで401を返すことを確認
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/execute
Expected: 401
- Step 6: 全テストを通しで実行
node --test
Expected: 全PASS
- Step 7: コンテナ停止
docker compose -f docker-compose.local.yml down
rm .env
このタスクはコード変更を伴わないため、commitは不要。
Task 19: 事前準備の実施とプリザンター疎通確認
- Step 1: プリザンター「フォーム管理テーブル」を事前準備の物理列構成で作成、SiteId・APIキーを控える
- Step 2: 対象フォーム(サンプルフォーム
055dd7b8bbd336b1bbfd8fca982e3bb9等)の回答格納先テーブルを作成、ユーザー照合列・targetColumn列群を用意 - Step 3: lineworks-user-authのCONSUMER_EXECUTION_KEYSへ本アプリ用エントリが追加済みであることを確認(Task 3で実施済み)
- Step 4:
.envへ本番相当の値を設定し、formManagementStoreのlistAll/saveManualRunSettingsが実際にItemを操作できることを1回だけ確認する(feedback_no_trial_and_error_debugging)。手順はproject_lineworks_user_authのTask 12と同様(node --env-file=.env -e "..."でライブラリ関数を直接呼ぶ) - Step 5: 疎通確認用に作ったテストレコードを削除
Task 20: Dokployへのデプロイ
.claude/skills/dokploy-webapp/SKILL.mdの手順に従う。本番操作のため、各コマンド実行前に必ずユーザーへ内容を提示し確認を取る。
- Step 1: Gitea remoteへpush(
git push gitea main) - Step 2: Dokploy Compose作成(
dokploy compose create --name "lineworks-form-sync" --environmentId "Cm0HjMIFyl11UdIcIGRy8" --composeType "docker-compose" --appName "lineworks-form-sync" --json) - Step 3: Gitea連携へ切替(
dokploy compose update --composeId "<composeId>" --sourceType "gitea" --giteaId "O5-CqLQwVdlzXw3KfmN-8" --giteaOwner "mygit-admin" --giteaRepository "NodeSrv" --giteaBranch "main" --composePath "apps/lineworks-form-sync/docker-compose.yml" --json) - Step 4: Dokploy上で環境変数設定(
.env.exampleの内容、実値をユーザーに確認しながら) - Step 5: デプロイ実行(
dokploy compose deploy --composeId "<composeId>" --title "初回デプロイ" --json) - Step 6: 外部疎通確認(
curl -I https://<ドメイン>/health) - Step 7: n8nで
Schedule Trigger(15分ごと)→HTTP Request(POST /execute、X-Execute-Keyヘッダー)のワークフローを作成(既存n8nワークフロー作成規約に従う、feedback_n8n_workflow_execution_confirmationにより実行前にユーザー確認) - Step 8: app-portalへ
PORTAL_APP_TYPE=web,PORTAL_APP_URL=https://<ドメイン>/manageとして登録(任意)
完了後
project_lineworks_form_pleasanter_sync・project_lineworks_user_authのメモリを更新し、稼働状態・対応済み事項を反映する。