Skip to main content

Count Users

countUsers returns the number of users that match an optional search term โ€” no documents, just a number. It exists to back pagination: a list UI calls getUsers for the page of rows and countUsers (with the same search) for the total, so it can render the page count. It belongs to the user model.

The act lives in src/user/countUsers/.

The validator (countUsers.val.ts)โ€‹

set spreads activeRoleMixin plus an optional search string. get is selectStruct("user", 1) โ€” a vestige of the shared pattern; the implementation ignores it (a count has no projection), but the validator keeps the shape consistent with the other user acts.

import { object, optional, string } from "lesan";
import { selectStruct } from "../../../mod.ts";
import { activeRoleMixin } from "@lib";

export const countUsersValidator = () => {
return object({
set: object({
...activeRoleMixin,
search: optional(string()),
}),
get: selectStruct("user", 1),
});
};
note

Runtime-agnostic imports "lesan" is this repo's path alias for the framework source. On npm/Bun you'd import from @hemedani/lesan, and on Deno from jsr:@hemedani/lesan. The @lib / ../../../mod.ts aliases stay as they are in your project (see Project Layout).

The registration (mod.ts)โ€‹

This is where countUsers differs from the browse acts: grantAccess here only allows the leadership roles โ€” Manager, Admin, OrgHead, UnitHead. Regular Employee / Ordinary users can list users (getUsers) but cannot get system-wide counts. That's a deliberate, fine-grained permission choice made entirely in the preAct.

import { grantAccess, setTokens, setUser } from "@lib";
import { coreApp } from "../../../mod.ts";
import { countUsersFn } from "./countUsers.fn.ts";
import { countUsersValidator } from "./countUsers.val.ts";

export const countUsersSetup = () =>
coreApp.acts.setAct({
schema: "user",
actName: "countUsers",
preAct: [setTokens, setUser, grantAccess([{ roles: ["Manager", "Admin", "OrgHead", "UnitHead"] }])],
validator: countUsersValidator(),
fn: countUsersFn,
});

The implementation (countUsers.fn.ts)โ€‹

Build a filters object; if search is present, set filters.$text = { $search: search } โ€” the same $text operator getUsers uses, backed by the first_name / last_name / email text index. Then call user.countDocument({ filter: filters }) and return the number.

import type { ActFn, Document } from "lesan";
import { user } from "../../../mod.ts";

export const countUsersFn: ActFn = async (body) => {
const {
set: { search },
} = body.details;

const filters: Document = {};
search && (filters.$text = { $search: search });

return await user.countDocument({ filter: filters });
};

countDocument returns a number directly (the count of matching documents), so the client gets { count: N } in the body. Omitting search counts all users in the collection.

In the workflowโ€‹

countUsers is half of the pagination pair: get the page with getUsers, get the total with countUsers using the same search string.

  • getUsers โ€” the matching list endpoint for the same search term.
  • Auth Utilities โ€” grantAccess and the activeRoleId mechanism.
  • User model โ€” the text-indexed fields behind $text.
  • Overview โ€” where this series starts.

Run itโ€‹

Needs token and activeRoleId. To count all users, omit search:

curl -X POST http://localhost:1380/lesan \
-H "Content-Type: application/json" \
-H "token: <jwt>" \
-d '{
"model": "user",
"act": "countUsers",
"details": {
"set": {
"activeRoleId": "<roleId>"
},
"get": {}
}
}'

Response: { "body": { "count": N }, "success": true }.

Errors & fixesโ€‹

countUsersFn has no throwError calls โ€” every failure comes from the preAct chain or validation.

MessageSourceWhat it meansHow to fix
you should send your id with token key in req headersetTokensMissing token header.Add -H "token: <jwt>".
Invalid or expired tokensetTokensJWT didn't verify.Re-login.
Invalid or missing token datasetUserToken payload had no _id.Re-login.
user not existsetUserThe token's user was deleted.Use a different account or re-create it.
activeRoleId is requiredgrantAccessset.activeRoleId was omitted.Always send activeRoleId in set.
Active role not foundgrantAccessThe activeRoleId doesn't match any role on the user.Use a roleId from the login response.
You cant do thisgrantAccessThe active role isn't one of Manager, Admin, OrgHead, UnitHead.The ghost superuser bypasses this; for others, use a role in the allowed list.
$text query failure (framework error)MongoDBsearch used without the text index.Ensure createUserTextIndex() ran at boot (restart the server).
Generic validation errorsuperstructsearch isn't a string.Pass search as a string, or omit it entirely.