# 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` `staff_campus`, `classes_campus`, `timetables_campus`,
`attendance_sessions_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: `staff`, `classes`, `timetables`, `attendance_sessions`,   `messages` (all child records keyed on `campusId`), `permissions.md`.
