304 lines
9.6 KiB
JavaScript
304 lines
9.6 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
} |