# 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 11 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`); their requests bypass both per-permission checks (`check-permissions.ts`) and the `organizationId` filter. 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 None yet. ## 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).