2026-02-17 21:09:07 +00:00

252 lines
7.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 ) {
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;
}
}
}