User
The user model is the authentication entity of the whole system. Every person โ the ghost superuser, managers, unit heads, store keepers, employees โ is a user. It carries roles (with an org/unit scope), features (fine-grained permission flags), and membership in organizations and units.
// models/user.ts (definition, trimmed of the doc comment)
import { coreApp } from "../mod.ts";
import {
array, boolean, coerce, defaulted, enums, object, optional,
pattern, type RelationDataType, type RelationSortOrderType, string,
} from "lesan";
import { createUpdateAt } from "@lib";
import { file_excludes, organization_excludes, unit_excludes } from "./excludes.ts";
import { feature_enums } from "./featureConstants.ts";
export const role_array = [
"Manager", "Admin", "OrgHead", "UnitHead", "StoreHead", "Employee", "Ordinary",
];
export const role_emums = enums(role_array);
export const role_scope_type_emums = enums(["organization", "unit", "store"]);
export const emailPattern = pattern(string(), /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/);
export const user_pure = {
first_name: string(),
last_name: string(),
email: emailPattern,
password: string(),
position: optional(string()),
isActive: defaulted(boolean(), true),
isGhost: defaulted(boolean(), false),
features: defaulted(array(object({ feature: feature_enums })), []),
roles: defaulted(
array(
object({
roleId: string(),
name: role_emums,
scopeType: optional(role_scope_type_emums),
scopeId: optional(string()),
}),
),
[{ roleId: crypto.randomUUID(), name: "Ordinary" }],
),
...createUpdateAt,
};
Pure fieldsโ
| Field | Type | Notes |
|---|---|---|
first_name | string() | |
last_name | string() | |
email | pattern(string(), email-regex) | validated at insert/update; indexed unique |
password | string() | excluded from every response by default (see below) |
position | optional(string()) | free-text job title |
isActive | defaulted(boolean(), true) | soft-disable a user |
isGhost | defaulted(boolean(), false) | true only for the bootstrap superuser |
features | defaulted(array({feature}), []) | permission flags, see featureConstants.ts |
roles | defaulted(array(role), [Ordinary]) | each role = { roleId, name, scopeType, scopeId } |
createdAt / updatedAt | spread from createUpdateAt |
Two details worth noticing:
roleshas a default of oneOrdinaryrole. A new user can be inserted without specifying any role and still get aroleId: crypto.randomUUID()โ every role object must have a uniqueroleIdso the active-role machinery can point at one of them.scopeType/scopeIdgive a role its scope: aUnitHeadrole withscopeType: "unit"andscopeId: <unit _id>only grants authority over that unit. This is what the workflow's per-unit approvals rely on.
Relationsโ
export const user_relations = {
avatar: {
schemaName: "file",
type: "single" as RelationDataType,
optional: true,
excludes: file_excludes,
relatedRelations: {},
},
organizations: {
schemaName: "organization",
type: "multiple" as RelationDataType,
optional: true,
excludes: organization_excludes,
limit: 50,
sort: { field: "_id", order: "desc" as RelationSortOrderType },
relatedRelations: {
users: { type: "multiple" as RelationDataType, limit: 50, sort: { field: "_id", order: "desc" as RelationSortOrderType } },
},
},
units: {
schemaName: "unit",
type: "multiple" as RelationDataType,
optional: true,
excludes: unit_excludes,
limit: 50,
sort: { field: "_id", order: "desc" as RelationSortOrderType },
relatedRelations: {
members: { type: "multiple" as RelationDataType, limit: 50, sort: { field: "_id", order: "desc" as RelationSortOrderType } },
},
},
};
| Relation | Target | Type | Back-reference |
|---|---|---|---|
avatar | file | single (optional) | โ |
organizations | organization | multiple (optional, limit 50) | organization.users |
units | unit | multiple (optional, limit 50) | unit.members |
The relatedRelations objects are the inverse snapshots. When a user is added to unit X, Lesan also writes the user's id into unit X's members array โ you never update the back-reference by hand.
Excludes and the passwordโ
export const users = () =>
coreApp.odm.newModel("user", user_pure, user_relations, {
createIndex: {
indexSpec: { email: 1 },
options: { unique: true },
},
excludes: ["password"],
});
Two protections here:
- Model-level
excludes: ["password"]โpasswordis stripped from every user document before it is returned to the client. This applies to the whole schema, not just relations. The password hash is only ever compared insidelogin. createIndexunique onemailโ two users can never share an email. A duplicate insert/update rejects with MongoDB'sE11000 duplicate key error.
There is also a text index created after registration (called from mod.ts):
export const createUserTextIndex = async () => {
const collection = coreApp.odm.getCollection("user");
try {
await collection.createIndex({ first_name: "text", last_name: "text", email: "text" });
} catch (error) {
console.error("Text index already exists or creation failed:", (error as Error).message);
}
};
This powers the $text search in the getUsers act.
In the workflowโ
Users are the actors. The Auth & Users chapter covers every user act:
- login verifies credentials and issues the JWT
- getMe, getUser, getUsers, countUsers read users
- addUser, updateUser, updateUserRelations, removeUser write them
- dashboardStatistic reports across roles
Run itโ
Any act on the user schema โ e.g. the list with a $text search:
curl -X POST http://localhost:1380/lesan \
-H "Content-Type: application/json" \
-H "token: <jwt>" \
-d '{
"service": "main",
"model": "user",
"act": "getUsers",
"details": {
"set": { "query": { "$text": "Sara" }, "sort": { "field": "_id", "order": "desc" }, "page": 1 },
"get": { "first_name": true, "last_name": true, "email": true }
}
}'
Errors & fixesโ
| Error | Cause | Fix |
|---|---|---|
E11000 duplicate key error collection: advancedTutorial.users | email (or another unique field) already exists | use a different email, or look the existing user up first |
password missing in responses | model-level excludes: ["password"] | expected behaviour โ never expose the hash |
Runtime
On npm/Bun import the framework from @hemedani/lesan; on Deno from jsr:@hemedani/lesan. The repo app itself uses the lesan path alias.