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 ) { try { if (!searchQuery) { throw new ValidationError('iam.errors.searchQueryRequired'); } const tableColumns = { "users": [ "firstName", "lastName", "phoneNumber", "email", ], "categories": [ "name", "slug", "icon", "description", ], "locations": [ "label", "city", "state", "zip", ], "businesses": [ "name", "slug", "description", "phone", "email", "website", "address", "city", "state", "zip", ], }; const columnsInt = { "businesses": [ "lat", "lng", "reliability_score", "response_time_median_minutes", ], }; let allFoundRecords = []; for (const tableName in tableColumns) { if (tableColumns.hasOwnProperty(tableName)) { const attributesToSearch = tableColumns[tableName]; const attributesIntToSearch = columnsInt[tableName] || []; const whereCondition = { [Op.or]: [ ...attributesToSearch.map(attribute => ({ [attribute]: { [Op.iLike] : `%${searchQuery}%`, }, })), ...attributesIntToSearch.map(attribute => ( Sequelize.where( Sequelize.cast(Sequelize.col(`${tableName}.${attribute}`), 'varchar'), { [Op.iLike]: `%${searchQuery}%` } ) )), ], }; 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: [...tableColumns[tableName], 'id', ...attributesIntToSearch], include }); const modifiedRecords = foundRecords.map((record) => { const matchAttribute = []; for (const attribute of attributesToSearch) { if (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({ 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 (!searchQuery.match(/\d{5}/) && !searchQuery.match(/in\s+[A-Za-z]+/i)) { refinedQuery = `${searchQuery} 22193`; // Default to Woodbridge, VA area } 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 (!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; } } }