ken_nogi/NodeSrv/docs/superpowers/plans/2026-08-08-org-master-sync-phase1.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

81 KiB
Raw Blame History

組織・社員マスタ基盤構築Phase 1: マスタテーブル作成 + LINEWORKS取得 + マージ) 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: プリザンターの既存サイトSiteId=1を「社員・組織マスタ管理テーブル」としてカスタマイズし、LINEWORKS Directory APIから取得したデータをそこへ投入・更新できるようにする設計文書3章の①fetch-lineworks・②merge-masterに対応。③sync-pleasanter・④sync-keycloakは別プランで扱う。

Architecture: apps/org-master-sync/配下に自己完結したNode.js CLIコマンド群を構築。外部ライブラリ非依存Node標準のfetch/cryptoのみ。LINEWORKS Service AccountのJWT自己署名認証、プリザンターの汎用Items APIApiVersion 1.1+ApiKey)を使用。設計文書: docs/superpowers/specs/2026-08-07-org-master-sync-design.md

Tech Stack: Node.jsCommonJS、node --testによるテスト、他appと同一パターン。依存パッケージなし。

Global Constraints

  • 外部npmパッケージを追加しないLINEWORKS JWT署名・CSVパース等すべて標準API実装。設計文書3章
  • apps/org-master-sync/は他システムから独立して切り出せる構成を維持する(.claude/js/等リポジトリ内の他フォルダへのrequire依存を持たない。README.md記載の方針
  • .envkeys/data/はgit管理対象外既存.gitignoreのまま)。新規に秘密情報を含むファイルを追加する場合は同様に除外する
  • プリザンター書き込み系API呼び出しは常にテスト環境PLEASANTER_BASE_URL=https://nextoffice2.next-hd.net/pleasanter/)を向いている.envをそのまま使う。本番URLへの向き先変更はこのプランのスコープ外
  • 破壊的・実書き込みを伴うスクリプトサイト構造変更等は、既定でドライラン差分・送信Body・curlコマンドの表示のみとし、--executeフラグを明示指定した場合のみ実際に送信する(.claude/skills/pleasanter-site-specが確立した安全設計を踏襲)
  • テストはnode:test+node:assert他appと同一。apps/portal-sample-batch/test/triggerAuth.test.js参照)。ネットワークを叩く関数はfetchImpl引数でDIし、テストではモックする

事前に確認済みの実データ(このプランの前提)

  • マスタテーブルの実装先: プリザンターSiteId=1(現状Titleは「記録テーブル1」、ReferenceTypeResults、レコード0件の空サイト.envPLEASANTER_MASTER_SITE_ID=1に設定済み
  • getsitePOST {baseUrl}/api/items/1/getsite)のレスポンスは{"StatusCode":200,"Response":{"Data":{...}}}Data直下にClassHash/NumHash/DateHash/DescriptionHash/CheckHash/AttachmentsHash(すべて現状{})があり、ここへ{"ClassA":"社員ID", ...}の形でラベルを設定する方式。Data.SiteSettingsVersion/ReferenceType等ごく一部のみで、フィールドラベルはここには入らない(.claude/js/apply-site-config.jsはこのSiteSettings側の差分しか見ないため今回のフィールド定義には使えない)
  • マスタ項目→プリザンターフィールドの割当設計文書5章・5.0〜5.3節で確定済み。3タブ構成・LINEWORKS準拠版。351人分の実データdata/lineworks-users-full.jsonで全フィールドの実在・値パターンを確認済み)。Bodyは標準フィールドのためHash設定不要buildHashUpdate()の対象外、hash: "none":

番号ルール2026-08-08確定・実機反映済み: 項目の種類Class/Date/Description/Checkを問わず、タブごとに通し番号を振る。タブ1の姓名・個人情報系は001〜、タブ2プリザンター011〜、タブ3LINEWORKS051〜。真偽値もCheck062のような文字割当ではなくCheck020のように番号付きで統一する。実機でCheck020Date007Description022等の作成・取得を確認済み。

タブ1: 基本情報

論理名(FIELD_MAPのキー) 内容 割当キー Hash種別
employeeId 社員IDuserId ClassA ClassHash
email メールアドレス ClassB ClassHash
lastName Class001 ClassHash
firstName Class002 ClassHash
phoneticLastName 姓(カナ) Class003 ClassHash
phoneticFirstName 名(カナ) Class004 ClassHash
lastNameI18n 英字、en_US Class005 ClassHash
firstNameI18n 英字、en_US Class006 ClassHash
birthday 生年月日 Date007 DateHash姓名等の続き番号
note 備考 Body none標準フィールド
lastSyncedAt 最終LINEWORKS同期日時 DateA DateHash実機ラベルは「LW同期日時」
pleasanterCreatedAt プリザンター登録日時 DateB DateHash実機ラベルは「PL登録日時」
pleasanterUpdatedAt プリザンターのユーザー情報更新日時 DateC DateHash実機ラベルは「PL更新日時」

タブ2: プリザンターユーザー管理画面の全項目、2026-08-08確定版

公式マニュアル「JSONデータレイアウトUser」に対応プロパティが見当たらない項目(管理者ドロップダウン・テーマ・各種「許可」チェックボックス・秘密鍵有効・ログイン有効期限・無効化までの日数)を一度除外する案としたが、画面の全項目をマスタで管理する方針に変更設計文書5.2節。API経由での読み書き可否は③sync-pleasanter構築時に個別検証する。

論理名 内容 割当キー Hash種別 備考
pleasanterUserId ユーザID Class011 ClassHash UserId
pleasanterLoginId ログインID Class012 ClassHash LoginId
pleasanterName 名前(結合表示) Class013 ClassHash Name
userCode ユーザコード Class014 ClassHash UserCode
gender 性別 Class015 ClassHash Gender
language 言語既定Japanese Class016 ClassHash Language
timeZone タイムゾーン既定Japanese Standard Time Class017 ClassHash TimeZone
dept 組織独立管理、DeptCode文字列 Class018 ClassHash[[Depts*]]選択式) DeptCode
manager 管理者(ユーザー選択) Class019 ClassHash[[Users*]]選択式) 対応する公式プロパティ名は未確認、③実装時に要検証
tenantManager テナント管理者 Check020 CheckHash TenantManager(真偽値)。「管理者」ドロップダウンとは別物
theme テーマ Class021 ClassHash 対応プロパティ未確認、③実装時に要検証
description 説明 Description022 DescriptionHash Body
passwordExpirationDate パスワード有効期限 Class023 ClassHash PasswordExpirationTime
allowCreateAtSiteTop サイトトップへの作成を許可 Check024 CheckHash 対応プロパティ未確認、③実装時に要検証
allowManageGroup グループの管理を許可 Check025 CheckHash 同上
allowCreateGroup グループの作成を許可 Check026 CheckHash 同上
allowApi APIを許可 Check027 CheckHash 同上
allowMoveFromSiteTop サイトトップからの移動を許可 Check028 CheckHash 同上
disabled 無効 Check029 CheckHash Disabled
locked ロック Check030 CheckHash Lockout
secretKeyEnabled 秘密鍵有効 Check031 CheckHash 対応プロパティ未確認、③実装時に要検証
loginExpirationDate ログイン有効期限 Class032 ClassHash 同上
daysToDisable 無効化までの日数 Class033 ClassHash 同上
password パスワード Class034 ClassHash 公式レイアウト表には無いがimport-prod-to-test.jsで動作実績あり
groups 所属グループ複数選択、GroupId Class035 ClassHash[[Groups*]]選択式、MultipleSelections: true。5.5節)

タブ3: LINEWORKS固有情報

論理名 内容 割当キー Hash種別
privateEmail 私用メールアドレス Class051 ClassHash
nickName ニックネーム Class052 ClassHash
employeeNumber 社員番号 Class053 ClassHash
telephone 電話番号 Class054 ClassHash
cellPhone 携帯電話番号 Class055 ClassHash
location 勤務地 Class056 ClassHash
position 役職 Class057 ClassHash
level 職級 Class058 ClassHash
userType 利用権限タイプ Class059 ClassHash
hiredDate 入社日 Date060 DateHash
aliasEmails エイリアスメール(複数、;区切り) Description061 DescriptionHash
active LINEWORKS在籍フラグ Check062 CheckHash
onLeave 休職中フラグ Check063 CheckHash
suspended 停止中フラグ Check064 CheckHash
administrator LINEWORKS管理者フラグ Check065 CheckHash

NumHashは使用しない数値項目もすべてClass型で保持する方針。除外したLINEWORKSフィールドemploymentTypeisDeletedisAwaiting/isPendingcustomFieldsとその理由は設計文書5.4節参照。氏名の結合表示は独立列を持たず、pleasanterNameClass013がその役割を兼ねるKeycloak同期のfullName属性もここを参照)。

タブへの視覚的な項目配置(SiteSettings.EditorColumnHash"General"/"_Tab-1"/"_Tab-2"キー、Tabs配列は実機で構造確認・反映済み設計文書5.6節)。


Task 1: マスタサイトのフィールド定義プッシュ(ClassHash等の設定)

Files:

  • Create: apps/org-master-sync/src/config/masterFields.js
  • Create: apps/org-master-sync/scripts/push-master-site-schema.js
  • Test: apps/org-master-sync/test/masterFields.test.js

Interfaces:

  • Produces: masterFields.jsmodule.exports = { FIELD_MAP, buildHashUpdate() }を提供。FIELD_MAPは後続タスクmerge-master.jsがマスタレコードのフィールド↔論理名変換に使う唯一のソース

  • Step 1: masterFields.jsを書く

"use strict";

