const db = require('../db/models'); const ValidationError = require('./notifications/errors/validation'); const RolesDBApi = require('../db/api/roles'); const GooglePlacesService = require('./googlePlaces'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; // Cache for the 'Public' role object let publicRoleCache = null; async function getPublicRole() { if (publicRoleCache) return publicRoleCache; publicRoleCache = await RolesDBApi.findBy({ name: 'Public' }); return publicRoleCache; } /** * @param {string} permission * @param {object} currentUser */ async function checkPermissions(permission, currentUser) { let role = null; if (currentUser) { const userPermission = currentUser.custom_permissions?.find( (cp) => cp.name === permission, ); if (userPermission) return true; role = currentUser.app_role; } else { role = await getPublicRole(); } if (!role) { return false; } try { let permissions = []; if (typeof role.getPermissions === 'function') { permissions = await role.getPermissions(); } else { permissions = role.permissions || []; } return !!permissions.find((p) => p.name === permission); } catch (e) { console.error("Search permission check error:", e); return false; } } module.exports = class SearchService { static async search(searchQuery, currentUser, location ) { try { if (!searchQuery) { throw new ValidationError('iam.errors.searchQueryRequired'); } const roleName = currentUser?.app_role?.name || 'Public'; const isAdmin = roleName === 'Administrator' || roleName === 'Platform Owner'; // Columns that can be searched using iLike const searchableColumns = { "users": [ "firstName", "lastName", "phoneNumber", "email", ], "categories": [ "name", "slug", "description", ], "locations": [ "label", "city", "state", "zip", ], "businesses": [ "name", "slug", "description", "phone", "email", "website", "address", "city", "state", "zip", ], }; // All columns to be returned in the result const returnColumns = { "users": [...searchableColumns.users], "categories": [...searchableColumns.categories, "icon"], "locations": [...searchableColumns.locations], "businesses": [ ...searchableColumns.businesses, "is_claimed", "rating", "lat", "lng", "reliability_score", "response_time_median_minutes" ], }; const columnsInt = { "businesses": [ "lat", "lng", "reliability_score", "response_time_median_minutes", ], }; let allFoundRecords = []; for (const tableName in searchableColumns) { if (searchableColumns.hasOwnProperty(tableName)) { const attributesToSearch = searchableColumns[tableName]; const attributesIntToSearch = columnsInt[tableName] || []; const searchConditions = [ ...attributesToSearch.map(attribute => ({ [attribute]: { [Op.iLike] : `%${searchQuery}%`, }, })), ...attributesIntToSearch.map(attribute => ( Sequelize.where( Sequelize.cast(Sequelize.col(`${tableName}.${attribute}`), 'varchar'), { [Op.iLike]: `%${searchQuery}%` } ) )), ]; const whereCondition = { [Op.or]: searchConditions, }; // Only show active businesses for non-admins if (tableName === 'businesses' && !isAdmin) { whereCondition[Op.and] = whereCondition[Op.and] || []; whereCondition[Op.and].push({ is_active: true }); } // If location is provided, bias local results by location for businesses and locations if (location && (tableName === 'businesses' || tableName === 'locations')) { const locationConditions = [ { zip: { [Op.iLike]: `%${location}%` } }, { city: { [Op.iLike]: `%${location}%` } }, { state: { [Op.iLike]: `%${location}%` } }, ]; // locations table doesn't have address column if (tableName === 'businesses') { locationConditions.push({ address: { [Op.iLike]: `%${location}%` } }); } whereCondition[Op.and] = whereCondition[Op.and] || []; whereCondition[Op.and].push({ [Op.or]: locationConditions }); } const hasPerm = await checkPermissions(`READ_${tableName.toUpperCase()}`, currentUser); if (!hasPerm) { continue; } const include = []; if (tableName === 'businesses') { include.push({ model: db.business_photos, as: 'business_photos_business', include: [{ model: db.file, as: 'photos' }] }); } const foundRecords = await db[tableName].findAll({ where: whereCondition, attributes: [...returnColumns[tableName], 'id'], include }); const modifiedRecords = foundRecords.map((record) => { const matchAttribute = []; for (const attribute of attributesToSearch) { if (record[attribute] && typeof record[attribute] === 'string' && record[attribute].toLowerCase().includes(searchQuery.toLowerCase())) { matchAttribute.push(attribute); } } for (const attribute of attributesIntToSearch) { const castedValue = String(record[attribute]); if (castedValue && castedValue.toLowerCase().includes(searchQuery.toLowerCase())) { matchAttribute.push(attribute); } } return { ...record.get(), matchAttribute, tableName, }; }); allFoundRecords = allFoundRecords.concat(modifiedRecords); } } // Special case: if we found categories, find businesses in those categories const foundCategories = allFoundRecords.filter(r => r.tableName === 'categories'); if (foundCategories.length > 0) { const categoryIds = foundCategories.map(c => c.id); const businessesInCategories = await db.businesses.findAll({ where: !isAdmin ? { is_active: true } : {}, include: [ { model: db.business_categories, as: 'business_categories_business', where: { categoryId: { [Op.in]: categoryIds } } }, { model: db.business_photos, as: 'business_photos_business', include: [{ model: db.file, as: 'photos' }] } ] }); for (const biz of businessesInCategories) { if (!allFoundRecords.find(r => r.id === biz.id && r.tableName === 'businesses')) { allFoundRecords.push({ ...biz.get(), matchAttribute: ['category'], tableName: 'businesses', }); } } } // If few local businesses found, try Google Places const localBusinessesCount = allFoundRecords.filter(r => r.tableName === 'businesses').length; if (localBusinessesCount < 5) { try { // If no location in search query, try to append a default location to help Google let refinedQuery = searchQuery; if (location) { refinedQuery = `${searchQuery} ${location}`; } else if (!searchQuery.match(/\d{5}/) && !searchQuery.match(/in\s+[A-Za-z]+/i)) { refinedQuery = `${searchQuery} USA`; // Search nationwide if no location specified } const googleResults = await GooglePlacesService.searchPlaces(refinedQuery); for (const gPlace of googleResults) { // Import each place to our DB const importedBusiness = await GooglePlacesService.importFromGoogle(gPlace); // Re-fetch with associations to get photos const fullBusiness = await db.businesses.findByPk(importedBusiness.id, { include: [{ model: db.business_photos, as: 'business_photos_business', include: [{ model: db.file, as: 'photos' }] }] }); // Add to search results if not already there if (fullBusiness && !allFoundRecords.find(r => r.id === fullBusiness.id)) { allFoundRecords.push({ ...fullBusiness.get(), matchAttribute: ['name'], tableName: 'businesses', isFromGoogle: true, }); } } } catch (gError) { console.error("Google Search fallback error:", gError); } } return allFoundRecords; } catch (error) { throw error; } } }