# Search Backend

## Purpose

The search slice provides a single cross-entity text search endpoint. It scans a fixed set of
tables for a query string across predefined text and numeric columns, applies per-table permission
and tenant filtering, and returns the matching records annotated with which columns matched.

## Slice Files (by layer)

- Route: `src/routes/search.ts` (thin wiring; `POST /`, guarded by
  `permissions.checkCrudPermissions('search')`).
- Controller: `src/api/controllers/search.controller.ts` (custom — `search`).
- Service (BLL): `src/services/search.ts` (`SearchService.search`; queries run through
  `db.sequelize.models[tableName]` inside the service, no separate `db/api/search.ts`).
- Repository (DAL): queries via `db.sequelize.models[<table>]` in the service.
- Shared used: `api/http/request.ts` (`wrapAsync`), `middlewares/check-permissions.ts`
  (`permissions.checkCrudPermissions`), `shared/logger.ts`, `shared/errors/validation.ts`
  (`ValidationError`), `db/models` (`db`).

## API

- `POST /api/search` -> `200` JSON array of matched records. Requires authentication (the route is
  mounted with the `authenticated` middleware in `src/index.ts`) and the `search` CRUD permission
  (`checkCrudPermissions('search')`).
  - Request body: `{ searchQuery, organizationId }`.
  - Missing/empty `searchQuery` -> `400` `{ error: 'Please enter a search query' }`.
  - On service error -> `500` `{ error: 'Internal Server Error' }` (logged via `logger.error`).
  - Success -> `200` with the array returned by `SearchService.search`.

## Access Rules

- The route requires the `search` CRUD permission via `checkCrudPermissions('search')`.
- `globalAccess` is read from `req.currentUser.app_role.globalAccess` (default `false`).
- Per table, the service calls `checkPermissions('READ_<TABLE>')`: the user must have a matching
  custom permission or an app-role permission named `READ_<TABLE>` (uppercased table name);
  tables the user cannot read are skipped. A missing `currentUser` raises
  `ValidationError('auth.unauthorized')`; a present user with no `app_role` raises
  `ValidationError('auth.forbidden')`.

## Tenant Scope

For each table other than `organizations`, when `globalAccess` is false and an `organizationId` is
supplied, the query adds `organizationId = <organizationId>` to the where clause. With
`globalAccess` true, or for the `organizations` table, no organization filter is applied.

## Data Contract

The searched tables and columns are fixed in the service:

- Text columns (`TABLE_COLUMNS`): `users` (firstName, lastName, phoneNumber, email);
  `organizations` (name); `campuses` (name, code, address, phone, email); `academic_years` (name);
  `grades` (name, code, description); `subjects` (name, code, description);
  `staff` (employee_number, job_title); `classes` (name, section); `timetables`
  (name); `timetable_periods` (room); `attendance_sessions` (notes); `attendance_records` (remarks);
  `assessments` (name, instructions); `assessment_results` (remarks);
  `messages` (subject, body); `message_recipients` (recipient_label, destination).
- Numeric columns (`COLUMNS_INT`, cast to varchar before matching): `grades` (sort_order); `classes`
  (capacity); `attendance_records` (minutes_late); `assessments`
  (max_score); `assessment_results` (score).

Text columns match with `Op.iLike '%searchQuery%'`; numeric columns are cast to `varchar` and
matched the same way; conditions are combined with `Op.or`. Each returned record is the plain row
limited to the searched columns plus `id`, with two added fields: `matchAttribute` (the list of
columns whose value contained the lowercased query) and `tableName`. Results from all permitted
tables are concatenated into one flat array.

## Behavior / Notes

- The service iterates tables in `TABLE_COLUMNS` order and skips any table the user lacks
  `READ_<TABLE>` permission for.
- Records are fetched with `attributes` restricted to the searched text columns, `id`, and the
  numeric columns; `matchAttribute` is recomputed in JS against the lowercased query.
- `SearchService.search` also independently raises `ValidationError('iam.errors.searchQueryRequired')`
  if `searchQuery` is falsy, though the controller already rejects empty queries with `400`.

## Tests

None yet (no `search` unit/e2e test in `src/`).

## Related

- `file.md` (the other backend infrastructure slice).
- Architecture contract: `backend/docs/backend-architecture.md`.
