# 組織・社員マスタ基盤構築(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 API(`ApiVersion 1.1`+`ApiKey`)を使用。設計文書: [docs/superpowers/specs/2026-08-07-org-master-sync-design.md](../specs/2026-08-07-org-master-sync-design.md)。 **Tech Stack:** Node.js(CommonJS、`node --test`によるテスト、他appと同一パターン)。依存パッケージなし。 ## Global Constraints - 外部npmパッケージを追加しない(LINEWORKS JWT署名・CSVパース等すべて標準API実装。設計文書3章) - `apps/org-master-sync/`は他システムから独立して切り出せる構成を維持する(`.claude/js/`等リポジトリ内の他フォルダへの`require`依存を持たない。README.md記載の方針) - `.env`・`keys/`・`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」、`ReferenceType`は`Results`、レコード0件の空サイト)。`.env`の`PLEASANTER_MASTER_SITE_ID=1`に設定済み - `getsite`(`POST {baseUrl}/api/items/1/getsite`)のレスポンスは`{"StatusCode":200,"Response":{"Data":{...}}}`。`Data`直下に`ClassHash`/`NumHash`/`DateHash`/`DescriptionHash`/`CheckHash`/`AttachmentsHash`(すべて現状`{}`)があり、ここへ`{"ClassA":"社員ID", ...}`の形でラベルを設定する方式。`Data.SiteSettings`は`Version`/`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`〜、タブ3(LINEWORKS)は`051`〜。真偽値も`Check062`のような文字割当ではなく`Check020`のように番号付きで統一する。実機で`Check020`・`Date007`・`Description022`等の作成・取得を確認済み。 **タブ1: 基本情報** | 論理名(`FIELD_MAP`のキー) | 内容 | 割当キー | Hash種別 | |---|---|---|---| | `employeeId` | 社員ID(`userId`) | `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](https://pleasanter.org/ja/manual/api-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フィールド(`employmentType`・`isDeleted`・`isAwaiting`/`isPending`・`customFields`等)とその理由は設計文書5.4節参照。氏名の結合表示は独立列を持たず、`pleasanterName`(`Class013`)がその役割を兼ねる(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.js`が`module.exports = { FIELD_MAP, buildHashUpdate() }`を提供。`FIELD_MAP`は後続タスク(merge-master.js)がマスタレコードのフィールド↔論理名変換に使う唯一のソース - [ ] **Step 1: `masterFields.js`を書く** ```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:true(5.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: テストを書く** ```js 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: PASS(3 tests) - [ ] **Step 4: `push-master-site-schema.js`を書く(既定ドライラン、`--execute`で実送信)** ```js "use strict"; /* * SiteId=1(記録テーブル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: コミット** ```bash 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.js`が`module.exports = { buildAssertion(opts), fetchAccessToken(opts) }`を提供。`fetchAccessToken`は後続タスク(Task 3の`lineworksClient.js`)が消費する - [ ] **Step 1: 失敗するテストを書く(`buildAssertion`のJWT構造検証)** ```js 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: FAIL(`buildAssertion is not a function` 等、モジュール未実装のため) - [ ] **Step 3: `lineworksAuth.js`を実装** ```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: PASS(1 test) - [ ] **Step 5: `fetchAccessToken`のモックテストを追加** ```js 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: PASS(2 tests) - [ ] **Step 7: コミット** ```bash 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.js`の`fetchAccessToken({clientId, clientSecret, serviceAccount, privateKey, scope, fetchImpl})` → `string`(Task 2) - Produces: `module.exports = { fetchUsers, fetchOrgUnits, fetchPositions, fetchLevels, fetchUserTypes }`。各関数は`{accessToken, baseUrl, fetchImpl}`を受け取り配列を返す。Task 5(`fetch-lineworks.js`)が消費する - [ ] **Step 1: 失敗するテストを書く(カーソルページングの結合)** ```js 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: FAIL(`fetchUsers is not a function`) - [ ] **Step 3: `lineworksClient.js`を実装** ```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: PASS(1 test) - [ ] **Step 5: 単一ページ(cursorなし)のケースを追加テスト** ```js 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: PASS(2 tests) - [ ] **Step 7: 実APIに対して疎通確認(`.env`の実認証情報を使用、件数の突き合わせ)** Run: ```bash 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: コミット** ```bash 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 6(`merge-master.js`)が消費する。`getSiteItems({baseUrl, apiKey, siteId, fetchImpl})` → `Array`、`createSiteItem({..., fields})` → `number`(新規Id、トップレベル)、`updateSiteItem({baseUrl, apiKey, itemId, fields, fetchImpl})` → `void`(`siteId`は受け取らない)、`getAllGroups({baseUrl, apiKey, fetchImpl})` → `Array`(`GroupId`/`GroupName`/`GroupMembers`等)、`createGroup({..., groupName})` → `number`(新規GroupId) - [ ] **Step 1: 失敗するテストを書く(`Offset`を使ったページ結合)** ```js 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: FAIL(`getSiteItems is not a function`) - [ ] **Step 3: `pleasanterClient.js`を実装** ```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: PASS(1 test) - [ ] **Step 5: `createSiteItem`/`updateSiteItem`のテストを追加** ```js 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: PASS(6 tests) - [ ] **Step 7: 実APIに対して疎通確認(SiteId=1、0件のはず)** Run: ```bash 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: `件数: 0`(Task 1完了後、まだレコードを1件も作っていないため)。エラーが出た場合はレスポンス生JSONを一時的にログ出力し、`Response.Data`/`Response.TotalCount`のキー名がこの環境と一致するか確認・修正する - [ ] **Step 8: コミット** ```bash 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.fetchAccessToken`(Task 2)、`lineworksClient.{fetchUsers,fetchOrgUnits,fetchPositions,fetchLevels,fetchUserTypes}`(Task 3) - Produces: `buildSnapshot(deps)` → `{fetchedAt, users, orgUnits, positions, levels, userTypes}`。ファイル`data/master-source/lineworks-snapshot.json`(Task 6の`merge-master.js`が読む唯一の入力) - [ ] **Step 1: 失敗するテストを書く(`buildSnapshot`のDI・整形ロジック)** ```js 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: FAIL(`buildSnapshot is not a function`) - [ ] **Step 3: `fetch-lineworks.js`を実装** ```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: PASS(1 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: コミット** ```bash 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 4)、`masterFields.FIELD_MAP`(Task 1)、`data/master-source/lineworks-snapshot.json`(Task 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(プリザンター、`Class011`〜`Class021`・`Check020`・`Description022`・`Class023`〜`Class034`・`Check024`〜`Check031`)はPleasanterユーザー登録APIの管理項目で、`groups`(`Class035`、`[[Groups*]]`選択式)を除きLINEWORKS由来ではないため②merge-masterは書き込まない(③sync-pleasanter・管理者の手動入力の担当。5.2節参照) - 同様に`note`(`Body`、備考)・`pleasanterCreatedAt`(`DateB`)・`pleasanterUpdatedAt`(`DateC`)も②では書き込まない - `groups`(`Class035`)は分類プレフィックス名(`組織:{orgUnitName}`/`役職:{positionName}`/`職級:{levelName}`/`利用権限タイプ:{userTypeName}`、7章)に対応するGroupIdの集合。値はカンマ区切りGroupId文字列(実機確認済み、例: `"1,4"`)。対応するグループがプリザンター側に無ければ`createGroup`で自動作成する(get-or-create)。既存の選択(手動・他システム由来含む)とは和集合でマージし、消さない(5.5節) - [ ] **Step 1: 失敗するテストを書く(新規作成の検出)** ```js 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: FAIL(`planMerge is not a function`) - [ ] **Step 3: `merge-master.js`を実装** ```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: PASS(1 test) - [ ] **Step 5: 既存レコードの更新・退職者検知のテストを追加** ```js 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: PASS(6 tests) - [ ] **Step 7: グループ解決ロジック(分類プレフィックス名・GroupId和集合マージ・get-or-create)のテストを追加** ```js 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: PASS(9 tests) - [ ] **Step 9: `applyMerge`のグループ解決込み統合テストを追加(実APIをモックし、作成・更新それぞれでClass035が正しく組み立てられることを確認)** ```js 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: PASS(10 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: コミット** ```bash 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`を書く** ```markdown --- 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`を書く** ```markdown --- 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: コミット** ```bash 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`を作成** ```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を更新(現状ステータスの記述を実態に合わせる)** ```markdown # 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: コミット** ```bash 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.js`の`toHashPayload`/`getSiteItems`のフラット化で対応 2. Date型フィールドは空文字列・nullを送ると400エラー。値が無ければキー自体を省略する方式に変更(`merge-master.js`の`userToFields`) 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節に理由を明記済み - `userToFields`(Task 6)は実際のLINEWORKS `/users`レスポンス(`data/lineworks-user-sample-raw.json`、351人分の`data/lineworks-users-full.json`)で確認済みのフィールド形状(`userName.phoneticLastName`/`i18nNames[]`/`privateEmail`/`aliasEmails[]`/`leaveOfAbsence.isLeaveOfAbsence`等)をそのまま使用。推測ベースの項目はない - タブ2(プリザンター、`Class011`〜`Class021`・`Check020`・`Description022`・`Class023`〜`Class034`・`Check024`〜`Check031`・`Body`・`DateB`・`DateC`)はプリザンターユーザー登録APIの管理項目であり、LINEWORKS由来ではないため②merge-masterでは`groups`(`Class035`)以外書き込まない(設計文書5.2節の通り、③sync-pleasanter・管理者の手動入力の担当)。`userToFields`が意図的にこれらのキーを含めていないことをレビュー時に確認すること - 氏名の結合表示(`pleasanterName`、`Class013`)はタブ2に属するため②では書き込まない。④sync-keycloakが参照する時点では③sync-pleasanterによって設定済みである前提(設計文書5.1節) - `NumHash`は一切使用しない(数値項目もすべてClass型で保持する方針、ユーザー指示による) - **【解決済み】所属グループ(`Class035`)のGroupId解決ロジック**: 設計文書5.5節・7章の確定仕様(分類プレフィックス名`組織:`/`役職:`/`職級:`/`利用権限タイプ:`、無ければ`createGroup`で自動作成、既存選択との和集合マージ)をTask 6の`classificationGroupNames`/`ensureGroupIds`/`mergeGroupSelection`として実装済み(2026-08-08)。`planMerge`は純粋関数のまま保つため、グループ解決(I/Oを伴う)は`applyMerge`側で実行する設計とした - Task間の型整合性: `lineworksClient`が返すuser/orgUnit等の生オブジェクト形状は実際のLINEWORKS Directory APIレスポンス構造で確認済み(Task 3のStep 7で件数も再確認する) - `pleasanterClient.js`(Task 4)の`createSiteItem`/`updateSiteItem`は実機確認済みの正しいレスポンス形状・URLパターン(`Id`はトップレベル、更新は`/api/items/{itemId}/update`でSiteId不要)に修正済み(設計文書5.7節「ハマった点」参照)