// 設計文書5章・5.0〜5.3節で確定した、マスタ項目 → プリザンターSiteId=1のフィールドキー割当
// 3タブ構成・LINEWORKS準拠版。hash: "none" は標準フィールドBody等でHash設定対象外
// 番号ルール: 種類Class/Date/Description/Checkを問わずタブごとに通し番号タブ1=001〜、タブ2=011〜、タブ3=051〜
const FIELD_MAP = {
  // --- タブ1: 基本情報 ---
  employeeId: { key: "ClassA", hash: "ClassHash", label: "社員ID" },
  email: { key: "ClassB", hash: "ClassHash", label: "メールアドレス" },
  lastName: { key: "Class001", hash: "ClassHash", label: "姓" },
  firstName: { key: "Class002", hash: "ClassHash", label: "名" },
  phoneticLastName: { key: "Class003", hash: "ClassHash", label: "姓(カナ)" },
  phoneticFirstName: { key: "Class004", hash: "ClassHash", label: "名(カナ)" },
  lastNameI18n: { key: "Class005", hash: "ClassHash", label: "姓(英字)" },
  firstNameI18n: { key: "Class006", hash: "ClassHash", label: "名(英字)" },
  birthday: { key: "Date007", hash: "DateHash", label: "生年月日" },
  note: { key: "Body", hash: "none", label: "備考" },
  lastSyncedAt: { key: "DateA", hash: "DateHash", label: "LW同期日時" },
  pleasanterCreatedAt: { key: "DateB", hash: "DateHash", label: "PL登録日時" },
  pleasanterUpdatedAt: { key: "DateC", hash: "DateHash", label: "PL更新日時" },

  // --- タブ2: プリザンターユーザー管理画面の全項目、2026-08-08確定版 ---
  pleasanterUserId: { key: "Class011", hash: "ClassHash", label: "ユーザID" },
  pleasanterLoginId: { key: "Class012", hash: "ClassHash", label: "ログインID" },
  pleasanterName: { key: "Class013", hash: "ClassHash", label: "名前" },
  userCode: { key: "Class014", hash: "ClassHash", label: "ユーザコード" },
  gender: { key: "Class015", hash: "ClassHash", label: "性別" },
  language: { key: "Class016", hash: "ClassHash", label: "言語" },
  timeZone: { key: "Class017", hash: "ClassHash", label: "タイムゾーン" },
  dept: { key: "Class018", hash: "ClassHash", label: "組織" },
  manager: { key: "Class019", hash: "ClassHash", label: "管理者" }, // ChoicesText: "[[Users*]]"5.5節、push-master-site-schema.jsで設定
  tenantManager: { key: "Check020", hash: "CheckHash", label: "テナント管理者" },
  theme: { key: "Class021", hash: "ClassHash", label: "テーマ" },
  description: { key: "Description022", hash: "DescriptionHash", label: "説明" },
  passwordExpirationDate: { key: "Class023", hash: "ClassHash", label: "パスワード有効期限" },
  allowCreateAtSiteTop: { key: "Check024", hash: "CheckHash", label: "サイトトップへの作成を許可" },
  allowManageGroup: { key: "Check025", hash: "CheckHash", label: "グループの管理を許可" },
  allowCreateGroup: { key: "Check026", hash: "CheckHash", label: "グループの作成を許可" },
  allowApi: { key: "Check027", hash: "CheckHash", label: "APIを許可" },
  allowMoveFromSiteTop: { key: "Check028", hash: "CheckHash", label: "サイトトップからの移動を許可" },
  disabled: { key: "Check029", hash: "CheckHash", label: "無効" },
  locked: { key: "Check030", hash: "CheckHash", label: "ロック" },
  secretKeyEnabled: { key: "Check031", hash: "CheckHash", label: "秘密鍵有効" },
  loginExpirationDate: { key: "Class032", hash: "ClassHash", label: "ログイン有効期限" },
  daysToDisable: { key: "Class033", hash: "ClassHash", label: "無効化までの日数" },
  password: { key: "Class034", hash: "ClassHash", label: "パスワード" },
  // groupsは[[Groups*]]選択式・GroupId格納・MultipleSelections:true5.5節。実際の書き込みはTask 6のapplyMergeが
  // classificationGroupNames/ensureGroupIds/mergeGroupSelectionで行うget-or-create、既存選択との和集合
  groups: { key: "Class035", hash: "ClassHash", label: "所属グループ" },

  // --- タブ3: LINEWORKS固有情報 ---
  privateEmail: { key: "Class051", hash: "ClassHash", label: "私用メールアドレス" },
  nickName: { key: "Class052", hash: "ClassHash", label: "ニックネーム" },
  employeeNumber: { key: "Class053", hash: "ClassHash", label: "社員番号" },
  telephone: { key: "Class054", hash: "ClassHash", label: "電話番号" },
  cellPhone: { key: "Class055", hash: "ClassHash", label: "携帯電話番号" },
  location: { key: "Class056", hash: "ClassHash", label: "勤務地" },
  position: { key: "Class057", hash: "ClassHash", label: "役職" },
  level: { key: "Class058", hash: "ClassHash", label: "職級" },
  userType: { key: "Class059", hash: "ClassHash", label: "利用権限タイプ" },
  hiredDate: { key: "Date060", hash: "DateHash", label: "入社日" },
  aliasEmails: { key: "Description061", hash: "DescriptionHash", label: "エイリアスメール(複数)" },
  active: { key: "Check062", hash: "CheckHash", label: "LINEWORKS在籍フラグ" },
  onLeave: { key: "Check063", hash: "CheckHash", label: "休職中フラグ" },
  suspended: { key: "Check064", hash: "CheckHash", label: "停止中フラグ" },
  administrator: { key: "Check065", hash: "CheckHash", label: "LINEWORKS管理者フラグ" },
};

// { ClassHash: {ClassA:"社員ID", ...}, NumHash: {}, ... } の形に変換するNumHashは常に空: Num型は使わない方針
function buildHashUpdate() {
  const result = { ClassHash: {}, NumHash: {}, DateHash: {}, DescriptionHash: {}, CheckHash: {} };
  for (const field of Object.values(FIELD_MAP)) {
    if (field.hash === "none") continue;
    result[field.hash][field.key] = field.label;
  }
  return result;
}

module.exports = { FIELD_MAP, buildHashUpdate };
  • Step 2: テストを書く
const { test } = require("node:test");
const assert = require("node:assert");
const { FIELD_MAP, buildHashUpdate } = require("../src/config/masterFields");

test("buildHashUpdate groups every field label under its own hash type", () => {
  const result = buildHashUpdate();
  assert.strictEqual(result.ClassHash.ClassA, "社員ID");
  assert.strictEqual(result.ClassHash.Class011, "ユーザID");
  assert.strictEqual(result.ClassHash.Class051, "私用メールアドレス");
  assert.strictEqual(result.DateHash.DateA, "LW同期日時");
  assert.strictEqual(result.DateHash.Date007, "生年月日");
  assert.strictEqual(result.DateHash.Date060, "入社日");
  assert.strictEqual(result.CheckHash.Check062, "LINEWORKS在籍フラグ");
  assert.strictEqual(result.DescriptionHash.Description022, "説明");
  assert.strictEqual(result.ClassHash.Class035, "所属グループ");
  assert.strictEqual(result.DescriptionHash.Description061, "エイリアスメール(複数)");
  assert.deepStrictEqual(result.NumHash, {});
});

test("buildHashUpdate excludes fields whose hash is \"none\" (standard fields like Body)", () => {
  const result = buildHashUpdate();
  const allLabels = [
    ...Object.values(result.ClassHash),
    ...Object.values(result.DateHash),
    ...Object.values(result.DescriptionHash),
    ...Object.values(result.CheckHash),
  ];
  assert.ok(!allLabels.includes("備考"));
});

test("FIELD_MAP has no duplicate field keys across all logical names", () => {
  const keys = Object.values(FIELD_MAP).map((f) => f.key);
  assert.strictEqual(new Set(keys).size, keys.length);
});
  • Step 3: テスト実行して通ることを確認

Run: cd apps/org-master-sync && node --test test/masterFields.test.js Expected: PASS3 tests

  • Step 4: push-master-site-schema.jsを書く(既定ドライラン、--executeで実送信)
"use strict";
/*
 * SiteId=1記録テーブルへ、社員・組織マスタ用のフィールドラベルClassHash等 * Titleを設定する。既定はドライラン差分・送信Body・curlコマンドの表示のみ * 実際に送信するには --execute を付ける。
 *
 * 実行: node scripts/push-master-site-schema.js [--execute]
 */
const fs = require("fs");
const path = require("path");
const { buildHashUpdate } = require("../src/config/masterFields");

const BASE_URL = process.env.PLEASANTER_BASE_URL;
const API_KEY = process.env.PLEASANTER_API_KEY;
const SITE_ID = process.env.PLEASANTER_MASTER_SITE_ID;
const NEW_TITLE = "社員・組織マスタ管理テーブル";

if (!BASE_URL || !API_KEY || !SITE_ID) {
  console.error("PLEASANTER_BASE_URL / PLEASANTER_API_KEY / PLEASANTER_MASTER_SITE_ID が未設定");
  process.exit(1);
}

async function getSite() {
  const res = await fetch(`${BASE_URL.replace(/\/$/, "")}/api/items/${SITE_ID}/getsite`, {
    method: "POST",
    headers: { "Content-Type": "application/json; charset=utf-8" },
    body: JSON.stringify({ ApiVersion: "1.1", ApiKey: API_KEY }),
  });
  const data = await res.json();
  if (!res.ok || data.StatusCode !== 200) {
    throw new Error(`getsite failed: ${res.status} ${JSON.stringify(data)}`);
  }
  return data.Response.Data;
}

async function updateSite(body) {
  const res = await fetch(`${BASE_URL.replace(/\/$/, "")}/api/items/${SITE_ID}/updatesite`, {
    method: "POST",
    headers: { "Content-Type": "application/json; charset=utf-8" },
    body: JSON.stringify(body),
  });
  const data = await res.json();
  if (!res.ok || data.StatusCode !== 200) {
    throw new Error(`updatesite failed: ${res.status} ${JSON.stringify(data)}`);
  }
  return data;
}

