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 plusbulk-import,count,autocomplete,deleteByIds; appliescheckCrudPermissions('users')to every route). - Controller:
src/api/controllers/users.controller.ts(resolves the UI host from the requestRefererfor 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 toAuthService). - 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->200true. Request body:{ data: <UserInput> }. Creates the user then sends an invitation email.POST /api/users/bulk-import->200true. Multipart CSV upload; every row must carry anemail. Missing file raisesValidationError('importer.errors.invalidFileEmpty').PUT /api/users/:id->200true. The controller callsService.update(req.body.data, req.body.id, ...)(readsreq.body.id, notreq.params.id).DELETE /api/users/:id->200true.POST /api/users/deleteByIds->200true. Request body:{ data: string[] }.GET /api/users->200{ rows, count }. When?filetype=csv, responds with a CSV attachment of fieldsid, firstName, lastName, phoneNumber, email.GET /api/users/count->200{ rows: [], count }.GET /api/users/autocomplete->200array 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. removeadds explicit service-level guards beyond the permission check:- A user cannot delete themselves:
currentUser.id === idraisesValidationError('iam.errors.deletingHimself'). - Only roles named
config.roles.adminorconfig.roles.super_adminmay delete a user; otherwiseValidationError('errors.forbidden.message').
- A user cannot delete themselves:
createrejects a duplicate email (iam.errors.userAlreadyExists) and a missing email (iam.errors.emailRequired).
Tenant Scope
findAllscopes tocurrentUser.organizationIdonly when the user has a loadedorganizationsassociation and anorganizationId.globalAccessusers (currentUser.app_role.globalAccess) have the org constraint removed and read across organizations.- On
create, organization membership is set fromdata.organizationsviasetOrganizations. - 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
createcommit,AuthService.sendPasswordResetEmailis called with type'invitation'and the host resolved from the requestReferer, sending the new user a/password-reset?token=...link. The token is generated byUsersDBApi.generatePasswordResetTokenwithEMAIL_ACTION_TOKEN_BYTES/EMAIL_ACTION_TOKEN_TTL_MS. - Bulk import: parses the uploaded CSV (
csv-parser); requires every row to carry anemail(elseValidationError('importer.errors.userEmailMissing'));bulkCreates withignoreDuplicates: trueand staggeredcreatedAt(BULK_IMPORT_TIMESTAMP_STEP_MS); attaches avatar files per row. - Note (inconsistency in source): both
createandbulkImportaccept asendInvitationEmailsflag (controller passestrue). Increate, emails are sent only whensendInvitationEmailsis truthy (if (!sendInvitationEmails) return;), but inbulkImportthe email loop runs only when!sendInvitationEmails. With the controller always passingtrue, 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).
findByreturns a per-requestAuthenticatedUser(role + its permissions, staff profile, custom permissions, organization) used by authentication/authorization;findProfileByIdreturns the trimmed profile DTO forGET /me.
Tests
None yet (no users unit/e2e test under src/).
Related
- Backend slices:
permissions.md(the${METHOD}_USERSgate and thecustom_permissions/app_role.permissionsmodel consumed bycheck-permissions.ts); therolesentity (app_role,SPECIAL_ROLE_NAMES.DEFAULT_USER). - Frontend / auth:
auth-profile.md(the profile DTO produced byfindProfileById, plus the invitation/password-reset email flow shared withAuthService).