# 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_ 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 ```json POST /api/search Content-Type: application/json Authorization: Bearer { "searchQuery": "vacation tour" } ``` ### Response Format ```json [ { "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 ```typescript export default class SearchService { static async search(searchQuery: string, currentUser: CurrentUser | undefined): Promise { // 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 ```javascript 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: 1. **Route-Level:** `READ_SEARCH` permission required to access the endpoint 2. **Table-Level:** `READ_
` 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` ```javascript 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: ```javascript // 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: ```javascript 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 ```typescript // 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_
` | See results from specific table | ### Example Role Configuration ```javascript // 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`: ```javascript 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=` ```tsx { router.push(`/search?query=${values.search}`); }} > ``` ### Search Results Page **Source:** `frontend/src/pages/search.tsx` **URL:** `/search?query=` **Permission:** `CREATE_SEARCH` (via LayoutAuthenticated wrapper) > **Note:** The page uses `CREATE_SEARCH` permission while the API requires `READ_SEARCH`. The search results page: - Extracts query from URL parameters - Calls `POST /search` with 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 `//-view/?id=` ```tsx // Results grouped by table {Object.keys(searchResults).map((tableName) => (
{/* Headers from result keys */} {/* Clickable rows linking to entity view */}
))} ``` ## Known Considerations 1. **No Pagination:** The search returns all results up to the per-table limit. For large result sets, consider implementing pagination. 2. **JSON Column Search:** Searching JSON columns (ui_schema_json, manifest_json, asset_list_json) searches the raw JSON text, not parsed values. 3. **Permission Inconsistency:** The search results page uses `CREATE_SEARCH` permission wrapper, but the API endpoint checks `READ_SEARCH`. Users need both permissions to use frontend search. 4. **Numeric Search:** Numbers are cast to strings for matching. Searching "10" will match "100", "210", etc. 5. **Permission Caching:** User permissions are fetched fresh for each search request. No caching is implemented at the service level. 6. **No Role Error:** If user has no app_role, the service throws `ValidationError('auth.forbidden')` - there is no fallback to a public role. 7. **Search Query Required:** Empty or missing search queries return a 400 error, not empty results. 8. **Minimum Query Length:** Frontend validates minimum 2 characters, but API accepts any non-empty string. 9. **Excluded Tables:** The following tables are intentionally not included in global search: - `permissions` - System configuration, not user content - `roles` - System configuration, not user content - `project_memberships` - Access control data, not searchable content - `element_type_defaults` - System configuration for default element settings - `project_element_defaults` - Project-specific element settings (managed separately) ## Usage Examples ### cURL Example ```bash curl -X POST http://localhost:3000/api/search \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{"searchQuery": "vacation"}' ``` ### JavaScript Example ```javascript 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; }, {}); ```