async function main() {
  const execute = process.argv.includes("--execute");
  const current = await getSite();
  const hashUpdate = buildHashUpdate();

  const body = {
    ApiVersion: "1.1",
    ApiKey: API_KEY,
    Title: NEW_TITLE,
    ReferenceType: current.ReferenceType,
    ParentId: current.ParentId,
    InheritPermission: current.InheritPermission,
    Permissions: current.Permissions,
    ...hashUpdate,
  };

  console.log("--- 現状 → 希望 ---");
  console.log(`Title: "${current.Title}" -> "${NEW_TITLE}"`);
  for (const hashName of ["ClassHash", "NumHash", "DateHash", "DescriptionHash", "CheckHash"]) {
    console.log(`${hashName}: ${JSON.stringify(current[hashName])} -> ${JSON.stringify(hashUpdate[hashName])}`);
  }
  console.log("\n--- 送信されるBody ---");
  console.log(JSON.stringify({ ...body, ApiKey: "****" }, null, 2));

  if (!execute) {
    console.log("\n[ドライラン] --execute を付けずに実行したため送信していません。内容を確認してから --execute を付けて再実行してください。");
    return;
  }

  const result = await updateSite(body);
  console.log("\n[OK] 送信結果:", JSON.stringify(result));

  const after = await getSite();
  const outPath = path.join(__dirname, "..", "data", "master-site-getsite-after-push.json");
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
  fs.writeFileSync(outPath, JSON.stringify(after, null, 2));
  console.log(`[OK] 反映後の状態を保存: ${outPath}`);
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});
  • Step 5: ドライラン実行して内容確認

Run: cd apps/org-master-sync && node scripts/push-master-site-schema.js Expected: 「現状 → 希望」差分と送信Bodyが表示され、末尾に「[ドライラン]」ログが出る。実際の送信は発生しない

  • Step 6: ユーザーに差分プレビューを提示し、明示的な許可を得てから実送信

Run: cd apps/org-master-sync && node scripts/push-master-site-schema.js --execute Expected: [OK] 送信結果[OK] 反映後の状態を保存が表示される

  • Step 7: 反映後のgetsiteを目視確認

Run: cd apps/org-master-sync && node -e "console.log(JSON.parse(require('fs').readFileSync('data/master-site-getsite-after-push.json')).Response.Data.ClassHash)" Expected: { ClassA: '社員ID', ClassB: 'メールアドレス', Class001: '姓', ... }が表示されるStep 1のFIELD_MAPと一致、ClassHash分すべて)

  • Step 8: コミット
git add apps/org-master-sync/src/config/masterFields.js apps/org-master-sync/test/masterFields.test.js apps/org-master-sync/scripts/push-master-site-schema.js
git commit -m "feat(org-master-sync): マスタサイト(SiteId=1)のフィールド定義をプッシュ"

Task 2: LINEWORKS JWT Service Account認証lineworksAuth.js

Files:

  • Create: apps/org-master-sync/src/lib/lineworksAuth.js
  • Test: apps/org-master-sync/test/lineworksAuth.test.js

Interfaces:

  • Produces: lineworksAuth.jsmodule.exports = { buildAssertion(opts), fetchAccessToken(opts) }を提供。fetchAccessTokenは後続タスクTask 3のlineworksClient.js)が消費する

  • Step 1: 失敗するテストを書く(buildAssertionのJWT構造検証

const { test } = require("node:test");
const assert = require("node:assert");
const crypto = require("node:crypto");
const { buildAssertion } = require("../src/lib/lineworksAuth");

function base64urlDecode(str) {
  return Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}

test("buildAssertion produces a valid RS256 JWT signed with the given private key", () => {
  const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
  const now = new Date("2026-08-08T00:00:00Z").getTime();

  const jwt = buildAssertion({
    clientId: "test-client-id",
    serviceAccount: "svc@example.com",
    privateKey: privateKey.export({ type: "pkcs1", format: "pem" }),
    now,
  });

  const [headerB64, payloadB64, sigB64] = jwt.split(".");
  const header = JSON.parse(base64urlDecode(headerB64).toString("utf8"));
  const payload = JSON.parse(base64urlDecode(payloadB64).toString("utf8"));

  assert.deepStrictEqual(header, { alg: "RS256", typ: "JWT" });
  assert.strictEqual(payload.iss, "test-client-id");
  assert.strictEqual(payload.sub, "svc@example.com");
  assert.strictEqual(payload.exp - payload.iat, 3600);

  const verifier = crypto.createVerify("RSA-SHA256");
  verifier.update(`${headerB64}.${payloadB64}`);
  const isValid = verifier.verify(publicKey, base64urlDecode(sigB64));
  assert.strictEqual(isValid, true);
});
  • Step 2: テスト実行して失敗確認

Run: cd apps/org-master-sync && node --test test/lineworksAuth.test.js Expected: FAILbuildAssertion is not a function 等、モジュール未実装のため)

  • Step 3: lineworksAuth.jsを実装
"use strict";
const crypto = require("node:crypto");

function base64url(buf) {
  return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function buildAssertion({ clientId, serviceAccount, privateKey, now = Date.now() }) {
  const header = { alg: "RS256", typ: "JWT" };
  const iat = Math.floor(now / 1000);
  const payload = { iss: clientId, sub: serviceAccount, iat, exp: iat + 3600 };
  const unsigned = `${base64url(Buffer.from(JSON.stringify(header)))}.${base64url(Buffer.from(JSON.stringify(payload)))}`;
  const signature = crypto.createSign("RSA-SHA256").update(unsigned).sign(privateKey);
  return `${unsigned}.${base64url(signature)}`;
}

async function fetchAccessToken({ clientId, clientSecret, serviceAccount, privateKey, scope, fetchImpl = fetch }) {
  const assertion = buildAssertion({ clientId, serviceAccount, privateKey });
  const body = new URLSearchParams({
    assertion,
    grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
    client_id: clientId,
    client_secret: clientSecret,
    scope,
  });
  const res = await fetchImpl("https://auth.worksmobile.com/oauth2/v2.0/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: body.toString(),
  });
  const data = await res.json();
  if (!res.ok) {
    throw new Error(`LINEWORKS token request failed: ${res.status} ${JSON.stringify(data)}`);
  }
  return data.access_token;
}

module.exports = { buildAssertion, fetchAccessToken };
  • Step 4: テスト実行して通過確認

Run: cd apps/org-master-sync && node --test test/lineworksAuth.test.js Expected: PASS1 test

  • Step 5: fetchAccessTokenのモックテストを追加
test("fetchAccessToken posts a jwt-bearer grant and returns the access_token", async () => {
  const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
  let capturedBody;
  const fetchImpl = async (url, opts) => {
    capturedBody = opts.body;
    return {
      ok: true,
      json: async () => ({ access_token: "fake-token", expires_in: 3600 }),
    };
  };

  const { fetchAccessToken } = require("../src/lib/lineworksAuth");
  const token = await fetchAccessToken({
    clientId: "cid",
    clientSecret: "secret",
    serviceAccount: "svc@example.com",
    privateKey: privateKey.export({ type: "pkcs1", format: "pem" }),
    scope: "directory.read",
    fetchImpl,
  });

  assert.strictEqual(token, "fake-token");
  assert.ok(capturedBody.includes("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer"));
  assert.ok(capturedBody.includes("client_id=cid"));
});
  • Step 6: テスト実行して通過確認

Run: cd apps/org-master-sync && node --test test/lineworksAuth.test.js Expected: PASS2 tests

  • Step 7: コミット
git add apps/org-master-sync/src/lib/lineworksAuth.js apps/org-master-sync/test/lineworksAuth.test.js
git commit -m "feat(org-master-sync): LINEWORKS Service Account JWT認証を実装"

Task 3: LINEWORKS Directory APIクライアントlineworksClient.js

Files:

  • Create: apps/org-master-sync/src/lib/lineworksClient.js
  • Test: apps/org-master-sync/test/lineworksClient.test.js

Interfaces:

  • Consumes: lineworksAuth.jsfetchAccessToken({clientId, clientSecret, serviceAccount, privateKey, scope, fetchImpl})stringTask 2

  • Produces: module.exports = { fetchUsers, fetchOrgUnits, fetchPositions, fetchLevels, fetchUserTypes }。各関数は{accessToken, baseUrl, fetchImpl}を受け取り配列を返す。Task 5fetch-lineworks.js)が消費する

  • Step 1: 失敗するテストを書く(カーソルページングの結合)

const { test } = require("node:test");
const assert = require("node:assert");
const { fetchUsers } = require("../src/lib/lineworksClient");

test("fetchUsers follows responseMetaData.nextCursor until it is null", async () => {
  const pages = [
    { users: [{ userId: "u1" }], responseMetaData: { nextCursor: "cursor-2" } },
    { users: [{ userId: "u2" }], responseMetaData: { nextCursor: null } },
  ];
  let call = 0;
  const fetchImpl = async () => {
    const body = pages[call++];
    return { ok: true, json: async () => body };
  };

  const users = await fetchUsers({ accessToken: "tok", fetchImpl });

  assert.strictEqual(call, 2);
  assert.deepStrictEqual(users.map((u) => u.userId), ["u1", "u2"]);
});
  • Step 2: テスト実行して失敗確認

Run: cd apps/org-master-sync && node --test test/lineworksClient.test.js Expected: FAILfetchUsers is not a function

  • Step 3: lineworksClient.jsを実装
"use strict";
const DEFAULT_BASE_URL = "https://www.worksapis.com/v1.0";

async function fetchAllPages({ baseUrl, path, accessToken, itemsKey, fetchImpl }) {
  const items = [];
  let cursor = null;
  do {
    const url = new URL(`${baseUrl}${path}`);
    url.searchParams.set("count", "100");
    if (cursor) url.searchParams.set("cursor", cursor);

    const res = await fetchImpl(url.toString(), {
      headers: { Authorization: `Bearer ${accessToken}` },
    });
    const data = await res.json();
    if (!res.ok) {
      throw new Error(`${path} failed: ${JSON.stringify(data)}`);
    }
    items.push(...(data[itemsKey] || []));
    cursor = (data.responseMetaData && data.responseMetaData.nextCursor) || null;
  } while (cursor);
  return items;
}

function makeFetcher(path, itemsKey) {
  return async function fetchResource({ accessToken, baseUrl = DEFAULT_BASE_URL, fetchImpl = fetch }) {
    return fetchAllPages({ baseUrl, path, accessToken, itemsKey, fetchImpl });
  };
}

