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 users collection, 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 a users row whose kind is one of the staff kinds (Admin, SuperAdmin, Company, StoreAdmin, Staff) and whose groupId points at an access group. A customer or supplier is the same row with kind=Customer/kind=Supplier and a finance control account. There is no staff, customers, suppliers, admins, or accounts collection — they all share users, separated only by kind.

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 (currentUserupdateUserchangePassword) and per-user UI preferences (table column visibility/order via userColumnPreferences).

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 set deleted/deletedAt/deletedBy; deleted rows drop out of all reads (incl. CRM balance aggregations).
  • Tenant scoping: rows carry companyId + branchId; the users collection lives in the tenant DB (see multi-tenancy). shared: true on @ApSchema marks 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"
}

kind vs roles. kind is the discriminator — it picks the document subtype and decides CRM-vs-operator behaviour (e.g. AccountService.getUserAccount routes Customer → debtor, else → creditor). roles is 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 users row of kind=Staff, linked by employee.userId. The employee doc holds the HR data (position, salary, join date, leave/attendance groups); the users row holds the login + groupId. They are created together in one transaction.

  • Access group is the bridge to RBAC. Both the users row and the employees row carry the same groupId. On employee update, the service writes groupId to both the employee and the user (employee.service.ts update()). The groupIdaccess_groups → permission modules/actions chain is the actual permission system — see permissions-access. This domain only records that a user has a groupId; it does not define what the group can do.
  • invite() reuses an existing users row (matched by email) and just creates the employees record 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 / supplier services (their own discriminators), get an accountId control account, and never receive a groupId from the employee flow (they are stamped the seeded Customer/Supplier group 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"
  1. CreateUserService.create() (user/user.service.ts):
    • Enforces the per-tenant user cap: if tenantConfigSvc.getMaxUsers() > 0, counts existing rows of the staff kinds and throws 406 "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(), defaults username = email, strips phoneNumber.
    • validateExist rejects duplicate email, username, phoneNumber, idNumber, and (create-only) name across all users (any kind) → 406.
    • Hashes password (or DEFAULT_PASSWORD), sets confirmEmailToken + confirmEmailExpiredIn (+emailTokenTimeSpan days).
  2. UpdateUserService.update(): validateUpdateExist re-checks email/phoneNumber/idNumber uniqueness excluding self; phone re-stripped. (Note: it does not re-check name/username.)
  3. Activate / suspend — there is no dedicated mutation. Status is just the active boolean (default true) and the status string (UserStatusTypes.Active/IN_ACTIVE). Toggling them goes through updateUser. Login enforcement of active/status lives in auth.
  4. Password reset — owned by the auth module (auth/auth.service.ts), operating on the same users row:
    • forgotPassword(email) / phoneForgotPassword(phone) → set forgetPasswordToken (6 digits) + forgetPasswordTokenExpiresIn (+passwordTokenTimeSpan hours); phone variant WhatsApps the token. Always returns a generic message (no account-existence leak).
    • resetPasswordchangePasswordByToken(token,newPw) validates token + expiry, rehashes, clears the token.
    • changePassword(old,new) (signed-in) → compareData(old) then rehash. changeUserPassword is the admin-forced variant (no old-password check).
    • See auth for the full surface.
  5. DeleteUserService.deleteAccount(userId) is a soft mangle: it suffixes username/phoneNumber/email with _<newObjectId>_deleted (freeing the unique values for reuse) rather than removing the row. The mongoose-delete plugin provides true soft-delete (deleted=true) for repository delete() 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.tsUSER_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/kinds filter leaks other party types. userPage / findOne filter by kinds only when supplied.
  • name uniqueness is create-only & global. Two parties (even different kinds) can't share a name at create time, but update does not re-check it — so renames can collide.
  • No activate/suspend mutation. "Suspension" is just active=false / status=IN_ACTIVE via updateUser; nothing in this module enforces it — the gate is in auth.
  • Delete is a mangle, not a removal. deleteAccount keeps the row and suffixes its unique fields; the row still exists (and still counts for some queries) unless also mongoose-delete-deleted.
  • User cap counts staff kinds only. Customers/suppliers are unlimited; only Admin/SuperAdmin/ Company/StoreAdmin/Staff count toward tenantConfig.maxUsers.
  • Operators come from HR. Don't expect createUser to be the operator-creation path in the UI — it is the Employee create/invite flow. createUser is wired but the admin user module mainly exposes page/find/import; the rich create form is in the employees module.
  • Admin updateUser arg-name mismatch. The admin profile mutation sends the variable as account: (updateUser($account: UpdateUserInput!)) while the BE resolver declares the arg as user. 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).