NodeBBでプラグインを使って新しいカテゴリーを作成する

NodeBBのプラグインを使って、新しいカテゴリーを作成する機能を実装してみました。この記事では、その実装方法について説明します。

## 目的

今作っている開発者向けのフォーラムとして、ルートカテゴリーをコミュニティとして扱えるようにしたいと考えています。たとえば `JavaScript` とは `PHP` といったルートカテゴリーを作成すると、 `https://example.com/javascript` といったURLでアクセスできるようになります。

各ルートカテゴリー（コミュニティ）は、3つのグループを用意しています。

- 管理者
- メンバー
- バン（ブラックリスト）

これらのグループは、カテゴリーの権限を設定するために使用されます。

## 実装の概要

実装した機能は以下の通りです。

- コミュニティ作成用モーダルの表示
- クライアントサイドからのWebSocket通信
- サーバーサイドでのカテゴリー作成
- 権限の設定
- サブカテゴリーの追加

## テンプレートの追加

まず、コミュニティ作成用のモーダルを表示するためのテンプレートを追加します。これは`templates`ディレクトリに`.tpl`ファイルとして配置します。

```html
<div class="modal fade" id="community-create-modal" tabindex="-1" role="dialog">
  <div class="modal-dialog" role="document">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">コミュニティを作成</h5>
        <button type="button" class="close" data-dismiss="modal">
          <span>&times;</span>
        </button>
      </div>
      <div class="modal-body">
        <form id="community-create-form">
          <div class="form-group">
            <label for="name">コミュニティ名</label>
            <input type="text" class="form-control" id="name" name="name" required>
          </div>
          <div class="form-group">
            <label for="description">説明</label>
            <textarea class="form-control" id="description" name="description" rows="3"></textarea>
          </div>
        </form>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-dismiss="modal">キャンセル</button>
        <button type="button" class="btn btn-primary" id="submit-community-create">作成</button>
      </div>
    </div>
  </div>
</div>
```

このテンプレートは、Bootstrapのモーダルコンポーネントを使用しています。モーダルは`id="community-create-modal"`で識別され、フォームの入力値は`name`と`description`という名前で取得できます。

## クライアントサイドの実装

クライアントサイドでは、JavaScriptとCSS（Less）を追加します。これらは`plugin.json`で指定して読み込みます。

```json
{
  "id": "nodebb-plugin-caiz",
  "name": "NodeBB Plugin for Caiz",
  "description": "NodeBB Plugin for Caiz",
  "version": "1.0.0",
  "library": "./library.js",
  "staticDirs": {
    "static": "./static"
  },
  "scripts": [
    "static/modal.js"
  ],
  "less": [
    "static/style.less"
  ]
}
```

`plugin.json`では、以下の設定を行っています：

- `staticDirs`: 静的ファイル（JavaScript、CSS、画像など）のディレクトリを指定
- `scripts`: クライアントサイドで読み込むJavaScriptファイルを指定
- `less`: クライアントサイドで読み込むLessファイルを指定

クライアントサイドのJavaScriptでは、モーダルの表示とWebSocket通信を実装します。

```javascript
'use strict';

async function getAlert() {
    return new Promise((resolve, reject) => {
        require(['alerts'], resolve);
    });
}

$(document).ready(function () {
    // モーダルを表示するトリガー
    $(document).on('click', '#create-community-trigger', function (e) {
        e.preventDefault();
        $('#community-create-modal').modal('show');
    });

    // モーダル内の作成ボタンのクリックイベント
    $('#submit-community-create').on('click', async () => {
        const form = $('#community-create-form');
        const formData = form.serializeArray().reduce((obj, item) => {
            obj[item.name] = item.value;
            return obj;
        }, {});

        // クライアントサイドバリデーション
        const { alert } = await getAlert();
        if (!formData.name) {
            alert({
                type: 'warning',
                message: '[[caiz:error.name_required]]',
                timeout: 3000,
            });
            return;
        }

        // ボタンを無効化（二重送信防止）
        const submitBtn = $(this);
        submitBtn.prop('disabled', true).addClass('disabled');

        // WebSocketでサーバーに送信
        socket.emit('plugins.caiz.createCommunity', formData, function(err, response) {
            if (err) {
                alert({
                    type: 'error',
                    message: err.message || '[[caiz:error.generic]]',
                    timeout: 3000,
                });
            } else {
                $('#community-create-modal').modal('hide');
                alert({
                    type: 'success',
                    message: '[[caiz:success.community_created]]',
                    timeout: 3000,
                });
                // 作成されたコミュニティにリダイレクト
                ajaxify.go(`/${response.community.handle}`);
            }
            form[0].reset();
            submitBtn.prop('disabled', false).removeClass('disabled');
        });
    });
});
```

