# Roles Backend

## Purpose

`roles` is the catalog of access roles. It is wired through the shared generic-CRUD router,
controller, and service factories, but its repository is **custom**: a role is not a plain record,
it carries a many-to-many link to `permissions`, and the repository manages that link on every
create/update and eager-loads it on reads. Roles are what `users` reference as their app role, so
this slice underpins the permission checks documented in `permissions.md` and `auth-profile.md`.

## Slice Files (by layer)

- Route: `src/routes/roles.ts` — `createCrudRouter(controller, { permission: 'roles' })`.
- Controller: `src/api/controllers/roles.controller.ts` — `createCrudController(service, { csvFields: ['id', 'name'] })`.
- Service (BLL): `src/services/roles.ts` — `createCrudService(DbApi, { notFoundCode: 'rolesNotFound' })`.
- Repository (DAL): `src/db/api/roles.ts` (`RolesDBApi`) — custom; see Behavior. `remove`/`deleteByIds`
  delegate to `db/api/shared/repository.ts`; `create`/`update`/`findBy`/`findAll`/`findAllAutocomplete`
  are entity-specific (autocomplete is implemented inline, not via the shared `autocompleteByField`).
- Model: `src/db/models/roles.ts`.
- Shared used: CRUD factories (`services/shared/crud-service.ts`,
  `api/controllers/shared/crud-controller.ts`, `api/http/crud-router.ts`), repository helpers
  (`db/api/shared/repository.ts` — `removeRecord`, `deleteRecordsByIds`),
  `shared/constants/pagination.ts` (`resolvePagination`), `shared/config` (`config.roles.super_admin`),
  `db/utils` (`uuid`, `ilike`).

## API

The standard generic-CRUD surface (all under `/api/roles`, JWT + `${METHOD}_ROLES` permission, all
`200`) — see `backend-architecture.md` for the shared contract:

- `POST /` — body `{ data }`, returns `true`.
- `POST /bulk-import` — multipart CSV file, returns `true`.
- `PUT /:id` — body `{ data, id }` (the service reads the id from the **body**, not the path param),
  returns `true`.
- `DELETE /:id` — returns `true`.
- `POST /deleteByIds` — body `{ data: string[] }`, returns `true`.
- `GET /` — query filters, returns `{ rows, count }`; `?filetype=csv` streams a CSV of `csvFields`.
- `GET /count` — returns `{ rows: [], count }`.
- `GET /autocomplete` — `?query&limit&offset`, returns `[{ id, label }]` where `label` is `name`.
- `GET /:id` — returns the record with eager associations (see Data Contract).

`csvFields`: `id`, `name`.

## Access Rules

- JWT required; the whole router is guarded by `checkCrudPermissions('roles')`, deriving
  `READ_ROLES` / `CREATE_ROLES` / `UPDATE_ROLES` / `DELETE_ROLES` per HTTP method.
- Access is granted by role permission or per-user `custom_permissions` (see `permissions.md`).
- Visibility gate on the super-admin role: in both `findAll` and `findAllAutocomplete`, when the
  caller does **not** have `globalAccess`, the where clause is reset to
  `name <> config.roles.super_admin`, so non-global callers never see the super-admin role.

## Tenant Scope

- `roles` has no `organizationId` column; the catalog is not tenant-scoped. The only access
  partition is the super-admin visibility gate above (driven by `globalAccess`, not by organization).

## Data Contract

Model columns (`paranoid`, soft-delete via `deletedAt`, `freezeTableName`):

- `id` (UUID PK), `name` (TEXT, nullable), `scope` (ENUM
  `system | organization | campus | external | guest`, NOT NULL),
  `globalAccess` (BOOLEAN, not null, default `false`),
  `importHash` (STRING(255), unique, nullable), `createdById`, `updatedById`,
  `createdAt` / `updatedAt` / `deletedAt`.

Associations:

- `belongsToMany` `permissions` as `permissions` — through table `rolesPermissionsPermissions`,
  foreign key `roles_permissionsId`, `constraints: false`.
