19 KiB
Search System
Complete documentation for the Tour Builder Platform's global full-text search system with permission-based filtering.
Overview
The platform implements a global search service that performs full-text search across multiple database tables simultaneously. Results are filtered based on the current user's permissions, ensuring users only see data they have access to.
┌─────────────────────────────────────────────────────────────────────────────┐
│ Search System Architecture │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Search Request │ │
│ │ │ │
│ │ POST /api/search │ │
│ │ { searchQuery: "vacation" } │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Permission Check (checkCrudPermissions) │ │ │
│ │ │ │ │ │
│ │ │ READ_SEARCH permission required │ │ │
│ │ └──────────────────────────┬──────────────────────────────────────┘ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ SearchService.search() │ │ │
│ │ │ │ │ │
│ │ │ For each table: │ │ │
│ │ │ 1. Check READ_<TABLE> permission │ │ │
│ │ │ 2. If allowed, search text + numeric columns │ │ │
│ │ │ 3. Return up to 50 matching records │ │ │
│ │ └──────────────────────────┬──────────────────────────────────────┘ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Search Results │ │ │
│ │ │ │ │ │
│ │ │ [{ id, tableName, matchAttribute, ...record }] │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
API Reference
Endpoint
URL: POST /api/search
Authentication: Required (JWT)
Permission: READ_SEARCH
Source: backend/src/routes/search.ts
Request Format
POST /api/search
Content-Type: application/json
Authorization: Bearer <jwt-token>
{
"searchQuery": "vacation tour"
}
Response Format
[
{
"id": "uuid-1",
"name": "Vacation Paradise Tour",
"slug": "vacation-paradise",
"tableName": "projects",
"matchAttribute": ["name", "slug"]
},
{
"id": "uuid-2",
"name": "Beach Vacation Page",
"slug": "beach-vacation",
"tableName": "tour_pages",
"matchAttribute": ["name"]
}
]
Response Fields
| Field | Type | Description |
|---|---|---|
id |
UUID | Record's unique identifier |
tableName |
string | Source table name |
matchAttribute |
string[] | Array of field names that matched the query |
...record |
object | All searchable fields from the record |
Error Responses
| Status | Error | Description |
|---|---|---|
| 400 | "Please enter a search query" | Missing searchQuery in request body |
| 401 | Unauthorized | Missing or invalid JWT token |
| 403 | Forbidden | User lacks READ_SEARCH permission |
| 500 | "Internal Server Error" | Database or server error |
Search Service
Source: backend/src/services/search.ts
Core Logic
export default class SearchService {
static async search(searchQuery: string, currentUser: CurrentUser | undefined): Promise<SearchResult> {
// 1. Validate search query
if (!searchQuery) {
throw new ValidationError('iam.errors.searchQueryRequired');
}
// 2. Get user's permissions
const permissionSet = await getUserPermissions(currentUser);
// 3. Search each table in parallel
const searchTasks = searchTables.map(async ({ tableName, textColumns }) => {
// Skip if user lacks READ permission for this table
if (!hasPermission(permissionSet, `READ_${tableName.toUpperCase()}`)) {
return [];
}
// Perform case-insensitive search
const model = db[tableName];
if (!isSearchModel(model)) {
return [];
}
const foundRecords = await model.findAll({
where: { [Op.or]: [...searchConditions] },
limit: 50,
});
return foundRecords.map(record => ({
...record.get(),
matchAttribute,
tableName,
}));
});
return (await Promise.all(searchTasks)).flat();
}
}
Search Configuration
const SEARCH_LIMIT_PER_TABLE = 50; // Max results per table
Searchable Tables
Text Columns (String Search)
| Table | Searchable Columns |
|---|---|
users |
firstName, lastName, phoneNumber, email |
projects |
name, slug, description, logo_url, favicon_url, og_image_url |
assets |
name, cdn_url, storage_key, mime_type, checksum |
asset_variants |
cdn_url |
presigned_url_requests |
requested_key, mime_type, status |
tour_pages |
source_key, name, slug, background_image_url, background_video_url, background_audio_url, ui_schema_json |
project_audio_tracks |
source_key, name, slug, url |
publish_events |
error_message |
pwa_caches |
cache_version, manifest_json, asset_list_json |
access_logs |
path, ip_address, user_agent |
Numeric Columns (Cast to String)
| Table | Searchable Columns |
|---|---|
assets |
size_mb, width_px, height_px, duration_sec |
asset_variants |
width_px, height_px, size_mb |
presigned_url_requests |
requested_size_mb |
tour_pages |
sort_order |
project_audio_tracks |
volume, sort_order |
publish_events |
pages_copied, transitions_copied, audios_copied |
Permission-Based Filtering
How It Works
The search system implements two levels of permission checking:
- Route-Level:
READ_SEARCHpermission required to access the endpoint - Table-Level:
READ_<TABLE>permission required for each table
┌─────────────────────────────────────────────────────────────────────────────┐
│ Permission-Based Search Filtering │
│ │
│ User with permissions: [READ_SEARCH, READ_PROJECTS, READ_TOUR_PAGES] │
│ │
│ Search Query: "beach" │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Table │ Permission Required │ Searched? │ Results │ │
│ ├──────────────────────────────────────────────────────────────────────┤ │
│ │ users │ READ_USERS │ ✗ Skipped │ - │ │
│ │ projects │ READ_PROJECTS │ ✓ Yes │ 3 matches │ │
│ │ assets │ READ_ASSETS │ ✗ Skipped │ - │ │
│ │ tour_pages │ READ_TOUR_PAGES │ ✓ Yes │ 5 matches │ │
│ │ project_audio_tracks│ READ_PROJECT_AUDIO_TRACKS│ ✗ Skipped │ - │ │
│ │ ... │ ... │ ... │ ... │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ Total Results: 8 (only from projects + tour_pages) │
└─────────────────────────────────────────────────────────────────────────────┘
Permission Resolution
Source: backend/src/services/search.ts
async function getUserPermissions(currentUser) {
if (!currentUser) {
throw new ValidationError('auth.unauthorized');
}
const permissions = new Set(
(currentUser.custom_permissions || [])
.map((cp) => cp.name)
.filter(Boolean),
);
if (!currentUser.app_role) {
throw new ValidationError('auth.forbidden');
}
// Add role-based permissions
const rolePermissions = await currentUser.app_role.getPermissions();
for (const permission of rolePermissions) {
if (permission?.name) {
permissions.add(permission.name);
}
}
return permissions;
}
Permission Sources
| Source | Description |
|---|---|
currentUser.custom_permissions |
User-specific permission overrides |
currentUser.app_role.getPermissions() |
Permissions inherited from assigned role |
Search Algorithm
Case-Insensitive Matching
The search uses PostgreSQL's ILIKE operator for case-insensitive partial matching:
// Text columns: direct ILIKE
{
[attribute]: {
[Op.iLike]: `%${searchQuery}%`,
},
}
// Numeric columns: cast to varchar then ILIKE
Sequelize.where(
Sequelize.cast(Sequelize.col(`${tableName}.${attribute}`), 'varchar'),
{ [Op.iLike]: `%${searchQuery}%` },
)
Match Detection
After finding records, the service identifies which attributes matched:
const matchAttribute = [];
// Check text columns
for (const attribute of attributesToSearch) {
if (record[attribute]?.toLowerCase()?.includes(normalizedSearchQuery)) {
matchAttribute.push(attribute);
}
}
// Check numeric columns (cast to string)
for (const attribute of attributesIntToSearch) {
const castedValue = String(record[attribute]);
if (castedValue && castedValue.toLowerCase().includes(normalizedSearchQuery)) {
matchAttribute.push(attribute);
}
}
Middleware Chain
Route Configuration
// backend/src/routes/search.ts
import { checkCrudPermissions } from '../middlewares/check-permissions.ts';
router.use(checkCrudPermissions('search'));
router.post('/', async (req, res) => {
const foundMatches = await SearchService.search(searchQuery, req.currentUser);
res.json(foundMatches);
});
Middleware Flow
Request → JWT Auth → checkCrudPermissions('search') → SearchService.search()
│
▼
Checks READ_SEARCH permission
using currentUser.app_role
Role Requirements
Minimum Permissions for Search
| Permission | Required For |
|---|---|
READ_SEARCH |
Access to search endpoint |
READ_<TABLE> |
See results from specific table |
Example Role Configuration
// Role: "ContentEditor" with search access to projects and pages
{
name: "ContentEditor",
permissions: [
"READ_SEARCH",
"READ_PROJECTS",
"READ_TOUR_PAGES",
"READ_PAGE_ELEMENTS",
"READ_ASSETS"
]
}
Performance Characteristics
Parallel Execution
All table searches execute in parallel using Promise.all:
const searchTasks = Object.keys(tableColumns).map(async (tableName) => {
// Each table search runs concurrently
});
const resultsByTable = await Promise.all(searchTasks);
return resultsByTable.flat();
Limits
| Limit | Value | Description |
|---|---|---|
| Results per table | 50 | Maximum records returned from each table |
| Total tables | 10 | Number of searchable tables |
| Max total results | 500 | Theoretical maximum (50 × 10 tables) |
File Structure
backend/src/
├── routes/
│ └── search.ts # Search route with permission check
├── services/
│ └── search.ts # SearchService with table definitions
└── middlewares/
└── check-permissions.ts # Permission checking middleware
frontend/src/
├── components/
│ ├── Search.tsx # Global search input in navbar
│ └── SearchResults.tsx # Search results display component
├── pages/
│ └── search.tsx # Search results page
└── layouts/
└── Authenticated.tsx # Includes Search component in navbar
Frontend Integration
Global Search Input
Source: frontend/src/components/Search.tsx
The navbar includes a global search input that:
- Validates minimum 2 characters
- Redirects to
/search?query=<searchQuery>
<Formik
onSubmit={(values) => {
router.push(`/search?query=${values.search}`);
}}
>
<Field name='search' placeholder='Search' validate={validateSearch} />
</Formik>
Search Results Page
Source: frontend/src/pages/search.tsx
URL: /search?query=<searchQuery>
Permission: CREATE_SEARCH (via LayoutAuthenticated wrapper)
Note: The page uses
CREATE_SEARCHpermission while the API requiresREAD_SEARCH.
The search results page:
- Extracts query from URL parameters
- Calls
POST /searchwith searchQuery - Groups results by tableName
- Displays results using SearchResults component
Search Results Component
Source: frontend/src/components/SearchResults.tsx
Displays search results grouped by table:
- Shows table name as section header (humanized)
- Renders results in a table with all searchable columns
- Clickable rows navigate to
/<tableName>/<tableName>-view/?id=<id>
// Results grouped by table
{Object.keys(searchResults).map((tableName) => (
<CardBox>
<table>
{/* Headers from result keys */}
{/* Clickable rows linking to entity view */}
</table>
</CardBox>
))}
Known Considerations
-
No Pagination: The search returns all results up to the per-table limit. For large result sets, consider implementing pagination.
-
JSON Column Search: Searching JSON columns (ui_schema_json, manifest_json, asset_list_json) searches the raw JSON text, not parsed values.
-
Permission Inconsistency: The search results page uses
CREATE_SEARCHpermission wrapper, but the API endpoint checksREAD_SEARCH. Users need both permissions to use frontend search. -
Numeric Search: Numbers are cast to strings for matching. Searching "10" will match "100", "210", etc.
-
Permission Caching: User permissions are fetched fresh for each search request. No caching is implemented at the service level.
-
No Role Error: If user has no app_role, the service throws
ValidationError('auth.forbidden')- there is no fallback to a public role. -
Search Query Required: Empty or missing search queries return a 400 error, not empty results.
-
Minimum Query Length: Frontend validates minimum 2 characters, but API accepts any non-empty string.
-
Excluded Tables: The following tables are intentionally not included in global search:
permissions- System configuration, not user contentroles- System configuration, not user contentproject_memberships- Access control data, not searchable contentelement_type_defaults- System configuration for default element settingsproject_element_defaults- Project-specific element settings (managed separately)
Usage Examples
cURL Example
curl -X POST http://localhost:3000/api/search \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <jwt-token>" \
-d '{"searchQuery": "vacation"}'
JavaScript Example
const response = await fetch('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ searchQuery: 'vacation' }),
});
const results = await response.json();
// Group results by table
const byTable = results.reduce((acc, result) => {
if (!acc[result.tableName]) acc[result.tableName] = [];
acc[result.tableName].push(result);
return acc;
}, {});