4.8 KiB
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 bypermissions.checkCrudPermissions('search')). - Controller:
src/api/controllers/search.controller.ts(custom —search). - Service (BLL):
src/services/search.ts(SearchService.search; queries run throughdb.sequelize.models[tableName]inside the service, no separatedb/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->200JSON array of matched records. Requires authentication (the route is mounted with theauthenticatedmiddleware insrc/index.ts) and thesearchCRUD 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 vialogger.error). - Success ->
200with the array returned bySearchService.search.
- Request body:
Access Rules
- The route requires the
searchCRUD permission viacheckCrudPermissions('search'). globalAccessis read fromreq.currentUser.app_role.globalAccess(defaultfalse).- Per table, the service calls
checkPermissions('READ_<TABLE>'): the user must have a matching custom permission or an app-role permission namedREAD_<TABLE>(uppercased table name); tables the user cannot read are skipped. A missingcurrentUserraisesValidationError('auth.unauthorized'); a present user with noapp_roleraisesValidationError('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);students(student_number, first_name, last_name, email, phone, address);guardians(full_name, phone, email, address);staff(employee_number, job_title);classes(name, section);timetables(name);timetable_periods(room);attendance_sessions(notes);attendance_records(remarks);fee_plans(name, notes);invoices(invoice_number, notes);payments(receipt_number, reference_code, notes);assessments(name, instructions);assessment_results(remarks);messages(subject, body);message_recipients(recipient_label, destination);documents(entity_reference, name, notes). - Numeric columns (
COLUMNS_INT, cast to varchar before matching):grades(sort_order);classes(capacity);attendance_records(minutes_late);fee_plans(total_amount);invoices(subtotal, discount_amount, tax_amount, total_amount, balance_due);payments(amount);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_COLUMNSorder and skips any table the user lacksREAD_<TABLE>permission for. - Records are fetched with
attributesrestricted to the searched text columns,id, and the numeric columns;matchAttributeis recomputed in JS against the lowercased query. SearchService.searchalso independently raisesValidationError('iam.errors.searchQueryRequired')ifsearchQueryis falsy, though the controller already rejects empty queries with400.
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.