Add User
addUser creates a new user: it hashes the password, writes the pure fields, and wires up the avatar, organizations, and units relations in one call. It belongs to the user model and is the most relation-heavy write act in the auth chapter โ the place to study how insertOne with a relations object works.
The act lives in src/user/addUser/.
The validator (addUser.val.ts)โ
set requires the four core identity fields โ first_name, last_name, email, password โ then accepts optional position, isActive (defaults to true via defaulted), features (array of { feature: feature_enums }), and the three relation inputs: avatar (a single objectIdValidation), organizations (array of ObjectIds), units (array of ObjectIds). get is selectStruct("user", 1).
import {
array,
boolean,
defaulted,
object,
objectIdValidation,
optional,
string,
} from "lesan";
import { selectStruct } from "../../../mod.ts";
import { activeRoleMixin } from "@lib";
import { feature_enums } from "@model";
export const addUserValidator = () => {
return object({
set: object({
...activeRoleMixin,
first_name: string(),
last_name: string(),
email: string(),
password: string(),
position: optional(string()),
isActive: defaulted(boolean(), true),
features: optional(array(object({ feature: feature_enums }))),
avatar: optional(objectIdValidation),
organizations: optional(array(objectIdValidation)),
units: optional(array(objectIdValidation)),
}),
get: selectStruct("user", 1),
});
};
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 @model / @lib / ../../../mod.ts aliases stay as they are in your project (see Project Layout).
The registration (mod.ts)โ
Three things make this registration special:
validationRunType: "create"โ the framework uses superstruct'screate(filling defaults likeisActive: true) instead ofassert.- The preAct is
setTokens,setUser, and then a two-rulegrantAccess:Manager/Admincan add users anywhere.OrgHeadcan add users, but only within their own organization โgetScopepulls the firstorganizationsid out of the request body (b?.details?.set?.organizations[0]) and requires the OrgHead's active-role scope to match that organization exactly.
- The ghost superuser (
isGhost) bypasses all of it.
import {
grantAccess,
setTokens,
setUser,
} from "@lib";
import { coreApp } from "../../../mod.ts";
import { addUserFn } from "./addUser.fn.ts";
import { addUserValidator } from "./addUser.val.ts";
export const addUserSetup = () =>
coreApp.acts.setAct({
schema: "user",
actName: "addUser",
validationRunType: "create",
preAct: [
setTokens,
setUser,
grantAccess([
{ roles: ["Manager", "Admin"] },
{
roles: ["OrgHead"],
getScope: (b) => {
const orgs = b?.details?.set?.organizations as string[] | undefined;
return orgs?.[0]
? { scopeType: "organization", scopeId: orgs[0] }
: null;
},
},
]),
],
validator: addUserValidator(),
fn: addUserFn,
});
The implementation (addUser.fn.ts)โ
The function destructures the relation-shaped fields out of set and keeps the scalar fields in rest. It builds a relations object typed as TInsertRelations<typeof user_relations> (the generated shape for this model's relation definitions):
avatarโ single relation:{ _ids: [new ObjectId(avatar)] }.organizationsโ multiple relation mapping each id to anObjectId, withrelatedRelations: { users: true }so the organization's reverseuserssnapshot is kept in sync.unitsโ multiple relation withrelatedRelations: { members: true }for the unit's reversememberssnapshot.
Then user.insertOne writes { ...rest, password: hashPassword(password) } โ the password is hashed before storage (SHA-256, see Auth Utilities) โ plus the relations, and returns the document projected by get.
import { type ActFn, ObjectId, type TInsertRelations } from "lesan";
import { user } from "../../../mod.ts";
import type { user_relations } from "@model";
import { hashPassword } from "../../../utils/password.ts";
export const addUserFn: ActFn = async (body) => {
const { set, get } = body.details;
const { activeRoleId, avatar, organizations, units, password, ...rest } =
set;
const relations: TInsertRelations<typeof user_relations> = {};
avatar &&
(relations.avatar = {
_ids: new ObjectId(avatar as string),
});
if (organizations && (organizations as string[]).length > 0) {
relations.organizations = {
_ids: (organizations as string[]).map((id: string) => new ObjectId(id)),
relatedRelations: {
users: true,
},
};
}
if (units && (units as string[]).length > 0) {
relations.units = {
_ids: (units as string[]).map((id: string) => new ObjectId(id)),
relatedRelations: {
members: true,
},
};
}
const addedUser = await user.insertOne({
doc: {
...rest,
password: password ? await hashPassword(password as string) : undefined,
},
relations,
projection: get,
});
return addedUser;
};
Why the relatedRelations flags? Because Lesan stores relations bi-directionally: the user embeds a pure snapshot of its organizations/units, and those documents embed a pure snapshot of the user in their reverse users / members arrays. Telling insertOne about the reverse relation keeps both sides consistent in the same write โ otherwise the back-reference would be stale.
Note that activeRoleId is destructured away and never persisted: it's a request-scoped value the preAct chain uses, not a user field.
In the workflowโ
addUser is the create side of user management. The new user's _id (from get) feeds updateUser, updateUserRelations, removeUser, and login.
- login โ new users log in with the
email/passwordyou set here. - updateUser โ editing the scalar fields afterwards.
- updateUserRelations โ replacing
avatar/organizations/unitslater. - Auth Utilities โ
grantAccesswithgetScope, andhashPassword. - User model โ the relation definitions and
feature_enums. - Overview โ where this series starts.
Run itโ
Needs token and activeRoleId. Here's an admin creating a user with an organization and a unit (assuming orgId / unitId were captured from the catalog chapter's add acts):
curl -X POST http://localhost:1380/lesan \
-H "Content-Type: application/json" \
-H "token: <jwt>" \
-d '{
"model": "user",
"act": "addUser",
"details": {
"set": {
"activeRoleId": "<roleId>",
"first_name": "Sara",
"last_name": "Ahmadi",
"email": "sara@medsupply.io",
"password": "SecretPass123!",
"position": "Purchasing Manager",
"isActive": true,
"organizations": ["<orgId>"],
"units": ["<unitId>"]
},
"get": {
"_id": 1,
"first_name": 1,
"last_name": 1,
"email": 1
}
}
}'
For an OrgHead, the organizations array must contain exactly the organization they're scoped to, or grantAccess rejects the request.
Errors & fixesโ
| Message | Source | What it means | How to fix |
|---|---|---|---|
you should send your id with token key in req header | setTokens | Missing token header. | Add -H "token: <jwt>". |
Invalid or expired token | setTokens | JWT didn't verify. | Re-login. |
Invalid or missing token data | setUser | Token payload had no _id. | Re-login. |
user not exist | setUser | The token's user was deleted. | Use a different account. |
activeRoleId is required | grantAccess | set.activeRoleId was omitted. | Always send activeRoleId in set. |
Active role not found | grantAccess | The activeRoleId doesn't match any role on the user. | Use a roleId from the login response. |
You cant do this | grantAccess | Role is neither Manager/Admin, or an OrgHead whose organizations[0] doesn't match their scope. | Use a manager/admin role, or make the organizations array start with the OrgHead's own organization id. |
| Duplicate key error (framework error) | MongoDB | The unique index on email rejected a second user with the same email. | user has createIndex: { indexSpec: { email: 1 }, options: { unique: true } } โ pick a different email. |
| Generic validation error | superstruct | Missing first_name/last_name/email/password, or invalid features/ObjectIds. | All four core strings are required; features must be { feature: <enum> }; relation ids must be 24-char hex. |