40227-vm/backend/docs/users.md
2026-06-10 18:27:19 +02:00

7.9 KiB

Users Backend

Purpose

users is the identity slice: it manages user accounts, their assigned role (app_role), per-user custom permissions, organization membership, optional avatar files, and the email-action tokens used for verification and password reset. The slice is hand-written: creating a user (or bulk-importing users) triggers an invitation email containing a password-reset link.

Slice Files (by layer)

  • Route: src/routes/users.ts (CRUD plus bulk-import, count, autocomplete, deleteByIds; applies checkCrudPermissions('users') to every route).
  • Controller: src/api/controllers/users.controller.ts (resolves the UI host from the request Referer for invitation links; handles CSV export and file upload).
  • Service (BLL): src/services/users.ts (invitation + bulk-import workflow; duplicate-email and self-delete guards; delegates email sending to AuthService).
  • Repository (DAL): src/db/api/users.ts (also exposes auth helpers: findBy, findProfileById, createFromAuth, updatePassword, token generation/lookup, markEmailVerified).
  • Model: src/db/models/users.ts.
  • Shared used: services/auth.ts (AuthService.sendPasswordResetEmail), db/api/shared/repository.ts, db/api/file.ts (replaceRelationFiles), db/utils.ts, shared/config.ts (config.roles, config.providers, bcrypt settings), shared/constants/roles.ts (SPECIAL_ROLE_NAMES), shared/constants/auth.ts (EMAIL_ACTION_TOKEN_BYTES, EMAIL_ACTION_TOKEN_TTL_MS), shared/constants/database.ts (BULK_IMPORT_TIMESTAMP_STEP_MS), shared/constants/pagination.ts (resolvePagination), shared/csv.ts (toCsv), middlewares/upload.ts (processFile), middlewares/check-permissions.ts, shared/errors/validation.ts.

API

All routes are mounted under /api/users and require JWT authentication (src/index.ts). Every route passes checkCrudPermissions('users'), requiring the permission ${METHOD}_USERS (see permissions.md); the middleware's self-access bypass also lets a user act on their own record when req.params.id/req.body.id equals their own id.

  • POST /api/users -> 200 true. Request body: { data: <UserInput> }. Creates the user then sends an invitation email.
  • POST /api/users/bulk-import -> 200 true. Multipart CSV upload; every row must carry an email. Missing file raises ValidationError('importer.errors.invalidFileEmpty').
  • PUT /api/users/:id -> 200 true. The controller calls Service.update(req.body.data, req.body.id, ...) (reads req.body.id, not req.params.id).
  • DELETE /api/users/:id -> 200 true.
  • POST /api/users/deleteByIds -> 200 true. Request body: { data: string[] }.
  • GET /api/users -> 200 { rows, count }. When ?filetype=csv, responds with a CSV attachment of fields id, firstName, lastName, phoneNumber, email.
  • GET /api/users/count -> 200 { rows: [], count }.
  • GET /api/users/autocomplete -> 200 array of { id, label }.
  • GET /api/users/:id -> 200, a single eager-loaded user record (role + permissions, staff profile, custom permissions, organization).

Access Rules

  • CRUD is gated by checkCrudPermissions('users') (${METHOD}_USERS), or a matching custom per-user permission, or the self-access bypass.
  • remove adds explicit service-level guards beyond the permission check:
    • A user cannot delete themselves: currentUser.id === id raises ValidationError('iam.errors.deletingHimself').
    • Only roles named config.roles.admin or config.roles.super_admin may delete a user; otherwise ValidationError('errors.forbidden.message').
  • create rejects a duplicate email (iam.errors.userAlreadyExists) and a missing email (iam.errors.emailRequired).

Tenant Scope

  • findAll scopes to currentUser.organizationId only when the user has a loaded organizations association and an organizationId. globalAccess users (currentUser.app_role.globalAccess) have the org constraint removed and read across organizations.
  • On create, organization membership is set from data.organizations via setOrganizations.
  • On update, role/org/custom-permission associations are only changed when their respective fields are present in the input.

Data Contract

Model columns (src/db/models/users.ts): id (UUID PK), firstName, lastName, phoneNumber (text), email (text, allowNull: false), disabled (boolean, default false), password (text), emailVerified (boolean, default false), emailVerificationToken + emailVerificationTokenExpiresAt, passwordResetToken + passwordResetTokenExpiresAt, provider (text), importHash (unique), organizationId, createdById, updatedById, createdAt, updatedAt, deletedAt (paranoid soft-delete). A beforeCreate/beforeUpdate hook trims email/firstName/lastName; for non-local OAuth providers beforeCreate forces emailVerified = true and generates a random bcrypt password when none is supplied.

Associations: belongsToMany permissions as custom_permissions (and custom_permissions_filter for list filtering) through usersCustom_permissionsPermissions; hasMany staff as staff_user; hasMany messages as messages_sent_by; belongsTo roles as app_role; belongsTo organizations as organizations; hasMany file as avatar; belongsTo users as createdBy/updatedBy.

On create/bulkImport the repository sets emailVerified to true on single create and to false (unless supplied) on bulk import. When no app_role is given on single create, the record is assigned the role named SPECIAL_ROLE_NAMES.DEFAULT_USER.

List filters (findAll): id, firstName, lastName, phoneNumber, email, password, emailVerificationToken, passwordResetToken, provider (ILIKE); emailVerificationTokenExpiresAtRange, passwordResetTokenExpiresAtRange, createdAtRange; active, disabled, emailVerified; app_role (join on id or name, |-separated); organizations (|-separated ids); custom_permissions (join on id or name); plus field/sort (default createdAt desc) and limit/page.

Behavior / Notes

  • Invitation workflow: after a successful create commit, AuthService.sendPasswordResetEmail is called with type 'invitation' and the host resolved from the request Referer, sending the new user a /password-reset?token=... link. The token is generated by UsersDBApi.generatePasswordResetToken with EMAIL_ACTION_TOKEN_BYTES/EMAIL_ACTION_TOKEN_TTL_MS.
  • Bulk import: parses the uploaded CSV (csv-parser); requires every row to carry an email (else ValidationError('importer.errors.userEmailMissing')); bulkCreates with ignoreDuplicates: true and staggered createdAt (BULK_IMPORT_TIMESTAMP_STEP_MS); attaches avatar files per row.
  • Note (inconsistency in source): both create and bulkImport accept a sendInvitationEmails flag (controller passes true). In create, emails are sent only when sendInvitationEmails is truthy (if (!sendInvitationEmails) return;), but in bulkImport the email loop runs only when !sendInvitationEmails. With the controller always passing true, single-create users are emailed while bulk-imported users are not.
  • All mutations run inside a manual Sequelize transaction (commit on success, rollback on error).
  • findBy returns a per-request AuthenticatedUser (role + its permissions, staff profile, custom permissions, organization) used by authentication/authorization; findProfileById returns the trimmed profile DTO for GET /me.

Tests

None yet (no users unit/e2e test under src/).

  • Backend slices: permissions.md (the ${METHOD}_USERS gate and the custom_permissions / app_role.permissions model consumed by check-permissions.ts); the roles entity (app_role, SPECIAL_ROLE_NAMES.DEFAULT_USER).
  • Frontend / auth: auth-profile.md (the profile DTO produced by findProfileById, plus the invitation/password-reset email flow shared with AuthService).