module.exports = {
  fetchUsers: makeFetcher("/users", "users"),
  fetchOrgUnits: makeFetcher("/orgunits", "orgUnits"),
  fetchPositions: makeFetcher("/directory/positions", "positions"),
  fetchLevels: makeFetcher("/directory/levels", "levels"),
  fetchUserTypes: makeFetcher("/directory/user-types", "userTypes"),
};
  • Step 4: テスト実行して通過確認

Run: cd apps/org-master-sync && node --test test/lineworksClient.test.js Expected: PASS1 test

  • Step 5: 単一ページcursorなしのケースを追加テスト
test("fetchOrgUnits returns items directly when there is no next cursor", async () => {
  const { fetchOrgUnits } = require("../src/lib/lineworksClient");
  const fetchImpl = async () => ({
    ok: true,
    json: async () => ({ orgUnits: [{ orgUnitId: "o1" }, { orgUnitId: "o2" }], responseMetaData: {} }),
  });
  const orgUnits = await fetchOrgUnits({ accessToken: "tok", fetchImpl });
  assert.strictEqual(orgUnits.length, 2);
});
  • Step 6: テスト実行して通過確認

Run: cd apps/org-master-sync && node --test test/lineworksClient.test.js Expected: PASS2 tests

  • Step 7: 実APIに対して疎通確認.envの実認証情報を使用、件数の突き合わせ)

Run:

cd apps/org-master-sync && node -e '
require("dotenv-lite-inline");
' 2>/dev/null; node -e '
const fs = require("fs");
const env = Object.fromEntries(fs.readFileSync(".env","utf8").split(/\r?\n/).filter(l=>l&&!l.startsWith("#")).map(l=>{const i=l.indexOf("=");return [l.slice(0,i),l.slice(i+1)];}));
const { fetchAccessToken } = require("./src/lib/lineworksAuth");
const { fetchUsers, fetchOrgUnits, fetchPositions, fetchLevels, fetchUserTypes } = require("./src/lib/lineworksClient");
(async () => {
  const privateKey = fs.readFileSync(env.LW_PRIVATE_KEY_FILE, "utf8");
  const accessToken = await fetchAccessToken({
    clientId: env.LW_CLIENT_ID, clientSecret: env.LW_CLIENT_SECRET,
    serviceAccount: env.LW_SERVICE_ACCOUNT, privateKey, scope: env.LW_SCOPE,
  });
  const [users, orgUnits, positions, levels, userTypes] = await Promise.all([
    fetchUsers({ accessToken }), fetchOrgUnits({ accessToken }),
    fetchPositions({ accessToken }), fetchLevels({ accessToken }), fetchUserTypes({ accessToken }),
  ]);
  console.log({ users: users.length, orgUnits: orgUnits.length, positions: positions.length, levels: levels.length, userTypes: userTypes.length });
})().catch(e => { console.error(e); process.exit(1); });
'

Expected: { users: 351, orgUnits: 46, positions: 8, levels: 6, userTypes: 4 }に近い件数design文書記載の既知件数と照合。ズレていたらresponseMetaData.nextCursorのフィールド名がこの環境と異なる可能性があるため、dataの生JSONをログ出力して構造を確認・修正する

  • Step 8: コミット
git add apps/org-master-sync/src/lib/lineworksClient.js apps/org-master-sync/test/lineworksClient.test.js
git commit -m "feat(org-master-sync): LINEWORKS Directory APIクライアントを実装"

Task 4: プリザンター汎用Items APIクライアントpleasanterClient.js

Files:

  • Create: apps/org-master-sync/src/lib/pleasanterClient.js
  • Test: apps/org-master-sync/test/pleasanterClient.test.js

Interfaces:

  • Produces: module.exports = { getSiteItems, createSiteItem, updateSiteItem, getAllGroups, createGroup }。Task 6merge-master.js)が消費する。getSiteItems({baseUrl, apiKey, siteId, fetchImpl})Array<Item>createSiteItem({..., fields})number新規Id、トップレベルupdateSiteItem({baseUrl, apiKey, itemId, fields, fetchImpl})voidsiteIdは受け取らない)、getAllGroups({baseUrl, apiKey, fetchImpl})Array<Group>GroupId/GroupName/GroupMembers等)、createGroup({..., groupName})number新規GroupId

  • Step 1: 失敗するテストを書く(Offsetを使ったページ結合)

const { test } = require("node:test");
const assert = require("node:assert");
const { getSiteItems } = require("../src/lib/pleasanterClient");

test("getSiteItems pages through results using top-level Offset until TotalCount is reached", 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: [{ ClassA: "a" }], TotalCount: 2 } }) };
    }
    return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [{ ClassA: "b" }], 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), ["a", "b"]);
});
  • Step 2: テスト実行して失敗確認

Run: cd apps/org-master-sync && node --test test/pleasanterClient.test.js Expected: FAILgetSiteItems is not a function

  • Step 3: pleasanterClient.jsを実装
"use strict";

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;
}

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);
    if (page.length === 0 || items.length >= data.Response.TotalCount) break;
    offset += page.length;
  }
  return items;
}

async function createSiteItem({ baseUrl, apiKey, siteId, fields, fetchImpl = fetch }) {
  // レスポンスの Id はトップレベルResponse.Id ではない。実機確認済み、設計文書9章「ハマった点」参照
  const data = await callApi({ baseUrl, apiKey, pathname: `/api/items/${siteId}/create`, body: fields, fetchImpl });
  return data.Id;
}

async function updateSiteItem({ baseUrl, apiKey, itemId, fields, fetchImpl = fetch }) {
  // URLは /api/items/{itemId}/update ResultIdのみ、SiteIdは含めない  // /api/items/{siteId}/{itemId}/update という3階層パスはnginxレベルで404になり、
  // アプリにすら到達しないことを実機確認済み設計文書5.7節「ハマった点」参照)
  await callApi({ baseUrl, apiKey, pathname: `/api/items/${itemId}/update`, body: fields, fetchImpl });
}

async function getAllGroups({ baseUrl, apiKey, fetchImpl = fetch }) {
  const items = [];
  let offset = 0;
  while (true) {
    const data = await callApi({ baseUrl, apiKey, pathname: "/api/groups/get", body: { Offset: offset }, fetchImpl });
    const page = data.Response.Data || [];
    items.push(...page);
    if (page.length === 0 || items.length >= data.Response.TotalCount) break;
    offset += page.length;
  }
  return items;
}

async function createGroup({ baseUrl, apiKey, groupName, fetchImpl = fetch }) {
  const data = await callApi({ baseUrl, apiKey, pathname: "/api/groups/create", body: { GroupName: groupName }, fetchImpl });
  return data.Id;
}

module.exports = { getSiteItems, createSiteItem, updateSiteItem, getAllGroups, createGroup };
  • Step 4: テスト実行して通過確認

Run: cd apps/org-master-sync && node --test test/pleasanterClient.test.js Expected: PASS1 test

  • Step 5: createSiteItem/updateSiteItemのテストを追加
test("createSiteItem returns the new item Id (top-level, not Response.Id)", async () => {
  const { createSiteItem } = require("../src/lib/pleasanterClient");
  const fetchImpl = async () => ({ ok: true, json: async () => ({ Id: 42, StatusCode: 200, Message: "created" }) });
  const id = await createSiteItem({ baseUrl: "https://example.test/", apiKey: "k", siteId: 1, fields: { ClassHash: {} }, fetchImpl });
  assert.strictEqual(id, 42);
});

test("updateSiteItem posts to /api/items/{itemId}/update without siteId in the path", async () => {
  const { updateSiteItem } = require("../src/lib/pleasanterClient");
  let capturedUrl;
  const fetchImpl = async (url) => { capturedUrl = url; return { ok: true, json: async () => ({ Id: 7, StatusCode: 200 }) }; };
  await updateSiteItem({ baseUrl: "https://example.test/", apiKey: "k", itemId: 7, fields: {}, fetchImpl });
  assert.strictEqual(capturedUrl, "https://example.test/api/items/7/update");
});

test("callApi throws when StatusCode is not 200", async () => {
  const { getSiteItems } = require("../src/lib/pleasanterClient");
  const fetchImpl = async () => ({ ok: true, json: async () => ({ StatusCode: 400, Message: "bad" }) });
  await assert.rejects(() => getSiteItems({ baseUrl: "https://example.test/", apiKey: "k", siteId: 1, fetchImpl }));
});

test("getAllGroups pages through /api/groups/get using top-level Offset", async () => {
  const { getAllGroups } = require("../src/lib/pleasanterClient");
  let call = 0;
  const fetchImpl = async () => {
    call++;
    return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [{ GroupId: 1, GroupName: "組織:情報システム部" }], TotalCount: 1 } }) };
  };
  const groups = await getAllGroups({ baseUrl: "https://example.test/", apiKey: "k", fetchImpl });
  assert.strictEqual(call, 1);
  assert.strictEqual(groups[0].GroupName, "組織:情報システム部");
});

test("createGroup returns the new GroupId (top-level Id)", async () => {
  const { createGroup } = require("../src/lib/pleasanterClient");
  const fetchImpl = async () => ({ ok: true, json: async () => ({ Id: 61, StatusCode: 200 }) });
  const id = await createGroup({ baseUrl: "https://example.test/", apiKey: "k", groupName: "役職:課長", fetchImpl });
  assert.strictEqual(id, 61);
});
  • Step 6: テスト実行して通過確認

Run: cd apps/org-master-sync && node --test test/pleasanterClient.test.js Expected: PASS6 tests

  • Step 7: 実APIに対して疎通確認SiteId=1、0件のはず

Run:

