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

## 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`).