このコードでは：

1. `getAlert()`でNodeBBのアラートモジュールを読み込みます
2. モーダルを表示するトリガーを設定します
3. 作成ボタンのクリックイベントを監視します：
   - フォームデータを取得してオブジェクトに変換
   - クライアントサイドバリデーションを実行
   - ボタンを無効化して二重送信を防止
   - WebSocketでサーバーに送信
   - 成功時はアラートを表示してリダイレクト
   - エラー時はエラーメッセージを表示
   - 処理完了後はフォームをリセットしてボタンを有効化

### 注意点

NodeBBでは、AjaxではなくWebSocketを使用してサーバーサイドの機能を呼び出します。WebSocketの関数は`module.exports.sockets`で指定し、例えば`module.exports.sockets.caiz.createCommunity`という関数をサーバーサイドで定義すると、クライアントサイドでは`socket.emit('plugins.caiz.createCommunity', formData, ...)`のようにRPC風に呼び出すことができます。

アラートは timeout オプションで自動で閉じる時間を指定できます。

## サーバーサイドの実装

サーバーサイドでは、WebSocketのイベントを受け取って新しいカテゴリーを作成します。

まず、`library.js`でWebSocketのイベントを登録します。WebSocketの場合は、plugin.jsonでの設定は不要です。

```javascript
'use strict';
const sockets = require.main.require('./src/socket.io/plugins'); 
const Community = require('./libs/community');

// WebSocketのイベントを登録
sockets.caiz = {};
sockets.caiz.createCommunity = Community.Create;

module.exports = plugin;
```

次に、`libs/community.js`でコミュニティ作成の処理を実装します。

```javascript
'use strict';

const db = require.main.require('./src/database');
const Plugins = require.main.require('./src/plugins');
const winston = require.main.require('winston'); 
const Categories = require.main.require('./src/categories');
const Privileges = require.main.require('./src/privileges');
const Groups = require.main.require('./src/groups');
const Base = require('./base');
const websockets = require.main.require('./src/socket.io/plugins');
const initialCategories = require.main.require('./install/data/categories.json'); 

class Community extends Base {
  static async Create(socket, data) {
    const { name, description } = data;
    winston.info(`[plugin/caiz] Creating community: ${name}`);
    const { uid } = socket;
    if (!uid) {
      throw new Error('Not logged in');
    }
    if (!name || name.length < 3) {
      throw new Error('Community name is too short');
    }  
    try {
      const community = await Community.createCommunity(uid, { name, description });
      return {
        message: 'Community created successfully!',
        community: community,
      };
    } catch (err) {
      winston.error(`[plugin/caiz] Error creating community: ${err.message}`);
      throw err;
    }
  }

  static async createCommunity(uid, { name, description }) {
    const ownerPrivileges = await Privileges.categories.getGroupPrivilegeList();
    const guestPrivileges = ['groups:find', 'groups:read', 'groups:topics:read'];

    // 新しいトップレベルカテゴリーを作成
    const categoryData = {
      name,
      description: description || '',
      order: 100,
      parentCid: 0,
      customFields: {
        isCommunity: true
      },
      icon: 'fa-users',
    };

    const newCategory = await Categories.create(categoryData);
    const cid = newCategory.cid;

    // コミュニティのオーナーグループを作成
    const ownerGroupName = `community-${cid}-owners`;
    const ownerGroupDesc = `Owners of Community: ${name}`;
    await Community.createCommunityGroup(ownerGroupName, ownerGroupDesc, uid, 1, 1);
    await Groups.join(ownerGroupName, uid);

    // コミュニティのメンバーグループを作成
    const communityGroupName = `community-${cid}-members`;
    const communityGroupDesc = `Members of Community: ${name}`;
    await Community.createCommunityGroup(communityGroupName, communityGroupDesc, uid, 0, 0);
    await Groups.leave(communityGroupName, uid);

    // コミュニティのバングループを作成
    const communityBanGroupName = `community-${cid}-banned`;
    const communityBanGroupDesc = `Banned members of Community: ${name}`;
    await Community.createCommunityGroup(communityBanGroupName, communityBanGroupDesc, uid, 1, 1);
    await Groups.leave(communityBanGroupName, uid);

    // オーナーグループ名をカテゴリーデータに保存
    await db.setObjectField(`category:${cid}`, 'ownerGroup', ownerGroupName);

    // 権限の設定
    await Privileges.categories.give(ownerPrivileges, cid, ownerGroupName);
    const communityPrivileges = ownerPrivileges.filter(p => p !== 'groups:posts:view_deleted' && p !== 'groups:purge' && p !== 'groups:moderate');
    await Privileges.categories.give(communityPrivileges, cid, communityGroupName);
    await Privileges.categories.give([], cid, communityBanGroupName);
    await Privileges.categories.rescind(ownerPrivileges, cid, 'guests');
    await Privileges.categories.give(guestPrivileges, cid, 'guests');
    await Privileges.categories.rescind(ownerPrivileges, cid, 'registered-users');
    await Privileges.categories.give(guestPrivileges, cid, 'registered-users');
    await Privileges.categories.give([], cid, 'banned-users');

    // サブカテゴリーの作成
    await Promise.all(initialCategories.map((category) => {
      return Categories.create({...category, parentCid: cid, cloneFromCid: cid});
    }));

    winston.info(`[plugin/caiz] Community created: ${name} (CID: ${cid}), Owner: ${uid}, Owner Group: ${ownerGroupName}`);
    return newCategory;
  }
}

module.exports = Community;
```

