NodeBBのプラグインを使って、新しいカテゴリーを作成する機能を実装してみました。この記事では、その実装方法について説明します。
目的
今作っている開発者向けのフォーラムとして、ルートカテゴリーをコミュニティとして扱えるようにしたいと考えています。たとえば JavaScript
とは PHP
といったルートカテゴリーを作成すると、 https://example.com/javascript
といったURLでアクセスできるようになります。
各ルートカテゴリー(コミュニティ)は、3つのグループを用意しています。
- 管理者
- メンバー
- バン(ブラックリスト)
これらのグループは、カテゴリーの権限を設定するために使用されます。
実装の概要
実装した機能は以下の通りです。
- コミュニティ作成用モーダルの表示
- クライアントサイドからのWebSocket通信
- サーバーサイドでのカテゴリー作成
- 権限の設定
- サブカテゴリーの追加
テンプレートの追加
まず、コミュニティ作成用のモーダルを表示するためのテンプレートを追加します。これはtemplates
ディレクトリに.tpl
ファイルとして配置します。
<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>×</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
で指定して読み込みます。
{
"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通信を実装します。
'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');
});
});
});
このコードでは:
-
getAlert()
でNodeBBのアラートモジュールを読み込みます - モーダルを表示するトリガーを設定します
- 作成ボタンのクリックイベントを監視します:
- フォームデータを取得してオブジェクトに変換
- クライアントサイドバリデーションを実行
- ボタンを無効化して二重送信を防止
- 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での設定は不要です。
'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
でコミュニティ作成の処理を実装します。
'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で渡すのがコツです。
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
});
}
サーバーサイドの実装は以下の通りです。
-
library.js
で:- 必要なモジュールを読み込み
- WebSocketのイベントを登録(
sockets.caiz.createCommunity = Community.Create
)
-
libs/community.js
で:-
Community
クラスをBase
クラスから継承 -
Create
メソッドでWebSocketイベントを処理 -
createCommunity
メソッドでコミュニティを作成 - グループと権限の設定
- サブカテゴリーの作成
-
-
エラーハンドリング:
- ログイン確認
- 名前のバリデーション
- エラーログの出力
Tips
新しいカテゴリを作成する際に、 cloneFromCid
を渡すと、カテゴリの初期設定(権限など)をコピーできます。
return Categories.create({...category, parentCid: cid, cloneFromCid: cid});
権限設定として、登録ユーザーなどはデフォルトの権限設定が行われます。そのため、一旦すべての権限を剥奪した後で、必要な権限を付与することで、柔軟な権限設定が可能です。
// 全権限を剥奪
await Privileges.categories.rescind(ownerPrivileges, cid, 'registered-users');
// 必要な権限を付与
await Privileges.categories.give(guestPrivileges, cid, 'registered-users');
WebSocketとのやり取りでは、エラーの場合は例外処理でOKです。処理成功時にはreturnで変数を返せば、それがクライアントサイドで受け取れます
まとめ
NodeBBのプラグインを使って、新しいカテゴリーを作成する機能を実装しました。主なポイントは以下の通りです。
- テンプレートを使ってモーダルを表示
- クライアントサイドでWebSocket通信を実装
- サーバーサイドでカテゴリーとサブカテゴリーを作成
- 権限の設定とクローン
この実装により、ユーザーは簡単に新しいコミュニティを作成できるようになります。また、サブカテゴリーも自動的に作成されるため、コミュニティの初期設定も効率的に行えるようになりました。