40227-vm/backend/docs/users.md

166 lines
10 KiB
Markdown

# 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')`); `bulkCreate`s 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.
## Related
- 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`).