`createCommunityGroup` は `libs/community.js` で定義しています。フラグは真偽値ではなく、 0/1で渡すのがコツです。

```javascript
static async createCommunityGroup(name, description, ownerUid, privateFlag = 0, hidden = 0) {
  const group = await Groups.getGroupData(name);
  if (group) return group;
  return Groups.create({
    name,
    description,
    private: privateFlag,
    hidden,
    ownerUid
  });
}
```

サーバーサイドの実装は以下の通りです。

1. `library.js`で：
   - 必要なモジュールを読み込み
   - WebSocketのイベントを登録（`sockets.caiz.createCommunity = Community.Create`）

2. `libs/community.js`で：
   - `Community`クラスを`Base`クラスから継承
   - `Create`メソッドでWebSocketイベントを処理
   - `createCommunity`メソッドでコミュニティを作成
   - グループと権限の設定
   - サブカテゴリーの作成

3. エラーハンドリング：
   - ログイン確認
   - 名前のバリデーション
   - エラーログの出力

### Tips

新しいカテゴリを作成する際に、 `cloneFromCid` を渡すと、カテゴリの初期設定（権限など）をコピーできます。

```javascript
return Categories.create({...category, parentCid: cid, cloneFromCid: cid});
```

権限設定として、登録ユーザーなどはデフォルトの権限設定が行われます。そのため、一旦すべての権限を剥奪した後で、必要な権限を付与することで、柔軟な権限設定が可能です。

```javascript
// 全権限を剥奪
await Privileges.categories.rescind(ownerPrivileges, cid, 'registered-users');
// 必要な権限を付与
await Privileges.categories.give(guestPrivileges, cid, 'registered-users');
```

WebSocketとのやり取りでは、エラーの場合は例外処理でOKです。処理成功時にはreturnで変数を返せば、それがクライアントサイドで受け取れます

## まとめ

NodeBBのプラグインを使って、新しいカテゴリーを作成する機能を実装しました。主なポイントは以下の通りです。

- テンプレートを使ってモーダルを表示
- クライアントサイドでWebSocket通信を実装
- サーバーサイドでカテゴリーとサブカテゴリーを作成
- 権限の設定とクローン

この実装により、ユーザーは簡単に新しいコミュニティを作成できるようになります。また、サブカテゴリーも自動的に作成されるため、コミュニティの初期設定も効率的に行えるようになりました。

[goofmint/caiz](https://github.com/goofmint/caiz)