cd apps/org-master-sync && node -e '
const fs = require("fs");
const env = Object.fromEntries(fs.readFileSync(".env","utf8").split(/\r?\n/).filter(l=>l&&!l.startsWith("#")).map(l=>{const i=l.indexOf("=");return [l.slice(0,i),l.slice(i+1)];}));
const { getSiteItems } = require("./src/lib/pleasanterClient");
getSiteItems({ baseUrl: env.PLEASANTER_BASE_URL, apiKey: env.PLEASANTER_API_KEY, siteId: env.PLEASANTER_MASTER_SITE_ID })
  .then(items => console.log("件数:", items.length))
  .catch(e => { console.error(e); process.exit(1); });
'

Expected: 件数: 0Task 1完了後、まだレコードを1件も作っていないため。エラーが出た場合はレスポンス生JSONを一時的にログ出力し、Response.Data/Response.TotalCountのキー名がこの環境と一致するか確認・修正する

  • Step 8: コミット
git add apps/org-master-sync/src/lib/pleasanterClient.js apps/org-master-sync/test/pleasanterClient.test.js
git commit -m "feat(org-master-sync): プリザンター汎用Items APIクライアントを実装"

Task 5: ①fetch-lineworks.jsコマンド

Files:

  • Create: apps/org-master-sync/src/commands/fetch-lineworks.js
  • Test: apps/org-master-sync/test/fetch-lineworks.test.js

Interfaces:

  • Consumes: lineworksAuth.fetchAccessTokenTask 2lineworksClient.{fetchUsers,fetchOrgUnits,fetchPositions,fetchLevels,fetchUserTypes}Task 3

  • Produces: buildSnapshot(deps){fetchedAt, users, orgUnits, positions, levels, userTypes}。ファイルdata/master-source/lineworks-snapshot.jsonTask 6のmerge-master.jsが読む唯一の入力)

  • Step 1: 失敗するテストを書く(buildSnapshotのDI・整形ロジック

const { test } = require("node:test");
const assert = require("node:assert");
const { buildSnapshot } = require("../src/commands/fetch-lineworks");

test("buildSnapshot fetches all five resource types and stamps fetchedAt", async () => {
  const calls = [];
  const deps = {
    fetchAccessToken: async () => "tok",
    fetchUsers: async () => { calls.push("users"); return [{ userId: "u1" }]; },
    fetchOrgUnits: async () => { calls.push("orgUnits"); return [{ orgUnitId: "o1" }]; },
    fetchPositions: async () => { calls.push("positions"); return [{ positionId: "p1" }]; },
    fetchLevels: async () => { calls.push("levels"); return [{ levelId: "l1" }]; },
    fetchUserTypes: async () => { calls.push("userTypes"); return [{ userTypeId: "t1" }]; },
    now: () => new Date("2026-08-08T01:00:00Z"),
  };

  const snapshot = await buildSnapshot({
    clientId: "cid", clientSecret: "sec", serviceAccount: "svc", privateKey: "pk", scope: "directory.read",
  }, deps);

  assert.deepStrictEqual(calls.sort(), ["levels", "orgUnits", "positions", "userTypes", "users"]);
  assert.strictEqual(snapshot.fetchedAt, "2026-08-08T01:00:00.000Z");
  assert.strictEqual(snapshot.users.length, 1);
  assert.strictEqual(snapshot.orgUnits[0].orgUnitId, "o1");
});
  • Step 2: テスト実行して失敗確認

Run: cd apps/org-master-sync && node --test test/fetch-lineworks.test.js Expected: FAILbuildSnapshot is not a function

  • Step 3: fetch-lineworks.jsを実装
"use strict";
const fs = require("fs");
const path = require("path");
const { fetchAccessToken } = require("../lib/lineworksAuth");
const lineworksClient = require("../lib/lineworksClient");

async function buildSnapshot(auth, deps = {}) {
  const {
    fetchAccessToken: fetchToken = fetchAccessToken,
    fetchUsers = lineworksClient.fetchUsers,
    fetchOrgUnits = lineworksClient.fetchOrgUnits,
    fetchPositions = lineworksClient.fetchPositions,
    fetchLevels = lineworksClient.fetchLevels,
    fetchUserTypes = lineworksClient.fetchUserTypes,
    now = () => new Date(),
  } = deps;

  const accessToken = await fetchToken(auth);
  const [users, orgUnits, positions, levels, userTypes] = await Promise.all([
    fetchUsers({ accessToken }),
    fetchOrgUnits({ accessToken }),
    fetchPositions({ accessToken }),
    fetchLevels({ accessToken }),
    fetchUserTypes({ accessToken }),
  ]);

  return { fetchedAt: now().toISOString(), users, orgUnits, positions, levels, userTypes };
}

async function main() {
  const privateKey = fs.readFileSync(process.env.LW_PRIVATE_KEY_FILE, "utf8");
  const snapshot = await buildSnapshot({
    clientId: process.env.LW_CLIENT_ID,
    clientSecret: process.env.LW_CLIENT_SECRET,
    serviceAccount: process.env.LW_SERVICE_ACCOUNT,
    privateKey,
    scope: process.env.LW_SCOPE,
  });

  const outDir = path.join(__dirname, "..", "..", "data", "master-source");
  fs.mkdirSync(outDir, { recursive: true });
  const outPath = path.join(outDir, "lineworks-snapshot.json");
  fs.writeFileSync(outPath, JSON.stringify(snapshot, null, 2));

  console.log(`[OK] ${outPath}`);
  console.log(`users=${snapshot.users.length} orgUnits=${snapshot.orgUnits.length} positions=${snapshot.positions.length} levels=${snapshot.levels.length} userTypes=${snapshot.userTypes.length}`);
}

module.exports = { buildSnapshot };

if (require.main === module) {
  main().catch((e) => { console.error(e); process.exit(1); });
}
  • Step 4: テスト実行して通過確認

Run: cd apps/org-master-sync && node --test test/fetch-lineworks.test.js Expected: PASS1 test

  • Step 5: 実行してdata/master-source/lineworks-snapshot.jsonが実データで生成されることを確認

Run: cd apps/org-master-sync && node -r dotenv/config src/commands/fetch-lineworks.js 2>/dev/null || (export $(cat .env | grep -v '^#' | xargs) && node src/commands/fetch-lineworks.js) Expected: users=351 orgUnits=46 positions=8 levels=6 userTypes=4程度のログと、data/master-source/lineworks-snapshot.jsonの生成(data/はgitignore対象なのでコミット不要

  • Step 6: コミット
git add apps/org-master-sync/src/commands/fetch-lineworks.js apps/org-master-sync/test/fetch-lineworks.test.js
git commit -m "feat(org-master-sync): ①fetch-lineworksコマンドを実装"

Task 6: ②merge-master.jsコマンド突合・Upsert・退職者検知

Files:

  • Create: apps/org-master-sync/src/commands/merge-master.js
  • Test: apps/org-master-sync/test/merge-master.test.js

Interfaces:

  • Consumes: pleasanterClient.{getSiteItems,createSiteItem,updateSiteItem,getAllGroups,createGroup}Task 4masterFields.FIELD_MAPTask 1data/master-source/lineworks-snapshot.jsonTask 5の出力
  • Produces: planMerge(snapshot, existingMasterItems){creates: [{fields, user}], updates: [{itemId, fields, user, existingGroupsValue}], retirements: [{itemId, fields}]}(純粋関数、ネットワーク非依存でテスト可能。userはグループ解決用に持ち越す)。applyMerge(plan, pleasanterConfig)が実際のAPI呼び出しグループのget-or-create含むを行う

設計文書7章・5.5節の確定仕様をそのまま実装する:

  • 突合キー: マスタに既存レコードがあればClassA(社員ID=LINEWORKS userId)で突合。初回(マスタが空)はメールアドレスのローカル部一致で突合

  • 退職者検知: マスタのCheck062(在籍フラグ)がON(true)の既存レコードのうち、今回のLINEWORKS取得結果に社員IDが存在しないものを退職と判定しCheck062をfalseにするUpdateを生成

  • フィールド変換は設計文書5章・5.0〜5.3節3タブ構成・LINEWORKS準拠版のうち、LINEWORKS由来の項目のみuserToFieldsでマッピングする。実際のLINEWORKS /usersレスポンス形状はdata/lineworks-user-sample-raw.jsonで確認済み

  • タブ2プリザンター、Class011Class021Check020Description022Class023Class034Check024Check031はPleasanterユーザー登録APIの管理項目で、groupsClass035[[Groups*]]選択式を除きLINEWORKS由来ではないため②merge-masterは書き込まない③sync-pleasanter・管理者の手動入力の担当。5.2節参照)

  • 同様にnoteBody、備考)・pleasanterCreatedAtDateB)・pleasanterUpdatedAtDateC)も②では書き込まない

  • groupsClass035)は分類プレフィックス名(組織:{orgUnitName}役職:{positionName}職級:{levelName}利用権限タイプ:{userTypeName}、7章に対応するGroupIdの集合。値はカンマ区切りGroupId文字列実機確認済み、例: "1,4")。対応するグループがプリザンター側に無ければcreateGroupで自動作成するget-or-create。既存の選択手動・他システム由来含むとは和集合でマージし、消さない5.5節)

  • Step 1: 失敗するテストを書く(新規作成の検出)

const { test } = require("node:test");
const assert = require("node:assert");
const { planMerge } = require("../src/commands/merge-master");

function makeUser(overrides = {}) {
  return {
    userId: "u1",
    email: "taro.yamada@next-hd.co.jp",
    privateEmail: "taro.private@example.com",
    aliasEmails: ["taro.alias@next-hd.co.jp"],
    userName: {
      lastName: "山田",
      firstName: "太郎",
      phoneticLastName: "ヤマダ",
      phoneticFirstName: "タロウ",
    },
    i18nNames: [{ language: "en_US", lastName: "Yamada", firstName: "Taro" }],
    nickName: "タロー",
    employeeNumber: "400123",
    telephone: "03-1234-5678",
    cellPhone: "+81 090-1234-5678",
    location: "東京本社",
    birthday: "1990-04-01",
    hiredDate: "2015-04-01",
    organizations: [{ levelName: "正社員", orgUnits: [{ orgUnitName: "情報システム部", primary: true, positionName: "課長" }] }],
    userTypeName: "一般",
    isSuspended: false,
    isAdministrator: false,
    leaveOfAbsence: { isLeaveOfAbsence: false },
    ...overrides,
  };
}

