# 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]](`apps/lineworks-user-auth`)の`GET /token`任せ、本アプリはOAuthを一切実装しない --- ## 事前準備(ユーザー側、コード実装と並行して進めてよい) 1. **DNS**: 本アプリのホスト先ドメインを決定・登録(Node A、`52.193.142.134`) 2. **プリザンター「フォーム管理テーブル」を新規作成**(以下の物理列構成) | 物理列 | 用途 | |---|---| | ClassA | FormId(LINEWORKS Form ID) | | ClassB | アンケート名(人間可読、管理画面の一覧表示用) | | Class001 | 回答格納先SiteId(数値を文字列として保存) | | Class002 | ユーザー照合列(保存先テーブルの物理列名、例:"Class005") | | Class003 | 連携キー | | Num001 | 周期(分単位: 15/30/60/180/360/1440) | | Description001 | 質問マッピングJSON | | Check001 | 手動実行済みフラグ | | Date001 | 最終手動実行日時 | | Date002 | 最終自動実行日時 | 3. **対象フォームごとに回答格納先テーブルを作成**。ユーザー照合列(メールアドレス保存用の物理列、例:"Class005")と、質問マッピングで使う`targetColumn`列群(Class/Description/Date、添付ファイルは`Attachments`プレフィックスの物理列、例:"AttachmentsA")を用意する 4. **LINEWORKS連携専用アカウント**を対象フォームへ「共同管理者」として追加(フォーム作成時のルール化)。[[project_lineworks_user_auth]]の認証UIで連携キーとして登録 5. **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`: ```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: テスト失敗を確認** ```bash 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`: ```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: テスト成功を確認** ```bash node --test test/executionKeys.test.js ``` Expected: PASS(4 tests) - [ ] **Step 5: `/token`ルートの呼び出しを更新** `apps/lineworks-user-auth/src/index.js`の`/token`ルート(47-53行目付近)を編集: ```js 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: 全テスト実行して確認** ```bash node --test ``` Expected: 全PASS(既存45テスト、変更分含む) - [ ] **Step 7: Commit** ```bash 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>`(`tokenStatus.computeStatus`を使い、`accessToken`/`refreshToken`は含めない) - [ ] **Step 1: 失敗するテストを書く** `apps/lineworks-user-auth/test/keyList.test.js`: ```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: テスト失敗を確認** ```bash node --test test/keyList.test.js ``` Expected: FAIL(モジュール未存在) - [ ] **Step 3: 実装** `apps/lineworks-user-auth/src/lib/keyList.js`: ```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: テスト成功を確認** ```bash node --test test/keyList.test.js ``` Expected: PASS(1 test) - [ ] **Step 5: ルート追加** `apps/lineworks-user-auth/src/index.js`の`require`群へ追加: ```js const { listKeys } = require("./lib/keyList"); ``` `/token`ルートの直後に追加: ```js 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完了時点の動作確認に含まれる)。全テスト実行** ```bash node --test ``` Expected: 全PASS - [ ] **Step 7: Commit** ```bash 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へ再デプロイ**(ユーザー確認の上で実行) ```bash git push gitea main dokploy compose deploy --composeId "axfqMkjwcRWYEaYMKiIwv" --title "実行キーモデル変更・GET /keys追加" --json ``` - [ ] **Step 2: 反映確認** ```bash 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`をコピー** ```bash 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のまま): ```json { "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.参照) ```bash 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: ローカル動作確認** ```bash npm install cp .env.example .env npm run dev ``` 別ターミナル: ```bash curl -s http://localhost:3000/health ``` Expected: `{"status":"healthy"}`。確認後devサーバー停止。 - [ ] **Step 6: Commit** ```bash 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`: ```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: テスト失敗を確認** ```bash cd apps/lineworks-form-sync && node --test test/pleasanterClient.test.js ``` Expected: FAIL(モジュール未存在) - [ ] **Step 3: 実装** `apps/lineworks-form-sync/src/lib/pleasanterClient.js`: ```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: テスト成功を確認** ```bash node --test test/pleasanterClient.test.js ``` Expected: PASS(5 tests) - [ ] **Step 5: Commit** ```bash 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>`、`fetchAccessToken({baseUrl, executionKey, key, fetchImpl}) -> Promise` - [ ] **Step 1: 失敗するテストを書く** `apps/lineworks-form-sync/test/userAuthClient.test.js`: ```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: テスト失敗を確認** ```bash node --test test/userAuthClient.test.js ``` Expected: FAIL(モジュール未存在) - [ ] **Step 3: 実装** `apps/lineworks-form-sync/src/lib/userAuthClient.js`: ```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: テスト成功を確認** ```bash node --test test/userAuthClient.test.js ``` Expected: PASS(3 tests) - [ ] **Step 5: Commit** ```bash 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` - `findByFormId({baseUrl, apiKey, siteId, formId, fetchImpl}) -> Promise` - `saveManualRunSettings({baseUrl, apiKey, itemId, connectionKey, intervalMinutes, fetchImpl, now}) -> Promise`(連携キー・周期保存+手動実行済みフラグON+最終手動実行日時更新) - `saveQuestionMapping({baseUrl, apiKey, itemId, mapping, fetchImpl}) -> Promise` - `touchAutoRun({baseUrl, apiKey, itemId, fetchImpl, now}) -> Promise` - `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`: ```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`: ```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: テスト失敗を確認** ```bash 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)。このセンチネル値を「未設定」として扱う変換を行う。 ```js "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: テスト成功を確認** ```bash node --test test/formManagementStore.test.js ``` Expected: PASS(7 tests) - [ ] **Step 6: Commit** ```bash 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`: ```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: テスト失敗を確認** ```bash node --test test/formsClient.test.js ``` Expected: FAIL(モジュール未存在) - [ ] **Step 3: 実装** `apps/lineworks-form-sync/src/lib/formsClient.js`: ```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: テスト成功を確認** ```bash node --test test/formsClient.test.js ``` Expected: PASS(4 tests) - [ ] **Step 5: Commit** ```bash 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`: ```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: テスト失敗を確認** ```bash node --test test/answerMapper.test.js ``` Expected: FAIL(モジュール未存在) - [ ] **Step 3: 実装** `apps/lineworks-form-sync/src/lib/answerMapper.js`: ```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: テスト成功を確認** ```bash node --test test/answerMapper.test.js ``` Expected: PASS(8 tests) - [ ] **Step 5: Commit** ```bash 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`: ```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: テスト失敗を確認** ```bash node --test test/mappingGenerator.test.js ``` Expected: FAIL(モジュール未存在) - [ ] **Step 3: 実装** `apps/lineworks-form-sync/src/lib/mappingGenerator.js`: ```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: テスト成功を確認** ```bash node --test test/mappingGenerator.test.js ``` Expected: PASS(3 tests) - [ ] **Step 5: Commit** ```bash 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`: ```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: テスト失敗を確認** ```bash node --test test/attachmentUploader.test.js ``` Expected: FAIL(モジュール未存在) - [ ] **Step 3: 実装** `apps/lineworks-form-sync/src/lib/attachmentUploader.js`: ```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: テスト成功を確認** ```bash node --test test/attachmentUploader.test.js ``` Expected: PASS(1 test) - [ ] **Step 5: Commit** ```bash 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`: ```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: テスト失敗を確認** ```bash node --test test/responseSync.test.js ``` Expected: FAIL(モジュール未存在) - [ ] **Step 3: 実装** `apps/lineworks-form-sync/src/lib/responseSync.js`: ```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: テスト成功を確認** ```bash node --test test/responseSync.test.js ``` Expected: PASS(3 tests) - [ ] **Step 5: Commit** ```bash 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`: ```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: テスト失敗を確認** ```bash node --test test/formSyncRunner.test.js ``` Expected: FAIL(モジュール未存在) - [ ] **Step 3: 実装** `apps/lineworks-form-sync/src/lib/formSyncRunner.js`: ```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: テスト成功を確認** ```bash node --test test/formSyncRunner.test.js ``` Expected: PASS(1 test) - [ ] **Step 5: Commit** ```bash 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`: ```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: テスト失敗を確認** ```bash node --test test/executeAuth.test.js ``` Expected: FAIL(モジュール未存在) - [ ] **Step 3: 実装** `apps/lineworks-form-sync/src/lib/executeAuth.js`: ```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: テスト成功を確認** ```bash node --test test/executeAuth.test.js ``` Expected: PASS(2 tests) - [ ] **Step 5: Commit** ```bash 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>` - `status`は`"skipped-not-manually-run"` / `"skipped-interval-not-elapsed"` / `"synced"` / `"error"` - [ ] **Step 1: 失敗するテストを書く** `apps/lineworks-form-sync/test/executeService.test.js`: ```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: テスト失敗を確認** ```bash node --test test/executeService.test.js ``` Expected: FAIL(モジュール未存在) - [ ] **Step 3: 実装** `apps/lineworks-form-sync/src/lib/executeService.js`: ```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: テスト成功を確認** ```bash node --test test/executeService.test.js ``` Expected: PASS(5 tests) - [ ] **Step 5: Commit** ```bash 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`: ```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: テスト失敗を確認** ```bash node --test test/adminAuth.test.js ``` Expected: FAIL(モジュール未存在) - [ ] **Step 3: 実装(lineworks-user-authから移植、Cookie名・Pathのみ変更)** `apps/lineworks-form-sync/src/adminAuth.js`: ```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)** ```bash node --test test/adminAuth.test.js ``` Expected: PASS(5 tests) - [ ] **Step 5: 失敗するテストを書く(adminView)** `apps/lineworks-form-sync/test/adminView.test.js`: ```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('`) .join(""); } function intervalOptions(selected) { return INTERVAL_OPTIONS.map((o) => ``).join(""); } function renderDashboardPage({ forms, connectionKeys, message }) { const rows = forms .map( (f) => ` ${escapeHtml(f.formName)}
${escapeHtml(f.formId)} ${f.manualRunDone ? "手動実行済み" : "未実行(自動実行されません)"} ${escapeHtml(f.lastManualRunAt)} ${escapeHtml(f.lastAutoRunAt)}
` ) .join(""); return ` lineworks-form-sync

