Writing a module
This page walks through adding a backend feature module from an empty folder to a merged, documented route. The running example is a module called Foo with one list route and one create route. The helpers it calls (tenantOf, fail, asyncHandler, MongoDbCrudOpration, socketEmitter, removeCache) are the real ones; copy the patterns, not the names.
Before you start, read Module layout and Multi-tenancy and data access. If the change is non-trivial, write the task's task.md first (see Contributing).
1. Create the folder
Modules/Foo/
├── init.js
├── routes.js
├── controller.js
└── helpers/
└── fooRules.jsThe folder is PascalCase and the route file is always routes.js (tests/conventions/naming-conventions.test.js).
2. Write the rules first
Put everything that does not need req or res in helpers/: validation, calculations, the shape of a change. Keep it pure so a unit test can call it directly. Modules/Reactions/helpers/reactionRules.js is a good model: it exports a fixed list, a validator that returns { valid, reason }, and a function that builds the Mongo update.
const validateFooInput = ({ name }) => {
if (typeof name !== 'string' || !name.trim()) return { valid: false, reason: 'A name is required.' };
if (name.length > 100) return { valid: false, reason: 'A name is at most 100 characters.' };
return { valid: true, reason: '' };
};
module.exports = { validateFooInput };3. Write the handlers
Take the company from tenantOf(req), query through MongoDbCrudOpration, and answer with { status, statusText, data }. Config/respond.js gives you ok, fail and asyncHandler:
const ok = (res, payload = {}) => res.send({ status: true, ...payload });
const fail = (res, statusText, statusCode = 400, extra = {}) => res.send({ status: false, statusText, statusCode, ...extra });
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);A handler built on them:
const { SCHEMA_TYPE } = require('../../Config/schemaType');
const { MongoDbCrudOpration } = require('../../utils/mongo-handler/mongoQueries');
const { tenantOf } = require('../../Config/tenant');
const { ok, fail, asyncHandler } = require('../../Config/respond');
const { removeCache } = require('../../utils/commonFunctions');
const socketEmitter = require('../../event/socketEventEmitter');
const logger = require('../../Config/loggerConfig');
const { validateFooInput } = require('./helpers/fooRules');
exports.listFoos = asyncHandler(async (req, res) => {
const companyId = tenantOf(req);
const rows = await MongoDbCrudOpration(companyId, { type: SCHEMA_TYPE.FOOS, data: [{ deletedStatusKey: 0 }] }, 'find');
return ok(res, { data: rows });
});
exports.createFoo = asyncHandler(async (req, res) => {
const companyId = tenantOf(req);
const check = validateFooInput(req.body || {});
if (!check.valid) return fail(res, check.reason, 400);
const saved = await MongoDbCrudOpration(companyId, {
type: SCHEMA_TYPE.FOOS,
data: { name: req.body.name.trim(), createdBy: String(req.uid) },
}, 'save');
removeCache(`fooList_${companyId}`);
try {
socketEmitter.emit('insert', { type: 'insert', data: saved, updatedFields: {}, module: 'foos', companyId });
} catch (error) {
logger.error(`ERROR emitting foo insert: ${error.message}`);
}
return ok(res, { statusText: 'Foo created.', data: saved });
});What each line is for:
tenantOf(req)throws aTenantError(403) when thecompanyidheader is missing, malformed or outside the token's audience. Never read the company from the body alone.SCHEMA_TYPE.FOOS, never a string. See step 4.removeCacheclears whatever cached the old value. If nothing caches it, there is nothing to clear; do not invent a cache.socketEmitter.emitwithmoduleandcompanyId, so other tabs, automations and webhooks hear the change. A failed emit must not fail the request.
Errors
- Answer an expected refusal with
fail(res, text, statusCode). The body stays{ status: false, statusText }with HTTP 200 for the web app;Config/strictStatus.jssends the real 4xx to API-token callers and to anyone sendingPrefer: status-codes. - Let anything unexpected throw.
asyncHandlerpasses it tonext(err), andConfig/errorHandler.jslogs the stack under the request id and answers{ status: false, statusText, requestId }. - Many older handlers
try/catchand send{ status: false, statusText: error.message }themselves (Modules/Pages/comments.jsdoes, throughfail). That still works. req.errorMessageObjectfollowed bynext()is a legacy pattern left inModules/Auth/. Do not add to it.
4. Add a collection if you need one
A new collection goes in three places in the same change:
Config/schemaType.js:FOOS: "foos",inSCHEMA_TYPE.Config/collections.js: the same entry indbCollections.utils/mongo-handler/: the field definitions inschema.js, the Mongoose schema and indexes increateSchema.js, and theSCHEMA_TYPE.FOOScase incheckType()ofmongoQueries.js.
Schemas are strict: a field that is not declared is dropped silently on save. Declare every field before code writes it.
If existing data must change, add a migration in migrations/NNN-name.js. The runner applies it at boot (or npm run migrate) and records it in global.schema_versions. A company-wide one, as migrations/008-agent-run-expiry.js does:
module.exports = {
id: 'NNN-example',
scope: 'company',
async up(ctx) {
await ctx.forEachCompany(async (companyId) => {
await ctx.company(companyId, { type: ctx.SCHEMA_TYPE.TASKS, data: [{ /* filter */ }, { $set: { /* ... */ } }] }, 'updateMany');
});
},
};id must equal the file name, with NNN the next free number after the highest one in migrations/. npm run migrate -- up --dry-run shows what it would write without writing.
5. Register the routes
routes.js:
const ctrl = require('./controller');
exports.init = (app) => {
app.get('/api/v2/foos', ctrl.listFoos);
app.post('/api/v2/foos', ctrl.createFoo);
};init.js:
exports.init = (app) => require('./routes').init(app);Then:
- Add
require('./Modules/Foo/init').init(app);insideinitializeControllers()inindex.js. - Guard the prefix. Add
"/api/v2/foos"to theverifyJWTTokenWithCRoutelist inConfig/setMiddleware.js(routes that act inside a company). If the route is public by design or checks the session itself, add it toPUBLIC_BY_DESIGNorSELF_GUARDEDintests/conventions/v2-guard.test.jswith a reason instead. The test fails until you choose. - If agents must not call the route, put
agentsRefused('<action>')fromModules/Agents/guard.jsin front of the handler, asModules/Pto/routes.jsdoes. To let agents do it on purpose, add an agent action instead (see Agent actions and MCP tools). - If the route reads an environment variable, describe it in
scripts/env-doc.meta.jsonand runnode scripts/env-doc.js.
6. Add the screen text
Every string a person sees goes through i18n: add keys under the feature's namespace in frontend/src/locales/en.js, use $t('Foo.key') in templates, then run npm run i18n:backfill. Error text the server sends (statusText) is shown as it is, so keep it short and plain. See Translations (i18n).
7. Test it
tests/foo-rules.test.jsfor the helpers (Jest projectunit, no database).tests/integration/foo.int.test.jsfor the routes against a real server, if the behaviour matters across roles (see Testing).- A
frontend/tests/*.spec.jsfor any new component.
Run npm run lint && npm test before you push.
8. Document the route in the docs pull request
A feature pull request leaves docs/API.md and docs/api/openapi.json alone. After it is merged into staging, the follow-up docs pull request describes the new route in scripts/api-doc.meta.json, runs npm run api:doc, and commits both generated files; npm run api:doc:check fails while they are stale. Until then the route is listed as undocumented. See Branches, versions and releases.
Checklist
- [ ]
Modules/Foo/withinit.js,routes.js, handlers and pure helpers - [ ] Every query takes the company from
tenantOf(req)and the collection fromSCHEMA_TYPE - [ ] Writes emit a socket event with
moduleandcompanyId, and clear any cache they invalidate - [ ] The prefix is guarded, or declared in
v2-guard.test.js - [ ] Text through i18n, colours through tokens
- [ ] A unit test for the helpers
- [ ] Comments only where the code cannot say why