test("planMerge creates a new record when the employeeId is not in the existing master", () => {
  const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
  const plan = planMerge(snapshot, []);

  assert.strictEqual(plan.creates.length, 1);
  assert.strictEqual(plan.updates.length, 0);
  assert.strictEqual(plan.retirements.length, 0);

  const fields = plan.creates[0].fields;
  // タブ1: 基本情報
  assert.strictEqual(fields.ClassA, "u1");
  assert.strictEqual(fields.ClassB, "taro.yamada@next-hd.co.jp");
  assert.strictEqual(fields.Class001, "山田");
  assert.strictEqual(fields.Class002, "太郎");
  assert.strictEqual(fields.Class003, "ヤマダ");
  assert.strictEqual(fields.Class004, "タロウ");
  assert.strictEqual(fields.Class005, "Yamada");
  assert.strictEqual(fields.Class006, "Taro");
  assert.strictEqual(fields.Date007, "1990-04-01");
  assert.strictEqual(fields.DateA, "2026-08-08T00:00:00.000Z");
  // タブ2: プリザンター管理項目はplanMergeの時点では書かないClass035はapplyMergeがグループ解決後に追加する
  assert.strictEqual(fields.Class035, undefined);
  assert.strictEqual(fields.Class011, undefined);
  assert.strictEqual(fields.Body, undefined);
  // planMergeはuserをそのまま持ち越すapplyMergeでのグループ解決に使う
  assert.strictEqual(plan.creates[0].user.userId, "u1");
  // タブ3: LINEWORKS固有情報
  assert.strictEqual(fields.Class051, "taro.private@example.com");
  assert.strictEqual(fields.Class052, "タロー");
  assert.strictEqual(fields.Class053, "400123");
  assert.strictEqual(fields.Class054, "03-1234-5678");
  assert.strictEqual(fields.Class055, "+81 090-1234-5678");
  assert.strictEqual(fields.Class056, "東京本社");
  assert.strictEqual(fields.Class057, "課長");
  assert.strictEqual(fields.Class058, "正社員");
  assert.strictEqual(fields.Class059, "一般");
  assert.strictEqual(fields.Date060, "2015-04-01");
  assert.strictEqual(fields.Description061, "taro.alias@next-hd.co.jp");
  assert.strictEqual(fields.Check062, true);
  assert.strictEqual(fields.Check063, false);
  assert.strictEqual(fields.Check064, false);
  assert.strictEqual(fields.Check065, false);
});
  • Step 2: テスト実行して失敗確認

Run: cd apps/org-master-sync && node --test test/merge-master.test.js Expected: FAILplanMerge is not a function

  • Step 3: merge-master.jsを実装
"use strict";
const fs = require("fs");
const path = require("path");
const { getSiteItems, createSiteItem, updateSiteItem, getAllGroups, createGroup } = require("../lib/pleasanterClient");

function toFullName(user) {
  const n = user.userName || {};
  return `${n.lastName || ""} ${n.firstName || ""}`.trim();
}

function orgUnitNames(user) {
  const orgs = user.organizations || [];
  return orgs.flatMap((org) => (org.orgUnits || []).map((ou) => ou.orgUnitName)).filter(Boolean);
}

function toGroupsText(user) {
  return orgUnitNames(user).join(";");
}

// マスタ「所属グループ」Class035、[[Groups*]]選択式)に対応させる、分類プレフィックス付きグループ名一覧
// 設計文書7章・5.5節。実際のGroupIdへの解決はensureGroupIdsが行う
const GROUP_PREFIXES = { org: "組織", position: "役職", level: "職級", userType: "利用権限タイプ" };

function classificationGroupNames(user) {
  const names = orgUnitNames(user).map((n) => `${GROUP_PREFIXES.org}:${n}`);
  const position = primaryPositionName(user);
  if (position) names.push(`${GROUP_PREFIXES.position}:${position}`);
  const level = primaryLevelName(user);
  if (level) names.push(`${GROUP_PREFIXES.level}:${level}`);
  if (user.userTypeName) names.push(`${GROUP_PREFIXES.userType}:${user.userTypeName}`);
  return names;
}

// Class035の格納形式はカンマ区切りGroupId文字列実機確認済み、例: "1,4")。
// 既存の選択手動・他システム由来含むとの和集合を取り、失われないようにする設計文書5.5節)
function mergeGroupSelection(existingValue, newIds) {
  const existingIds = (existingValue || "").split(",").map((s) => s.trim()).filter(Boolean);
  const merged = new Set([...existingIds, ...newIds.map(String)]);
  return [...merged].sort((a, b) => Number(a) - Number(b)).join(",");
}

// グループ名→GroupIdのキャッシュgetAllGroupsで事前構築を見て、無ければ作成する
async function ensureGroupIds(names, groupNameToId, { baseUrl, apiKey, fetchImpl }) {
  const ids = [];
  for (const name of names) {
    let id = groupNameToId.get(name);
    if (!id) {
      id = await createGroup({ baseUrl, apiKey, groupName: name, fetchImpl });
      groupNameToId.set(name, id);
    }
    ids.push(id);
  }
  return ids;
}

function primaryPositionName(user) {
  const orgs = user.organizations || [];
  for (const org of orgs) {
    const primaryUnit = (org.orgUnits || []).find((ou) => ou.primary);
    if (primaryUnit && primaryUnit.positionName) return primaryUnit.positionName;
  }
  return "";
}

function primaryLevelName(user) {
  const orgs = user.organizations || [];
  const primaryOrg = orgs.find((org) => org.orgUnits && org.orgUnits.some((ou) => ou.primary)) || orgs[0];
  return (primaryOrg && primaryOrg.levelName) || "";
}

function i18nName(user, language = "en_US") {
  const entry = (user.i18nNames || []).find((n) => n.language === language);
  return entry ? { lastName: entry.lastName || "", firstName: entry.firstName || "" } : { lastName: "", firstName: "" };
}

// LINEWORKS由来の項目のみを書き込む。タブ2プリザンター管理項目はgroups以外触らない設計文書5.2節)
function userToFields(user, now) {
  const n = user.userName || {};
  const i18n = i18nName(user);
  return {
    // タブ1: 基本情報
    ClassA: user.userId,
    ClassB: user.email || "",
    Class001: n.lastName || "",
    Class002: n.firstName || "",
    Class003: n.phoneticLastName || "",
    Class004: n.phoneticFirstName || "",
    Class005: i18n.lastName,
    Class006: i18n.firstName,
    Date007: user.birthday || null,
    DateA: now,
    // タブ2: プリザンター管理項目は触らないgroupsはClass035、applyMergeが別途GroupId解決して書き込む。5.5節)
    // タブ3: LINEWORKS固有情報
    Class051: user.privateEmail || "",
    Class052: user.nickName || "",
    Class053: user.employeeNumber || "",
    Class054: user.telephone || "",
    Class055: user.cellPhone || "",
    Class056: user.location || "",
    Class057: primaryPositionName(user),
    Class058: primaryLevelName(user),
    Class059: user.userTypeName || "",
    Date060: user.hiredDate || null,
    Description061: (user.aliasEmails || []).join(";"),
    Check062: true,
    Check063: !!(user.leaveOfAbsence && user.leaveOfAbsence.isLeaveOfAbsence),
    Check064: !!user.isSuspended,
    Check065: !!user.isAdministrator,
  };
}

function emailLocalPart(email) {
  return (email || "").split("@")[0].toLowerCase();
}

function planMerge(snapshot, existingItems) {
  const byEmployeeId = new Map(existingItems.filter((i) => i.ClassA).map((i) => [i.ClassA, i]));
  const byEmailLocal = new Map(
    existingItems.filter((i) => i.ClassB).map((i) => [emailLocalPart(i.ClassB), i])
  );

  const creates = [];
  const updates = [];
  const seenEmployeeIds = new Set();

  for (const user of snapshot.users) {
    seenEmployeeIds.add(user.userId);
    const fields = userToFields(user, snapshot.fetchedAt);

    // 突合: 社員ID一致を優先。無ければメールアドレスのローカル部一致初回import・手動作成レコードの引き継ぎ用、設計文書7章
    const existing = byEmployeeId.get(user.userId) || byEmailLocal.get(emailLocalPart(user.email));

    if (existing) {
      // user/existingGroupsValueはapplyMergeでのグループ解決Class035に使う
      updates.push({ itemId: existing.ResultId || existing.Id, fields, user, existingGroupsValue: existing.Class035 });
    } else {
      creates.push({ fields, user });
    }
  }

  const retirements = existingItems
    .filter((i) => i.Check062 === true && i.ClassA && !seenEmployeeIds.has(i.ClassA))
    .map((i) => ({ itemId: i.ResultId || i.Id, fields: { Check062: false, DateA: snapshot.fetchedAt } }));

  return { creates, updates, retirements };
}

// 所属グループClass035はLINEWORKS取得結果ごとに動的なグループ名を要するため、
// 純粋関数のplanMergeでは決定できないグループの存在確認・新規作成というI/Oが必要// applyMergeが実行時に解決する設計文書5.5節・7章
async function applyMerge(plan, { baseUrl, apiKey, siteId, fetchImpl }) {
  const groups = await getAllGroups({ baseUrl, apiKey, fetchImpl });
  const groupNameToId = new Map(groups.map((g) => [g.GroupName, g.GroupId]));

  let created = 0, updated = 0, retired = 0;

  for (const c of plan.creates) {
    const names = classificationGroupNames(c.user);
    const groupIds = await ensureGroupIds(names, groupNameToId, { baseUrl, apiKey, fetchImpl });
    const fields = { ...c.fields, Class035: mergeGroupSelection("", groupIds) };
    await createSiteItem({ baseUrl, apiKey, siteId, fields, fetchImpl });
    created++;
  }

  for (const u of plan.updates) {
    const names = classificationGroupNames(u.user);
    const groupIds = await ensureGroupIds(names, groupNameToId, { baseUrl, apiKey, fetchImpl });
    const fields = { ...u.fields, Class035: mergeGroupSelection(u.existingGroupsValue, groupIds) };
    await updateSiteItem({ baseUrl, apiKey, itemId: u.itemId, fields, fetchImpl });
    updated++;
  }

  for (const r of plan.retirements) {
    await updateSiteItem({ baseUrl, apiKey, itemId: r.itemId, fields: r.fields, fetchImpl });
    retired++;
  }

  return { created, updated, retired };
}

