ken_nogi/NodeSrv/docs/superpowers/plans/2026-08-24-lineworks-form-pleasanter-sync.md
Kenichiro NOGI ce58cb4be4 初回コミット: dev配下(NodeSrv/Pleasanter等)をGitea管理下に統合
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>
2026-09-04 15:37:06 +09:00

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を一切実装しない

事前準備(ユーザー側、コード実装と並行して進めてよい)

  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 最終自動実行日時
  1. 対象フォームごとに回答格納先テーブルを作成。ユーザー照合列(メールアドレス保存用の物理列、例:"Class005")と、質問マッピングで使うtargetColumn列群(Class/Description/Date、添付ファイルはAttachmentsプレフィックスの物理列、例:"AttachmentsA")を用意する
  2. LINEWORKS連携専用アカウントを対象フォームへ「共同管理者」として追加(フォーム作成時のルール化)。project_lineworks_user_authの認証UIで連携キーとして登録
  3. 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.jsrequire群へ追加:

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.jsonnamelineworks-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_authadminAuth.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})(formsFormConfig[]+status表示用の整形済み値、connectionKeyslineworks-user-authGET /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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 /healthPOST /executeGET /manage/loginPOST /manage/loginPOST /manage/logoutPOST /manage/temp-keyGET /managePOST /manage/run-nowPOST /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'))"

出力値を.envAUTH_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へ本番相当の値を設定し、formManagementStorelistAll/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_syncproject_lineworks_user_authのメモリを更新し、稼働状態・対応済み事項を反映する。