User & Access (the users party master) — one collection for every human/party in zerp
The whole user model reduces to one idea:
There is exactly one
userscollection, and every person or party in zerp is a row in it — system operators, employees, customers, and suppliers alike. They are told apart by a single Mongoose discriminator key,kind(UserKindTypes). A "system user" (someone who logs into the admin) is just ausersrow whosekindis one of the staff kinds (Admin,SuperAdmin,Company,StoreAdmin,Staff) and whosegroupIdpoints at an access group. A customer or supplier is the same row withkind=Customer/kind=Supplierand a finance control account. There is nostaff,customers,suppliers,admins, oraccountscollection — they all shareusers, separated only bykind.
Source: BE src/modules/user, src/modules/user-preference · Admin src/modules/user, src/modules/profile, src/modules/preferences, src/modules/employees
Sub-modules:
- users.md — system / admin user management: how operators are created (via the Employee invite/create flow), kinds & roles, access-group assignment, activation/suspension, the per-tenant user cap, password reset and account deletion.
- profile-preferences.md — the signed-in user's own profile (
currentUser→updateUser→changePassword) and per-user UI preferences (table column visibility/order viauserColumnPreferences).
Related: crm/_overview (customers/suppliers are also users discriminators) · platform/permissions-access (access groups, permission modules/actions, CASL — the authoritative RBAC doc, not duplicated here) · platform/auth (login, JWT, OTP, password reset internals) · platform/multi-tenancy (the per-tenant DB the users collection lives in) · HR employee (the staff-onboarding wrapper around users).
1. Entity map
users (one collection — ApSchema discriminatorKey: "kind")
│
┌──────────────┬───────────────┬─────┴──────┬───────────────┬──────────────┐
kind=Admin kind=SuperAdmin kind=Company kind=StoreAdmin kind=Staff kind=Customer / Supplier
SuperAdmin │ tenant-wide │ tenant │ store-scoped │ operator │ (CRM parties)
▲ │ super-operator │ owner │ operator │ │ │
│ └────────────────┴─────────────┴─────────────────┘ │ │
│ "system users" (log in to admin) │ │
│ │ │ ▼
│ groupId ─────────────────────────┼──────────────► access_groups finance_accounts (control acct)
│ (RBAC: see permissions-access) │ (group=Admin/Salesman/…) AR (customer) / AP (supplier)
│ │ │
employees ──── userId ────────────────┘ finance_account_transactions
(HR record: 1 Staff user ⇄ 1 employee per company) (GL legs → CRM balance)
Key collections:
| Collection | What it is | Doc |
|---|---|---|
users |
The single party master. Every operator, employee, customer, supplier is a row, discriminated by kind. |
this domain |
userColumnPreferences |
Per-user, per-table column-visibility/order preference. | profile-preferences.md |
employees (HR) |
The employment record. Holds userId (the Staff user) + groupId + HR fields. Onboarding an operator goes through here. |
HR domain |
access_groups (permission/group) |
The role bundle a user's groupId points at. Seeded Admin / Salesman / Marketing / Customer / Supplier. |
platform/permissions-access |
finance_accounts |
Per-party control account (accountId), for customer/supplier kinds only. |
crm/_overview |
Discriminator registration: Customer and Supplier schemas register against the users model via model.discriminator(...) (customer/customer.module.ts, supplier/supplier.module.ts). The base User schema declares @ApSchema({ discriminatorKey: "kind", … }) with default: UserKindTypes.Customer (user/user.schema.ts).
2. The users collection data model
user/user.schema.ts defines a two-level class: UserEntity (the flat profile fields, extends BaseSchema) and User extends UserEntity (adds the discriminator + finance/currency links). The single UserSchema is created from User and is what every kind shares.
2.1 UserEntity — the shared profile body
| Field | Type | Default | Description |
|---|---|---|---|
idNumber |
string | "" |
National ID / passport number. Uniqueness-checked on write. |
groupId |
ObjectId | — | Access group the user belongs to → drives RBAC. See permissions-access. |
activeBranchId |
ObjectId | — | The branch/store the user is currently operating in. |
branchIds |
ObjectId[] | [] |
All branches the user may operate in. |
parentId |
ObjectId | — | Self-ref → parent party (used by CRM per-currency sub-accounts). |
active |
boolean | true |
Account enabled flag (see §4 lifecycle). |
referralId |
string | "" |
Public referral id, set to helper.nanoId() on create. |
username |
string | "" |
Login handle. Defaults to email on create if omitted. Uniqueness-checked. |
name |
string | "" |
Display name. Uniqueness-checked on create only. |
keywords |
string[] | [] |
Search tokens (name split into words). Backs the text index. |
nickname |
string | "" |
— |
email |
string (sparse) | "" |
Email. Uniqueness-checked. sparse index. |
nationality |
string | "" |
— |
dateOfBirth |
number | 0 |
Unix ms. |
weddingDate |
number | 0 |
Unix ms. |
phoneNumber |
string (sparse) | "" |
Phone. Stripped of special chars (helper.removeSpecialChar). Uniqueness-checked. |
password |
string | "" |
bcrypt hash. Falls back to hashed DEFAULT_PASSWORD. Never exposed in GraphQL. |
status |
string | "active" |
Free-text status (see UserStatusTypes below). |
roles |
string[] | [] |
UserRoleTypes[]. e.g. Staff users get [Staff]. |
emailVerified |
boolean | false |
— |
phoneNumberVerified |
boolean | false |
— |
confirmEmailToken |
string | "" |
Email-confirm / set-password token, set on create. |
confirmEmailExpiredIn |
number | 0 |
Token expiry (unix ms), +emailTokenTimeSpan days from create. |
forgetPasswordToken |
string | "" |
Password-reset OTP/token (set by forgotPassword). |
forgetPasswordTokenExpiresIn |
number | 0 |
Reset-token expiry. |
pushNotificationId |
string | "" |
OneSignal/device push id. |
address, latitude, longitude |
string | "" |
Location block. |
wingoldMemberCode |
number | 0 |
Legacy external member code. |
Plus all BaseSchema fields: _id, ref, client ("zerp"), documentCode, documentDate, createdAt/By, updatedAt/By, deletedAt/By, deleted, canUpdate/View/Delete/Post, branchId, companyId (core/database/database.scheme.ts).
2.2 User — the discriminated head
@ApSchema({ discriminatorKey: "kind", timestamps: true, shared: true })
export class User extends UserEntity {
@Prop({ type: String, required: true, enum: UserKindTypes, default: UserKindTypes.Customer })
kind: string; // ← the discriminator
@Prop(/* toObjectId */) accountId: Types.ObjectId; // finance control acct (CRM kinds)
@Prop(/* toObjectId */) currencyId: Types.ObjectId; // master(key=currency)
// resolve-only / transient: currency, company, dbName, kinds, ignoreStoreId, xHash
}2.3 Indexes & soft-delete
UserSchema.plugin(SoftDelete, { deletedAt: true, deletedBy: true });
UserSchema.index({ name: "text", email: "text", phoneNumber: "text" });
UserSchema.index({ _type: 1, "$**": "text" });- Soft delete (
mongoose-delete): deletes setdeleted/deletedAt/deletedBy; deleted rows drop out of all reads (incl. CRM balance aggregations). - Tenant scoping: rows carry
companyId+branchId; theuserscollection lives in the tenant DB (see multi-tenancy).shared: trueon@ApSchemamarks it as a cross-context shared model.
2.4 Enums (user/user.schema.ts)
export enum UserStatusTypes { Active = "active", IN_ACTIVE = "IN_ACTIVE" }
export enum StaffRoleTypes { Admin = "Admin", SalesMan = "SalesMan", Staff = "Staff" }
export enum UserRoleTypes {
Admin = "Admin", SuperAdmin = "SuperAdmin", Customer = "Customer", StoreAdmin = "StoreAdmin",
Staff = "Staff", Employee = "Employee", SalesMan = "SalesMan", Supplier = "Supplier"
}
export enum UserKindTypes { // ← the discriminator values
Admin = "Admin", SuperAdmin = "SuperAdmin", Company = "Company",
StoreAdmin = "StoreAdmin", Staff = "Staff",
Customer = "Customer", Supplier = "Supplier"
}
kindvsroles.kindis the discriminator — it picks the document subtype and decides CRM-vs-operator behaviour (e.g.AccountService.getUserAccountroutesCustomer → debtor, else → creditor).rolesis a separate string array used for coarse role gates. The two overlap in values but are not the same field. Fine-grained access is neither — it is the access group (groupId), documented in permissions-access.
The "staff kinds" used for the per-tenant user cap are exactly: Admin, SuperAdmin, Company, StoreAdmin, Staff (user.service.ts create()).
3. How a system user relates to employee & access group
A system user (someone who can log into the admin) is created almost exclusively through the HR Employee module, not by calling createUser directly. This is the load-bearing relationship of the domain:
Admin "Create Employee" form
│ { user:{name,email,phone,password}, groupId, branchId }
▼
EmployeeService.create() (hr/employee/employee.service.ts)
│
├─ if a users row already has model.user.email ──► invite() (reuse existing user)
│
└─ else withRetryTransaction:
1. UserService.create({ ...user, kind: Staff, roles:[Staff], groupId }) → users row
2. employeeRepo.create({ userId: user._id, groupId, branchId, ...hrFields }) → employees row
So the invariant is:
1 employment record ⇄ 1
usersrow ofkind=Staff, linked byemployee.userId. The employee doc holds the HR data (position, salary, join date, leave/attendance groups); theusersrow holds the login +groupId. They are created together in one transaction.
- Access group is the bridge to RBAC. Both the
usersrow and theemployeesrow carry the samegroupId. On employeeupdate, the service writesgroupIdto both the employee and the user (employee.service.tsupdate()). ThegroupId→access_groups→ permission modules/actions chain is the actual permission system — see permissions-access. This domain only records that a user has agroupId; it does not define what the group can do. invite()reuses an existingusersrow (matched by email) and just creates theemployeesrecord pointing at it — so one person can be an employee in multiple companies without duplicate logins.- Customers/suppliers do not get an employee record. They are created via the CRM
customer/supplierservices (their own discriminators), get anaccountIdcontrol account, and never receive agroupIdfrom the employee flow (they are stamped the seededCustomer/Suppliergroup instead — see crm/_overview).
4. User lifecycle
create (cap-checked) update deactivate / suspend
┌───────┐ ─────────────────────► ┌────────┐ ─────────► ┌────────┐ active=false / status=IN_ACTIVE
│ (—) │ UserService.create() │ active │ │ same │ ──────────────────────────────►
└───────┘ ref+referralId+hash pw │ users │ validate- │ row │ (reactivate: active=true / "active")
validateExist (unique) │ row │ UpdateExist└────────┘
└────────┘ │
│ forgotPassword(email/phone) │ deleteAccount (self) → soft mangle
▼ token + expiry ▼
resetPassword(token,newPw) deleted=true (mongoose-delete)
/ changePassword(old,new) username/phone/email suffixed "_<id>_deleted"
- Create —
UserService.create()(user/user.service.ts):- Enforces the per-tenant user cap: if
tenantConfigSvc.getMaxUsers() > 0, counts existing rows of the staff kinds and throws406 "User limit of N reached…"if at/over the cap. (Customers/ suppliers do not count against this cap.) - Generates
ref(generateUserId()— date-stamped numeric code, regenerated on collision),referralId = helper.nanoId(), defaultsusername = email, stripsphoneNumber. validateExistrejects duplicateemail,username,phoneNumber,idNumber, and (create-only)nameacross all users (any kind) →406.- Hashes
password(orDEFAULT_PASSWORD), setsconfirmEmailToken+confirmEmailExpiredIn(+emailTokenTimeSpandays).
- Enforces the per-tenant user cap: if
- Update —
UserService.update():validateUpdateExistre-checksemail/phoneNumber/idNumberuniqueness excluding self; phone re-stripped. (Note: it does not re-checkname/username.) - Activate / suspend — there is no dedicated mutation. Status is just the
activeboolean (defaulttrue) and thestatusstring (UserStatusTypes.Active/IN_ACTIVE). Toggling them goes throughupdateUser. Login enforcement ofactive/statuslives in auth. - Password reset — owned by the auth module (
auth/auth.service.ts), operating on the sameusersrow:forgotPassword(email)/phoneForgotPassword(phone)→ setforgetPasswordToken(6 digits) +forgetPasswordTokenExpiresIn(+passwordTokenTimeSpanhours); phone variant WhatsApps the token. Always returns a generic message (no account-existence leak).resetPassword→changePasswordByToken(token,newPw)validates token + expiry, rehashes, clears the token.changePassword(old,new)(signed-in) →compareData(old)then rehash.changeUserPasswordis the admin-forced variant (no old-password check).- See auth for the full surface.
- Delete —
UserService.deleteAccount(userId)is a soft mangle: it suffixesusername/phoneNumber/emailwith_<newObjectId>_deleted(freeing the unique values for reuse) rather than removing the row. Themongoose-deleteplugin provides true soft-delete (deleted=true) for repositorydelete()calls. CRM parties additionally gate delete on a zero GL-derived balance (canDelete, see crm/_overview).
5. End-to-end flows (cross-module)
5.1 Onboard an operator (most common)
Admin /maintenance/users → Create Employee (employees module, NOT createUser)
→ CreateEmployeeInput { user{name,email,phone,password}, groupId, store(branch) }
→ EmployeeService.create() [txn]
→ UserService.create({ kind:Staff, roles:[Staff], groupId }) (cap + uniqueness checks)
→ employeeRepo.create({ userId, groupId, branchId })
→ operator can now log in; groupId → access group → permission modules/actions (permissions-access)
5.2 Onboard a trading party (CRM)
Admin /customers or /vendors → Create (customer/supplier modules)
→ CommonUserInput + accountId(control) + currencyId → users.discriminator(kind=Customer|Supplier)
→ groupId stamped to seeded "Customer"/"Supplier" group; balance is GL-derived (see crm/_overview)
5.3 Self profile + preferences (any signed-in user)
currentUser (whoami) → updateUser (edit own fields) → changePassword
getColumnPreference(tableKey) ⇄ updateColumnPreference (per-user table UI state)
(see profile-preferences.md)
5.4 Bulk import parties
importUsers(file) → parse XLSX → [UserImport] preview (name/email/phone/Dr+Cr account/currency/balance)
confirmUsersImport({ userKind, users }) [txn]
→ per row: validateExist → super.create({ kind, groupId:<seeded group>, accountId })
→ 2 opening-balance GL legs (Dr/Cr) → accountSvc.validateBalanced()
(Import is implemented on the user service but driven from the CRM admin screens — see crm/_overview. Confirm-import requires a seeded access group matching userKind.)
6. Permissions (admin)
System-user/employee management lives under the user-maintenance admin permission module (zerp-admin/src/constants/UserAccess.ts → USER_ACCESS.USER_MAINTENANCE):
| Action key | Used for |
|---|---|
view |
View the user-maintenance area. |
create-user / update-user / delete-user |
Operator user CRUD. |
import-users |
Bulk import (CRM parties). |
view-user-access |
View a user's access/permissions. |
view-user-details |
Open a user detail page. |
view-employees / create-employee |
Employee list / create (the operator-onboarding path). |
view-department / create-department |
HR department admin. |
Page guards use ApGuardBuilder.haveAccess(MODULE, ACTION, redirect) in getServerSideProps; BE mutations carry @ApGqlAuthorize() + @AuditMeta({ module:'user', collection:'users', … }). CRM parties use the separate customers / vendors modules. Self-profile (/profile) is gated only by ApGuardBuilder.isAuth() — no module/action. admin-users.tsx is hard-gated to kind=SuperAdmin. The full RBAC mechanism (modules, actions, CASL, master-access) is platform — see permissions-access; do not infer it from this list.
7. Shared gotchas (domain-wide)
- One collection, many kinds. Any query that forgets the
kind/kindsfilter leaks other party types.userPage/findOnefilter bykindsonly when supplied. nameuniqueness is create-only & global. Two parties (even different kinds) can't share anameat create time, butupdatedoes not re-check it — so renames can collide.- No activate/suspend mutation. "Suspension" is just
active=false/status=IN_ACTIVEviaupdateUser; nothing in this module enforces it — the gate is in auth. - Delete is a mangle, not a removal.
deleteAccountkeeps the row and suffixes its unique fields; the row still exists (and still counts for some queries) unless alsomongoose-delete-deleted. - User cap counts staff kinds only. Customers/suppliers are unlimited; only
Admin/SuperAdmin/ Company/StoreAdmin/Staffcount towardtenantConfig.maxUsers. - Operators come from HR. Don't expect
createUserto be the operator-creation path in the UI — it is the Employee create/invite flow.createUseris wired but the admin user module mainly exposes page/find/import; the rich create form is in the employees module. - Admin
updateUserarg-name mismatch. The adminprofilemutation sends the variable asaccount:(updateUser($account: UpdateUserInput!)) while the BE resolver declares the arg asuser. Field order in GraphQL still binds by position for a single arg, but treat the admin alias as cosmetic — the BE truth is@Args("user")(user.resolver.ts).