async function main() {
  const snapshotPath = path.join(__dirname, "..", "..", "data", "master-source", "lineworks-snapshot.json");
  const snapshot = JSON.parse(fs.readFileSync(snapshotPath, "utf8"));

  const config = {
    baseUrl: process.env.PLEASANTER_BASE_URL,
    apiKey: process.env.PLEASANTER_API_KEY,
    siteId: process.env.PLEASANTER_MASTER_SITE_ID,
  };

  const existingItems = await getSiteItems(config);
  const plan = planMerge(snapshot, existingItems);

  console.log(`create=${plan.creates.length} update=${plan.updates.length} retire=${plan.retirements.length}`);

  const result = await applyMerge(plan, config);
  console.log(`[OK] created=${result.created} updated=${result.updated} retired=${result.retired}`);
}

module.exports = {
  planMerge, applyMerge, userToFields, toFullName, toGroupsText, primaryPositionName, primaryLevelName, i18nName,
  classificationGroupNames, mergeGroupSelection, ensureGroupIds,
};

if (require.main === module) {
  main().catch((e) => { console.error(e); process.exit(1); });
}
  • Step 4: テスト実行して通過確認

Run: cd apps/org-master-sync && node --test test/merge-master.test.js Expected: PASS1 test

  • Step 5: 既存レコードの更新・退職者検知のテストを追加
test("planMerge updates an existing record matched by employeeId (ClassA)", () => {
  const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser({ organizations: [{ levelName: "正社員", orgUnits: [{ orgUnitName: "情報システム部", primary: true, positionName: "部長" }] }] })], orgUnits: [], positions: [], levels: [], userTypes: [] };
  const existing = [{ ResultId: 10, ClassA: "u1", ClassB: "taro.yamada@next-hd.co.jp", Check062: true }];

  const plan = planMerge(snapshot, existing);

  assert.strictEqual(plan.creates.length, 0);
  assert.strictEqual(plan.updates.length, 1);
  assert.strictEqual(plan.updates[0].itemId, 10);
  assert.strictEqual(plan.updates[0].fields.Class057, "部長");
});

test("planMerge matches an existing record by email local-part on first-time import", () => {
  const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
  const existing = [{ ResultId: 20, ClassA: "", ClassB: "taro.yamada@example.com", Check062: true }];

  const plan = planMerge(snapshot, existing);

  assert.strictEqual(plan.updates.length, 1);
  assert.strictEqual(plan.updates[0].itemId, 20);
});

test("planMerge marks a previously-active record as retired when it disappears from the LINEWORKS snapshot", () => {
  const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [], orgUnits: [], positions: [], levels: [], userTypes: [] };
  const existing = [{ ResultId: 30, ClassA: "gone-user", ClassB: "gone@next-hd.co.jp", Check062: true }];

  const plan = planMerge(snapshot, existing);

  assert.strictEqual(plan.retirements.length, 1);
  assert.strictEqual(plan.retirements[0].itemId, 30);
  assert.strictEqual(plan.retirements[0].fields.Check062, false);
});

test("planMerge does not re-retire a record that is already inactive", () => {
  const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [], orgUnits: [], positions: [], levels: [], userTypes: [] };
  const existing = [{ ResultId: 40, ClassA: "already-gone", Check062: false }];

  const plan = planMerge(snapshot, existing);

  assert.strictEqual(plan.retirements.length, 0);
});

test("i18nName falls back to empty strings when there is no en_US entry", () => {
  const { i18nName } = require("../src/commands/merge-master");
  assert.deepStrictEqual(i18nName({ i18nNames: [] }), { lastName: "", firstName: "" });
  assert.deepStrictEqual(i18nName({ i18nNames: [{ language: "zh_CN", lastName: "山", firstName: "田" }] }), { lastName: "", firstName: "" });
});
  • Step 6: テスト実行して通過確認

Run: cd apps/org-master-sync && node --test test/merge-master.test.js Expected: PASS6 tests

  • Step 7: グループ解決ロジック分類プレフィックス名・GroupId和集合マージ・get-or-createのテストを追加
test("classificationGroupNames builds prefixed names for org units, position, level, and userType", () => {
  const { classificationGroupNames } = require("../src/commands/merge-master");
  const user = makeUser({
    organizations: [{
      levelName: "正社員",
      orgUnits: [
        { orgUnitName: "情報システム部", primary: true, positionName: "課長" },
        { orgUnitName: "管理本部", primary: false, positionName: "課長" },
      ],
    }],
    userTypeName: "一般",
  });

  const names = classificationGroupNames(user);

  assert.deepStrictEqual(names, [
    "組織:情報システム部",
    "組織:管理本部",
    "役職:課長",
    "職級:正社員",
    "利用権限タイプ:一般",
  ]);
});

test("mergeGroupSelection unions new GroupIds with the existing comma-separated selection", () => {
  const { mergeGroupSelection } = require("../src/commands/merge-master");
  // 手動選択済み(99)を保持しつつ、新規解決分(1,4)を追加。重複(4)は1回だけ、数値昇順
  assert.strictEqual(mergeGroupSelection("4,99", [1, 4]), "1,4,99");
  assert.strictEqual(mergeGroupSelection("", [7, 2]), "2,7");
  assert.strictEqual(mergeGroupSelection(null, []), "");
});

test("ensureGroupIds reuses cached GroupIds and creates missing ones via createGroup", async () => {
  const { ensureGroupIds } = require("../src/commands/merge-master");
  const groupNameToId = new Map([["組織:情報システム部", 5]]);
  const createCalls = [];
  const fetchImpl = async (url, opts) => {
    const body = JSON.parse(opts.body);
    createCalls.push(body.GroupName);
    return { ok: true, json: async () => ({ Id: 61, StatusCode: 200 }) };
  };

  const ids = await ensureGroupIds(
    ["組織:情報システム部", "役職:課長"],
    groupNameToId,
    { baseUrl: "https://example.test/", apiKey: "k", fetchImpl }
  );

  assert.deepStrictEqual(ids, [5, 61]);
  assert.deepStrictEqual(createCalls, ["役職:課長"]); // 既存の「組織:情報システム部」は作成しない
  assert.strictEqual(groupNameToId.get("役職:課長"), 61); // キャッシュに追加される
});
  • Step 8: テスト実行して通過確認

Run: cd apps/org-master-sync && node --test test/merge-master.test.js Expected: PASS9 tests

  • Step 9: applyMergeのグループ解決込み統合テストを追加実APIをモックし、作成・更新それぞれでClass035が正しく組み立てられることを確認
test("applyMerge resolves classification groups (get-or-create) and merges Class035 with the existing selection", async () => {
  const snapshot = { fetchedAt: "2026-08-08T00:00:00.000Z", users: [makeUser()], orgUnits: [], positions: [], levels: [], userTypes: [] };
  // 既存レコード: 手動選択済みGroupId=99を持つ
  const existing = [{ ResultId: 10, ClassA: "u1", ClassB: "taro.yamada@next-hd.co.jp", Check062: true, Class035: "99" }];
  const plan = planMerge(snapshot, existing);

  const calls = [];
  const fetchImpl = async (url, opts) => {
    const urlStr = String(url);
    const body = opts.body ? JSON.parse(opts.body) : {};
    calls.push({ url: urlStr, body });
    if (urlStr.endsWith("/api/groups/get")) {
      return { ok: true, json: async () => ({ StatusCode: 200, Response: { Data: [{ GroupId: 5, GroupName: "組織:情報システム部" }], TotalCount: 1 } }) };
    }
    if (urlStr.endsWith("/api/groups/create")) {
      return { ok: true, json: async () => ({ Id: 61, StatusCode: 200 }) };
    }
    if (urlStr.match(/\/api\/items\/\d+\/update$/)) {
      return { ok: true, json: async () => ({ Id: 10, StatusCode: 200 }) };
    }
    throw new Error(`unexpected fetch: ${urlStr}`);
  };

  const result = await applyMerge(plan, { baseUrl: "https://example.test/", apiKey: "k", siteId: 1, fetchImpl });

  assert.strictEqual(result.updated, 1);
  const updateCall = calls.find((c) => c.url.match(/\/api\/items\/10\/update$/));
  // 既存の手動選択(99) + 新規解決分(組織:情報システム部=5, 役職:課長=作成された61, 職級:正社員, 利用権限タイプ:一般は作成される)
  assert.ok(updateCall.body.Class035.split(",").includes("99"));
  assert.ok(updateCall.body.Class035.split(",").includes("5"));
});
  • Step 10: テスト実行して通過確認

Run: cd apps/org-master-sync && node --test test/merge-master.test.js Expected: PASS10 tests

  • Step 11: 実行して実データでマージTask 5のsnapshotを使用

Run: cd apps/org-master-sync && export $(cat .env | grep -v '^#' | xargs) && node src/commands/merge-master.js Expected: create=351 update=0 retire=0程度のログ初回実行のため全件createに続き[OK] created=351 updated=0 retired=0。実行中、LINEWORKSの組織・役職・職級・利用権限タイプの分類グループがプリザンター側に存在しない場合、自動作成されるログがensureGroupIds経由で)暗黙的に発生する

  • Step 12: プリザンター管理画面でSiteId=1のテーブルを開き、351件のレコードが作成され、氏名・所属グループ組織:等のプレフィックス付きグループが選択された状態)等が入っていることを目視確認。あわせてグループ一覧(/groups/index)に新規分類グループが作成されていることを確認

  • Step 13: コミット

git add apps/org-master-sync/src/commands/merge-master.js apps/org-master-sync/test/merge-master.test.js
git commit -m "feat(org-master-sync): ②merge-masterコマンド(突合・Upsert・退職者検知)を実装"

