# Campuses Backend ## Purpose `campuses` is the per-organization catalog of school campuses (branding tokens, contact details, online/active flags). This doc covers the **authenticated CRUD surface** at `/api/campuses`. The public read-only surface at `GET /api/public/campuses` is a separate slice documented in `campus-catalog.md` — it is not re-documented here. Note that `src/db/api/campuses.ts` is the repository for **this** authenticated slice; the public catalog slice queries `db.campuses` directly and does not go through this repository. ## Slice Files (by layer) - Route: `src/routes/campuses.ts` — `createCrudRouter(controller, { permission: 'campuses' })`. - Controller: `src/api/controllers/campuses.controller.ts` — `createCrudController(service, { csvFields: ['id', 'name', 'code', 'address', 'phone', 'email'] })`. - Service (BLL): `src/services/campuses.ts` — `createCrudService(DbApi, { notFoundCode: 'campusesNotFound' })`. - Repository (DAL): `src/db/api/campuses.ts` (`CampusesDBApi`) — entity-specific `create`/`bulkImport`/`update`/`findBy`/`findAll`; `remove`/`deleteByIds`/`findAllAutocomplete` delegate to `db/api/shared/repository.ts` (autocomplete via `autocompleteByField`). - Model: `src/db/models/campuses.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`, `autocompleteByField`), `shared/constants/pagination.ts` (`resolvePagination`), `shared/errors/validation` (`ValidationError`), `db/utils` (`uuid`, `ilike`). ## API The standard generic-CRUD surface (all under `/api/campuses`, JWT + `${METHOD}_CAMPUSES` 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`, `code`, `address`, `phone`, `email`. ## Access Rules - JWT required; the whole router is guarded by `checkCrudPermissions('campuses')`, deriving `READ_CAMPUSES` / `CREATE_CAMPUSES` / `UPDATE_CAMPUSES` / `DELETE_CAMPUSES` per HTTP method. - Access is granted by role permission or per-user `custom_permissions` (see `permissions.md`). - The public `GET /api/public/campuses` surface has no JWT and no permission check — see `campus-catalog.md`. ## Tenant Scope - `findAll` scopes `where.organizationId` to `currentUser.organizationId` (only when the caller has both `currentUser.organizations.id` and `currentUser.organizationId`); a `globalAccess` caller has the `organizationId` filter deleted, so it sees all tenants. - `create` assigns the organization from `currentUser.organizationId` via `setOrganization`. - `update` only reassigns the organization when `data.organization` is provided: a `globalAccess` caller may set it to the supplied value; otherwise it is forced back to `currentUser.organizationId`. ## Data Contract Model columns (`paranoid`, soft-delete via `deletedAt`, `freezeTableName`): - `id` (UUID PK), `name` (TEXT, not null), `code` (TEXT, not null), `address`, `phone`, `email`, `mascot`, `color`, `bgGradient`, `borderColor`, `textColor`, `bgLight`, `description` (all TEXT, nullable), `isOnline` (BOOLEAN, not null, default `false`), `active` (BOOLEAN, not null, default `false`), `importHash` (STRING(255), unique, nullable), `organizationId` (UUID, nullable), `createdById`, `updatedById`, `createdAt` / `updatedAt` / `deletedAt`. Associations: `belongsTo` organization (`organization`, fk `organizationId`), createdBy/updatedBy (users); `hasMany` `students_campus`, `staff_campus`, `classes_campus`, `timetables_campus`, `attendance_sessions_campus`, `invoices_campus`, `messages_campus`, `documents_campus` (all keyed on `campusId`, `constraints: false`). `findBy` (backing `GET /:id`) returns the plain campus plus all eight `hasMany` collections and the `organization`, fetched in a single `Promise.all`. `findAll` eager-loads only `organization`. List filters (`CampusesFilter`): `id`, `name`, `code`, `address`, `phone`, `email` (all ilike), `active`, `organization` (`|`-separated org ids, matched on `organizationId`), `createdAtRange`, plus `field`/`sort` ordering and `limit`/`page` pagination. Default order is `createdAt desc`. ## Behavior / Notes - `create` and `bulkImport` both throw `ValidationError` when `name` or `code` is missing (`name` and `code` are not-null columns); `bulkImport` validates per row. - `create`/`update` manage the organization link via `setOrganization` rather than writing `organizationId` directly; `update` applies only the fields present in the body (each guarded by `!== undefined`). - `bulkImport` offsets `createdAt` per row by `BULK_IMPORT_TIMESTAMP_STEP_MS` to preserve order. - List pagination uses the shared `resolvePagination` defaults; the query uses `distinct: true` alongside the `organization` include. ## Tests None yet. ## Related - Public read surface: `campus-catalog.md` (`GET /api/public/campuses`, shares the `src/db/models/campuses.ts` model but not the `src/db/api/campuses.ts` repository). - Generic-CRUD contract: `backend-architecture.md`. - Related slices: `students`, `staff`, `classes`, `timetables`, `attendance_sessions`, `invoices`, `messages`, `documents` (all child records keyed on `campusId`), `permissions.md`.