40227-vm/backend/docs/users.md

10 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) provisions a generated temporary password and triggers an invitation email with the login URL and credentials.

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.sendInvitationEmail), 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 (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 { id, organizationId, temporaryPassword? }. Request body: { data: <UserInput> }. Creates the user then sends an invitation email when mailer is configured. If mailer is not configured, the response includes the generated temporaryPassword exactly once so the creator can copy it and deliver it manually.
  • 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, 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).
  • Teacher users are seeded with CREATE_USERS / UPDATE_USERS and CREATE_GUARDIAN_STUDENTS so they can manage student and guardian accounts from /my-class. Service-level role policy limits that access to the student and guardian target roles. Class-scope guards treat a student as in the teacher's class when either users.classId matches or an active class_enrollments row links the student to that class. Requested student classId values must stay unset or match the teacher's own class. Guardian updates require an existing guardian_students link to a student in the teacher's class, using the same direct-class or enrollment-backed membership rule. Class-scoped user management cannot add custom permissions or permission exclusions.
  • Guardian-student links are handled by guardian_students. Teachers also receive CREATE_GUARDIAN_STUDENTS; the link service verifies class-scoped actors only link guardian users to student users in their own class, including students whose class membership is stored only in class_enrollments.

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 create with classId, the service resolves the class and stamps the user's campusId and organization from the class's campus.
  • 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), 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 messages as messages_sent_by; belongsTo roles as app_role; belongsTo organizations as organizations; belongsTo classes as class; hasMany class_enrollments as class_enrollments_student for enrollment-backed student class display and scope checks; hasMany file as avatar; belongsTo users as createdBy/updatedBy.

On create/bulkImport, the service generates a random temporary password, hashes it before storing it, and marks the account emailVerified = true so the invited internal user can sign in immediately. A user created without an explicit app_role has no role and falls back to the guest role until one is assigned (roles are assigned explicitly by the provisioning flow). The service layer (services/users.ts) also enforces the relational role policy (assertCanManageUserWithRole), a same-organization guard (assertSameTenant) for non-global actors, and auto-creates the company when an owner is created (§3.3/§3.4).

List filters (findAll): id, firstName, lastName, phoneNumber, email, provider (ILIKE); createdAtRange; disabled, emailVerified; app_role (join on id or name, |-separated); campusId (direct campus users plus class-scoped users whose class belongs to the campus); classId (direct class-scoped users plus students enrolled through class_enrollments); organizations (|-separated ids); custom_permissions (join on id or name); plus field/sort (default createdAt desc) and limit/page.

Behavior / Notes

  • Invitation workflow: before persistence, the service generates a temporary password with crypto.randomBytes(...).toString('base64url'), stores only the bcrypt hash, and retains the plaintext value only long enough to send AuthService.sendInvitationEmail(...) after commit. The email contains the login URL, the user's email, and the temporary password. This is accepted for the internal platform workflow; users are expected to change the password from their profile. If EmailSender.isConfigured is false, the same plaintext temporary password is returned once from POST /api/users / POST /api/users/owner-with-organization instead of relying on email. It is still not stored outside the bcrypt hash.
  • 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. Invitation emails are sent only for rows returned by the repository as created, so duplicate/ignored CSV rows do not receive unsaved temporary passwords.
  • All mutations run inside a manual Sequelize transaction (commit on success, rollback on error).
  • findBy returns a per-request AuthenticatedUser (role + its permissions, custom permissions, organization) used by authentication/authorization; findProfileById returns the trimmed profile DTO for GET /me.

Tests

  • src/services/users.test.ts covers temporary-password provisioning, invite delivery for created users only, password hashing on admin updates, and teacher create/update access for student and guardian accounts, including enrollment-backed students that do not have a direct users.classId.
  • src/services/guardian_students.test.ts covers class-scoped guardian-student linking for enrollment-backed students and rejects links outside the teacher's class.
  • src/services/shared/role-policy.test.ts covers the role-management matrix, including teacher limits to student and guardian accounts.
  • src/db/seeders/user-roles.test.ts covers the seeded teacher roster-management permission grants.
  • 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; a user created without a role falls back to guest).
  • Frontend / auth: auth-profile.md (the profile DTO produced by findProfileById, plus the invitation/password-reset email flow shared with AuthService).