Task 7: Claude Skill化①②の指示駆動起動

Files:

  • Create: apps/org-master-sync/.claude/skills/org-fetch-lineworks/SKILL.md
  • Create: apps/org-master-sync/.claude/skills/org-merge-master/SKILL.md

Interfaces:

  • Consumes: Task 5のnode src/commands/fetch-lineworks.js、Task 6のnode src/commands/merge-master.js(コマンドとして呼び出すのみ、コード依存なし)

  • Step 1: org-fetch-lineworks/SKILL.mdを書く

---
name: org-fetch-lineworks
description: LINEWORKS Directory APIから社員・組織・役職・職級・利用権限タイプの全件を取得し、apps/org-master-sync/data/master-source/lineworks-snapshot.jsonへ保存する。「LINEWORKSから取得して」「LINEWORKSの最新情報を取ってきて」等の依頼で使う。
---

# LINEWORKSデータ取得

`apps/org-master-sync/`配下で以下を実行する。

\`\`\`bash
cd apps/org-master-sync
node src/commands/fetch-lineworks.js
\`\`\`

- `.env`のLINEWORKS Service Account認証情報を使用`LW_CLIENT_ID`等)
- 実行結果は`data/master-source/lineworks-snapshot.json`に保存gitignore対象、上書き
- 実行後、ログの件数users/orgUnits/positions/levels/userTypesをユーザーに報告する
- 次のステップとして`org-merge-master`スキル(マスタテーブルへの反映)を案内する
  • Step 2: org-merge-master/SKILL.mdを書く
---
name: org-merge-master
description: apps/org-master-sync/data/master-source/lineworks-snapshot.jsonの内容を、プリザンターの社員・組織マスタ管理テーブル(SiteId=1)へ突合・反映する。新規社員の追加、既存社員の情報更新、退職者の在籍フラグOFFを行う。「マスタに反映して」「マスタを更新して」等の依頼で使う。
---

# マスタテーブルへの反映

前提: `org-fetch-lineworks`スキルで`lineworks-snapshot.json`を取得済みであること。未取得なら先にそちらを実行するよう案内する。

`apps/org-master-sync/`配下で以下を実行する。

\`\`\`bash
cd apps/org-master-sync
node src/commands/merge-master.js
\`\`\`

- 突合キー: 社員ID(LINEWORKSのuserId)。初回のみメールアドレスのローカル部一致でも突合設計文書7章
- 退職者前回は在籍フラグONだったが今回のLINEWORKS取得結果に含まれない社員は自動的に在籍フラグOFFになる物理削除はしない
- 実行後、ログの`created`/`updated`/`retired`件数をユーザーに報告する
- `retired`が0件でない場合は、対象者の氏名をプリザンター管理画面で確認するようユーザーに案内する意図しない大量退職判定の誤検知チェック
  • Step 3: 両スキルが認識されることを確認

Run: cat "apps/org-master-sync/.claude/skills/org-fetch-lineworks/SKILL.md" "apps/org-master-sync/.claude/skills/org-merge-master/SKILL.md" | head -5 Expected: 両ファイルのfrontmatterが表示されるYAML構文エラーがないことの簡易確認

  • Step 4: コミット
git add apps/org-master-sync/.claude/skills/
git commit -m "feat(org-master-sync): ①②をClaude Skill化"

Task 8: package.json整備・README更新・通し実行確認

Files:

  • Create: apps/org-master-sync/package.json

  • Modify: apps/org-master-sync/README.md

  • Step 1: package.jsonを作成

{
  "name": "org-master-sync",
  "version": "0.1.0",
  "private": true,
  "type": "commonjs",
  "scripts": {
    "test": "node --test",
    "fetch-lineworks": "node src/commands/fetch-lineworks.js",
    "merge-master": "node src/commands/merge-master.js"
  }
}
  • Step 2: README.mdを更新現状ステータスの記述を実態に合わせる
# org-master-sync

LINEWORKS・プリザンター・Keycloak横断の組織・社員管理データベース構築プロジェクト。
設計: [docs/superpowers/specs/2026-08-07-org-master-sync-design.md](../../docs/superpowers/specs/2026-08-07-org-master-sync-design.md)

## 現状ステータス

Phase 1完了: LINEWORKS取得①fetch-lineworks→ プリザンターマスタテーブル反映②merge-masterが動作。
マスタテーブルはプリザンターSiteId=1`.env`の`PLEASANTER_MASTER_SITE_ID`)。
③sync-pleasanter・④sync-keycloakは未実装別プランで対応## セットアップ

\`\`\`bash
cd apps/org-master-sync
npm test   # ユニットテスト実行(node --test)
\`\`\`

`.env`は3システム分の認証情報を含むgitignore対象、既に設定済み## コマンド

\`\`\`bash
node src/commands/fetch-lineworks.js   # ① LINEWORKSから全件取得 → data/master-source/lineworks-snapshot.json
node src/commands/merge-master.js      # ② マスタテーブル(SiteId=1)へ突合・反映
\`\`\`

Claude Skill経由でも起動可能`.claude/skills/org-fetch-lineworks/` / `org-merge-master/`)。

## ディレクトリ構成

\`\`\`
org-master-sync/
  .env           認証情報(git管理対象外)
  keys/          LINEWORKS秘密鍵(git管理対象外)
  src/
    lib/         lineworksAuth.js / lineworksClient.js / pleasanterClient.js
    config/      masterFields.jsマスタ項目→プリザンターフィールドの割当定義
    commands/    fetch-lineworks.js / merge-master.js
  scripts/       一回限りの移行・保守スクリプト(本番→テスト環境インポート等)
  test/          node --testによるユニットテスト
  data/          取得データ・スナップショット(git管理対象外)
  pleasanter-saml/  SAML SSO設定一式
\`\`\`

他システムから独立したフォルダとして構成。単体で別リポジトリへ切り出す場合もこのフォルダごと移動すればよい。
  • Step 3: 全テストを通しで実行

Run: cd apps/org-master-sync && npm test Expected: 全ファイルmasterFields/lineworksAuth/lineworksClient/pleasanterClient/fetch-lineworks/merge-masterのテストがPASS

  • Step 4: ①②を通しで再実行し、2回目実行時に差分が正しく検出されることを確認冪等性チェック

Run: cd apps/org-master-sync && node src/commands/fetch-lineworks.js && node src/commands/merge-master.js Expected: create=0 update=351 retire=0程度のログ1回目でcreate済みのため、2回目は全件updateになる。社員IDが変わらない限りcreateは発生しない

  • Step 5: コミット
git add apps/org-master-sync/package.json apps/org-master-sync/README.md
git commit -m "chore(org-master-sync): package.json整備・README更新(Phase 1完了)"

Self-Review メモ

  • 【2026-08-08追記・実装後の重要な仕様変更】 実装・実データ投入時に3つの問題が発覚し対応済み。詳細は設計文書7章「プリザンター実ユーザーとの突合」・5.2節・5.7節参照:
    1. プリザンターItems APIはHash形式ClassHashへのネストが必須フラット送信は200 OKで受理されるが値が書き込まれないpleasanterClient.jstoHashPayload/getSiteItemsのフラット化で対応
    2. Date型フィールドは空文字列・nullを送ると400エラー。値が無ければキー自体を省略する方式に変更merge-master.jsuserToFields
    3. ②merge-masterのスコープ変更: 当初「タブ2はLINEWORKS由来ではないため②は書き込まない」としていたが、ユーザー指摘により②実行時に/api/users/getでプリザンター実ユーザーも取得し、メールのローカル部一致でタブ2へミラーリングするよう拡張pleasanterUserToFields/resolvePleasanterUser/groupPleasanterUsersByEmail)。重複時はdata/pleasanter-user-email-overrides.jsonで人手解決
  • 設計文書5章5.0〜5.3節、3タブ構成・LINEWORKS準拠版の全項目にmasterFields.jsのFIELD_MAPで対応するキーを割当済み。除外した項目employmentType重複・isDeletedは設計文書5.4節に理由を明記済み
  • userToFieldsTask 6は実際のLINEWORKS /usersレスポンス(data/lineworks-user-sample-raw.json、351人分のdata/lineworks-users-full.json)で確認済みのフィールド形状(userName.phoneticLastName/i18nNames[]/privateEmail/aliasEmails[]/leaveOfAbsence.isLeaveOfAbsence等)をそのまま使用。推測ベースの項目はない
  • タブ2プリザンター、Class011Class021Check020Description022Class023Class034Check024Check031BodyDateBDateCはプリザンターユーザー登録APIの管理項目であり、LINEWORKS由来ではないため②merge-masterではgroupsClass035以外書き込まない設計文書5.2節の通り、③sync-pleasanter・管理者の手動入力の担当userToFieldsが意図的にこれらのキーを含めていないことをレビュー時に確認すること
  • 氏名の結合表示(pleasanterNameClass013はタブ2に属するため②では書き込まない。④sync-keycloakが参照する時点では③sync-pleasanterによって設定済みである前提設計文書5.1節)
  • NumHashは一切使用しない数値項目もすべてClass型で保持する方針、ユーザー指示による
  • 【解決済み】所属グループ(Class035のGroupId解決ロジック: 設計文書5.5節・7章の確定仕様分類プレフィックス名組織:/役職:/職級:/利用権限タイプ:、無ければcreateGroupで自動作成、既存選択との和集合マージをTask 6のclassificationGroupNames/ensureGroupIds/mergeGroupSelectionとして実装済み2026-08-08planMergeは純粋関数のまま保つため、グループ解決I/Oを伴うapplyMerge側で実行する設計とした
  • Task間の型整合性: lineworksClientが返すuser/orgUnit等の生オブジェクト形状は実際のLINEWORKS Directory APIレスポンス構造で確認済みTask 3のStep 7で件数も再確認する
  • pleasanterClient.jsTask 4createSiteItem/updateSiteItemは実機確認済みの正しいレスポンス形状・URLパターンIdはトップレベル、更新は/api/items/{itemId}/updateでSiteId不要に修正済み設計文書5.7節「ハマった点」参照)