LINEWORKS Form連携 管理画面

${message ? `

${escapeHtml(message)}

` : ""} ${rows}
アンケート名/FormId手動実行状態最終手動実行最終自動実行連携キー/周期設定・今すぐ実行マッピング雛形生成
`; } function renderTempKeyPage({ url }) { return ` 一時アクセスキー発行

一時アクセスキーを発行しました

有効期限60分。このURLを共有してください(Master Keyは含まれません):

${escapeHtml(url)}

ダッシュボードへ戻る

`; } module.exports = { renderLoginPage, renderDashboardPage, renderTempKeyPage }; ``` - [ ] **Step 8: テスト成功を確認(adminView)** ```bash node --test test/adminView.test.js ``` Expected: PASS(4 tests) - [ ] **Step 9: Commit** ```bash 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`: ```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** ```bash 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呼び出しをしない範囲で確認)** ```bash 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でビルド・起動** ```bash docker compose -f docker-compose.local.yml up --build -d ``` - [ ] **Step 3: `/health`確認** ```bash curl -s http://localhost:3000/health ``` Expected: `{"status":"healthy"}` - [ ] **Step 4: `/manage/login`画面確認** ```bash curl -s http://localhost:3000/manage/login | grep -o 'name="masterKey"' ``` Expected: `name="masterKey"` - [ ] **Step 5: `/execute`が未設定実行キーで401を返すことを確認** ```bash curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/execute ``` Expected: `401` - [ ] **Step 6: 全テストを通しで実行** ```bash node --test ``` Expected: 全PASS - [ ] **Step 7: コンテナ停止** ```bash 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 "" --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 "" --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]]のメモリを更新し、稼働状態・対応済み事項を反映する。