- `belongsToMany` `permissions` as `permissions_filter` — same through table and foreign key
  (`rolesPermissionsPermissions` / `roles_permissionsId`), used only for filtered list queries.
- `hasMany` `users` as `users_app_role` (foreign key `app_roleId`) — the users whose app role is
  this role.
- `belongsTo` `users` as `createdBy` / `updatedBy`.

`findBy` (backing `GET /:id`) returns the plain role plus two eager-loaded keys fetched in a single
`Promise.all`: `users_app_role` (from `getUsers_app_role`) and `permissions` (from `getPermissions`).

List filters (`RolesFilter`): `id`, `name` (ilike), `active`, `globalAccess`, `permissions`
(`|`-separated; matched by permission id or permission name ilike via the `permissions_filter`
include), `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination. Default order
is `createdAt desc`.

## Behavior / Notes

- Role <-> permission linkage is the defining custom behavior. `create` builds the role row, then
  calls `roles.setPermissions(data.permissions || [])` to replace the through-table rows. `update`
  only touches the through table when `data.permissions !== undefined`, calling
  `roles.setPermissions(data.permissions)` (a full replace of the role's permission set). `findBy`
  reads them back with `roles.getPermissions()`. So a role's permissions are passed as a flat
  `string[]` of permission ids on create/update and returned as the eager `permissions` array on
  read — this is the main way this slice differs from a plain CRUD entity, whose repository only
  manages its own columns.
- `findAll` always eager-loads `permissions` (`required: false`). When the `permissions` filter is
  present it additionally joins `permissions_filter` (the second alias over the same through table)
  with an `Op.or` of permission id `Op.in` and permission name ilike, and the list uses
  `distinct: true` to avoid row multiplication from the join.
- The super-admin gate (see Access Rules) **replaces** the accumulated where clause rather than
  merging into it, so for a non-global caller other filters on the same query are overridden by the
  `name <> super_admin` condition (kept here for source accuracy).
- `bulkImport` offsets `createdAt` per row by `BULK_IMPORT_TIMESTAMP_STEP_MS` to preserve order; it
  does **not** set permissions (only `create`/`update` do).
- List pagination uses the shared `resolvePagination` defaults; `findAllAutocomplete` orders by
  `name ASC` and selects only `id`/`name`.
- Note: `RolesFilter` accepts an `active` flag and `findAll` filters on an `active` column the
  `roles` model does not declare; it is currently inert (kept for source accuracy).
- **Seeded roles**: The seeder (`20200430130760-user-roles.ts`) creates the first-class roles from
  `ROLE_DEFINITIONS` (`shared/constants/roles.ts`), each with its `scope`. `globalAccess: true` is set
  for the two system-scope roles (`super_admin`, `system_admin`) so their tenant reach is platform-wide.
  `system_admin` still resolves permissions from explicit role rows like the tenant roles; only
  `super_admin` bypasses the standard per-permission checks (`check-permissions.ts`). Org/campus roles
  (`owner`, `superintendent`, `director`, `office_manager`, `teacher`, `support_staff`) are constrained
  to their tenant/campus by scoping; `student`, `guardian`, and the unauthenticated-fallback `guest`
  have no entity-CRUD permissions. The seeder also assigns roles to the seeded users and writes the
  preset role→permission matrix. Services use `getOrganizationIdOrGlobal` / `hasGlobalAccess`
  (`services/shared/access.ts`) to honor global access.

## Tests

- `src/db/seeders/user-roles.test.ts` covers role-seeder product permission
  grants that are part of the role contract.
- `src/services/shared/role-policy.test.ts` covers target-role management
  hierarchy rules.

## Related

- Generic-CRUD contract: `backend-architecture.md`.
- Related slices: `permissions.md` (the linked entity), `users.md` (references a role via
  `app_roleId`), `auth-profile.md` (how the signed-in user's role/permissions are resolved).
