I have implemented a function to create new categories using NodeBB plugins. This article explains how to implement this.

Purpose

As a forum for developers that I am currently developing, I would like to be able to handle the root category as a community. For example, if you create a root category such as JavaScript and PHP, you will be able to access it with a URL such as https://example.com/javascript.

Each root category (community) has three groups.

  • Administrator
  • Member
  • Ban (blacklist)

These groups are used to set category permissions.

Implementation Overview

The implemented functions are as follows.

  • Displaying the modal for creating communities
  • WebSocket communication from the client side
  • Creating categories on the server side
  • Setting permissions
  • Adding subcategories

Adding a template

First, we'll add a template for displaying the modal for creating communities. This will be placed in the templates directory as a .tpl file.

class="modal fade" id="community-create-modal" tabindex="-1" role="dialog">
   class="modal-dialog" role="document">
     class="modal-content">
       class="modal-header">
         class="modal-title">Create a community
         type="button" class="close" data-dismiss="modal">
          ×
        
      
       class="modal-body">
         id="community-create-form">
           class="form-group">
             for="name">Name
             type="text" class="form-control" id="name" name="name" required>
          
           class="form-group">
             for="description">Description
             class="form-control" id="description" name="description" rows="3">
          
        
      
       class="modal-footer">
         type="button" class="btn btn-secondary" data-dismiss="modal">Cancel
         type="button" class="btn btn-primary" id="submit-community-create">Create

This template uses the Bootstrap modal component. The modal is identified by id=‘community-create-modal’ and the form input values can be obtained by the names name and description.

Client-side implementation

On the client-side, we will add JavaScript and CSS (Less). These are specified in plugin.json and loaded.

{
  "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"
  ]
}

In plugin.json, the following settings are made:

  • staticDirs: Specify the directory for static files (JavaScript, CSS, images, etc.)
  • scripts: Specify the JavaScript files to be loaded on the client side
  • less: Specify the Less files to be loaded on the client side

The client-side JavaScript implements the display of the modal and WebSocket communication.

'use strict';

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

$(document).ready(function () {
    // Trigger of showing modal
    $(document).on('click', '#create-community-trigger', function (e) {
        e.preventDefault();
        $('#community-create-modal').modal('show');
    });

    // Click event in the modal
    $('#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;
        }, {});

        // Client validation
        const { alert } = await getAlert();
        if (!formData.name) {
            alert({
                type: 'warning',
                message: '[[caiz:error.name_required]]',
                timeout: 3000,
            });
            return;
        }

        // Disable to the button
        const submitBtn = $(this);
        submitBtn.prop('disabled', true).addClass('disabled');

        // Send to server by 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,
                });
                // Redirect to community you made
                ajaxify.go(`/${response.community.handle}`);
            }
            form[0].reset();
            submitBtn.prop('disabled', false).removeClass('disabled');
        });
    });
});

In this code:

  1. getAlert() loads the NodeBB alert module
  2. Set up the trigger to display the modal
  3. Monitor the click event on the create button:
  4. Get the form data and convert it into an object
  5. Perform client-side validation
  6. Disable the button to prevent double submissions
  7. Send the data to the server via WebSocket
  8. If successful, display the alert and redirect
  9. If there is an error, display the error message
  10. After processing is complete, reset the form and enable the button

Notes

In NodeBB, server-side functions are called using WebSocket, not Ajax. WebSocket functions are specified in module.exports.sockets, and if a function such as module.exports.sockets.caiz.createCommunity is defined on the server side, it can be called on the client side in an RPC-like way, for example, socket.emit(‘plugins.caiz.createCommunity’, formData, ...).

You can specify the time to close the alert automatically using the timeout option.

Server-side implementation

On the server side, you will receive WebSocket events and create a new category.

First, register the WebSocket event in library.js. For WebSocket, there is no need to configure anything in plugin.json.

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

// Register event of WebSocket
sockets.caiz = {};
sockets.caiz.createCommunity = Community.Create;

module.exports = plugin;

Next, implement the community creation process in 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'];

    // Create new top level category
    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;

    // Create an owner community group
    const ownerGroupName = `community-${cid}-owners`;
    const ownerGroupDesc = `Owners of Community: ${name}`;
    await Community.createCommunityGroup(ownerGroupName, ownerGroupDesc, uid, 1, 1);
    await Groups.join(ownerGroupName, uid);

    // Create a member community group
    const communityGroupName = `community-${cid}-members`;
    const communityGroupDesc = `Members of Community: ${name}`;
    await Community.createCommunityGroup(communityGroupName, communityGroupDesc, uid, 0, 0);
    await Groups.leave(communityGroupName, uid);

    // Create a burned community group
    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);

    // Save owner group name in category data
    await db.setObjectField(`category:${cid}`, 'ownerGroup', ownerGroupName);

    // Setting privileges
    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');

    // Create sub categories
    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 is defined in libs/community.js. The trick is to pass the flag as 0/1, not as a boolean.

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

The server-side implementation is as follows.

  1. In library.js:
  2. Load the necessary modules
  3. Register WebSocket events (sockets.caiz.createCommunity = Community.Create)

  4. In libs/community.js:

  5. Inherit the Community class from the Base class

  6. Handle WebSocket events in the Create method

  7. Create the community in the createCommunity method

  8. Set up groups and permissions

  9. Creating subcategories

  10. Error handling:

  11. Login confirmation

  12. Name validation

  13. Error log output

Tips

When creating a new category, you can copy the category's initial settings (such as permissions) by passing cloneFromCid.

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

As for permission settings, registered users etc. will have the default permission settings applied. Therefore, it is possible to set permissions flexibly by first revoking all permissions and then granting the necessary permissions.

// Revoke all permissions
// 全権限を剥奪
await Privileges.categories.rescind(ownerPrivileges, cid, 'registered-users');
// Grant the necessary permissions
await Privileges.categories.give(guestPrivileges, cid, 'registered-users');

For WebSocket communication, if there is an error, you can handle it with exception handling. If the process is successful, you can return a variable with return, and the client side will receive it

Summary

Using the NodeBB plugin, we have implemented a function to create new categories. The main points are as follows

  • Display a modal using a template
  • Implement WebSocket communication on the client side
  • Create categories and subcategories on the server side
  • Set permissions and clone

With this implementation, users can easily create new communities. In addition, since subcategories are also created automatically, it is now possible to efficiently perform initial settings for communities.

goofmint/caiz