Flatlogic Bot b03b911e99 111
2026-02-18 13:28:04 +00:00

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;
}
}
}